LibreTube/app/src/main/java/com/github/libretube/services/DownloadService.kt

493 lines
19 KiB
Kotlin
Raw Normal View History

2022-06-28 20:02:26 +05:30
package com.github.libretube.services
2022-03-03 12:08:36 +05:30
import android.app.NotificationManager
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
2022-03-03 12:08:36 +05:30
import android.content.Intent
import android.os.Binder
2022-03-03 12:08:36 +05:30
import android.os.IBinder
import android.util.SparseBooleanArray
2022-03-03 12:08:36 +05:30
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
2023-03-31 06:09:38 +05:30
import androidx.core.content.getSystemService
import androidx.core.util.set
import androidx.core.util.valueIterator
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
2022-09-08 22:12:52 +05:30
import com.github.libretube.R
import com.github.libretube.api.CronetHelper
import com.github.libretube.api.RetrofitInstance
2022-09-08 22:11:57 +05:30
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
import com.github.libretube.constants.IntentData
2023-02-11 07:26:07 +05:30
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.formatAsFileSize
import com.github.libretube.extensions.getContentLength
import com.github.libretube.extensions.toastFromMainThread
2023-01-31 22:27:24 +05:30
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.helpers.DownloadHelper.getNotificationId
import com.github.libretube.helpers.ImageHelper
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.util.concurrent.Executors
import kotlin.io.path.absolute
import kotlin.io.path.createFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.fileSize
import kotlin.math.min
2022-03-04 23:30:50 +05:30
/**
* Download service with custom implementation of downloading using [HttpURLConnection].
*/
class DownloadService : LifecycleService() {
private val binder = LocalBinder()
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineContext = dispatcher + SupervisorJob()
2022-10-23 23:12:33 +05:30
private lateinit var notificationManager: NotificationManager
private lateinit var summaryNotificationBuilder: NotificationCompat.Builder
private val downloadQueue = SparseBooleanArray()
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow
2022-09-09 16:41:54 +05:30
2022-03-04 21:27:10 +05:30
override fun onCreate() {
super.onCreate()
2022-09-19 23:37:55 +05:30
IS_DOWNLOAD_RUNNING = true
notifyForeground()
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
2022-03-04 21:27:10 +05:30
}
2022-03-03 12:08:36 +05:30
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
2022-08-24 21:26:57 +05:30
}
2022-09-09 21:09:49 +05:30
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId
val videoFormat = intent.getStringExtra(IntentData.videoFormat)
val videoQuality = intent.getStringExtra(IntentData.videoQuality)
val audioFormat = intent.getStringExtra(IntentData.audioFormat)
val audioQuality = intent.getStringExtra(IntentData.audioQuality)
val subtitleCode = intent.getStringExtra(IntentData.subtitleCode)
lifecycleScope.launch(coroutineContext) {
try {
val streams = withContext(Dispatchers.IO) {
RetrofitInstance.api.getStreams(videoId)
}
val thumbnailTargetPath = getDownloadPath(DownloadHelper.THUMBNAIL_DIR, fileName)
2023-01-14 22:47:34 +05:30
val download = Download(
videoId,
streams.title,
streams.description,
streams.uploader,
2023-03-07 09:35:50 +05:30
streams.uploadDate,
thumbnailTargetPath,
)
Database.downloadDao().insertDownload(download)
ImageHelper.downloadImage(
this@DownloadService,
streams.thumbnailUrl,
thumbnailTargetPath,
)
val downloadItems = streams.toDownloadItems(
videoId,
fileName,
videoFormat,
videoQuality,
audioFormat,
audioQuality,
subtitleCode,
)
downloadItems.forEach { start(it) }
} catch (e: Exception) {
return@launch
}
2022-06-05 23:10:16 +05:30
}
2022-03-03 12:08:36 +05:30
return START_NOT_STICKY
2022-03-03 12:08:36 +05:30
}
2022-05-21 13:32:04 +05:30
/**
* Initiate download [Job] using [DownloadItem] by creating file according to [FileType]
* for the requested file.
*/
private fun start(item: DownloadItem) {
item.path = when (item.type) {
FileType.AUDIO -> getDownloadPath(DownloadHelper.AUDIO_DIR, item.fileName)
FileType.VIDEO -> getDownloadPath(DownloadHelper.VIDEO_DIR, item.fileName)
FileType.SUBTITLE -> getDownloadPath(DownloadHelper.SUBTITLE_DIR, item.fileName)
2023-04-21 07:07:41 +05:30
}.apply { deleteIfExists() }.createFile()
lifecycleScope.launch(coroutineContext) {
item.id = Database.downloadDao().insertDownloadItem(item).toInt()
downloadFile(item)
}
2022-03-03 12:08:36 +05:30
}
/**
* Download file and emit [DownloadStatus] to the collectors of [downloadFlow]
* and notification.
*/
@Suppress("KotlinConstantConditions")
private suspend fun downloadFile(item: DownloadItem) {
downloadQueue[item.id] = true
val notificationBuilder = getNotificationBuilder(item)
setResumeNotification(notificationBuilder, item)
val path = item.path
var totalRead = path.fileSize()
val url = URL(item.url ?: return)
// only fetch the content length if it's not been returned by the API
if (item.downloadSize == 0L) {
url.getContentLength()?.let { size ->
item.downloadSize = size
Database.downloadDao().updateDownloadItem(item)
2022-05-21 13:32:04 +05:30
}
2022-03-03 12:08:36 +05:30
}
while (totalRead < item.downloadSize) {
try {
val con = startConnection(item, url, totalRead, item.downloadSize) ?: return
@Suppress("NewApi") // The StandardOpenOption enum is desugared.
val sink = path.sink(StandardOpenOption.APPEND).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] && 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,
),
2022-12-25 18:36:23 +05:30
)
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.getNotificationId(),
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))
}
withContext(Dispatchers.IO) {
sink.flush()
sink.close()
sourceByte.close()
con.disconnect()
}
} catch (_: Exception) {}
}
2022-09-09 21:09:49 +05:30
2022-12-25 14:37:01 +05:30
val completed = when {
totalRead < item.downloadSize -> {
_downloadFlow.emit(item.id to DownloadStatus.Paused)
false
}
2022-12-25 14:37:01 +05:30
else -> {
_downloadFlow.emit(item.id to DownloadStatus.Completed)
true
}
}
setPauseNotification(notificationBuilder, item, completed)
pause(item.id)
}
2022-09-09 16:41:54 +05:30
private suspend fun startConnection(
item: DownloadItem,
url: URL,
alreadyRead: Long,
readLimit: Long?
): HttpURLConnection? {
// Set start range where last downloading was held.
val con = CronetHelper.cronetEngine.openConnection(url) as HttpURLConnection
con.requestMethod = "GET"
val limit = if (readLimit == null) {
""
} else {
min(readLimit, alreadyRead + BYTES_PER_REQUEST)
}
con.setRequestProperty("Range", "bytes=$alreadyRead-$limit")
con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT
con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT
withContext(Dispatchers.IO) {
// Retry connecting to server for n times.
for (i in 1..DownloadHelper.DEFAULT_RETRY) {
try {
con.connect()
break
} catch (_: SocketTimeoutException) {
val message = getString(R.string.downloadfailed) + " " + i
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
toastFromMainThread(message)
}
}
}
// If link is expired try to regenerate using available info.
if (con.responseCode == 403) {
regenerateLink(item)
con.disconnect()
downloadFile(item)
return null
} else if (con.responseCode !in 200..299) {
val message = getString(R.string.downloadfailed) + ": " + con.responseMessage
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
toastFromMainThread(message)
con.disconnect()
pause(item.id)
return null
}
return con
}
/**
* Resume download which may have been paused.
*/
fun resume(id: Int) {
// If file is already downloading then avoid new download job.
if (downloadQueue[id]) {
return
}
val downloadCount = downloadQueue.valueIterator().asSequence().count { it }
if (downloadCount >= DownloadHelper.getMaxConcurrentDownloads()) {
toastFromMainThread(getString(R.string.concurrent_downloads_limit_reached))
lifecycleScope.launch(coroutineContext) {
_downloadFlow.emit(id to DownloadStatus.Paused)
}
return
}
lifecycleScope.launch(coroutineContext) {
downloadFile(Database.downloadDao().findDownloadItemById(id))
}
}
/**
* Pause downloading job for given [id]. If no downloads are active, stop the service.
*/
fun pause(id: Int) {
downloadQueue[id] = false
// Stop the service if no downloads are active.
if (downloadQueue.valueIterator().asSequence().none { it }) {
2023-02-06 08:10:05 +05:30
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
stopSelf()
}
2022-03-03 12:08:36 +05:30
}
/**
* Regenerate stream url using available info format and quality.
*/
private suspend fun regenerateLink(item: DownloadItem) {
val streams = RetrofitInstance.api.getStreams(item.videoId)
val stream = when (item.type) {
FileType.AUDIO -> streams.audioStreams
FileType.VIDEO -> streams.videoStreams
else -> null
}
stream?.find { it.format == item.format && it.quality == item.quality }?.let {
item.url = it.url
}
Database.downloadDao().updateDownloadItem(item)
}
/**
* Check whether the file downloading or not.
*/
fun isDownloading(id: Int): Boolean {
return downloadQueue[id]
2022-06-05 21:38:47 +05:30
}
private fun notifyForeground() {
2023-03-31 06:09:38 +05:30
notificationManager = getSystemService()!!
2022-09-09 21:09:49 +05:30
summaryNotificationBuilder = NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID)
2023-01-14 22:47:34 +05:30
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.setContentTitle(getString(R.string.downloading))
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.setGroupSummary(true)
startForeground(DOWNLOAD_PROGRESS_NOTIFICATION_ID, summaryNotificationBuilder.build())
}
private fun getNotificationBuilder(item: DownloadItem): NotificationCompat.Builder {
val intent = Intent(this@DownloadService, MainActivity::class.java)
.putExtra("fragmentToOpen", "downloads")
val activityIntent = PendingIntentCompat
.getActivity(this@DownloadService, 0, intent, FLAG_CANCEL_CURRENT, false)
return NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID)
.setContentTitle("[${item.type}] ${item.fileName}")
.setProgress(0, 0, true)
.setOngoing(true)
.setContentIntent(activityIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
}
2022-12-25 18:36:23 +05:30
private fun setResumeNotification(
notificationBuilder: NotificationCompat.Builder,
item: DownloadItem,
2022-12-25 18:36:23 +05:30
) {
notificationBuilder
.setSmallIcon(android.R.drawable.stat_sys_download)
.setWhen(System.currentTimeMillis())
.setOngoing(true)
.clearActions()
.addAction(getPauseAction(item.id))
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
}
private fun setPauseNotification(
notificationBuilder: NotificationCompat.Builder,
item: DownloadItem,
isCompleted: Boolean = false,
) {
notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
if (isCompleted) {
notificationBuilder
.setSmallIcon(R.drawable.ic_done)
.setContentText(getString(R.string.download_completed))
} else {
notificationBuilder
.setSmallIcon(R.drawable.ic_pause)
.setContentText(getString(R.string.download_paused))
.addAction(getResumeAction(item.id))
}
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
2022-06-05 21:38:47 +05:30
}
private fun getResumeAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java)
.setAction(ACTION_DOWNLOAD_RESUME)
.putExtra("id", id)
return NotificationCompat.Action.Builder(
R.drawable.ic_play,
getString(R.string.resume),
PendingIntentCompat.getBroadcast(this, id, intent, FLAG_UPDATE_CURRENT, false),
).build()
2022-06-05 23:10:16 +05:30
}
private fun getPauseAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java)
.setAction(ACTION_DOWNLOAD_PAUSE)
.putExtra("id", id)
2022-06-05 23:10:16 +05:30
return NotificationCompat.Action.Builder(
R.drawable.ic_pause,
getString(R.string.pause),
PendingIntentCompat.getBroadcast(this, id, intent, FLAG_UPDATE_CURRENT, false),
).build()
}
2022-09-08 22:11:57 +05:30
2023-01-14 22:47:34 +05:30
/**
* Get a [File] from the corresponding download directory and the file name
*/
private fun getDownloadPath(directory: String, fileName: String): Path {
2023-04-21 07:07:41 +05:30
return DownloadHelper.getDownloadDir(this, directory).resolve(fileName).absolute()
2023-01-14 22:47:34 +05:30
}
override fun onDestroy() {
downloadQueue.clear()
IS_DOWNLOAD_RUNNING = false
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
2022-03-03 12:08:36 +05:30
super.onDestroy()
}
2022-09-19 23:37:55 +05:30
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
intent.getIntArrayExtra("ids")?.forEach { resume(it) }
return binder
}
inner class LocalBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
}
2022-09-19 23:37:55 +05:30
companion object {
private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group"
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"
2022-09-19 23:37:55 +05:30
var IS_DOWNLOAD_RUNNING = false
private const val BYTES_PER_REQUEST = 10000000L
2022-09-19 23:37:55 +05:30
}
2022-03-03 12:08:36 +05:30
}