diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1c9bf7da2..91a8375aa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,9 +1,10 @@
-
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
index 22e24649b..9344a8670 100644
--- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -8,6 +8,7 @@ import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
import java.util.concurrent.TimeUnit
@@ -40,6 +41,10 @@ class KotatsuApp : Application() {
factory {
AppSettings(applicationContext)
}
+ }, module {
+ single {
+ PagesCache(applicationContext)
+ }
}
))
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
index 3820270bd..30de9db09 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
@@ -13,12 +13,11 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
- @ColumnInfo(name = "localized_title") val localizedTitle: String? = null,
+ @ColumnInfo(name = "alt_title") val altTitle: String? = null,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "rating") val rating: Float = Manga.NO_RATING, //normalized value [0..1] or -1
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null,
- @ColumnInfo(name = "summary") val summary: String,
@ColumnInfo(name = "state") val state: String? = null,
@ColumnInfo(name = "source") val source: String
) {
@@ -26,8 +25,7 @@ data class MangaEntity(
fun toManga(tags: Set = emptySet()) = Manga(
id = this.id,
title = this.title,
- localizedTitle = this.localizedTitle,
- summary = this.summary,
+ altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
url = this.url,
@@ -45,10 +43,9 @@ data class MangaEntity(
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
- localizedTitle = manga.localizedTitle,
+ altTitle = manga.altTitle,
rating = manga.rating,
state = manga.state?.name,
- summary = manga.summary,
// tags = manga.tags.map(TagEntity.Companion::fromMangaTag),
title = manga.title
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/local/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/local/PagesCache.kt
new file mode 100644
index 000000000..ee2d36af2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/local/PagesCache.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
index 1746e5000..79d87bce8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
@@ -7,12 +7,11 @@ import kotlinx.android.parcel.Parcelize
data class Manga(
val id: Long,
val title: String,
- val localizedTitle: String? = null,
+ val altTitle: String? = null,
val url: String,
val rating: Float = NO_RATING, //normalized value [0..1] or -1
val coverUrl: String,
val largeCoverUrl: String? = null,
- val summary: String,
val description: String? = null, //HTML
val tags: Set = emptySet(),
val state: MangaState? = null,
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
index d3283f024..cf0173b8e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
@@ -42,10 +42,9 @@ abstract class GroupleRepository(
Manga(
id = href.longHashCode(),
url = href,
- localizedTitle = title,
- title = descDiv.selectFirst("h4")?.text() ?: title,
+ title = title,
+ altTitle = descDiv.selectFirst("h4")?.text(),
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
- summary = "",
rating = safe {
node.selectFirst("div.rating")
?.attr("title")
diff --git a/app/src/main/java/org/koitharu/kotatsu/domain/local/MangaZip.kt b/app/src/main/java/org/koitharu/kotatsu/domain/local/MangaZip.kt
new file mode 100644
index 000000000..c4cac97b2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/domain/local/MangaZip.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/common/BasePresenter.kt b/app/src/main/java/org/koitharu/kotatsu/ui/common/BasePresenter.kt
index 30a924aac..de5b61a0b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/common/BasePresenter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/common/BasePresenter.kt
@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.ui.common
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import moxy.MvpPresenter
import moxy.MvpView
import org.koin.core.KoinComponent
@@ -14,7 +16,7 @@ abstract class BasePresenter : MvpPresenter(), KoinComponent, Co
get() = Dispatchers.Main + job
override fun onDestroy() {
- coroutineContext.cancel()
+ job.cancel()
super.onDestroy()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt
new file mode 100644
index 000000000..68959b7fa
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/details/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/details/ChaptersFragment.kt
index bce5f29b0..d9eacee0d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/details/ChaptersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/details/ChaptersFragment.kt
@@ -13,7 +13,9 @@ import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
+import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.ui.reader.ReaderActivity
+import org.koitharu.kotatsu.utils.ext.showPopupMenu
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener {
@@ -63,4 +65,21 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
)
)
}
+
+ override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
+ view.showPopupMenu(R.menu.popup_chapter) {
+ val ctx = context ?: return@showPopupMenu false
+ val m = manga ?: return@showPopupMenu false
+ when (it.itemId) {
+ R.id.action_save_this -> DownloadService.start(ctx, m, setOf(item.id))
+ R.id.action_save_this_next -> DownloadService.start(ctx, m, m.chapters.orEmpty()
+ .filter { x -> x.number >= item.number }.map { x -> x.id })
+ R.id.action_save_this_prev -> DownloadService.start(ctx, m, m.chapters.orEmpty()
+ .filter { x -> x.number <= item.number }.map { x -> x.id })
+ else -> return@showPopupMenu false
+ }
+ true
+ }
+ return true
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsActivity.kt
index f632c5470..8e638b370 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsActivity.kt
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseActivity
+import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -57,6 +58,12 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
}
true
}
+ R.id.action_save -> {
+ manga?.let {
+ DownloadService.start(this, it)
+ }
+ true
+ }
else -> super.onOptionsItemSelected(item)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsFragment.kt
index d1a209084..c548235ee 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/details/MangaDetailsFragment.kt
@@ -26,7 +26,7 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
this.manga = manga
imageView_cover.load(manga.largeCoverUrl ?: manga.coverUrl)
textView_title.text = manga.title
- textView_subtitle.text = manga.localizedTitle
+ textView_subtitle.text = manga.altTitle
textView_description.text = manga.description?.parseAsHtml()
if (manga.rating == Manga.NO_RATING) {
ratingBar.isVisible = false
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadNotification.kt
new file mode 100644
index 000000000..0c2fcba88
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadNotification.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadService.kt
new file mode 100644
index 000000000..28ecc3a3b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/download/DownloadService.kt
@@ -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()
+ private val cache by inject()
+
+ override fun onCreate() {
+ super.onCreate()
+ notification = DownloadNotification(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val manga = intent?.getParcelableExtra(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?) {
+ 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? = 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/main/list/MangaListDetailsHolder.kt b/app/src/main/java/org/koitharu/kotatsu/ui/main/list/MangaListDetailsHolder.kt
index 7850f40dc..5540efbe1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/main/list/MangaListDetailsHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/main/list/MangaListDetailsHolder.kt
@@ -21,7 +21,7 @@ class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder(
override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose()
textView_title.text = data.title
- textView_subtitle.textAndVisible = data.localizedTitle
+ textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/reader/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/ui/reader/PageLoader.kt
index e7f02cbeb..c8ee3491c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/reader/PageLoader.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/reader/PageLoader.kt
@@ -6,8 +6,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
import org.koin.core.inject
+import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.utils.ext.await
-import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File
import kotlin.coroutines.CoroutineContext
@@ -16,13 +16,7 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
private val job = SupervisorJob()
private val tasks = HashMap()
private val okHttp by inject()
- private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
-
- init {
- if (!cacheDir.exists()) {
- cacheDir.mkdir()
- }
- }
+ private val cache by inject()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
@@ -35,19 +29,20 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
}
private suspend fun loadFile(url: String, force: Boolean): File {
- val file = File(cacheDir, url.longHashCode().toString())
- if (!force && file.exists()) {
- return file
+ if (!force) {
+ cache[url]?.let {
+
+ return it
+ }
}
val request = Request.Builder()
.url(url)
.get()
.build()
- okHttp.newCall(request).await().use { response ->
- file.outputStream().use { out ->
+ return okHttp.newCall(request).await().use { response ->
+ cache.put(url) { out ->
response.body!!.byteStream().copyTo(out)
}
- return file
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/FileSizeUtils.kt b/app/src/main/java/org/koitharu/kotatsu/utils/FileSizeUtils.kt
new file mode 100644
index 000000000..9b73c486e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/FileSizeUtils.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
index 46d250b12..3d2f20ea5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
+import kotlinx.coroutines.delay
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import java.io.IOException
@@ -14,7 +15,23 @@ inline fun T.safe(action: T.() -> R?) = try {
null
}
-fun Throwable.getDisplayMessage(resources: Resources) = when(this) {
+suspend inline fun T.retryUntilSuccess(maxAttempts: Int, action: T.() -> R): R {
+ var attempts = maxAttempts
+ while (true) {
+ try {
+ return this.action()
+ } catch (e: Exception) {
+ attempts--
+ if (attempts <= 0) {
+ throw e
+ } else {
+ delay(1000)
+ }
+ }
+ }
+}
+
+fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is IOException -> resources.getString(R.string.network_error)
else -> if (BuildConfig.DEBUG) {
message ?: resources.getString(R.string.error_occurred)
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt
new file mode 100644
index 000000000..93ee64752
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt
@@ -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() }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt
index 382f18f80..84a2d3442 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt
@@ -32,4 +32,29 @@ fun String.removeSurrounding(vararg chars: Char): String {
}
}
return this
-}
\ No newline at end of file
+}
+
+fun String.transliterate(skipMissing: Boolean): String {
+ val cyr = charArrayOf(
+ 'a', 'б', 'в', 'г', 'д', 'ё', 'ж', 'з', 'и', 'к', 'л', 'м', 'н',
+ 'п', 'р', 'с', 'т', 'у', 'ў', 'ф', 'х', 'ц', 'ш', 'щ', 'ы', 'э', 'ю', 'я'
+ )
+ val lat = arrayOf(
+ "a", "b", "v", "g", "d", "jo", "zh", "z", "i", "k", "l", "m", "n",
+ "p", "r", "s", "t", "u", "w", "f", "h", "ts", "sh", "sch", "", "e", "ju", "ja"
+ )
+ return buildString(length + 5) {
+ for (c in this@transliterate) {
+ val p = cyr.binarySearch(c)
+ if (p in lat.indices) {
+ append(lat[p])
+ } else if (!skipMissing) {
+ append(c)
+ }
+ }
+ }
+}
+
+fun String.toFileName() = this.transliterate(false)
+ .replace(Regex("[^a-z0-9_\\-]", setOf(RegexOption.IGNORE_CASE)), " ")
+ .replace(Regex("\\s+"), "_")
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
index 5a06f1c32..0875d7d23 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
@@ -3,12 +3,15 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
+import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.TextView
import androidx.annotation.LayoutRes
+import androidx.annotation.MenuRes
+import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isGone
import androidx.core.view.postDelayed
import androidx.recyclerview.widget.GridLayoutManager
@@ -96,4 +99,11 @@ fun View.disableFor(timeInMillis: Long) {
postDelayed(timeInMillis) {
isEnabled = true
}
+}
+
+fun View.showPopupMenu(@MenuRes menuRes: Int, onItemClick: (MenuItem) -> Boolean) {
+ val menu = PopupMenu(context, this)
+ menu.inflate(menuRes)
+ menu.setOnMenuItemClickListener(onItemClick)
+ menu.show()
}
\ No newline at end of file
diff --git a/app/src/main/res/menu/popup_chapter.xml b/app/src/main/res/menu/popup_chapter.xml
new file mode 100644
index 000000000..09cb4ccaa
--- /dev/null
+++ b/app/src/main/res/menu/popup_chapter.xml
@@ -0,0 +1,17 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 61db1aaf5..23d478a4c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -37,4 +37,12 @@
Search
Search manga
Search results
+ Manga downloading…
+ Preparing…
+ Processing…
+ Download complete
+ Downloads
+ Save this chapter and prev.
+ Save this chapter and next
+ Save this chapter
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index df2381637..16a6315f0 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -6,6 +6,7 @@
- @color/primary
- @color/primary_dark
- @color/accent
+ - true
\ No newline at end of file