Get data from downloaded cbz

This commit is contained in:
Koitharu
2020-02-10 19:59:43 +02:00
parent 5b858edc97
commit 4f02060d50
17 changed files with 415 additions and 53 deletions

View File

@@ -36,6 +36,17 @@
</activity>
<service android:name=".ui.download.DownloadService" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
</application>
</manifest>

View File

@@ -2,6 +2,9 @@ package org.koitharu.kotatsu
import android.app.Application
import androidx.room.Room
import coil.Coil
import coil.ImageLoader
import coil.util.CoilUtils
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@@ -18,6 +21,7 @@ class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
initKoin()
initCoil()
}
private fun initKoin() {
@@ -50,6 +54,16 @@ class KotatsuApp : Application() {
}
}
private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) {
okHttpClient {
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
}
})
}
private fun okHttp() = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.local
import java.io.File
import java.io.FilenameFilter
class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String) = name.endsWith(".cbz", ignoreCase = true)
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.site.MintMangaRepository
import org.koitharu.kotatsu.core.parser.site.ReadmangaRepository
@@ -9,7 +10,12 @@ import org.koitharu.kotatsu.core.parser.site.SelfMangaRepository
@Suppress("SpellCheckingInspection")
@Parcelize
enum class MangaSource(val title: String, val locale: String, val cls: Class<out MangaRepository>): Parcelable {
enum class MangaSource(
val title: String,
val locale: String?,
val cls: Class<out MangaRepository>
) : Parcelable {
LOCAL("Local", null, LocalMangaRepository::class.java),
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java),
MINTMANGA("MintManga", "ru", MintMangaRepository::class.java),
SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java)

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koin.core.inject
import org.koitharu.kotatsu.core.local.CbzFilter
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.domain.local.MangaIndex
import org.koitharu.kotatsu.domain.local.MangaZip
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe
import java.io.File
import java.util.zip.ZipFile
class LocalMangaRepository(loaderContext: MangaLoaderContext) : BaseMangaRepository(loaderContext) {
private val context by loaderContext.inject<Context>()
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val files = context.getExternalFilesDirs("manga")
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getDetails(x) } }
}
override suspend fun getDetails(manga: Manga) = manga
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val file = Uri.parse(chapter.url).toFile()
val zip = ZipFile(file)
val pattern = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
?.getChapterNamesPattern(chapter)
val entries = if (pattern != null) {
zip.entries().asSequence()
.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
zip.entries().asSequence().filter { x -> !x.isDirectory }
}
return entries.map { x ->
val uri = zipUri(file, x.name)
MangaPage(
id = uri.longHashCode(),
url = uri,
source = MangaSource.LOCAL
)
}.toList()
}
private fun getDetails(file: File): Manga {
val zip = ZipFile(file)
val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
return index?.let {
it.getMangaInfo()?.let { x ->
x.copy(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(file, it.getCoverEntry() ?: zip.entries().nextElement().name),
chapters = x.chapters?.map { c -> c.copy(url = fileUri) }
)
}
} ?: run {
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, zip.entries().nextElement().name),
chapters = listOf(
MangaChapter(
id = file.absolutePath.longHashCode(),
url = fileUri,
number = 1,
source = MangaSource.LOCAL,
name = title
)
)
)
}
}
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("zip", file.path, entryName).toString()
}

View File

@@ -0,0 +1,108 @@
package org.koitharu.kotatsu.domain.local
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.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.safe
class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga) {
json.put("id", manga.id)
json.put("title", manga.title)
json.put("title_alt", manga.altTitle)
json.put("url", manga.url)
json.put("cover", manga.coverUrl)
json.put("description", manga.description)
json.put("rating", manga.rating)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.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)
}
})
json.put("chapters", JSONObject())
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
}
fun getMangaInfo(): Manga? = if (json.length() == 0) null else safe {
val source = MangaSource.valueOf(json.getString("source"))
Manga(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getString("title_alt"),
url = json.getString("url"),
source = source,
rating = json.getDouble("rating").toFloat(),
coverUrl = json.getString("cover"),
description = json.getString("description"),
tags = json.getJSONArray("tags").map { x ->
MangaTag(
title = x.getString("title"),
key = x.getString("key"),
source = source
)
}.toSet(),
chapters = getChapters(json.getJSONObject("chapters"), source)
)
}
fun getCoverEntry(): String? = json.optString("cover_entry")
fun addChapter(chapter: MangaChapter) {
val chapters = json.getJSONObject("chapters")
if (!chapters.has(chapter.id.toString())) {
val jo = JSONObject()
jo.put("number", chapter.number)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
jo.put("entries", "%03d\\d{3}".format(chapter.number))
chapters.put(chapter.number.toString(), jo)
}
}
fun setCoverEntry(name: String) {
json.put("cover_entry", name)
}
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
json.getJSONObject("chapters")
.getJSONObject(chapter.id.toString())
.getString("entries")
)
private fun getChapters(json: JSONObject, source: MangaSource): List<MangaChapter> {
val chapters = ArrayList<MangaChapter>(json.length())
for (k in json.keys()) {
val v = json.getJSONObject(k)
chapters.add(
MangaChapter(
id = k.toLong(),
name = v.getString("name"),
url = v.getString("url"),
number = v.getInt("number"),
source = source
)
)
}
return chapters.sortedBy { it.number }
}
override fun toString(): String = if (BuildConfig.DEBUG) {
json.toString(4)
} else {
json.toString()
}
}

View File

