Dantotsu/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt
2025-01-04 04:49:49 -06:00

451 lines
No EOL
17 KiB
Kotlin

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<DownloadsManager>()
private val downloadJobs = mutableMapOf<String, Job>()
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<Int, Deferred<Bitmap?>>()
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<CrashlyticsInterface>().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<CrashlyticsInterface>().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<SChapter> {
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<ImageData>,
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<ImageData> = listOf()
var sourceMedia: Media? = null
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
@Volatile
var isServiceRunning: Boolean = false
}