diff --git a/app/build.gradle b/app/build.gradle index 96c100c84b..d8c8a3e9da 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -179,6 +179,9 @@ dependencies { // Crash reports compile 'ch.acra:acra:4.9.2' + // Sort + compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1' + // UI compile 'com.dmitrymalkovich.android:material-design-dimens:1.4' compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9813e42df8..22cd05ecf8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,6 +76,11 @@ android:resource="@xml/provider_paths" /> + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt index 663de0c8e0..32abfb49aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt @@ -1,8 +1,10 @@ package eu.kanade.tachiyomi.data.glide import android.content.Context +import android.net.Uri import android.util.LruCache import com.bumptech.glide.Glide +import com.bumptech.glide.Priority import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.* import com.bumptech.glide.load.model.stream.StreamModelLoader @@ -43,6 +45,12 @@ class MangaModelLoader(context: Context) : StreamModelLoader { private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java, 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 * and the file where it should be stored in case the manga is a favorite. @@ -82,6 +90,18 @@ class MangaModelLoader(context: Context) : StreamModelLoader { 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 { + 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 // and add them to the cache. val (glideUrl, file) = lruCache.get(url) ?: diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt new file mode 100644 index 0000000000..0ff0349416 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -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 { + 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> { + 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> { + val chapFile = File(chapter.url) + if (chapFile.isDirectory) { + return Observable.just(chapFile.listFiles() + .filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) } + .sortedWith(Comparator { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance().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 { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance().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 { + 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()) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 8e8a6e6e7b..925353e1c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -48,6 +48,7 @@ open class SourceManager(private val context: Context) { } private fun createInternalSources(): List = listOf( + LocalSource(context), Batoto(), Mangahere(), Mangafox(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 9e05ed763a..45e1d53813 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.combineLatest @@ -345,6 +346,11 @@ class LibraryPresenter : BasePresenter() { */ @Throws(IOException::class) 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) { coverCache.copyToCache(manga.thumbnail_url!!, inputStream) return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 79676444e7..860371cf70 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackUpdateService +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource @@ -539,6 +540,13 @@ class ReaderPresenter : BasePresenter() { */ internal fun setImageAsCover(page: Page) { 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") if (manga.favorite) { val input = context.contentResolver.openInputStream(page.uri) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt index 2cef6d76e5..e63723fd09 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt @@ -50,7 +50,7 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter) // Set source + chapter title val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble()) 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 itemView.last_read.text = df.format(Date(history.last_read)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt index 5382d72509..16df65b598 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.util -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga /** * -R> = regex conversion. @@ -37,7 +37,7 @@ object ChapterRecognition { */ 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.chapter_number == -2f || chapter.chapter_number > -1f) return @@ -91,7 +91,7 @@ object ChapterRecognition { * @param chapter chapter object * @return true if volume is found */ - fun updateChapter(match: MatchResult?, chapter: Chapter): Boolean { + fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean { match?.let { val initial = it.groups[1]?.value?.toFloat()!! val subChapterDecimal = it.groups[2]?.value diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt index fa775183da..d07303c534 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt @@ -1,11 +1,53 @@ 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.InputStream +import java.net.URLConnection import java.security.MessageDigest import java.security.NoSuchAlgorithmException 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 { return try { val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) @@ -31,9 +73,26 @@ object DiskUtil { return size } + /** + * Returns the root folders of all the available external storages. + */ + fun getExternalStorages(context: Context): List { + 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, - * 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. */ fun buildValidFilename(origName: String): String { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt new file mode 100644 index 0000000000..b95cc5b390 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt @@ -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?, p2: String?, p3: Array?, 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?): Int { + throw UnsupportedOperationException("not implemented") + } + + override fun delete(p0: Uri?, p1: String?, p2: Array?): Int { + throw UnsupportedOperationException("not implemented") + } +} \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 44b5b98bd5..89301160ac 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1,6 +1,4 @@ - Tachiyomi - Име diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2f4cfe2fec..40dbdadc7e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,6 +1,4 @@ - Tachiyomi - Nombre diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1de24a0546..96a272ec9f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,6 +1,4 @@ - Tachiyomi - Nom diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index caf8b96bf3..f1dc9eaf72 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,6 +1,4 @@ - Tachiyomi - Nome diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index add5e9df13..6c9191347d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,7 +1,5 @@ - Tachiyomi - Nome diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0c58070f57..363fd5a093 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,6 +1,5 @@ - Tachiyomi Добавить Добавить категорию Добавить на домашний экран diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6665fc0a2f..b980a00158 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Tachiyomi + Tachiyomi Name @@ -224,6 +224,7 @@ Select a source Please enable at least one valid source No more results + Local manga This manga was removed from the database!