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.
This commit is contained in:
Krunal Patel 2022-12-21 21:10:48 +05:30
parent 8af9e20748
commit 4f0f9b7560
17 changed files with 420 additions and 174 deletions

View File

@ -77,7 +77,7 @@ class LibreTubeApp : Application() {
private fun initializeNotificationChannels() { private fun initializeNotificationChannels() {
val downloadChannel = NotificationChannelCompat.Builder( val downloadChannel = NotificationChannelCompat.Builder(
DOWNLOAD_CHANNEL_ID, DOWNLOAD_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_DEFAULT NotificationManagerCompat.IMPORTANCE_LOW
) )
.setName(getString(R.string.download_channel_name)) .setName(getString(R.string.download_channel_name))
.setDescription(getString(R.string.download_channel_description)) .setDescription(getString(R.string.download_channel_description))

View File

@ -15,4 +15,5 @@ object IntentData {
const val audioFormat = "audioFormate" const val audioFormat = "audioFormate"
const val audioQuality = "audioQuality" const val audioQuality = "audioQuality"
const val subtitleCode = "subtitleCode" const val subtitleCode = "subtitleCode"
const val downloading = "downloading"
} }

View File

@ -27,7 +27,7 @@ interface DownloadDao {
@Query("SELECT * FROM downloadItem WHERE path = :path") @Query("SELECT * FROM downloadItem WHERE path = :path")
fun findDownloadItemByFilePath(path: String): DownloadItem fun findDownloadItemByFilePath(path: String): DownloadItem
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertDownload(download: Download) fun insertDownload(download: Download)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -8,13 +8,14 @@ import java.net.URL
suspend fun URL.getContentLength(def: Long = -1): Long { suspend fun URL.getContentLength(def: Long = -1): Long {
try { try {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val con = openConnection() as HttpURLConnection val connection = openConnection() as HttpURLConnection
con.setRequestProperty("Range", "bytes=0-") 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 // 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() value.toLong()
} }
} catch (e: Exception) { e.printStackTrace() } } catch (e: Exception) { e.printStackTrace() }

View File

@ -23,7 +23,9 @@ fun Streams.toDownloadItems(
videoId = videoId, videoId = videoId,
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
path = "", path = "",
url = stream?.url url = stream?.url,
format = videoFormat,
quality = videoQuality
) )
) )
} }
@ -36,7 +38,9 @@ fun Streams.toDownloadItems(
videoId = videoId, videoId = videoId,
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(), fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
path = "", path = "",
url = stream?.url url = stream?.url,
format = audioFormat,
quality = audioQuality
) )
) )
} }

View File

@ -2,13 +2,11 @@ package com.github.libretube.obj
sealed class DownloadStatus { sealed class DownloadStatus {
object Unknown : DownloadStatus()
object Completed : DownloadStatus() object Completed : DownloadStatus()
object Paused : 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() data class Error(val message: String, val cause: Throwable? = null) : DownloadStatus()
} }

View File

@ -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)
}
}

View File

@ -23,4 +23,9 @@ class NotificationReceiver : BroadcastReceiver() {
context?.startService(serviceIntent) 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"
}
} }

View File

