diff --git a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt index ce910675..4cdd981b 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt @@ -2,13 +2,17 @@ package ani.dantotsu.download.manga import android.Manifest import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri import android.os.Environment import android.os.IBinder import android.widget.Toast +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import ani.dantotsu.R @@ -33,6 +37,7 @@ import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FINI import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_STARTED import ani.dantotsu.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER import ani.dantotsu.snackString +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.source.model.SChapter @@ -41,19 +46,21 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue class MangaDownloaderService : Service() { - private var title: String = "" - private var chapter: String = "" - private var retries: Int = 2 - private var simultaneousDownloads: Int = 2 - private var imageData: List = listOf() - private var sourceMedia: Media? = null private lateinit var notificationManager: NotificationManagerCompat private lateinit var builder: NotificationCompat.Builder private val downloadsManager: DownloadsManager = Injekt.get() + private val downloadJobs = mutableMapOf() + private val mutex = Mutex() + var isCurrentlyProcessing = false + override fun onBind(intent: Intent?): IBinder? { // This is only required for bound services. return null @@ -64,34 +71,93 @@ class MangaDownloaderService : Service() { notificationManager = NotificationManagerCompat.from(this) builder = NotificationCompat.Builder(this, CHANNEL_DOWNLOADER_PROGRESS).apply { setContentTitle("Manga Download Progress") - setContentText("Downloading $title - $chapter") setSmallIcon(R.drawable.ic_round_download_24) priority = NotificationCompat.PRIORITY_DEFAULT setOnlyAlertOnce(true) setProgress(0, 0, false) } startForeground(NOTIFICATION_ID, builder.build()) + registerReceiver(cancelReceiver, IntentFilter(ACTION_CANCEL_DOWNLOAD)) + } + + override fun onDestroy() { + super.onDestroy() + ServiceDataSingleton.downloadQueue.clear() + downloadJobs.clear() + ServiceDataSingleton.isServiceRunning = false + unregisterReceiver(cancelReceiver) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { snackString("Download started") - title = intent?.getStringExtra("title") ?: "" - chapter = intent?.getStringExtra("chapter") ?: "" - retries = intent?.getIntExtra("retries", 2) ?: 2 - simultaneousDownloads = intent?.getIntExtra("simultaneousDownloads", 2) ?: 2 - imageData = ServiceDataSingleton.imageData - sourceMedia = ServiceDataSingleton.sourceMedia - ServiceDataSingleton.imageData = listOf() - ServiceDataSingleton.sourceMedia = null - - CoroutineScope(Dispatchers.Default).launch { - download() + val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + serviceScope.launch { + mutex.withLock { + if (!isCurrentlyProcessing) { + isCurrentlyProcessing = true + processQueue() + isCurrentlyProcessing = false + } + } } - return START_NOT_STICKY } - suspend fun download() { + private fun processQueue() { + CoroutineScope(Dispatchers.Default).launch { + while (ServiceDataSingleton.downloadQueue.isNotEmpty()) { + val task = ServiceDataSingleton.downloadQueue.poll() + if (task != null) { + val job = launch { download(task) } + mutex.withLock { + downloadJobs[task.chapter] = job + } + job.join() // Wait for the job to complete before continuing to the next task + mutex.withLock { + downloadJobs.remove(task.chapter) + } + updateNotification() // Update the notification after each task is completed + } + if (ServiceDataSingleton.downloadQueue.isEmpty()) { + withContext(Dispatchers.Main) { + stopSelf() // Stop the service when the queue is empty + } + } + } + } + } + + fun cancelDownload(chapter: String) { + CoroutineScope(Dispatchers.Default).launch { + mutex.withLock { + downloadJobs[chapter]?.cancel() + downloadJobs.remove(chapter) + ServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter } + updateNotification() // Update the notification after cancellation + } + } + } + + private fun updateNotification() { + // Update the notification to reflect the current state of the queue + val pendingDownloads = ServiceDataSingleton.downloadQueue.size + val text = if (pendingDownloads > 0) { + "Pending downloads: $pendingDownloads" + } else { + "All downloads completed" + } + builder.setContentText(text) + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + suspend fun download(task: DownloadTask) { withContext(Dispatchers.Main) { if (ContextCompat.checkSelfPermission( this@MangaDownloaderService, @@ -105,15 +171,16 @@ class MangaDownloaderService : Service() { ).show() return@withContext } - notificationManager.notify(NOTIFICATION_ID, builder.build()) val deferredList = mutableListOf>() + builder.setContentText("Downloading ${task.title} - ${task.chapter}") + notificationManager.notify(NOTIFICATION_ID, builder.build()) - // Loop through each ImageData object + // Loop through each ImageData object from the task var farthest = 0 - for ((index, image) in imageData.withIndex()) { - // Limit the number of simultaneous downloads - if (deferredList.size >= simultaneousDownloads) { + for ((index, image) in task.imageData.withIndex()) { + // Limit the number of simultaneous downloads from the task + if (deferredList.size >= task.simultaneousDownloads) { // Wait for all deferred to complete and clear the list deferredList.awaitAll() deferredList.clear() @@ -124,10 +191,10 @@ class MangaDownloaderService : Service() { var bitmap: Bitmap? = null var retryCount = 0 - while (bitmap == null && retryCount < retries) { - bitmap = imageData[index].fetchAndProcessImage( - imageData[index].page, - imageData[index].source, + while (bitmap == null && retryCount < task.retries) { + bitmap = image.fetchAndProcessImage( + image.page, + image.source, this@MangaDownloaderService ) retryCount++ @@ -135,10 +202,10 @@ class MangaDownloaderService : Service() { // Cache the image if successful if (bitmap != null) { - saveToDisk("$index.jpg", bitmap) + saveToDisk("$index.jpg", bitmap, task.title, task.chapter) } farthest++ - builder.setProgress(imageData.size, farthest + 1, false) + builder.setProgress(task.imageData.size, farthest, false) notificationManager.notify(NOTIFICATION_ID, builder.build()) bitmap @@ -150,21 +217,20 @@ class MangaDownloaderService : Service() { // Wait for any remaining deferred to complete deferredList.awaitAll() - builder.setContentText("Download complete") + builder.setContentText("${task.title} - ${task.chapter} Download complete") .setProgress(0, 0, false) notificationManager.notify(NOTIFICATION_ID, builder.build()) - saveMediaInfo() - downloadsManager.addDownload(Download(title, chapter, Download.Type.MANGA)) - downloadsManager.exportDownloads(Download(title, chapter, Download.Type.MANGA)) - broadcastDownloadFinished(chapter) - snackString("Download finished") - stopSelf() - + saveMediaInfo(task) + downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.MANGA)) + downloadsManager.exportDownloads(Download(task.title, task.chapter, Download.Type.MANGA)) + broadcastDownloadFinished(task.chapter) + snackString("${task.title} - ${task.chapter} Download finished") } } - fun saveToDisk(fileName: String, bitmap: Bitmap) { + + fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) { try { // Define the directory within the private external storage space val directory = File( @@ -187,16 +253,16 @@ class MangaDownloaderService : Service() { } catch (e: Exception) { println("Exception while saving image: ${e.message}") - Toast.makeText(this, "Exception while saving image: ${e.message}", Toast.LENGTH_LONG) - .show() + snackString("Exception while saving image: ${e.message}") + FirebaseCrashlytics.getInstance().recordException(e) } } - fun saveMediaInfo() { + fun saveMediaInfo(task: DownloadTask) { GlobalScope.launch(Dispatchers.IO) { val directory = File( getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$title/$chapter" + "Dantotsu/Manga/${task.title}/${task.chapter}" ) if (!directory.exists()) directory.mkdirs() @@ -206,7 +272,7 @@ class MangaDownloaderService : Service() { SChapterImpl() // Provide an instance of SChapterImpl }) .create() - val mediaJson = gson.toJson(sourceMedia) //need a deep copy of sourceMedia + val mediaJson = gson.toJson(task.sourceMedia) // Assuming sourceMedia is part of DownloadTask val media = gson.fromJson(mediaJson, Media::class.java) if (media != null) { media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") } @@ -220,6 +286,7 @@ class MangaDownloaderService : Service() { } } + suspend fun downloadImage(url: String, directory: File, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") @@ -262,12 +329,38 @@ class MangaDownloaderService : Service() { sendBroadcast(intent) } + private val cancelReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_CANCEL_DOWNLOAD) { + val chapter = intent.getStringExtra(EXTRA_CHAPTER) + chapter?.let { + cancelDownload(it) + } + } + } + } + + + data class DownloadTask( + val title: String, + val chapter: String, + val imageData: List, + val sourceMedia: Media? = null, + val retries: Int = 2, + val simultaneousDownloads: Int = 2, + ) + companion object { private const val NOTIFICATION_ID = 1103 + const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download" + const val EXTRA_CHAPTER = "extra_chapter" } } object ServiceDataSingleton { var imageData: List = listOf() var sourceMedia: Media? = null + var downloadQueue: Queue = ConcurrentLinkedQueue() + @Volatile + var isServiceRunning: Boolean = false } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt index 28b1ebbd..a396abe1 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt @@ -357,18 +357,32 @@ open class MangaReadFragment : Fragment() { val parser = model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser parser?.let { CoroutineScope(Dispatchers.IO).launch { - // Fetch the image list and set it in the singleton - ServiceDataSingleton.imageData = parser.imageList("", chapter.sChapter) + val images = parser.imageList("", chapter.sChapter) - // Now that imageData is set, start the service - ServiceDataSingleton.sourceMedia = media - val intent = Intent(context, MangaDownloaderService::class.java).apply { - putExtra("title", media.nameMAL) - putExtra("chapter", chapter.title) + // Create a download task + val downloadTask = MangaDownloaderService.DownloadTask( + title = media.nameMAL ?: "", + chapter = chapter.title!!, + imageData = images, + sourceMedia = media, + retries = 2, + simultaneousDownloads = 2 + ) + + ServiceDataSingleton.downloadQueue.offer(downloadTask) + + // If the service is not already running, start it + if (!ServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, MangaDownloaderService::class.java) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(requireContext(), intent) + } + ServiceDataSingleton.isServiceRunning = true } + + // Inform the adapter that the download has started withContext(Dispatchers.Main) { chapterAdapter.startDownload(i) - ContextCompat.startForegroundService(requireContext(), intent) } } } @@ -376,15 +390,21 @@ open class MangaReadFragment : Fragment() { } + fun onMangaChapterRemoveDownloadClick(i: String){ downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA)) chapterAdapter.deleteDownload(i) } fun onMangaChapterStopDownloadClick(i: String) { - val intent = Intent(requireContext(), MangaDownloaderService::class.java) - requireContext().stopService(intent) + val cancelIntent = Intent().apply { + action = MangaDownloaderService.ACTION_CANCEL_DOWNLOAD + putExtra(MangaDownloaderService.EXTRA_CHAPTER, i) + } + requireContext().sendBroadcast(cancelIntent) + + // Remove the download from the manager and update the UI downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA)) - chapterAdapter.deleteDownload(i) + chapterAdapter.stopDownload(i) } private val downloadStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) {