Downloading manga
parent
b69c624442
commit
5b858edc97
@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.core.local
|
||||
|
||||
import android.content.Context
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
class PagesCache(context: Context) {
|
||||
|
||||
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
|
||||
|
||||
init {
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdir()
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(url: String) = cacheDir.sub(url.longHashCode().toString()).takeIfReadable()
|
||||
|
||||
fun put(url: String, writer: (OutputStream) -> Unit): File {
|
||||
val file = cacheDir.sub(url.longHashCode().toString())
|
||||
file.outputStream().use(writer)
|
||||
return file
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package org.koitharu.kotatsu.domain.local
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.utils.ext.toFileName
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@WorkerThread
|
||||
class MangaZip(private val file: File) {
|
||||
|
||||
private val dir = file.parentFile?.sub(file.name + ".dir")?.takeIf { it.mkdir() }
|
||||
?: throw RuntimeException("Cannot create temporary directory")
|
||||
|
||||
private lateinit var index: JSONObject
|
||||
|
||||
fun prepare(manga: Manga) {
|
||||
extract()
|
||||
index = dir.sub("index.json").takeIfReadable()?.readText()?.let { JSONObject(it) } ?: JSONObject()
|
||||
|
||||
index.put("id", manga.id)
|
||||
index.put("title", manga.title)
|
||||
index.put("title_alt", manga.altTitle)
|
||||
index.put("url", manga.url)
|
||||
index.put("cover", manga.coverUrl)
|
||||
index.put("description", manga.description)
|
||||
index.put("rating", manga.rating)
|
||||
index.put("source", manga.source.name)
|
||||
index.put("cover_large", manga.largeCoverUrl)
|
||||
index.put("tags", JSONArray().also { a ->
|
||||
for (tag in manga.tags) {
|
||||
val jo = JSONObject()
|
||||
jo.put("key", tag.key)
|
||||
jo.put("title", tag.title)
|
||||
a.put(jo)
|
||||
}
|
||||
})
|
||||
index.put("chapters", JSONObject())
|
||||
index.put("app_id", BuildConfig.APPLICATION_ID)
|
||||
index.put("app_version", BuildConfig.VERSION_CODE)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
fun compress() {
|
||||
dir.sub("index.json").writeText(index.toString(4))
|
||||
ZipOutputStream(file.outputStream()).use { out ->
|
||||
for (file in dir.listFiles().orEmpty()) {
|
||||
val entry = ZipEntry(file.name)
|
||||
out.putNextEntry(entry)
|
||||
file.inputStream().use { stream ->
|
||||
stream.copyTo(out)
|
||||
}
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract() {
|
||||
if (!file.exists()) {
|
||||
return
|
||||
}
|
||||
ZipInputStream(file.inputStream()).use { input ->
|
||||
while(true) {
|
||||
val entry = input.nextEntry ?: return
|
||||
if (!entry.isDirectory) {
|
||||
dir.sub(entry.name).outputStream().use { out->
|
||||
input.copyTo(out)
|
||||
}
|
||||
}
|
||||
input.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addCover(file: File) {
|
||||
val name = FILENAME_PATTERN.format(0, 0)
|
||||
file.copyTo(dir.sub(name), overwrite = true)
|
||||
}
|
||||
|
||||
fun addPage(page: MangaPage, chapter: MangaChapter, file: File, pageNumber: Int) {
|
||||
val name = FILENAME_PATTERN.format(chapter.number, pageNumber)
|
||||
file.copyTo(dir.sub(name), overwrite = true)
|
||||
val chapters = index.getJSONObject("chapters")
|
||||
if (!chapters.has(chapter.number.toString())) {
|
||||
val jo = JSONObject()
|
||||
jo.put("id", chapter.id)
|
||||
jo.put("url", chapter.url)
|
||||
jo.put("name", chapter.name)
|
||||
chapters.put(chapter.number.toString(), jo)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FILENAME_PATTERN = "%03d%03d"
|
||||
|
||||
fun findInDir(root: File, manga: Manga): MangaZip {
|
||||
val name = manga.title.toFileName() + ".cbz"
|
||||
val file = File(root, name)
|
||||
return MangaZip(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.ui.common
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.CallSuper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.KoinComponent
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class BaseService : Service(), KoinComponent, CoroutineScope {
|
||||
|
||||
private val job = SupervisorJob()
|
||||
|
||||
final override val coroutineContext: CoroutineContext
|
||||
get() = Dispatchers.Main + job
|
||||
|
||||
@CallSuper
|
||||
override fun onDestroy() {
|
||||
job.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package org.koitharu.kotatsu.ui.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DownloadNotification(private val context: Context) {
|
||||
|
||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
private val manager =
|
||||
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.downloads),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.enableVibration(false)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
builder.setOnlyAlertOnce(true)
|
||||
}
|
||||
|
||||
fun fillFrom(manga: Manga) {
|
||||
builder.setContentTitle(manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
builder.setSubText(context.getText(R.string.preparing_))
|
||||
builder.setLargeIcon(null)
|
||||
}
|
||||
|
||||
fun setLargeIcon(icon: Drawable?) {
|
||||
builder.setLargeIcon((icon as? BitmapDrawable)?.bitmap)
|
||||
}
|
||||
|
||||
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
|
||||
val max = chaptersTotal * PROGRESS_STEP
|
||||
val progress =
|
||||
chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
|
||||
val percent = (progress / max.toFloat() * 100).roundToInt()
|
||||
builder.setProgress(max, progress, false)
|
||||
builder.setSubText("$percent%")
|
||||
}
|
||||
|
||||
fun setPostProcessing() {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setSubText(context.getString(R.string.processing_))
|
||||
}
|
||||
|
||||
fun setDone() {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.download_complete))
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setSubText(null)
|
||||
}
|
||||
|
||||
fun update(id: Int = NOTIFICATION_ID) {
|
||||
manager.notify(id, builder.build())
|
||||
}
|
||||
|
||||
operator fun invoke(): Notification = builder.build()
|
||||
|
||||
companion object {
|
||||
|
||||
const val NOTIFICATION_ID = 201
|
||||
const val CHANNEL_ID = "download"
|
||||
|
||||
private const val PROGRESS_STEP = 20
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package org.koitharu.kotatsu.ui.download
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.Coil
|
||||
import coil.api.get
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koin.core.inject
|
||||
import org.koitharu.kotatsu.core.local.PagesCache
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.domain.local.MangaZip
|
||||
import org.koitharu.kotatsu.ui.common.BaseService
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
|
||||
import org.koitharu.kotatsu.utils.ext.safe
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import java.io.File
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var notification: DownloadNotification
|
||||
|
||||
private val okHttp by inject<OkHttpClient>()
|
||||
private val cache by inject<PagesCache>()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notification = DownloadNotification(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
|
||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet()
|
||||
if (manga != null) {
|
||||
downloadManga(manga, chapters)
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?) {
|
||||
val destination = getExternalFilesDir("manga")!!
|
||||
notification.fillFrom(manga)
|
||||
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
|
||||
launch(Dispatchers.IO) {
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
val repo = MangaProviderFactory.create(manga.source)
|
||||
val cover = safe {
|
||||
Coil.loader().get(manga.coverUrl)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
notification.setLargeIcon(cover)
|
||||
notification.update()
|
||||
}
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
downloadPage(data.largeCoverUrl ?: data.coverUrl, destination).let { file ->
|
||||
output.addCover(file)
|
||||
}
|
||||
val chapters = if (chaptersIds == null) {
|
||||
data.chapters.orEmpty()
|
||||
} else {
|
||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
val url = repo.getPageFullUrl(page)
|
||||
val file = cache[url] ?: downloadPage(url, destination)
|
||||
output.addPage(page, chapter, file, pageIndex)
|
||||
withContext(Dispatchers.Main) {
|
||||
notification.setProgress(
|
||||
chapters.size,
|
||||
pages.size,
|
||||
chapterIndex,
|
||||
pageIndex
|
||||
)
|
||||
notification.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
notification.setPostProcessing()
|
||||
notification.update()
|
||||
}
|
||||
output.compress()
|
||||
withContext(Dispatchers.Main) {
|
||||
notification.setDone()
|
||||
notification.update(manga.id.toInt().absoluteValue)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
destination.sub("page.tmp").delete()
|
||||
withContext(Dispatchers.Main) {
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadPage(url: String, destination: File): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
return retryUntilSuccess(3) {
|
||||
okHttp.newCall(request).await().use { response ->
|
||||
val file = destination.sub("page.tmp")
|
||||
file.outputStream().use { out ->
|
||||
response.body!!.byteStream().copyTo(out)
|
||||
}
|
||||
file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
|
||||
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra(EXTRA_MANGA, manga)
|
||||
if (chaptersIds != null) {
|
||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
object FileSizeUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun mbToBytes(mb: Int) = 1024L * 1024L * mb
|
||||
|
||||
@JvmStatic
|
||||
fun kbToBytes(kb: Int) = 1024L * kb
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import java.io.File
|
||||
|
||||
fun File.sub(name: String) = File(this, name)
|
||||
|
||||
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
|
||||
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save_this"
|
||||
android:title="@string/save_this_chapter" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save_this_next"
|
||||
android:title="@string/save_this_chapter_and_next" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save_this_prev"
|
||||
android:title="@string/save_this_chapter_and_prev" />
|
||||
|
||||
</menu>
|
||||
Loading…
Reference in New Issue