
This includes shadowed variables, unnecessary parameters, layouts with string literals, items that cause performance bottlenecks, and the merge of extension types into only the necessary separate classes.
428 lines
No EOL
16 KiB
Kotlin
428 lines
No EOL
16 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.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 androidx.core.content.ContextCompat
|
|
import ani.dantotsu.R
|
|
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
|
|
import ani.dantotsu.download.DownloadedType
|
|
import ani.dantotsu.download.DownloadsManager
|
|
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 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.GlobalScope
|
|
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.io.File
|
|
import java.io.FileOutputStream
|
|
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() // 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 (MangaServiceDataSingleton.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)
|
|
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.Main) {
|
|
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 deferredList = mutableListOf<Deferred<Bitmap?>>()
|
|
val deferredMap = mutableMapOf<Int, Deferred<Bitmap?>>()
|
|
builder.setContentText("Downloading ${task.title} - ${task.chapter}")
|
|
if (notifi) {
|
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
|
}
|
|
|
|
// Loop through each ImageData object from the task
|
|
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.jpg", bitmap, task.title, task.chapter)
|
|
}
|
|
farthest++
|
|
builder.setProgress(task.imageData.size, farthest, false)
|
|
broadcastDownloadProgress(
|
|
task.chapter,
|
|
farthest * 100 / task.imageData.size
|
|
)
|
|
if (notifi) {
|
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
|
}
|
|
|
|
bitmap
|
|
}
|
|
}
|
|
|
|
// Wait for any remaining deferred to complete
|
|
deferredMap.values.awaitAll()
|
|
|
|
builder.setContentText("${task.title} - ${task.chapter} Download complete")
|
|
.setProgress(0, 0, false)
|
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
|
|
|
saveMediaInfo(task)
|
|
downloadsManager.addDownload(
|
|
DownloadedType(
|
|
task.title,
|
|
task.chapter,
|
|
MediaType.MANGA
|
|
)
|
|
)
|
|
broadcastDownloadFinished(task.chapter)
|
|
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.chapter)
|
|
}
|
|
}
|
|
|
|
|
|
private fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) {
|
|
try {
|
|
// Define the directory within the private external storage space
|
|
val directory = File(
|
|
this.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
|
"Dantotsu/Manga/$title/$chapter"
|
|
)
|
|
|
|
if (!directory.exists()) {
|
|
directory.mkdirs()
|
|
}
|
|
|
|
// Create a file reference within that directory for your image
|
|
val file = File(directory, fileName)
|
|
|
|
// Use a FileOutputStream to write the bitmap to the file
|
|
FileOutputStream(file).use { outputStream ->
|
|
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) {
|
|
launchIO {
|
|
val directory = File(
|
|
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
|
"Dantotsu/Manga/${task.title}"
|
|
)
|
|
if (!directory.exists()) directory.mkdirs()
|
|
|
|
val file = File(directory, "media.json")
|
|
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.writeText(jsonString)
|
|
} 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: File, 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}")
|
|
}
|
|
|
|
val file = File(directory, name)
|
|
FileOutputStream(file).use { output ->
|
|
connection.inputStream.use { input ->
|
|
input.copyTo(output)
|
|
}
|
|
}
|
|
return@withContext file.absolutePath
|
|
} 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 imageData: List<ImageData>,
|
|
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 MangaServiceDataSingleton {
|
|
var imageData: List<ImageData> = listOf()
|
|
var sourceMedia: Media? = null
|
|
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
|
|
|
|
@Volatile
|
|
var isServiceRunning: Boolean = false
|
|
} |