Downloading manga

pull/1/head
Koitharu 6 years ago
parent b69c624442
commit 5b858edc97

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
package="org.koitharu.kotatsu"> package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@ -33,6 +34,8 @@
android:name="android.app.searchable" android:name="android.app.searchable"
android:resource="@xml/search" /> android:resource="@xml/search" />
</activity> </activity>
<service android:name=".ui.download.DownloadService" />
</application> </application>
</manifest> </manifest>

@ -8,6 +8,7 @@ import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.MangaDatabase 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.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext import org.koitharu.kotatsu.domain.MangaLoaderContext
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -40,6 +41,10 @@ class KotatsuApp : Application() {
factory { factory {
AppSettings(applicationContext) AppSettings(applicationContext)
} }
}, module {
single {
PagesCache(applicationContext)
}
} }
)) ))
} }

@ -13,12 +13,11 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @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 = "url") val url: String,
@ColumnInfo(name = "rating") val rating: Float = Manga.NO_RATING, //normalized value [0..1] or -1 @ColumnInfo(name = "rating") val rating: Float = Manga.NO_RATING, //normalized value [0..1] or -1
@ColumnInfo(name = "cover_url") val coverUrl: String, @ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null,
@ColumnInfo(name = "summary") val summary: String,
@ColumnInfo(name = "state") val state: String? = null, @ColumnInfo(name = "state") val state: String? = null,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String
) { ) {
@ -26,8 +25,7 @@ data class MangaEntity(
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga( fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,
localizedTitle = this.localizedTitle, altTitle = this.altTitle,
summary = this.summary,
state = this.state?.let { MangaState.valueOf(it) }, state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating, rating = this.rating,
url = this.url, url = this.url,
@ -45,10 +43,9 @@ data class MangaEntity(
source = manga.source.name, source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl, largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl, coverUrl = manga.coverUrl,
localizedTitle = manga.localizedTitle, altTitle = manga.altTitle,
rating = manga.rating, rating = manga.rating,
state = manga.state?.name, state = manga.state?.name,
summary = manga.summary,
// tags = manga.tags.map(TagEntity.Companion::fromMangaTag), // tags = manga.tags.map(TagEntity.Companion::fromMangaTag),
title = manga.title 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( data class Manga(
val id: Long, val id: Long,
val title: String, val title: String,
val localizedTitle: String? = null, val altTitle: String? = null,
val url: String, val url: String,
val rating: Float = NO_RATING, //normalized value [0..1] or -1 val rating: Float = NO_RATING, //normalized value [0..1] or -1
val coverUrl: String, val coverUrl: String,
val largeCoverUrl: String? = null, val largeCoverUrl: String? = null,
val summary: String,
val description: String? = null, //HTML val description: String? = null, //HTML
val tags: Set<MangaTag> = emptySet(), val tags: Set<MangaTag> = emptySet(),
val state: MangaState? = null, val state: MangaState? = null,

@ -42,10 +42,9 @@ abstract class GroupleRepository(
Manga( Manga(
id = href.longHashCode(), id = href.longHashCode(),
url = href, url = href,
localizedTitle = title, title = title,
title = descDiv.selectFirst("h4")?.text() ?: title, altTitle = descDiv.selectFirst("h4")?.text(),
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(), coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
summary = "",
rating = safe { rating = safe {
node.selectFirst("div.rating") node.selectFirst("div.rating")
?.attr("title") ?.attr("title")

@ -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 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.MvpPresenter
import moxy.MvpView import moxy.MvpView
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@ -14,7 +16,7 @@ abstract class BasePresenter<V : MvpView> : MvpPresenter<V>(), KoinComponent, Co
get() = Dispatchers.Main + job get() = Dispatchers.Main + job
override fun onDestroy() { override fun onDestroy() {
coroutineContext.cancel() job.cancel()
super.onDestroy() 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.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener 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.ui.reader.ReaderActivity
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView, class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> { 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.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseActivity 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.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@ -57,6 +58,12 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
} }
true true
} }
R.id.action_save -> {
manga?.let {
DownloadService.start(this, it)
}
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }

@ -26,7 +26,7 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
this.manga = manga this.manga = manga
imageView_cover.load(manga.largeCoverUrl ?: manga.coverUrl) imageView_cover.load(manga.largeCoverUrl ?: manga.coverUrl)
textView_title.text = manga.title textView_title.text = manga.title
textView_subtitle.text = manga.localizedTitle textView_subtitle.text = manga.altTitle
textView_description.text = manga.description?.parseAsHtml() textView_description.text = manga.description?.parseAsHtml()
if (manga.rating == Manga.NO_RATING) { if (manga.rating == Manga.NO_RATING) {
ratingBar.isVisible = false 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?) { override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose() coverRequest?.dispose()
textView_title.text = data.title textView_title.text = data.title
textView_subtitle.textAndVisible = data.localizedTitle textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) { coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true) crossfade(true)
} }

@ -17,7 +17,7 @@ class MangaListHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(
override fun onBind(data: Manga, extra: MangaHistory?) { override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose() coverRequest?.dispose()
textView_title.text = data.title textView_title.text = data.title
textView_subtitle.textAndVisible = data.localizedTitle textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) { coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true) crossfade(true)
} }

@ -6,8 +6,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject 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.await
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File import java.io.File
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -16,13 +16,7 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
private val job = SupervisorJob() private val job = SupervisorJob()
private val tasks = HashMap<String, Job>() private val tasks = HashMap<String, Job>()
private val okHttp by inject<OkHttpClient>() private val okHttp by inject<OkHttpClient>()
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages") private val cache by inject<PagesCache>()
init {
if (!cacheDir.exists()) {
cacheDir.mkdir()
}
}
override val coroutineContext: CoroutineContext override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job get() = Dispatchers.Main + job
@ -35,19 +29,20 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
} }
private suspend fun loadFile(url: String, force: Boolean): File { private suspend fun loadFile(url: String, force: Boolean): File {
val file = File(cacheDir, url.longHashCode().toString()) if (!force) {
if (!force && file.exists()) { cache[url]?.let {
return file
return it
}
} }
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.get() .get()
.build() .build()
okHttp.newCall(request).await().use { response -> return okHttp.newCall(request).await().use { response ->
file.outputStream().use { out -> cache.put(url) { out ->
response.body!!.byteStream().copyTo(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 package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources import android.content.res.Resources
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import java.io.IOException import java.io.IOException
@ -14,7 +15,23 @@ inline fun <T, R> T.safe(action: T.() -> R?) = try {
null 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) is IOException -> resources.getString(R.string.network_error)
else -> if (BuildConfig.DEBUG) { else -> if (BuildConfig.DEBUG) {
message ?: resources.getString(R.string.error_occurred) 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() }

@ -33,3 +33,28 @@ fun String.removeSurrounding(vararg chars: Char): String {
} }
return this 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.app.Activity
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@ -97,3 +100,10 @@ fun View.disableFor(timeInMillis: Long) {
isEnabled = true isEnabled = true
} }
} }
fun View.showPopupMenu(@MenuRes menuRes: Int, onItemClick: (MenuItem) -> Boolean) {
val menu = PopupMenu(context, this)
menu.inflate(menuRes)
menu.setOnMenuItemClickListener(onItemClick)
menu.show()
}

@ -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>

@ -37,4 +37,12 @@
<string name="search">Search</string> <string name="search">Search</string>
<string name="search_manga">Search manga</string> <string name="search_manga">Search manga</string>
<string name="search_results">Search results</string> <string name="search_results">Search results</string>
<string name="manga_downloading_">Manga downloading…</string>
<string name="preparing_">Preparing…</string>
<string name="processing_">Processing…</string>
<string name="download_complete">Download complete</string>
<string name="downloads">Downloads</string>
<string name="save_this_chapter_and_prev">Save this chapter and prev.</string>
<string name="save_this_chapter_and_next">Save this chapter and next</string>
<string name="save_this_chapter">Save this chapter</string>
</resources> </resources>

@ -6,6 +6,7 @@
<item name="colorPrimary">@color/primary</item> <item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item> <item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item> <item name="colorAccent">@color/accent</item>
<item name="windowActionModeOverlay">true</item>
</style> </style>
</resources> </resources>
Loading…
Cancel
Save