Downloading manga
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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<MangaTag> = 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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<MangaTag> = emptySet(),
|
||||
val state: MangaState? = null,
|
||||
|
||||
@@ -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")
|
||||
|
||||
115
app/src/main/java/org/koitharu/kotatsu/domain/local/MangaZip.kt
Normal file
115
app/src/main/java/org/koitharu/kotatsu/domain/local/MangaZip.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<V : MvpView> : MvpPresenter<V>(), KoinComponent, Co
|
||||
get() = Dispatchers.Main + job
|
||||
|
||||
override fun onDestroy() {
|
||||
coroutineContext.cancel()
|
||||
job.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<MangaChapter> {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHis
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class MangaListHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<String, Job>()
|
||||
private val okHttp by inject<OkHttpClient>()
|
||||
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
|
||||
|
||||
init {
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdir()
|
||||
}
|
||||
}
|
||||
private val cache by inject<PagesCache>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, R> T.safe(action: T.() -> R?) = try {
|
||||
null
|
||||
}
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources) = when(this) {
|
||||
suspend inline fun <T, R> 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)
|
||||
|
||||
@@ -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() }
|
||||
@@ -32,4 +32,29 @@ fun String.removeSurrounding(vararg chars: Char): String {
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
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+"), "_")
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user