@ -13,7 +13,7 @@ import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
import com.github.libretube.constants.IntentData 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.Download
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
@ -25,29 +25,29 @@ import com.github.libretube.extensions.toDownloadItems
import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.obj.DownloadStatus import com.github.libretube.obj.DownloadStatus
import com.github.libretube.receivers.NotificationReceiver 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.ui.activities.MainActivity
import com.github.libretube.util.DownloadHelper import com.github.libretube.util.DownloadHelper
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.BufferedSink import okio.BufferedSink
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source
import java.io.File import java.io.File
import java.net.HttpURLConnection
import java.net.URL 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() { class DownloadService : Service() {
@ -59,6 +59,7 @@ class DownloadService : Service() {
private lateinit var summaryNotificationBuilder: NotificationCompat.Builder private lateinit var summaryNotificationBuilder: NotificationCompat.Builder
private val jobs = mutableMapOf<Int, Job>() private val jobs = mutableMapOf<Int, Job>()
private val downloadQueue = mutableMapOf<Int, Boolean>()
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>() private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow
@ -66,12 +67,13 @@ class DownloadService : Service() {
super.onCreate() super.onCreate()
IS_DOWNLOAD_RUNNING = true IS_DOWNLOAD_RUNNING = true
notifyForeground() notifyForeground()
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_RESUME -> resume(intent.getIntExtra("id", -1)) ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
ACTION_PAUSE -> pause(intent.getIntExtra("id", -1)) ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
} }
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
@ -87,7 +89,7 @@ class DownloadService : Service() {
val streams = RetrofitInstance.api.getStreams(videoId) val streams = RetrofitInstance.api.getStreams(videoId)
awaitQuery { awaitQuery {
DatabaseHolder.Database.downloadDao().insertDownload( Database.downloadDao().insertDownload(
Download( Download(
videoId = videoId, videoId = videoId,
title = streams.title ?: "", title = streams.title ?: "",
@ -101,7 +103,17 @@ class DownloadService : Service() {
) )
} }
streams.thumbnailUrl?.let { url -> 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( val downloadItems = streams.toDownloadItems(
@ -154,7 +166,7 @@ class DownloadService : Service() {
item.path = file.absolutePath item.path = file.absolutePath
item.id = awaitQuery { item.id = awaitQuery {
DatabaseHolder.Database.downloadDao().insertDownloadItem(item) Database.downloadDao().insertDownloadItem(item)
}.toInt() }.toInt()
jobs[item.id] = scope.launch { jobs[item.id] = scope.launch {
@ -167,14 +179,9 @@ class DownloadService : Service() {
* and notification. * and notification.
*/ */
private suspend fun downloadFile(item: DownloadItem) { private suspend fun downloadFile(item: DownloadItem) {
downloadQueue[item.id] = true
val notificationBuilder = getNotificationBuilder(item) val notificationBuilder = getNotificationBuilder(item)
setResumeNotification(notificationBuilder, 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) val file = File(item.path)
var totalRead = file.length() var totalRead = file.length()
val url = URL(item.url ?: return) val url = URL(item.url ?: return)
@ -183,50 +190,69 @@ class DownloadService : Service() {
if (size > 0 && size != item.downloadSize) { if (size > 0 && size != item.downloadSize) {
item.downloadSize = size item.downloadSize = size
query { 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 { try {
val response = okHttpClient.newCall(request).execute() // Set start range where last downloading was held.
val sourceBytes = response.body!!.source() 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. if (con.responseCode !in 200..299) {
while (coroutineContext.isActive && val message = getString(R.string.downloadfailed) + ": " + con.responseMessage
sourceBytes _downloadFlow.emit(item.id to DownloadStatus.Error(message))
.read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE) toastFromMainThread(message)
.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
}
} }
} catch (e: Exception) {
toastFromMainThread("${getString(R.string.download)}: ${e.message.toString()}") val sink: BufferedSink = file.sink(true).buffer()
_downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e)) val sourceByte = con.inputStream.source()
} finally {
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.flush()
sink.close() sink.close()
} sourceByte.close()
con.disconnect()
} catch (_: Exception) { }
val completed = when (totalRead) { val completed = when (totalRead) {
item.downloadSize -> { item.downloadSize -> {
@ -238,45 +264,46 @@ class DownloadService : Service() {
false false
} }
} }
pause(item.id)
setPauseNotification(notificationBuilder, item, completed) setPauseNotification(notificationBuilder, item, completed)
pause(item.id)
} }
/** /**
* Resume download which may have been paused. * Resume download which may have been paused.
*/ */
fun resume(id: Int) { fun resume(id: Int) {
// Cancel last job if it is still active to avoid multiple // If file is already downloading then avoid new download job.
// jobs for same file. if (downloadQueue[id] == true) return
jobs[id]?.cancel()
val downloadItem = awaitQuery { val downloadItem = awaitQuery {
DatabaseHolder.Database.downloadDao().findDownloadItemById(id) Database.downloadDao().findDownloadItemById(id)
} }
jobs[id] = scope.launch { scope.launch {
downloadFile(downloadItem) 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) { fun pause(id: Int) {
jobs[id]?.cancel() downloadQueue[id] = false
// Stop the service if no downloads are active. // 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH) stopForeground(STOP_FOREGROUND_DETACH)
} }
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
stopSelf() stopSelf()
} }
} }
/** /**
* Check whether the file downloading job is active or not. * Check whether the file downloading or not.
*/ */
fun isDownloading(id: Int): Boolean { fun isDownloading(id: Int): Boolean {
return jobs[id]?.isActive ?: false return downloadQueue[id] ?: false
} }
private fun notifyForeground() { private fun notifyForeground() {
@ -314,7 +341,7 @@ class DownloadService : Service() {
return NotificationCompat return NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID) .Builder(this, DOWNLOAD_CHANNEL_ID)
.setContentTitle(getString(R.string.downloading)) .setContentTitle("[${item.type}] ${item.fileName}")
.setProgress(0, 0, true) .setProgress(0, 0, true)
.setOngoing(true) .setOngoing(true)
.setContentIntent(activityIntent) .setContentIntent(activityIntent)
@ -325,7 +352,6 @@ class DownloadService : Service() {
private fun setResumeNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem) { private fun setResumeNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem) {
notificationBuilder notificationBuilder
.setContentTitle(item.fileName)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setWhen(System.currentTimeMillis()) .setWhen(System.currentTimeMillis())
.setOngoing(true) .setOngoing(true)
@ -335,7 +361,11 @@ class DownloadService : Service() {
notificationManager.notify(item.id, notificationBuilder.build()) 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 notificationBuilder
.setProgress(0, 0, false) .setProgress(0, 0, false)
.setOngoing(false) .setOngoing(false)
@ -357,7 +387,7 @@ class DownloadService : Service() {
private fun getResumeAction(id: Int): NotificationCompat.Action { private fun getResumeAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java) val intent = Intent(this, NotificationReceiver::class.java)
intent.action = ACTION_RESUME intent.action = ACTION_DOWNLOAD_RESUME
intent.putExtra("id", id) intent.putExtra("id", id)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 { private fun getPauseAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java) val intent = Intent(this, NotificationReceiver::class.java)
intent.action = ACTION_PAUSE intent.action = ACTION_DOWNLOAD_PAUSE
intent.putExtra("id", id) intent.putExtra("id", id)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@ -393,12 +423,15 @@ class DownloadService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
jobMain.cancel() downloadQueue.clear()
IS_DOWNLOAD_RUNNING = false IS_DOWNLOAD_RUNNING = false
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
super.onDestroy() super.onDestroy()
} }
override fun onBind(intent: Intent?): IBinder { override fun onBind(intent: Intent?): IBinder {
val ids = intent?.getIntArrayExtra("ids")
ids?.forEach { id -> resume(id) }
return binder return binder
} }
@ -408,8 +441,10 @@ class DownloadService : Service() {
companion object { companion object {
private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group" private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group"
const val ACTION_RESUME = "com.github.libretube.services.DownloadService.ACTION_RESUME" const val ACTION_SERVICE_STARTED =
const val ACTION_PAUSE = "com.github.libretube.services.DownloadService.ACTION_PAUSE" "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 var IS_DOWNLOAD_RUNNING = false
} }
} }

View File

@ -21,6 +21,7 @@ import androidx.core.view.children
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData 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.services.ClosingService
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.ErrorDialog 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.fragments.PlayerFragment
import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.models.SearchViewModel import com.github.libretube.ui.models.SearchViewModel
@ -382,11 +384,19 @@ class MainActivity : BaseActivity() {
navController.navigate(R.id.subscriptionsFragment) navController.navigate(R.id.subscriptionsFragment)
"library" -> "library" ->
navController.navigate(R.id.libraryFragment) navController.navigate(R.id.libraryFragment)
"downloads" ->
navController.navigate(R.id.downloadsFragment)
} }
if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) { if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) {
PlayingQueueSheet() PlayingQueueSheet()
.show(supportFragmentManager) .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?) { private fun loadVideo(videoId: String, timeStamp: Long?) {

View File

@ -33,7 +33,6 @@ import java.io.File
class OfflinePlayerActivity : BaseActivity() { class OfflinePlayerActivity : BaseActivity() {
private lateinit var binding: ActivityOfflinePlayerBinding private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var fileName: String
private lateinit var videoId: String private lateinit var videoId: String
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var playerView: StyledPlayerView private lateinit var playerView: StyledPlayerView

View File

@ -1,24 +1,29 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DownloadedMediaRowBinding import com.github.libretube.databinding.DownloadedMediaRowBinding
import com.github.libretube.extensions.formatShort import com.github.libretube.db.DatabaseHolder
import com.github.libretube.obj.DownloadedFile 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.activities.OfflinePlayerActivity
import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.ui.viewholders.DownloadsViewHolder
import com.github.libretube.util.DownloadHelper import com.github.libretube.util.ImageHelper
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 java.io.File
class DownloadsAdapter( class DownloadsAdapter(
private val files: MutableList<DownloadedFile> private val context: Context,
private val downloads: MutableList<DownloadWithItems>,
private val toogleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter<DownloadsViewHolder>() { ) : RecyclerView.Adapter<DownloadsViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
val binding = DownloadedMediaRowBinding.inflate( val binding = DownloadedMediaRowBinding.inflate(
@ -31,24 +36,51 @@ class DownloadsAdapter(
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) { override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
val file = files[position] val download = downloads[position].download
val items = downloads[position].downloadItems
holder.binding.apply { holder.binding.apply {
fileName.text = file.name title.text = download.title
fileSize.text = "${file.size / (1024 * 1024)} MiB" uploaderName.text = download.uploader
videoInfo.text = download.uploadDate
file.metadata?.let { val downloadSize = items.sumOf { it.downloadSize }
uploaderName.text = it.uploader val currentSize = items.sumOf { File(it.path).length() }
videoInfo.text = it.views.formatShort() + " " +
root.context.getString(R.string.views_placeholder) + if (downloadSize == -1L) {
TextUtils.SEPARATOR + it.uploadDate 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 { root.setOnClickListener {
val intent = Intent(root.context, OfflinePlayerActivity::class.java).also { val intent = Intent(root.context, OfflinePlayerActivity::class.java)
it.putExtra(IntentData.fileName, file.name) intent.putExtra(IntentData.videoId, download.videoId)
}
root.context.startActivity(intent) root.context.startActivity(intent)
} }
@ -61,27 +93,18 @@ class DownloadsAdapter(
) { _, index -> ) { _, index ->
when (index) { when (index) {
0 -> { 0 -> {
val audioDir = DownloadHelper.getDownloadDir( items.map { File(it.path) }.forEach { file ->
root.context, if (file.exists()) {
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()) {
try { try {
f.delete() file.delete()
} catch (e: Exception) { } catch (_: Exception) { }
e.printStackTrace()
}
} }
} }
files.removeAt(position) query {
DatabaseHolder.Database.downloadDao().deleteDownload(download)
}
downloads.removeAt(position)
notifyItemRemoved(position) notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount) notifyItemRangeChanged(position, itemCount)
} }
@ -95,6 +118,6 @@ class DownloadsAdapter(
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return files.size return downloads.size
} }
} }

View File

@ -1,21 +1,62 @@
package com.github.libretube.ui.fragments 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.Bundle
import android.os.IBinder
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.size import androidx.core.view.size
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.databinding.FragmentDownloadsBinding 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.adapters.DownloadsAdapter
import com.github.libretube.ui.base.BaseFragment 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.DownloadHelper
import com.github.libretube.util.ImageHelper import kotlinx.coroutines.Job
import com.github.libretube.util.MetadataHelper import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
class DownloadsFragment : BaseFragment() { class DownloadsFragment : BaseFragment() {
private lateinit var binding: FragmentDownloadsBinding private lateinit var binding: FragmentDownloadsBinding
private var binder: DownloadService.LocalBinder? = null
private val downloads = mutableListOf<DownloadWithItems>()
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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val files = DownloadHelper.getDownloadedFiles(requireContext()) awaitQuery {
downloads.addAll(Database.downloadDao().getAll())
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
}
} }
if (downloads.isEmpty()) return
binding.downloadsEmpty.visibility = View.GONE binding.downloadsEmpty.visibility = View.GONE
binding.downloads.visibility = View.VISIBLE binding.downloads.visibility = View.VISIBLE
binding.downloads.layoutManager = LinearLayoutManager(context) 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( binding.downloads.adapter?.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() { object : RecyclerView.AdapterDataObserver() {
override fun onChanged() { override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
if (binding.downloads.size == 0) { if (binding.downloads.size == 0) {
binding.downloads.visibility = View.GONE binding.downloads.visibility = View.GONE
binding.downloadsEmpty.visibility = View.VISIBLE 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)
}
}
} }

View File

@ -4,7 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import com.github.libretube.constants.IntentData 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 com.github.libretube.services.DownloadService
import java.io.File import java.io.File
@ -15,7 +15,7 @@ object DownloadHelper {
const val METADATA_DIR = "metadata" const val METADATA_DIR = "metadata"
const val THUMBNAIL_DIR = "thumbnail" const val THUMBNAIL_DIR = "thumbnail"
const val DOWNLOAD_CHUNK_SIZE = 8L * 1024 const val DOWNLOAD_CHUNK_SIZE = 8L * 1024
const val DEFAULT_TIMEOUT = 30L const val DEFAULT_TIMEOUT = 15 * 1000
fun getOfflineStorageDir(context: Context): File { fun getOfflineStorageDir(context: Context): File {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir 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<DownloadedFile> {
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty()
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList()
val files = mutableListOf<DownloadedFile>()
videoFiles.forEach {
audioFiles.removeIf { audioFile -> audioFile.name == it.name }
files.add(it.toDownloadedFile())
}
audioFiles.forEach {
files.add(it.toDownloadedFile())
}
return files
}
fun startDownloadService( fun startDownloadService(
context: Context, context: Context,
videoId: String? = null, videoId: String? = null,

View File

@ -59,15 +59,12 @@ object ImageHelper {
if (!dataSaverModeEnabled) target.load(url, imageLoader) 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) val request = ImageRequest.Builder(context)
.data(url) .data(url)
.target { result -> .target { result ->
val bitmap = (result as BitmapDrawable).bitmap val bitmap = (result as BitmapDrawable).bitmap
val file = File( val file = File(path)
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
fileName
)
saveImage(context, bitmap, Uri.fromFile(file)) saveImage(context, bitmap, Uri.fromFile(file))
} }
.build() .build()
@ -75,11 +72,8 @@ object ImageHelper {
imageLoader.enqueue(request) imageLoader.enqueue(request)
} }
fun getDownloadedImage(context: Context, fileName: String): Bitmap? { fun getDownloadedImage(context: Context, path: String): Bitmap? {
val file = File( val file = File(path)
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
fileName
)
if (!file.exists()) return null if (!file.exists()) return null
return getImage(context, Uri.fromFile(file)) return getImage(context, Uri.fromFile(file))
} }

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape
android:shape="ring"
android:thickness="2.5dp"
android:useLevel="false">
<solid android:color="#4e4e4e" />
</shape>
</item>
<item android:id="@android:id/progress">
<rotate android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:thickness="2.5dp"
android:useLevel="true">
<solid android:color="?android:colorAccent" />
<corners android:radius="20dp" />
</shape>
</rotate>
</item>
</layer-list>

View File

@ -23,6 +23,34 @@
android:scaleType="fitXY" android:scaleType="fitXY"
tools:src="@tools:sample/backgrounds/scenic" /> tools:src="@tools:sample/backgrounds/scenic" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/downloadOverlay"
android:layout_width="140dp"
android:layout_height="80dp"
android:background="#BF000000">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="52dp"
android:layout_height="52dp"
android:indeterminateOnly="false"
android:progressDrawable="@drawable/circular_progress"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/resumePauseBtn"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_download"
app:layout_constraintBottom_toBottomOf="@id/progressBar"
app:layout_constraintLeft_toLeftOf="@id/progressBar"
app:layout_constraintRight_toRightOf="@id/progressBar"
app:layout_constraintTop_toTopOf="@id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<LinearLayout <LinearLayout
@ -33,7 +61,7 @@
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
android:id="@+id/fileName" android:id="@+id/title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="2dp" android:layout_marginVertical="2dp"