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() {
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))

View File

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

View File

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

View File

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

View File

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

View File

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

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)
}
}
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_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<Int, Job>()
private val downloadQueue = mutableMapOf<Int, Boolean>()
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _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
}
}

View File

@ -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?) {

View File

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

View File

@ -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<DownloadedFile>
private val context: Context,
private val downloads: MutableList<DownloadWithItems>,
private val toogleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter<DownloadsViewHolder>() {
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
}
}

View File

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

View File

@ -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<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(
context: Context,
videoId: String? = null,

View File

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

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"
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>
<LinearLayout
@ -33,7 +61,7 @@
android:orientation="vertical">
<TextView
android:id="@+id/fileName"
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="2dp"