From 4f0f9b75600d150bd95d157496269afeaa188ff8 Mon Sep 17 00:00:00 2001 From: Krunal Patel Date: Wed, 21 Dec 2022 21:10:48 +0530 Subject: [PATCH] Fix notification action, download fragment and resource leak - Bind service when service started using notification resume action. - Use `HttpURLConnection` to download file. - Use progress bar to determine overall progress. --- .../java/com/github/libretube/LibreTubeApp.kt | 2 +- .../github/libretube/constants/IntentData.kt | 1 + .../github/libretube/db/dao/DownloadDao.kt | 2 +- .../libretube/extensions/ContentLength.kt | 9 +- .../libretube/extensions/ToDownloadItems.kt | 8 +- .../github/libretube/obj/DownloadStatus.kt | 4 +- .../libretube/receivers/DownloadReceiver.kt | 24 +++ .../receivers/NotificationReceiver.kt | 5 + .../libretube/services/DownloadService.kt | 179 +++++++++++------- .../libretube/ui/activities/MainActivity.kt | 10 + .../ui/activities/OfflinePlayerActivity.kt | 1 - .../libretube/ui/adapters/DownloadsAdapter.kt | 93 +++++---- .../ui/fragments/DownloadsFragment.kt | 157 +++++++++++++-- .../github/libretube/util/DownloadHelper.kt | 29 +-- .../com/github/libretube/util/ImageHelper.kt | 14 +- .../main/res/drawable/circular_progress.xml | 26 +++ .../main/res/layout/downloaded_media_row.xml | 30 ++- 17 files changed, 420 insertions(+), 174 deletions(-) create mode 100644 app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt create mode 100644 app/src/main/res/drawable/circular_progress.xml diff --git a/app/src/main/java/com/github/libretube/LibreTubeApp.kt b/app/src/main/java/com/github/libretube/LibreTubeApp.kt index 15e87b3c5..2fab6a40f 100644 --- a/app/src/main/java/com/github/libretube/LibreTubeApp.kt +++ b/app/src/main/java/com/github/libretube/LibreTubeApp.kt @@ -77,7 +77,7 @@ class LibreTubeApp : Application() { private fun initializeNotificationChannels() { val downloadChannel = NotificationChannelCompat.Builder( DOWNLOAD_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_DEFAULT + NotificationManagerCompat.IMPORTANCE_LOW ) .setName(getString(R.string.download_channel_name)) .setDescription(getString(R.string.download_channel_description)) diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index b55609f49..1583d80d1 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -15,4 +15,5 @@ object IntentData { const val audioFormat = "audioFormate" const val audioQuality = "audioQuality" const val subtitleCode = "subtitleCode" + const val downloading = "downloading" } diff --git a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt index 6eedbfcf6..1bd6023f4 100644 --- a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt +++ b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt @@ -27,7 +27,7 @@ interface DownloadDao { @Query("SELECT * FROM downloadItem WHERE path = :path") fun findDownloadItemByFilePath(path: String): DownloadItem - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertDownload(download: Download) @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/github/libretube/extensions/ContentLength.kt b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt index 648b07051..575497453 100644 --- a/app/src/main/java/com/github/libretube/extensions/ContentLength.kt +++ b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt @@ -8,13 +8,14 @@ import java.net.URL suspend fun URL.getContentLength(def: Long = -1): Long { try { return withContext(Dispatchers.IO) { - val con = openConnection() as HttpURLConnection - con.setRequestProperty("Range", "bytes=0-") + val connection = openConnection() as HttpURLConnection + connection.setRequestProperty("Range", "bytes=0-") - val value = con.getHeaderField("content-length") + val value = connection.getHeaderField("content-length") // If connection accepts range header, try to get total bytes - ?: con.getHeaderField("content-range").split("/")[1] + ?: connection.getHeaderField("content-range").split("/")[1] + connection.disconnect() value.toLong() } } catch (e: Exception) { e.printStackTrace() } diff --git a/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt index ea3d9d29a..497daaf34 100644 --- a/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt +++ b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt @@ -23,7 +23,9 @@ fun Streams.toDownloadItems( videoId = videoId, fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), path = "", - url = stream?.url + url = stream?.url, + format = videoFormat, + quality = videoQuality ) ) } @@ -36,7 +38,9 @@ fun Streams.toDownloadItems( videoId = videoId, fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), path = "", - url = stream?.url + url = stream?.url, + format = audioFormat, + quality = audioQuality ) ) } diff --git a/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt index cc77e8435..b150413e7 100644 --- a/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt +++ b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt @@ -2,13 +2,11 @@ package com.github.libretube.obj sealed class DownloadStatus { - object Unknown : DownloadStatus() - object Completed : DownloadStatus() object Paused : DownloadStatus() - data class Progress(val downloaded: Long, val total: Long) : DownloadStatus() + data class Progress(val progress: Long, val downloaded: Long, val total: Long) : DownloadStatus() data class Error(val message: String, val cause: Throwable? = null) : DownloadStatus() } diff --git a/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt new file mode 100644 index 000000000..fcaac60ed --- /dev/null +++ b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt @@ -0,0 +1,24 @@ +package com.github.libretube.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.github.libretube.constants.IntentData +import com.github.libretube.services.DownloadService +import com.github.libretube.ui.activities.MainActivity + +class DownloadReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val activityIntent = Intent(context, MainActivity::class.java) + + when (intent?.action) { + DownloadService.ACTION_SERVICE_STARTED -> { + activityIntent.putExtra(IntentData.downloading, true) + } + DownloadService.ACTION_SERVICE_STOPPED -> { + activityIntent.putExtra(IntentData.downloading, false) + } + } + context?.startActivity(activityIntent) + } +} diff --git a/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt index a77ec0f2a..05bd78563 100644 --- a/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt +++ b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt @@ -23,4 +23,9 @@ class NotificationReceiver : BroadcastReceiver() { context?.startService(serviceIntent) } } + + companion object { + const val ACTION_DOWNLOAD_RESUME = "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_RESUME" + const val ACTION_DOWNLOAD_PAUSE = "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_PAUSE" + } } diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index 3a0c3c9e2..4e1b541c4 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -13,7 +13,7 @@ import com.github.libretube.api.RetrofitInstance import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID import com.github.libretube.constants.IntentData -import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.obj.Download import com.github.libretube.db.obj.DownloadItem import com.github.libretube.enums.FileType @@ -25,29 +25,29 @@ import com.github.libretube.extensions.toDownloadItems import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.obj.DownloadStatus import com.github.libretube.receivers.NotificationReceiver +import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_PAUSE +import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_RESUME import com.github.libretube.ui.activities.MainActivity import com.github.libretube.util.DownloadHelper import com.github.libretube.util.ImageHelper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import okhttp3.OkHttpClient -import okhttp3.Request import okio.BufferedSink import okio.buffer import okio.sink +import okio.source import java.io.File +import java.net.HttpURLConnection import java.net.URL -import java.util.concurrent.TimeUnit -import kotlin.coroutines.coroutineContext /** - * Download service with custom implementation of downloading using [OkHttpClient]. + * Download service with custom implementation of downloading using [HttpURLConnection]. */ class DownloadService : Service() { @@ -59,6 +59,7 @@ class DownloadService : Service() { private lateinit var summaryNotificationBuilder: NotificationCompat.Builder private val jobs = mutableMapOf() + private val downloadQueue = mutableMapOf() private val _downloadFlow = MutableSharedFlow>() val downloadFlow: SharedFlow> = _downloadFlow @@ -66,12 +67,13 @@ class DownloadService : Service() { super.onCreate() IS_DOWNLOAD_RUNNING = true notifyForeground() + sendBroadcast(Intent(ACTION_SERVICE_STARTED)) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_RESUME -> resume(intent.getIntExtra("id", -1)) - ACTION_PAUSE -> pause(intent.getIntExtra("id", -1)) + ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1)) + ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1)) } val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY @@ -87,7 +89,7 @@ class DownloadService : Service() { val streams = RetrofitInstance.api.getStreams(videoId) awaitQuery { - DatabaseHolder.Database.downloadDao().insertDownload( + Database.downloadDao().insertDownload( Download( videoId = videoId, title = streams.title ?: "", @@ -101,7 +103,17 @@ class DownloadService : Service() { ) } streams.thumbnailUrl?.let { url -> - ImageHelper.downloadImage(this@DownloadService, url, fileName) + ImageHelper.downloadImage( + this@DownloadService, + url, + File( + DownloadHelper.getDownloadDir( + this@DownloadService, + DownloadHelper.THUMBNAIL_DIR + ), + fileName + ).absolutePath + ) } val downloadItems = streams.toDownloadItems( @@ -154,7 +166,7 @@ class DownloadService : Service() { item.path = file.absolutePath item.id = awaitQuery { - DatabaseHolder.Database.downloadDao().insertDownloadItem(item) + Database.downloadDao().insertDownloadItem(item) }.toInt() jobs[item.id] = scope.launch { @@ -167,14 +179,9 @@ class DownloadService : Service() { * and notification. */ private suspend fun downloadFile(item: DownloadItem) { + downloadQueue[item.id] = true val notificationBuilder = getNotificationBuilder(item) setResumeNotification(notificationBuilder, item) - - val okHttpClient = OkHttpClient.Builder() - .connectTimeout(DownloadHelper.DEFAULT_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(DownloadHelper.DEFAULT_TIMEOUT, TimeUnit.SECONDS) - .build() - val file = File(item.path) var totalRead = file.length() val url = URL(item.url ?: return) @@ -183,50 +190,69 @@ class DownloadService : Service() { if (size > 0 && size != item.downloadSize) { item.downloadSize = size query { - DatabaseHolder.Database.downloadDao().updateDownloadItem(item) + Database.downloadDao().updateDownloadItem(item) } } } - // Set start range where last downloading was held. - val request = Request.Builder() - .url(url) - .addHeader("Range", "bytes=$totalRead-").build() - var lastTime = System.currentTimeMillis() / 1000 - var lastRead: Long = 0 - val sink: BufferedSink = file.sink(true).buffer() - try { - val response = okHttpClient.newCall(request).execute() - val sourceBytes = response.body!!.source() + // Set start range where last downloading was held. + val con = url.openConnection() as HttpURLConnection + con.requestMethod = "GET" + con.setRequestProperty("Range", "bytes=$totalRead-") + con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT + con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT + con.connect() - // Check if job is still active and read next bytes. - while (coroutineContext.isActive && - sourceBytes - .read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE) - .also { lastRead = it } != -1L - ) { - sink.emit() - totalRead += lastRead - _downloadFlow.emit(item.id to DownloadStatus.Progress(totalRead, item.downloadSize)) - - if (item.downloadSize != -1L && - System.currentTimeMillis() / 1000 > lastTime - ) { - notificationBuilder - .setContentText("${totalRead.formatAsFileSize()} / ${item.downloadSize.formatAsFileSize()}") - .setProgress(item.downloadSize.toInt(), totalRead.toInt(), false) - notificationManager.notify(item.id, notificationBuilder.build()) - lastTime = System.currentTimeMillis() / 1000 - } + if (con.responseCode !in 200..299) { + val message = getString(R.string.downloadfailed) + ": " + con.responseMessage + _downloadFlow.emit(item.id to DownloadStatus.Error(message)) + toastFromMainThread(message) } - } catch (e: Exception) { - toastFromMainThread("${getString(R.string.download)}: ${e.message.toString()}") - _downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e)) - } finally { + + val sink: BufferedSink = file.sink(true).buffer() + val sourceByte = con.inputStream.source() + + var lastTime = System.currentTimeMillis() / 1000 + var lastRead: Long = 0 + + try { + // Check if downloading is still active and read next bytes. + while (downloadQueue[item.id] == true && + sourceByte + .read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE) + .also { lastRead = it } != -1L + ) { + sink.emit() + totalRead += lastRead + _downloadFlow.emit( + item.id to DownloadStatus.Progress( + lastRead, + totalRead, + item.downloadSize + ) + ) + if (item.downloadSize != -1L && + System.currentTimeMillis() / 1000 > lastTime + ) { + notificationBuilder + .setContentText("${totalRead.formatAsFileSize()} / ${item.downloadSize.formatAsFileSize()}") + .setProgress(item.downloadSize.toInt(), totalRead.toInt(), false) + notificationManager.notify(item.id, notificationBuilder.build()) + lastTime = System.currentTimeMillis() / 1000 + } + } + } catch (_: CancellationException) { + } catch (e: Exception) { + toastFromMainThread("${getString(R.string.download)}: ${e.message}") + _downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e)) + } + sink.flush() sink.close() - } + sourceByte.close() + con.disconnect() + } catch (_: Exception) { } val completed = when (totalRead) { item.downloadSize -> { @@ -238,45 +264,46 @@ class DownloadService : Service() { false } } - pause(item.id) setPauseNotification(notificationBuilder, item, completed) + pause(item.id) } /** * Resume download which may have been paused. */ fun resume(id: Int) { - // Cancel last job if it is still active to avoid multiple - // jobs for same file. - jobs[id]?.cancel() + // If file is already downloading then avoid new download job. + if (downloadQueue[id] == true) return + val downloadItem = awaitQuery { - DatabaseHolder.Database.downloadDao().findDownloadItemById(id) + Database.downloadDao().findDownloadItemById(id) } - jobs[id] = scope.launch { + scope.launch { downloadFile(downloadItem) } } /** - * Pause downloading job for given [id]. If no [jobs] are active, stop the service. + * Pause downloading job for given [id]. If no downloads are active, stop the service. */ fun pause(id: Int) { - jobs[id]?.cancel() + downloadQueue[id] = false // Stop the service if no downloads are active. - if (jobs.values.none { it.isActive }) { + if (downloadQueue.none { it.value }) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_DETACH) } + sendBroadcast(Intent(ACTION_SERVICE_STOPPED)) stopSelf() } } /** - * Check whether the file downloading job is active or not. + * Check whether the file downloading or not. */ fun isDownloading(id: Int): Boolean { - return jobs[id]?.isActive ?: false + return downloadQueue[id] ?: false } private fun notifyForeground() { @@ -314,7 +341,7 @@ class DownloadService : Service() { return NotificationCompat .Builder(this, DOWNLOAD_CHANNEL_ID) - .setContentTitle(getString(R.string.downloading)) + .setContentTitle("[${item.type}] ${item.fileName}") .setProgress(0, 0, true) .setOngoing(true) .setContentIntent(activityIntent) @@ -325,7 +352,6 @@ class DownloadService : Service() { private fun setResumeNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem) { notificationBuilder - .setContentTitle(item.fileName) .setSmallIcon(android.R.drawable.stat_sys_download) .setWhen(System.currentTimeMillis()) .setOngoing(true) @@ -335,7 +361,11 @@ class DownloadService : Service() { notificationManager.notify(item.id, notificationBuilder.build()) } - private fun setPauseNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem, isCompleted: Boolean = false) { + private fun setPauseNotification( + notificationBuilder: NotificationCompat.Builder, + item: DownloadItem, + isCompleted: Boolean = false + ) { notificationBuilder .setProgress(0, 0, false) .setOngoing(false) @@ -357,7 +387,7 @@ class DownloadService : Service() { private fun getResumeAction(id: Int): NotificationCompat.Action { val intent = Intent(this, NotificationReceiver::class.java) - intent.action = ACTION_RESUME + intent.action = ACTION_DOWNLOAD_RESUME intent.putExtra("id", id) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -376,7 +406,7 @@ class DownloadService : Service() { private fun getPauseAction(id: Int): NotificationCompat.Action { val intent = Intent(this, NotificationReceiver::class.java) - intent.action = ACTION_PAUSE + intent.action = ACTION_DOWNLOAD_PAUSE intent.putExtra("id", id) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -393,12 +423,15 @@ class DownloadService : Service() { } override fun onDestroy() { - jobMain.cancel() + downloadQueue.clear() IS_DOWNLOAD_RUNNING = false + sendBroadcast(Intent(ACTION_SERVICE_STOPPED)) super.onDestroy() } override fun onBind(intent: Intent?): IBinder { + val ids = intent?.getIntArrayExtra("ids") + ids?.forEach { id -> resume(id) } return binder } @@ -408,8 +441,10 @@ class DownloadService : Service() { companion object { private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group" - const val ACTION_RESUME = "com.github.libretube.services.DownloadService.ACTION_RESUME" - const val ACTION_PAUSE = "com.github.libretube.services.DownloadService.ACTION_PAUSE" + const val ACTION_SERVICE_STARTED = + "com.github.libretube.services.DownloadService.ACTION_SERVICE_STARTED" + const val ACTION_SERVICE_STOPPED = + "com.github.libretube.services.DownloadService.ACTION_SERVICE_STOPPED" var IS_DOWNLOAD_RUNNING = false } } diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt index 70a486148..cbbe88dfb 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.core.view.children import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.findNavController +import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import com.github.libretube.R import com.github.libretube.constants.IntentData @@ -30,6 +31,7 @@ import com.github.libretube.extensions.toID import com.github.libretube.services.ClosingService import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.dialogs.ErrorDialog +import com.github.libretube.ui.fragments.DownloadsFragment import com.github.libretube.ui.fragments.PlayerFragment import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.SearchViewModel @@ -382,11 +384,19 @@ class MainActivity : BaseActivity() { navController.navigate(R.id.subscriptionsFragment) "library" -> navController.navigate(R.id.libraryFragment) + "downloads" -> + navController.navigate(R.id.downloadsFragment) } if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) { PlayingQueueSheet() .show(supportFragmentManager) } + if (intent?.getBooleanExtra(IntentData.downloading, false) == true) { + (supportFragmentManager.fragments.find { it is NavHostFragment }) + ?.childFragmentManager?.fragments?.forEach { fragment -> + (fragment as? DownloadsFragment)?.bindDownloadService() + } + } } private fun loadVideo(videoId: String, timeStamp: Long?) { diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 83f257fcc..6b39a8744 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -33,7 +33,6 @@ import java.io.File class OfflinePlayerActivity : BaseActivity() { private lateinit var binding: ActivityOfflinePlayerBinding - private lateinit var fileName: String private lateinit var videoId: String private lateinit var player: ExoPlayer private lateinit var playerView: StyledPlayerView diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 5a94d9f54..954a367b0 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -1,24 +1,29 @@ package com.github.libretube.ui.adapters import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.databinding.DownloadedMediaRowBinding -import com.github.libretube.extensions.formatShort -import com.github.libretube.obj.DownloadedFile +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.extensions.query import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.viewholders.DownloadsViewHolder -import com.github.libretube.util.DownloadHelper -import com.github.libretube.util.TextUtils +import com.github.libretube.util.ImageHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File class DownloadsAdapter( - private val files: MutableList + private val context: Context, + private val downloads: MutableList, + private val toogleDownload: (DownloadWithItems) -> Boolean ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { val binding = DownloadedMediaRowBinding.inflate( @@ -31,24 +36,51 @@ class DownloadsAdapter( @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) { - val file = files[position] + val download = downloads[position].download + val items = downloads[position].downloadItems holder.binding.apply { - fileName.text = file.name - fileSize.text = "${file.size / (1024 * 1024)} MiB" + title.text = download.title + uploaderName.text = download.uploader + videoInfo.text = download.uploadDate - file.metadata?.let { - uploaderName.text = it.uploader - videoInfo.text = it.views.formatShort() + " " + - root.context.getString(R.string.views_placeholder) + - TextUtils.SEPARATOR + it.uploadDate + val downloadSize = items.sumOf { it.downloadSize } + val currentSize = items.sumOf { File(it.path).length() } + + if (downloadSize == -1L) { + progressBar.isIndeterminate = true + } else { + progressBar.max = downloadSize.toInt() + progressBar.progress = currentSize.toInt() } - thumbnailImage.setImageBitmap(file.thumbnail) + if (downloadSize > currentSize) { + downloadOverlay.visibility = View.VISIBLE + resumePauseBtn.setImageResource(R.drawable.ic_download) + fileSize.text = "${currentSize.formatAsFileSize()} / ${downloadSize.formatAsFileSize()}" + } else { + downloadOverlay.visibility = View.GONE + fileSize.text = downloadSize.formatAsFileSize() + } + + download.thumbnailPath?.let { path -> + thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path)) + } + + progressBar.setOnClickListener { + val isDownloading = toogleDownload(downloads[position]) + + resumePauseBtn.setImageResource( + if (isDownloading) { + R.drawable.ic_pause + } else { + R.drawable.ic_download + } + ) + } root.setOnClickListener { - val intent = Intent(root.context, OfflinePlayerActivity::class.java).also { - it.putExtra(IntentData.fileName, file.name) - } + val intent = Intent(root.context, OfflinePlayerActivity::class.java) + intent.putExtra(IntentData.videoId, download.videoId) root.context.startActivity(intent) } @@ -61,27 +93,18 @@ class DownloadsAdapter( ) { _, index -> when (index) { 0 -> { - val audioDir = DownloadHelper.getDownloadDir( - root.context, - DownloadHelper.AUDIO_DIR - ) - val videoDir = DownloadHelper.getDownloadDir( - root.context, - DownloadHelper.VIDEO_DIR - ) - - listOf(audioDir, videoDir).forEach { - val f = File(it, file.name) - if (f.exists()) { + items.map { File(it.path) }.forEach { file -> + if (file.exists()) { try { - f.delete() - } catch (e: Exception) { - e.printStackTrace() - } + file.delete() + } catch (_: Exception) { } } } - files.removeAt(position) + query { + DatabaseHolder.Database.downloadDao().deleteDownload(download) + } + downloads.removeAt(position) notifyItemRemoved(position) notifyItemRangeChanged(position, itemCount) } @@ -95,6 +118,6 @@ class DownloadsAdapter( } override fun getItemCount(): Int { - return files.size + return downloads.size } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt index fb7812ca9..8047e017e 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt @@ -1,21 +1,62 @@ package com.github.libretube.ui.fragments +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection import android.os.Bundle +import android.os.IBinder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.size +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.R import com.github.libretube.databinding.FragmentDownloadsBinding +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.obj.DownloadStatus +import com.github.libretube.receivers.DownloadReceiver +import com.github.libretube.services.DownloadService import com.github.libretube.ui.adapters.DownloadsAdapter import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.util.DownloadHelper -import com.github.libretube.util.ImageHelper -import com.github.libretube.util.MetadataHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.File class DownloadsFragment : BaseFragment() { private lateinit var binding: FragmentDownloadsBinding + private var binder: DownloadService.LocalBinder? = null + private val downloads = mutableListOf() + private val downloadReceiver = DownloadReceiver() + + private val serviceConnection = object : ServiceConnection { + var isBound = false + var job: Job? = null + + override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) { + binder = iBinder as DownloadService.LocalBinder + isBound = true + job?.cancel() + job = lifecycleScope.launch { + binder?.getService()?.downloadFlow?.collectLatest { + updateProgress(it.first, it.second) + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + binder = null + isBound = false + } + } override fun onCreateView( inflater: LayoutInflater, @@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val files = DownloadHelper.getDownloadedFiles(requireContext()) - - if (files.isEmpty()) return - - val metadataHelper = MetadataHelper(requireContext()) - files.forEach { - metadataHelper.getMetadata(it.name)?.let { streams -> - it.metadata = streams - } - ImageHelper.getDownloadedImage(requireContext(), it.name)?.let { bitmap -> - it.thumbnail = bitmap - } + awaitQuery { + downloads.addAll(Database.downloadDao().getAll()) } + if (downloads.isEmpty()) return binding.downloadsEmpty.visibility = View.GONE binding.downloads.visibility = View.VISIBLE binding.downloads.layoutManager = LinearLayoutManager(context) - binding.downloads.adapter = DownloadsAdapter(files) + + binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) { + var isDownloading = false + val ids = it.downloadItems + .filter { item -> File(item.path).length() < item.downloadSize } + .map { item -> item.id } + + if (!serviceConnection.isBound) { + DownloadHelper.startDownloadService(requireContext()) + bindDownloadService(ids.toIntArray()) + return@DownloadsAdapter true + } + + binder?.getService()?.let { service -> + isDownloading = ids.any { id -> service.isDownloading(id) } + + ids.forEach { id -> + if (isDownloading) { + service.pause(id) + } else { + service.resume(id) + } + } + } + return@DownloadsAdapter isDownloading.not() + } binding.downloads.adapter?.registerAdapterDataObserver( object : RecyclerView.AdapterDataObserver() { - override fun onChanged() { + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { if (binding.downloads.size == 0) { binding.downloads.visibility = View.GONE binding.downloadsEmpty.visibility = View.VISIBLE } - super.onChanged() + super.onItemRangeRemoved(positionStart, itemCount) } } ) } + + override fun onStart() { + if (DownloadService.IS_DOWNLOAD_RUNNING) { + val intent = Intent(requireContext(), DownloadService::class.java) + context?.bindService(intent, serviceConnection, 0) + } + super.onStart() + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter() + filter.addAction(DownloadService.ACTION_SERVICE_STARTED) + filter.addAction(DownloadService.ACTION_SERVICE_STOPPED) + context?.registerReceiver(downloadReceiver, filter) + } + + fun bindDownloadService(ids: IntArray? = null) { + if (serviceConnection.isBound) return + + val intent = Intent(context, DownloadService::class.java) + intent.putExtra("ids", ids) + context?.bindService(intent, serviceConnection, 0) + } + + fun updateProgress(id: Int, status: DownloadStatus) { + val index = downloads.indexOfFirst { + it.downloadItems.any { item -> item.id == id } + } + val view = binding.downloads.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder + + view?.binding?.apply { + when (status) { + DownloadStatus.Paused -> { + resumePauseBtn.setImageResource(R.drawable.ic_download) + } + DownloadStatus.Completed -> { + downloadOverlay.visibility = View.GONE + } + is DownloadStatus.Progress -> { + downloadOverlay.visibility = View.VISIBLE + resumePauseBtn.setImageResource(R.drawable.ic_pause) + if (progressBar.isIndeterminate) return + progressBar.incrementProgressBy(status.progress.toInt()) + val progressInfo = progressBar.progress.formatAsFileSize() + + " / " + progressBar.max.formatAsFileSize() + fileSize.text = progressInfo + } + is DownloadStatus.Error -> { + resumePauseBtn.setImageResource(R.drawable.ic_restart) + } + } + } + } + + override fun onPause() { + super.onPause() + context?.unregisterReceiver(downloadReceiver) + } + + override fun onStop() { + super.onStop() + if (serviceConnection.isBound) { + context?.unbindService(serviceConnection) + } + } } diff --git a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt index 66f15ec2e..1225896ac 100644 --- a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt +++ b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Build import com.github.libretube.constants.IntentData -import com.github.libretube.obj.DownloadedFile +import com.github.libretube.db.obj.DownloadItem import com.github.libretube.services.DownloadService import java.io.File @@ -15,7 +15,7 @@ object DownloadHelper { const val METADATA_DIR = "metadata" const val THUMBNAIL_DIR = "thumbnail" const val DOWNLOAD_CHUNK_SIZE = 8L * 1024 - const val DEFAULT_TIMEOUT = 30L + const val DEFAULT_TIMEOUT = 15 * 1000 fun getOfflineStorageDir(context: Context): File { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir @@ -36,31 +36,6 @@ object DownloadHelper { } } - private fun File.toDownloadedFile(): DownloadedFile { - return DownloadedFile( - name = this.name, - size = this.length() - ) - } - - fun getDownloadedFiles(context: Context): MutableList { - val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty() - val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList() - - val files = mutableListOf() - - videoFiles.forEach { - audioFiles.removeIf { audioFile -> audioFile.name == it.name } - files.add(it.toDownloadedFile()) - } - - audioFiles.forEach { - files.add(it.toDownloadedFile()) - } - - return files - } - fun startDownloadService( context: Context, videoId: String? = null, diff --git a/app/src/main/java/com/github/libretube/util/ImageHelper.kt b/app/src/main/java/com/github/libretube/util/ImageHelper.kt index 02315d8b2..6436da21d 100644 --- a/app/src/main/java/com/github/libretube/util/ImageHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ImageHelper.kt @@ -59,15 +59,12 @@ object ImageHelper { if (!dataSaverModeEnabled) target.load(url, imageLoader) } - fun downloadImage(context: Context, url: String, fileName: String) { + fun downloadImage(context: Context, url: String, path: String) { val request = ImageRequest.Builder(context) .data(url) .target { result -> val bitmap = (result as BitmapDrawable).bitmap - val file = File( - DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), - fileName - ) + val file = File(path) saveImage(context, bitmap, Uri.fromFile(file)) } .build() @@ -75,11 +72,8 @@ object ImageHelper { imageLoader.enqueue(request) } - fun getDownloadedImage(context: Context, fileName: String): Bitmap? { - val file = File( - DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), - fileName - ) + fun getDownloadedImage(context: Context, path: String): Bitmap? { + val file = File(path) if (!file.exists()) return null return getImage(context, Uri.fromFile(file)) } diff --git a/app/src/main/res/drawable/circular_progress.xml b/app/src/main/res/drawable/circular_progress.xml new file mode 100644 index 000000000..2aab83901 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/downloaded_media_row.xml b/app/src/main/res/layout/downloaded_media_row.xml index f04296262..32bdff7d2 100644 --- a/app/src/main/res/layout/downloaded_media_row.xml +++ b/app/src/main/res/layout/downloaded_media_row.xml @@ -23,6 +23,34 @@ android:scaleType="fitXY" tools:src="@tools:sample/backgrounds/scenic" /> + + + + + + +