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.content.pm.ServiceInfo import android.graphics.Bitmap import android.os.Build import android.os.IBinder import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.manga.ImageData import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FAILED import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FINISHED import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_PROGRESS 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 ani.dantotsu.util.Logger import ani.dantotsu.util.NumberConverter.Companion.ofLength import com.anggrayudi.storage.file.deleteRecursively import com.anggrayudi.storage.file.forceDelete import com.anggrayudi.storage.file.openOutputStream import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_DOWNLOADER_PROGRESS import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import tachiyomi.core.util.lang.launchIO import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.net.HttpURLConnection import java.net.URL import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue class MangaDownloaderService : Service() { 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() private var isCurrentlyProcessing = false override fun onBind(intent: Intent?): IBinder? { // This is only required for bound services. return null } override fun onCreate() { super.onCreate() notificationManager = NotificationManagerCompat.from(this) builder = NotificationCompat.Builder(this, CHANNEL_DOWNLOADER_PROGRESS).apply { setContentTitle("Manga Download Progress") setSmallIcon(R.drawable.ic_download_24) priority = NotificationCompat.PRIORITY_DEFAULT setOnlyAlertOnce(true) setProgress(0, 0, false) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { startForeground(NOTIFICATION_ID, builder.build()) } ContextCompat.registerReceiver( this, cancelReceiver, IntentFilter(ACTION_CANCEL_DOWNLOAD), ContextCompat.RECEIVER_EXPORTED ) } override fun onDestroy() { super.onDestroy() MangaServiceDataSingleton.downloadQueue.clear() downloadJobs.clear() MangaServiceDataSingleton.isServiceRunning = false unregisterReceiver(cancelReceiver) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { snackString("Download started") val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) serviceScope.launch { mutex.withLock { if (!isCurrentlyProcessing) { isCurrentlyProcessing = true processQueue() isCurrentlyProcessing = false } } } return START_NOT_STICKY } private fun processQueue() { CoroutineScope(Dispatchers.Default).launch { while (MangaServiceDataSingleton.downloadQueue.isNotEmpty()) { val task = MangaServiceDataSingleton.downloadQueue.poll() if (task != null) { val job = launch { download(task) } mutex.withLock { downloadJobs[task.chapter] = job } job.join() mutex.withLock { downloadJobs.remove(task.chapter) } updateNotification() } if (MangaServiceDataSingleton.downloadQueue.isEmpty()) { withContext(Dispatchers.Main) { stopSelf() } } } } } fun cancelDownload(chapter: String) { CoroutineScope(Dispatchers.Default).launch { mutex.withLock { downloadJobs[chapter]?.cancel() downloadJobs.remove(chapter) MangaServiceDataSingleton.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 = MangaServiceDataSingleton.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) { try { withContext(Dispatchers.IO) { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( this@MangaDownloaderService, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED } else { true } val deferredMap = mutableMapOf>() builder.setContentText("Downloading ${task.title} - ${task.chapter}") if (notifi) { withContext(Dispatchers.Main) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } } val baseOutputDir = getSubDirectory( this@MangaDownloaderService, MediaType.MANGA, false, task.title ) ?: throw Exception("Base output directory not found") val outputDir = getSubDirectory( this@MangaDownloaderService, MediaType.MANGA, false, task.title, task.chapter ) ?: throw Exception("Output directory not found") outputDir.deleteRecursively(this@MangaDownloaderService, true) var farthest = 0 for ((index, image) in task.imageData.withIndex()) { if (deferredMap.size >= task.simultaneousDownloads) { deferredMap.values.awaitAll() deferredMap.clear() } deferredMap[index] = async(Dispatchers.IO) { var bitmap: Bitmap? = null var retryCount = 0 while (bitmap == null && retryCount < task.retries) { bitmap = image.fetchAndProcessImage( image.page, image.source ) retryCount++ } if (bitmap != null) { saveToDisk("${index.ofLength(3)}.jpg", outputDir, bitmap) } farthest++ builder.setProgress(task.imageData.size, farthest, false) broadcastDownloadProgress( task.uniqueName, farthest * 100 / task.imageData.size ) if (notifi) { withContext(Dispatchers.Main) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } } bitmap } } deferredMap.values.awaitAll() withContext(Dispatchers.Main) { builder.setContentText("${task.title} - ${task.chapter} Download complete") .setProgress(0, 0, false) if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } } saveMediaInfo(task, baseOutputDir) downloadsManager.addDownload( DownloadedType( task.title, task.chapter, MediaType.MANGA, scanlator = task.scanlator, ) ) broadcastDownloadFinished(task.uniqueName) snackString("${task.title} - ${task.chapter} Download finished") } } catch (e: Exception) { Logger.log("Exception while downloading file: ${e.message}") snackString("Exception while downloading file: ${e.message}") Injekt.get().logException(e) broadcastDownloadFailed(task.uniqueName) } } private fun saveToDisk( fileName: String, directory: DocumentFile, bitmap: Bitmap ) { try { directory.findFile(fileName)?.forceDelete(this) val file = directory.createFile("image/jpeg", fileName) ?: throw Exception("File not created") file.openOutputStream(this, false).use { outputStream -> if (outputStream == null) throw Exception("Output stream is null") bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) } } catch (e: Exception) { println("Exception while saving image: ${e.message}") snackString("Exception while saving image: ${e.message}") Injekt.get().logException(e) } } @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: DownloadTask, directory: DocumentFile) { launchIO { directory.findFile("media.json")?.forceDelete(this@MangaDownloaderService) val file = directory.createFile("application/json", "media.json") ?: throw Exception("File not created") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl }) .create() val mediaJson = gson.toJson(task.sourceMedia) val media = gson.fromJson(mediaJson, Media::class.java) if (media != null) { media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") } media.banner = media.banner?.let { downloadImage(it, directory, "banner.jpg") } val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { try { file.openOutputStream(this@MangaDownloaderService, false).use { output -> if (output == null) throw Exception("Output stream is null") output.write(jsonString.toByteArray()) } } catch (e: android.system.ErrnoException) { e.printStackTrace() Toast.makeText( this@MangaDownloaderService, "Error while saving: ${e.localizedMessage}", Toast.LENGTH_LONG ).show() } } } } } private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") try { connection = URL(url).openConnection() as HttpURLConnection connection.connect() if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } directory.findFile(name)?.forceDelete(this@MangaDownloaderService) val file = directory.createFile("image/jpeg", name) ?: throw Exception("File not created") file.openOutputStream(this@MangaDownloaderService, false).use { output -> if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { Toast.makeText( this@MangaDownloaderService, "Exception while saving ${name}: ${e.message}", Toast.LENGTH_LONG ).show() } null } finally { connection?.disconnect() } } private fun broadcastDownloadStarted(chapterNumber: String) { val intent = Intent(ACTION_DOWNLOAD_STARTED).apply { putExtra(EXTRA_CHAPTER_NUMBER, chapterNumber) } sendBroadcast(intent) } private fun broadcastDownloadFinished(chapterNumber: String) { val intent = Intent(ACTION_DOWNLOAD_FINISHED).apply { putExtra(EXTRA_CHAPTER_NUMBER, chapterNumber) } sendBroadcast(intent) } private fun broadcastDownloadFailed(chapterNumber: String) { val intent = Intent(ACTION_DOWNLOAD_FAILED).apply { putExtra(EXTRA_CHAPTER_NUMBER, chapterNumber) } sendBroadcast(intent) } private fun broadcastDownloadProgress(chapterNumber: String, progress: Int) { val intent = Intent(ACTION_DOWNLOAD_PROGRESS).apply { putExtra(EXTRA_CHAPTER_NUMBER, chapterNumber) putExtra("progress", progress) } 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 scanlator: String, val imageData: List, val sourceMedia: Media? = null, val retries: Int = 2, val simultaneousDownloads: Int = 2, ) { val uniqueName: String get() = "$chapter-$scanlator" } companion object { private const val NOTIFICATION_ID = 1103 const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download" const val EXTRA_CHAPTER = "extra_chapter" } } object MangaServiceDataSingleton { var imageData: List = listOf() var sourceMedia: Media? = null var downloadQueue: Queue = ConcurrentLinkedQueue() @Volatile var isServiceRunning: Boolean = false }