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