Use java.nio.file APIs in download functionality.

This commit is contained in:
Isira Seneviratne 2023-03-07 07:14:09 +05:30
parent 9c237861c5
commit 13f8b3d49d
12 changed files with 82 additions and 71 deletions

View File

@ -2,6 +2,7 @@ package com.github.libretube.api.obj
import com.github.libretube.db.obj.DownloadItem import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
import java.nio.file.Paths
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -33,6 +34,7 @@ data class Streams(
val uploaderSubscriberCount: Long = 0, val uploaderSubscriberCount: Long = 0,
val previewFrames: List<PreviewFrames> = emptyList() val previewFrames: List<PreviewFrames> = emptyList()
) { ) {
@Suppress("NewApi") // The Paths class is desugared.
fun toDownloadItems( fun toDownloadItems(
videoId: String, videoId: String,
fileName: String, fileName: String,
@ -53,7 +55,7 @@ data class Streams(
type = FileType.VIDEO, type = FileType.VIDEO,
videoId = videoId, videoId = videoId,
fileName = stream?.getQualityString(fileName).orEmpty(), fileName = stream?.getQualityString(fileName).orEmpty(),
path = "", path = Paths.get(""),
url = stream?.url, url = stream?.url,
format = videoFormat, format = videoFormat,
quality = videoQuality quality = videoQuality
@ -70,7 +72,7 @@ data class Streams(
type = FileType.AUDIO, type = FileType.AUDIO,
videoId = videoId, videoId = videoId,
fileName = stream?.getQualityString(fileName).orEmpty(), fileName = stream?.getQualityString(fileName).orEmpty(),
path = "", path = Paths.get(""),
url = stream?.url, url = stream?.url,
format = audioFormat, format = audioFormat,
quality = audioQuality quality = audioQuality
@ -84,7 +86,7 @@ data class Streams(
type = FileType.SUBTITLE, type = FileType.SUBTITLE,
videoId = videoId, videoId = videoId,
fileName = "${fileName}_$subtitleCode.srt", fileName = "${fileName}_$subtitleCode.srt",
path = "", path = Paths.get(""),
url = subtitles.find { it.code == subtitleCode }?.url url = subtitles.find { it.code == subtitleCode }?.url
) )
) )

View File

@ -1,6 +1,8 @@
package com.github.libretube.db package com.github.libretube.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.nio.file.Path
import java.nio.file.Paths
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.toLocalDate import kotlinx.datetime.toLocalDate
@ -10,4 +12,10 @@ object Converters {
@TypeConverter @TypeConverter
fun stringToLocalDate(string: String?) = string?.toLocalDate() fun stringToLocalDate(string: String?) = string?.toLocalDate()
@TypeConverter
fun pathToString(path: Path?) = path?.toString()
@TypeConverter
fun stringToPath(string: String?) = string?.let { Paths.get(it) }
} }

View File

@ -2,6 +2,7 @@ package com.github.libretube.db.obj
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import java.nio.file.Path
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@Entity(tableName = "download") @Entity(tableName = "download")
@ -12,5 +13,5 @@ data class Download(
val description: String = "", val description: String = "",
val uploader: String = "", val uploader: String = "",
val uploadDate: LocalDate? = null, val uploadDate: LocalDate? = null,
val thumbnailPath: String? = null val thumbnailPath: Path? = null
) )

View File