@@ -1,12 +1,8 @@
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
@@ -21,32 +17,11 @@ 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
private val index = MangaIndex(dir.sub(INDEX_ENTRY).takeIfReadable()?.readText())
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)
index.setMangaInfo(manga)
}
fun cleanup() {
@@ -54,7 +29,7 @@ class MangaZip(private val file: File) {
}
fun compress() {
dir.sub("index.json").writeText(index.toString(4))
dir.sub(INDEX_ENTRY).writeText(index.toString())
ZipOutputStream(file.outputStream()).use { out ->
for (file in dir.listFiles().orEmpty()) {
val entry = ZipEntry(file.name)
@@ -72,10 +47,10 @@ class MangaZip(private val file: File) {
return
}
ZipInputStream(file.inputStream()).use { input ->
while(true) {
while (true) {
val entry = input.nextEntry ?: return
if (!entry.isDirectory) {
dir.sub(entry.name).outputStream().use { out->
dir.sub(entry.name).outputStream().use { out ->
input.copyTo(out)
}
}
@@ -84,28 +59,36 @@ class MangaZip(private val file: File) {
}
}
fun addCover(file: File) {
val name = FILENAME_PATTERN.format(0, 0)
fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
file.copyTo(dir.sub(name), overwrite = true)
index.setCoverEntry(name)
}
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)
fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
file.copyTo(dir.sub(name), overwrite = true)
index.addChapter(chapter)
}
companion object {
private const val FILENAME_PATTERN = "%03d%03d"
const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileName() + ".cbz"
val file = File(root, name)

View File

@@ -2,15 +2,18 @@ package org.koitharu.kotatsu.ui.details
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.net.toFile
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_details.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.ShareHelper
@@ -36,6 +39,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
override fun onMangaUpdated(manga: Manga) {
this.manga = manga
title = manga.title
invalidateOptionsMenu()
}
override fun onHistoryChanged(history: MangaHistory?) = Unit
@@ -51,10 +55,20 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
return super.onCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.action_save).isEnabled =
manga?.source != null && manga?.source != MangaSource.LOCAL
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_share -> {
manga?.let {
ShareHelper.shareMangaLink(this, it)
if (it.source == MangaSource.LOCAL) {
ShareHelper.shareCbz(this, Uri.parse(it.url).toFile())
} else {
ShareHelper.shareMangaLink(this, it)
}
}
true
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.download
import android.content.Context
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import coil.Coil
import coil.api.get
@@ -65,8 +66,9 @@ class DownloadService : BaseService() {
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 coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadPage(coverUrl, destination).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = if (chaptersIds == null) {
data.chapters.orEmpty()
@@ -79,7 +81,12 @@ class DownloadService : BaseService() {
for ((pageIndex, page) in pages.withIndex()) {
val url = repo.getPageFullUrl(page)
val file = cache[url] ?: downloadPage(url, destination)
output.addPage(page, chapter, file, pageIndex)
output.addPage(
chapter,
file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
)
withContext(Dispatchers.Main) {
notification.setProgress(
chapters.size,

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
import org.koitharu.kotatsu.utils.SearchHelper
@@ -33,15 +34,15 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
navigationView.setNavigationItemSelectedListener(this)
if (!supportFragmentManager.isStateSaved) {
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
navigationView.setCheckedItem(R.id.nav_local_storage)
setPrimaryFragment(LocalListFragment.newInstance())
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
initSideMenu(MangaSource.values().asList())
initSideMenu(MangaSource.values().asList() - MangaSource.LOCAL)
}
override fun onConfigurationChanged(newConfig: Configuration) {
@@ -56,7 +57,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return drawerToggle.onOptionsItemSelected(item) || when(item.itemId) {
return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) {
else -> super.onOptionsItemSelected(item)
}
}
@@ -68,7 +69,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} else when (item.itemId) {
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance())
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance())
R.id.nav_local_storage -> Unit
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance())
else -> return false
}
drawer.closeDrawers()

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.ui.main.list.local
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import java.io.File
class LocalListFragment : MangaListFragment<File>() {
private val presenter by moxyPresenter(factory = ::LocalListPresenter)
override fun onRequestMoreItems(offset: Int) {
if (offset == 0) {
presenter.loadList()
}
}
override fun getTitle(): CharSequence? {
return getString(R.string.local_storage)
}
override fun setUpEmptyListHolder() {
textView_holder.setText(R.string.no_saved_manga)
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
companion object {
fun newInstance() = LocalListFragment()
}
}

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.ui.main.list.local
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
import java.io.File
@InjectViewState
class LocalListPresenter : BasePresenter<MangaListView<File>>() {
private lateinit var repository: LocalMangaRepository
override fun onFirstViewAttach() {
repository = MangaProviderFactory.create(MangaSource.LOCAL) as LocalMangaRepository
super.onFirstViewAttach()
}
fun loadList() {
launch {
viewState.onLoadingChanged(true)
try {
val list = withContext(Dispatchers.IO) {
repository.getList(0)
}
viewState.onListChanged(list)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
} finally {
viewState.onLoadingChanged(false)
}
}
}
}

View File

@@ -2,8 +2,11 @@ package org.koitharu.kotatsu.utils
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import java.io.File
object ShareHelper {
@@ -19,4 +22,14 @@ object ShareHelper {
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_s, manga.title))
context.startActivity(shareIntent)
}
@JvmStatic
fun shareCbz(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
context.startActivity(shareIntent)
}
}

View File

@@ -1,7 +1,13 @@
package org.koitharu.kotatsu.utils.ext
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
fun File.sub(name: String) = File(this, name)
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText()
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.utils.ext
import org.json.JSONArray
import org.json.JSONObject
fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
val len = length()
val result = ArrayList<T>(len)
for(i in 0 until len) {
val jo = getJSONObject(i)
result.add(block(jo))
}
return result
}

View File

@@ -45,4 +45,5 @@
<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>
<string name="no_saved_manga">No saved manga</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="manga"
path="/manga" />
</paths>