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