@ -5,6 +5,7 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
import java.nio.file.Path
@Entity( @Entity(
tableName = "downloadItem", tableName = "downloadItem",
@ -24,7 +25,7 @@ data class DownloadItem(
val type: FileType, val type: FileType,
val videoId: String, val videoId: String,
val fileName: String, val fileName: String,
var path: String, var path: Path,
var url: String? = null, var url: String? = null,
var format: String? = null, var format: String? = null,
var quality: String? = null, var quality: String? = null,

View File

@ -0,0 +1,11 @@
package com.github.libretube.extensions
import android.net.Uri
import androidx.core.net.toUri
import java.nio.file.Path
import kotlin.io.path.exists
fun Path.toAndroidUri(): Uri? {
@Suppress("NewApi") // The Path class is desugared.
return if (exists()) toFile().toUri() else null
}

View File

@ -8,7 +8,8 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.obj.DownloadItem import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.services.DownloadService import com.github.libretube.services.DownloadService
import java.io.File import java.nio.file.Path
import kotlin.io.path.createDirectories
object DownloadHelper { object DownloadHelper {
const val VIDEO_DIR = "video" const val VIDEO_DIR = "video"
@ -20,23 +21,21 @@ object DownloadHelper {
const val DEFAULT_TIMEOUT = 15 * 1000 const val DEFAULT_TIMEOUT = 15 * 1000
const val DEFAULT_RETRY = 3 const val DEFAULT_RETRY = 3
fun getOfflineStorageDir(context: Context): File { private fun getOfflineStorageDir(context: Context): Path {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir val file = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return try {
context.getExternalFilesDir(null)!!
} catch (e: Exception) {
context.filesDir context.filesDir
} else {
try {
context.getExternalFilesDir(null)!!
} catch (e: Exception) {
context.filesDir
}
} }
return file.toPath()
} }
fun getDownloadDir(context: Context, path: String): File { fun getDownloadDir(context: Context, path: String): Path {
return File( return getOfflineStorageDir(context).resolve(path).createDirectories()
getOfflineStorageDir(context),
path
).apply {
if (!this.exists()) this.mkdirs()
}
} }
fun getMaxConcurrentDownloads(): Int { fun getMaxConcurrentDownloads(): Int {

View File

@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import coil.ImageLoader import coil.ImageLoader
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.load import coil.load
@ -13,8 +14,9 @@ import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import com.github.libretube.api.CronetHelper import com.github.libretube.api.CronetHelper
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.util.DataSaverMode import com.github.libretube.util.DataSaverMode
import java.io.File import java.nio.file.Path
object ImageHelper { object ImageHelper {
lateinit var imageLoader: ImageLoader lateinit var imageLoader: ImageLoader
@ -54,9 +56,10 @@ object ImageHelper {
if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader) if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader)
} }
fun downloadImage(context: Context, url: String, path: String) { fun downloadImage(context: Context, url: String, path: Path) {
getAsync(context, url) { bitmap -> getAsync(context, url) { bitmap ->
saveImage(context, bitmap, Uri.fromFile(File(path))) @Suppress("NewApi") // The Path class is desugared.
saveImage(context, bitmap, path.toFile().toUri())
} }
} }
@ -69,10 +72,8 @@ object ImageHelper {
imageLoader.enqueue(request) imageLoader.enqueue(request)
} }
fun getDownloadedImage(context: Context, path: String): Bitmap? { fun getDownloadedImage(context: Context, path: Path): Bitmap? {
val file = File(path) return path.toAndroidUri()?.let { getImage(context, it) }
if (!file.exists()) return null
return getImage(context, Uri.fromFile(file))
} }
private fun saveImage(context: Context, bitmapImage: Bitmap, imagePath: Uri) { private fun saveImage(context: Context, bitmapImage: Bitmap, imagePath: Uri) {

View File

@ -38,7 +38,12 @@ import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.URL import java.net.URL
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.io.path.absolute
import kotlin.io.path.createFile
import kotlin.io.path.fileSize
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -48,7 +53,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
@ -96,7 +100,8 @@ class DownloadService : LifecycleService() {
RetrofitInstance.api.getStreams(videoId) RetrofitInstance.api.getStreams(videoId)
} }
val thumbnailTargetFile = getDownloadFile(DownloadHelper.THUMBNAIL_DIR, fileName) val thumbnailTargetPath = getDownloadPath(DownloadHelper.THUMBNAIL_DIR, fileName)
.absolute()
val download = Download( val download = Download(
videoId, videoId,
@ -104,13 +109,13 @@ class DownloadService : LifecycleService() {
streams.description, streams.description,
streams.uploader, streams.uploader,
streams.uploadDate, streams.uploadDate,
thumbnailTargetFile.absolutePath thumbnailTargetPath
) )
Database.downloadDao().insertDownload(download) Database.downloadDao().insertDownload(download)
ImageHelper.downloadImage( ImageHelper.downloadImage(
this@DownloadService, this@DownloadService,
streams.thumbnailUrl, streams.thumbnailUrl,
thumbnailTargetFile.absolutePath thumbnailTargetPath
) )
val downloadItems = streams.toDownloadItems( val downloadItems = streams.toDownloadItems(
@ -136,13 +141,11 @@ class DownloadService : LifecycleService() {
* for the requested file. * for the requested file.
*/ */
private fun start(item: DownloadItem) { private fun start(item: DownloadItem) {
val file = when (item.type) { item.path = when (item.type) {
FileType.AUDIO -> getDownloadFile(DownloadHelper.AUDIO_DIR, item.fileName) FileType.AUDIO -> getDownloadPath(DownloadHelper.AUDIO_DIR, item.fileName)
FileType.VIDEO -> getDownloadFile(DownloadHelper.VIDEO_DIR, item.fileName) FileType.VIDEO -> getDownloadPath(DownloadHelper.VIDEO_DIR, item.fileName)
FileType.SUBTITLE -> getDownloadFile(DownloadHelper.SUBTITLE_DIR, item.fileName) FileType.SUBTITLE -> getDownloadPath(DownloadHelper.SUBTITLE_DIR, item.fileName)
} }.createFile().absolute()
file.createNewFile()
item.path = file.absolutePath
lifecycleScope.launch(coroutineContext) { lifecycleScope.launch(coroutineContext) {
item.id = Database.downloadDao().insertDownloadItem(item).toInt() item.id = Database.downloadDao().insertDownloadItem(item).toInt()
@ -158,8 +161,8 @@ class DownloadService : LifecycleService() {
downloadQueue[item.id] = true downloadQueue[item.id] = true
val notificationBuilder = getNotificationBuilder(item) val notificationBuilder = getNotificationBuilder(item)
setResumeNotification(notificationBuilder, item) setResumeNotification(notificationBuilder, item)
val file = File(item.path) val path = item.path
var totalRead = file.length() var totalRead = path.fileSize()
val url = URL(item.url ?: return) val url = URL(item.url ?: return)
url.getContentLength().let { size -> url.getContentLength().let { size ->
@ -206,7 +209,8 @@ class DownloadService : LifecycleService() {
return return
} }
val sink: BufferedSink = file.sink(true).buffer() @Suppress("NewApi") // The StandardOpenOption enum is desugared.
val sink = path.sink(StandardOpenOption.APPEND).buffer()
val sourceByte = con.inputStream.source() val sourceByte = con.inputStream.source()
var lastTime = System.currentTimeMillis() / 1000 var lastTime = System.currentTimeMillis() / 1000
@ -437,14 +441,8 @@ class DownloadService : LifecycleService() {
/** /**
* Get a [File] from the corresponding download directory and the file name * Get a [File] from the corresponding download directory and the file name
*/ */
private fun getDownloadFile(directory: String, fileName: String): File { private fun getDownloadPath(directory: String, fileName: String): Path {
return File( return DownloadHelper.getDownloadDir(this, directory).resolve(fileName)
DownloadHelper.getDownloadDir(
this@DownloadService,
directory
),
fileName
)
} }
override fun onDestroy() { override fun onDestroy() {

View File

@ -15,6 +15,7 @@ import com.github.libretube.databinding.ActivityOfflinePlayerBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams
@ -33,7 +34,6 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.FileDataSource import com.google.android.exoplayer2.upstream.FileDataSource
import com.google.android.exoplayer2.util.MimeTypes import com.google.android.exoplayer2.util.MimeTypes
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -107,10 +107,6 @@ class OfflinePlayerActivity : BaseActivity() {
) )
} }
private fun File.toUri(): Uri? {
return if (this.exists()) Uri.fromFile(this) else null
}
private fun playVideo() { private fun playVideo() {
lifecycleScope.launch { lifecycleScope.launch {
val downloadFiles = withContext(Dispatchers.IO) { val downloadFiles = withContext(Dispatchers.IO) {
@ -121,9 +117,9 @@ class OfflinePlayerActivity : BaseActivity() {
val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO } val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE } val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
val videoUri = video?.path?.let { File(it).toUri() } val videoUri = video?.path?.toAndroidUri()
val audioUri = audio?.path?.let { File(it).toUri() } val audioUri = audio?.path?.toAndroidUri()
val subtitleUri = subtitle?.path?.let { File(it).toUri() } val subtitleUri = subtitle?.path?.toAndroidUri()
setMediaSource(videoUri, audioUri, subtitleUri) setMediaSource(videoUri, audioUri, subtitleUri)
@ -143,7 +139,6 @@ class OfflinePlayerActivity : BaseActivity() {
.setMimeType(MimeTypes.APPLICATION_SUBRIP) .setMimeType(MimeTypes.APPLICATION_SUBRIP)
.build() .build()
} }
subtitle?.id
when { when {
videoUri != null && audioUri != null -> { videoUri != null && audioUri != null -> {

View File

@ -18,7 +18,8 @@ import com.github.libretube.ui.activities.OfflinePlayerActivity
import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.ui.viewholders.DownloadsViewHolder
import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File import kotlin.io.path.deleteIfExists
import kotlin.io.path.fileSize
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -46,7 +47,7 @@ class DownloadsAdapter(
videoInfo.text = download.uploadDate?.let { TextUtils.localizeDate(it) } videoInfo.text = download.uploadDate?.let { TextUtils.localizeDate(it) }
val downloadSize = items.sumOf { it.downloadSize } val downloadSize = items.sumOf { it.downloadSize }
val currentSize = items.sumOf { File(it.path).length() } val currentSize = items.sumOf { it.path.fileSize() }
if (downloadSize == -1L) { if (downloadSize == -1L) {
progressBar.isIndeterminate = true progressBar.isIndeterminate = true
@ -96,16 +97,10 @@ class DownloadsAdapter(
.setTitle(R.string.delete) .setTitle(R.string.delete)
.setMessage(R.string.irreversible) .setMessage(R.string.irreversible)
.setPositiveButton(R.string.okay) { _, _ -> .setPositiveButton(R.string.okay) { _, _ ->
items.map { File(it.path) }.forEach { file -> items.forEach {
runCatching { it.path.deleteIfExists()
if (file.exists()) file.delete()
}
}
runCatching {
download.thumbnailPath?.let {
File(it).delete()
}
} }
download.thumbnailPath?.deleteIfExists()
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
DatabaseHolder.Database.downloadDao().deleteDownload(download) DatabaseHolder.Database.downloadDao().deleteDownload(download)

View File

@ -24,7 +24,7 @@ import com.github.libretube.receivers.DownloadReceiver
import com.github.libretube.services.DownloadService import com.github.libretube.services.DownloadService
import com.github.libretube.ui.adapters.DownloadsAdapter import com.github.libretube.ui.adapters.DownloadsAdapter
import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.ui.viewholders.DownloadsViewHolder
import java.io.File import kotlin.io.path.fileSize
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -84,7 +84,7 @@ class DownloadsFragment : Fragment() {
binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) { binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) {
var isDownloading = false var isDownloading = false
val ids = it.downloadItems val ids = it.downloadItems
.filter { item -> File(item.path).length() < item.downloadSize } .filter { item -> item.path.fileSize() < item.downloadSize }
.map { item -> item.id } .map { item -> item.id }
if (!serviceConnection.isBound) { if (!serviceConnection.isBound) {

View File

@ -36,7 +36,7 @@ androidx-work-runtime = { group = "androidx.work", name="work-runtime-ktx", vers
exoplayer = { group = "com.google.android.exoplayer", name = "exoplayer", version.ref = "exoplayer" } exoplayer = { group = "com.google.android.exoplayer", name = "exoplayer", version.ref = "exoplayer" }
exoplayer-extension-mediasession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" } exoplayer-extension-mediasession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" }
square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugaring" } desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" }
exoplayer-extension-cronet = { group = "com.google.android.exoplayer", name = "extension-cronet", version.ref = "exoplayer" } exoplayer-extension-cronet = { group = "com.google.android.exoplayer", name = "extension-cronet", version.ref = "exoplayer" }
exoplayer-dash = { group = "com.google.android.exoplayer", name = "exoplayer-dash", version.ref = "exoplayer" } exoplayer-dash = { group = "com.google.android.exoplayer", name = "exoplayer-dash", version.ref = "exoplayer" }
cronet-embedded = { group = "org.chromium.net", name = "cronet-embedded", version.ref = "cronetEmbedded" } cronet-embedded = { group = "org.chromium.net", name = "cronet-embedded", version.ref = "cronetEmbedded" }