Local manga in zip/cbz/folder format (#648)
* add local source * small fixes * change Chapter to SChapter and and Manga to SManga in ChapterRecognition. Use ChapterRecognition.parseChapterNumber() to recognize chapter numbers. * use thread poll * update isImage() * add isImage() function to DiskUtil * improve cover handling * Support external SD cards * use R.string.app_name as root folder name
This commit is contained in:
parent
e25ce768bb
commit
2b73a9d2a4
@ -179,6 +179,9 @@ dependencies {
|
|||||||
// Crash reports
|
// Crash reports
|
||||||
compile 'ch.acra:acra:4.9.2'
|
compile 'ch.acra:acra:4.9.2'
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||||
|
@ -76,6 +76,11 @@
|
|||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
||||||
|
android:authorities="${applicationId}.zip-provider"
|
||||||
|
android:exported="false"></provider>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.notification.NotificationReceiver"
|
android:name=".data.notification.NotificationReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
package eu.kanade.tachiyomi.data.glide
|
package eu.kanade.tachiyomi.data.glide
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.load.data.DataFetcher
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
import com.bumptech.glide.load.model.*
|
import com.bumptech.glide.load.model.*
|
||||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||||
@ -43,6 +45,12 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
|||||||
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||||
InputStream::class.java, context)
|
InputStream::class.java, context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base file loader.
|
||||||
|
*/
|
||||||
|
private val baseFileLoader = Glide.buildModelLoader(Uri::class.java,
|
||||||
|
InputStream::class.java, context)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
|
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
|
||||||
* and the file where it should be stored in case the manga is a favorite.
|
* and the file where it should be stored in case the manga is a favorite.
|
||||||
@ -82,6 +90,18 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url!!.startsWith("file://")) {
|
||||||
|
val cover = File(url.substring(7))
|
||||||
|
val id = url + File.separator + cover.lastModified()
|
||||||
|
val rf = baseFileLoader.getResourceFetcher(Uri.fromFile(cover), width, height)
|
||||||
|
return object : DataFetcher<InputStream> {
|
||||||
|
override fun cleanup() = rf.cleanup()
|
||||||
|
override fun loadData(priority: Priority?): InputStream = rf.loadData(priority)
|
||||||
|
override fun cancel() = rf.cancel()
|
||||||
|
override fun getId() = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||||
// and add them to the cache.
|
// and add them to the cache.
|
||||||
val (glideUrl, file) = lruCache.get(url) ?:
|
val (glideUrl, file) = lruCache.get(url) ?:
|
||||||
|
178
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
178
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||||
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
|
import eu.kanade.tachiyomi.util.ZipContentProvider
|
||||||
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
|
import rx.Observable
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
class LocalSource(private val context: Context) : CatalogueSource {
|
||||||
|
companion object {
|
||||||
|
private val FILE_PROTOCOL = "file://"
|
||||||
|
private val COVER_NAME = "cover.jpg"
|
||||||
|
private val POPULAR_FILTERS = FilterList(OrderBy())
|
||||||
|
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
|
||||||
|
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
|
||||||
|
val ID = 0L
|
||||||
|
|
||||||
|
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
|
||||||
|
val dir = getBaseDirectories(context).firstOrNull()
|
||||||
|
if (dir == null) {
|
||||||
|
input.close()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
||||||
|
|
||||||
|
// It might not exist if using the external SD card
|
||||||
|
cover.parentFile.mkdirs()
|
||||||
|
input.use {
|
||||||
|
cover.outputStream().use {
|
||||||
|
input.copyTo(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cover
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBaseDirectories(context: Context): List<File> {
|
||||||
|
val c = File.separator + context.getString(R.string.app_name) + File.separator + "local"
|
||||||
|
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath + c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val id = ID
|
||||||
|
override val name = "LocalSource"
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override fun toString() = context.getString(R.string.local_source)
|
||||||
|
|
||||||
|
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
val chapters = getBaseDirectories(context)
|
||||||
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||||
|
.flatten()
|
||||||
|
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
||||||
|
.map { chapterFile ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = chapterFile.absolutePath
|
||||||
|
val chapName = if (chapterFile.isDirectory) {
|
||||||
|
chapterFile.name
|
||||||
|
} else {
|
||||||
|
chapterFile.nameWithoutExtension
|
||||||
|
}
|
||||||
|
val chapNameCut = chapName.replace(manga.title, "", true)
|
||||||
|
name = if (chapNameCut.isEmpty()) chapName else chapNameCut
|
||||||
|
date_upload = chapterFile.lastModified()
|
||||||
|
ChapterRecognition.parseChapterNumber(this, manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.just(chapters.sortedByDescending { it.chapter_number })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
val chapFile = File(chapter.url)
|
||||||
|
if (chapFile.isDirectory) {
|
||||||
|
return Observable.just(chapFile.listFiles()
|
||||||
|
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||||
|
.sortedWith(Comparator<File> { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance<String>().compare(t1.name, t2.name) })
|
||||||
|
.mapIndexed { i, v -> Page(i, FILE_PROTOCOL + v.absolutePath, FILE_PROTOCOL + v.absolutePath, Uri.fromFile(v)).apply { status = Page.READY } })
|
||||||
|
} else {
|
||||||
|
val zip = ZipFile(chapFile)
|
||||||
|
return Observable.just(ZipFile(chapFile).entries().toList()
|
||||||
|
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||||
|
.sortedWith(Comparator<ZipEntry> { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance<String>().compare(t1.name, t2.name) })
|
||||||
|
.mapIndexed { i, v ->
|
||||||
|
val path = "content://${ZipContentProvider.PROVIDER}${chapFile.absolutePath}!/${v.name}"
|
||||||
|
Page(i, path, path, Uri.parse(path)).apply { status = Page.READY }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
val baseDirs = getBaseDirectories(context)
|
||||||
|
|
||||||
|
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
|
||||||
|
var mangaDirs = baseDirs.mapNotNull { it.listFiles()?.toList() }
|
||||||
|
.flatten()
|
||||||
|
.filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
|
||||||
|
.distinctBy { it.name }
|
||||||
|
|
||||||
|
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
|
||||||
|
when (state?.index) {
|
||||||
|
0 -> {
|
||||||
|
if (state!!.ascending)
|
||||||
|
mangaDirs = mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
|
||||||
|
else
|
||||||
|
mangaDirs = mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
if (state!!.ascending)
|
||||||
|
mangaDirs = mangaDirs.sortedBy(File::lastModified)
|
||||||
|
else
|
||||||
|
mangaDirs = mangaDirs.sortedByDescending(File::lastModified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mangas = mangaDirs.map { mangaDir ->
|
||||||
|
SManga.create().apply {
|
||||||
|
title = mangaDir.name
|
||||||
|
url = mangaDir.name
|
||||||
|
|
||||||
|
// Try to find the cover
|
||||||
|
for (dir in baseDirs) {
|
||||||
|
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
|
||||||
|
if (cover.exists()) {
|
||||||
|
thumbnail_url = FILE_PROTOCOL + cover.absolutePath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the cover from the first chapter found.
|
||||||
|
if (thumbnail_url == null) {
|
||||||
|
val chapters = fetchChapterList(this).toBlocking().first()
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
val url = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.url
|
||||||
|
if (url != null) {
|
||||||
|
val input = context.contentResolver.openInputStream(Uri.parse(url))
|
||||||
|
try {
|
||||||
|
val dest = updateCover(context, this, input)
|
||||||
|
thumbnail_url = dest?.let { FILE_PROTOCOL + it.absolutePath }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Observable.just(MangasPage(mangas, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
|
||||||
|
|
||||||
|
private fun isSupportedFormat(extension: String): Boolean {
|
||||||
|
return extension.equals("zip", true) || extension.equals("cbz", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(OrderBy())
|
||||||
|
}
|
@ -48,6 +48,7 @@ open class SourceManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createInternalSources(): List<Source> = listOf(
|
private fun createInternalSources(): List<Source> = listOf(
|
||||||
|
LocalSource(context),
|
||||||
Batoto(),
|
Batoto(),
|
||||||
Mangahere(),
|
Mangahere(),
|
||||||
Mangafox(),
|
Mangafox(),
|
||||||
|
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
|||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.source.LocalSource
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.util.combineLatest
|
import eu.kanade.tachiyomi.util.combineLatest
|
||||||
@ -345,6 +346,11 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
|
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
|
||||||
|
if (manga.source == LocalSource.ID) {
|
||||||
|
LocalSource.updateCover(context, manga, inputStream)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (manga.thumbnail_url != null && manga.favorite) {
|
if (manga.thumbnail_url != null && manga.favorite) {
|
||||||
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
|
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
|
||||||
return true
|
return true
|
||||||
|
@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
|
|||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.TrackUpdateService
|
import eu.kanade.tachiyomi.data.track.TrackUpdateService
|
||||||
|
import eu.kanade.tachiyomi.source.LocalSource
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
@ -539,6 +540,13 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
|||||||
*/
|
*/
|
||||||
internal fun setImageAsCover(page: Page) {
|
internal fun setImageAsCover(page: Page) {
|
||||||
try {
|
try {
|
||||||
|
if (manga.source == LocalSource.ID) {
|
||||||
|
val input = context.contentResolver.openInputStream(page.uri)
|
||||||
|
LocalSource.updateCover(context, manga, input)
|
||||||
|
context.toast(R.string.cover_updated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
|
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
|
||||||
if (manga.favorite) {
|
if (manga.favorite) {
|
||||||
val input = context.contentResolver.openInputStream(page.uri)
|
val input = context.contentResolver.openInputStream(page.uri)
|
||||||
|
@ -50,7 +50,7 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter)
|
|||||||
// Set source + chapter title
|
// Set source + chapter title
|
||||||
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
|
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
|
||||||
itemView.manga_source.text = itemView.context.getString(R.string.recent_manga_source)
|
itemView.manga_source.text = itemView.context.getString(R.string.recent_manga_source)
|
||||||
.format(adapter.sourceManager.get(manga.source)?.name, formattedNumber)
|
.format(adapter.sourceManager.get(manga.source)?.toString(), formattedNumber)
|
||||||
|
|
||||||
// Set last read timestamp title
|
// Set last read timestamp title
|
||||||
itemView.last_read.text = df.format(Date(history.last_read))
|
itemView.last_read.text = df.format(Date(history.last_read))
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.util
|
package eu.kanade.tachiyomi.util
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* -R> = regex conversion.
|
* -R> = regex conversion.
|
||||||
@ -37,7 +37,7 @@ object ChapterRecognition {
|
|||||||
*/
|
*/
|
||||||
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
|
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
|
||||||
|
|
||||||
fun parseChapterNumber(chapter: Chapter, manga: Manga) {
|
fun parseChapterNumber(chapter: SChapter, manga: SManga) {
|
||||||
// If chapter number is known return.
|
// If chapter number is known return.
|
||||||
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f)
|
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f)
|
||||||
return
|
return
|
||||||
@ -91,7 +91,7 @@ object ChapterRecognition {
|
|||||||
* @param chapter chapter object
|
* @param chapter chapter object
|
||||||
* @return true if volume is found
|
* @return true if volume is found
|
||||||
*/
|
*/
|
||||||
fun updateChapter(match: MatchResult?, chapter: Chapter): Boolean {
|
fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean {
|
||||||
match?.let {
|
match?.let {
|
||||||
val initial = it.groups[1]?.value?.toFloat()!!
|
val initial = it.groups[1]?.value?.toFloat()!!
|
||||||
val subChapterDecimal = it.groups[2]?.value
|
val subChapterDecimal = it.groups[2]?.value
|
||||||
|
@ -1,11 +1,53 @@
|
|||||||
package eu.kanade.tachiyomi.util
|
package eu.kanade.tachiyomi.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import android.support.v4.content.ContextCompat
|
||||||
|
import android.support.v4.os.EnvironmentCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.URLConnection
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.NoSuchAlgorithmException
|
import java.security.NoSuchAlgorithmException
|
||||||
|
|
||||||
object DiskUtil {
|
object DiskUtil {
|
||||||
|
|
||||||
|
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||||
|
val contentType = URLConnection.guessContentTypeFromName(name)
|
||||||
|
if (contentType != null)
|
||||||
|
return contentType.startsWith("image/")
|
||||||
|
|
||||||
|
if (openStream != null) try {
|
||||||
|
openStream.invoke().buffered().use {
|
||||||
|
var bytes = ByteArray(11)
|
||||||
|
it.mark(bytes.size)
|
||||||
|
var length = it.read(bytes, 0, bytes.size)
|
||||||
|
it.reset()
|
||||||
|
if (length == -1)
|
||||||
|
return false
|
||||||
|
if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
|
||||||
|
return true // image/gif
|
||||||
|
} else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
|
||||||
|
&& bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
|
||||||
|
&& bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
|
||||||
|
return true // image/png
|
||||||
|
} else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
|
||||||
|
if (bytes[3] == 0xE0.toByte() || bytes[3] == 0xE1.toByte() && bytes[6] == 'E'.toByte()
|
||||||
|
&& bytes[7] == 'x'.toByte() && bytes[8] == 'i'.toByte()
|
||||||
|
&& bytes[9] == 'f'.toByte() && bytes[10] == 0.toByte()) {
|
||||||
|
return true // image/jpeg
|
||||||
|
} else if (bytes[3] == 0xEE.toByte()) {
|
||||||
|
return true // image/jpg
|
||||||
|
}
|
||||||
|
} else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
|
||||||
|
return true // image/webp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
fun hashKeyForDisk(key: String): String {
|
fun hashKeyForDisk(key: String): String {
|
||||||
return try {
|
return try {
|
||||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||||
@ -31,9 +73,26 @@ object DiskUtil {
|
|||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the root folders of all the available external storages.
|
||||||
|
*/
|
||||||
|
fun getExternalStorages(context: Context): List<File> {
|
||||||
|
return ContextCompat.getExternalFilesDirs(context, null)
|
||||||
|
.filterNotNull()
|
||||||
|
.mapNotNull {
|
||||||
|
val file = File(it.absolutePath.substringBefore("/Android/"))
|
||||||
|
val state = EnvironmentCompat.getStorageState(file)
|
||||||
|
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
|
||||||
|
file
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||||
* replacing any invalid characters with "_". This method doesn't allow private files (starting
|
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||||
* with a dot), but you can manually add it later.
|
* with a dot), but you can manually add it later.
|
||||||
*/
|
*/
|
||||||
fun buildValidFilename(origName: String): String {
|
fun buildValidFilename(origName: String): String {
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
package eu.kanade.tachiyomi.util
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.res.AssetFileDescriptor
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLConnection
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class ZipContentProvider : ContentProvider() {
|
||||||
|
|
||||||
|
private val pool by lazy { Executors.newCachedThreadPool() }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PROVIDER = "${BuildConfig.APPLICATION_ID}.zip-provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String? {
|
||||||
|
return URLConnection.guessContentTypeFromName(uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
|
||||||
|
try {
|
||||||
|
val url = "jar:file://" + uri.toString().substringAfter("content://$PROVIDER")
|
||||||
|
val input = URL(url).openStream()
|
||||||
|
val pipe = ParcelFileDescriptor.createPipe()
|
||||||
|
pool.execute {
|
||||||
|
try {
|
||||||
|
val output = ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])
|
||||||
|
input.use {
|
||||||
|
output.use {
|
||||||
|
input.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AssetFileDescriptor(pipe[0], 0, -1)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?): Cursor? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(p0: Uri?, p1: ContentValues?): Uri {
|
||||||
|
throw UnsupportedOperationException("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?): Int {
|
||||||
|
throw UnsupportedOperationException("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?): Int {
|
||||||
|
throw UnsupportedOperationException("not implemented")
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
|
|
||||||
<string name="name">Име</string>
|
<string name="name">Име</string>
|
||||||
|
|
||||||
<!-- Activities and fragments labels (toolbar title) -->
|
<!-- Activities and fragments labels (toolbar title) -->
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
|
|
||||||
<string name="name">Nombre</string>
|
<string name="name">Nombre</string>
|
||||||
|
|
||||||
<!-- Activities and fragments labels (toolbar title) -->
|
<!-- Activities and fragments labels (toolbar title) -->
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
|
|
||||||
<string name="name">Nom</string>
|
<string name="name">Nom</string>
|
||||||
|
|
||||||
<!-- Activities and fragments labels (toolbar title) -->
|
<!-- Activities and fragments labels (toolbar title) -->
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
|
|
||||||
<string name="name">Nome</string>
|
<string name="name">Nome</string>
|
||||||
|
|
||||||
<!-- Activities and fragments labels (toolbar title) -->
|
<!-- Activities and fragments labels (toolbar title) -->
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
|
|
||||||
<string name="name">Nome</string>
|
<string name="name">Nome</string>
|
||||||
|
|
||||||
<!-- Activities and fragments labels (toolbar title) -->
|
<!-- Activities and fragments labels (toolbar title) -->
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
|
||||||
<string name="action_add">Добавить</string>
|
<string name="action_add">Добавить</string>
|
||||||
<string name="action_add_category">Добавить категорию</string>
|
<string name="action_add_category">Добавить категорию</string>
|
||||||
<string name="action_add_to_home_screen">Добавить на домашний экран</string>
|
<string name="action_add_to_home_screen">Добавить на домашний экран</string>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tachiyomi</string>
|
<string name="app_name" translatable="false">Tachiyomi</string>
|
||||||
|
|
||||||
<string name="name">Name</string>
|
<string name="name">Name</string>
|
||||||
|
|
||||||
@ -224,6 +224,7 @@
|
|||||||
<string name="select_source">Select a source</string>
|
<string name="select_source">Select a source</string>
|
||||||
<string name="no_valid_sources">Please enable at least one valid source</string>
|
<string name="no_valid_sources">Please enable at least one valid source</string>
|
||||||
<string name="no_more_results">No more results</string>
|
<string name="no_more_results">No more results</string>
|
||||||
|
<string name="local_source">Local manga</string>
|
||||||
|
|
||||||
<!-- Manga activity -->
|
<!-- Manga activity -->
|
||||||
<string name="manga_not_in_db">This manga was removed from the database!</string>
|
<string name="manga_not_in_db">This manga was removed from the database!</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user