diff --git a/app/build.gradle b/app/build.gradle index a565780b..1c95935e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,7 +99,7 @@ dependencies { implementation ("xyz.quaver:libpupil:1.1") { exclude group: 'org.jetbrains.kotlinx', module: 'kotlinx-serialization-core-jvm' } - implementation "xyz.quaver:documentfilex:0.2" + implementation "xyz.quaver:documentfilex:0.2.2" testImplementation 'junit:junit:4.13' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test:rules:1.3.0' diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 6f4a61bc..8a1fc913 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -90,7 +90,7 @@ class Pupil : Application() { } try { - Preferences.get("dl_location").also { + Preferences.get("download_folder").also { if (!File(it).canWrite()) throw Exception() } diff --git a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt index 66c52661..5406c95b 100644 --- a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt +++ b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt @@ -19,32 +19,21 @@ package xyz.quaver.pupil.services import android.app.Service -import android.content.Context -import android.content.ContextWrapper import android.content.Intent -import android.os.Binder -import android.os.IBinder import android.util.SparseArray import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import okhttp3.Interceptor import okhttp3.ResponseBody import okio.* import xyz.quaver.pupil.PupilInterceptor import xyz.quaver.pupil.R import xyz.quaver.pupil.interceptors +import xyz.quaver.pupil.util.downloader.Cache private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit - -class Cache(context: Context) : ContextWrapper(context) { - - - -} - class DownloadService : Service() { data class Tag(val galleryID: Int, val index: Int) @@ -144,8 +133,17 @@ class DownloadService : Service() { private val binder = Binder() override fun onBind(p0: Intent?) = binder + val cache = SparseArray() fun load(galleryID: Int) { + if (progress.indexOfKey(galleryID) < 0) + progress.put(galleryID, mutableListOf()) + if (cache.indexOfKey(galleryID) < 0) + cache.put(galleryID, Cache.getInstance(this, galleryID)) + + cache[galleryID].metadata.imageList?.forEach { + progress[galleryID]?.add(if (it == null) Float.POSITIVE_INFINITY else 0F) + } } fun download(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch { diff --git a/app/src/main/java/xyz/quaver/pupil/util/download/Cache.kt b/app/src/main/java/xyz/quaver/pupil/util/download/Cache.kt index a0011718..70d92b3a 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/download/Cache.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/download/Cache.kt @@ -25,6 +25,8 @@ import android.util.SparseArray import androidx.preference.PreferenceManager import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -41,6 +43,7 @@ import java.io.FileOutputStream import java.io.InputStream import java.net.URL +@Deprecated("Use downloader.Cache instead") class Cache(context: Context) : ContextWrapper(context) { companion object { diff --git a/app/src/main/java/xyz/quaver/pupil/util/download/DownloadWorker.kt b/app/src/main/java/xyz/quaver/pupil/util/download/DownloadWorker.kt index cbc07dc3..2420e32b 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/download/DownloadWorker.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/download/DownloadWorker.kt @@ -48,6 +48,7 @@ import java.io.File import java.io.IOException import java.util.concurrent.LinkedBlockingQueue +@Deprecated("Use DownloadService instead") @OptIn(ExperimentalCoroutinesApi::class) class DownloadWorker private constructor(context: Context) : ContextWrapper(context) { diff --git a/app/src/main/java/xyz/quaver/pupil/util/download/Metadata.kt b/app/src/main/java/xyz/quaver/pupil/util/download/Metadata.kt index 1ec0c80a..c86ffb6f 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/download/Metadata.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/download/Metadata.kt @@ -22,12 +22,13 @@ import kotlinx.serialization.Serializable import xyz.quaver.hitomi.GalleryBlock import xyz.quaver.hitomi.Reader +@Deprecated("Use downloader.Cache.Metadata instead") @Serializable data class Metadata( - val thumbnail: String? = null, - val galleryBlock: GalleryBlock? = null, - val reader: Reader? = null, - val isDownloading: Boolean? = null + var thumbnail: String? = null, + var galleryBlock: GalleryBlock? = null, + var reader: Reader? = null, + var isDownloading: Boolean? = null ) { constructor( metadata: Metadata?, diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt new file mode 100644 index 00000000..7eedf8d8 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt @@ -0,0 +1,239 @@ +/* + * Pupil, Hitomi.la viewer for Android + * Copyright (C) 2020 tom5079 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xyz.quaver.pupil.util.downloader + +import android.content.Context +import android.content.ContextWrapper +import android.util.Base64 +import android.util.SparseArray +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.Request +import xyz.quaver.Code +import xyz.quaver.hitomi.Gallery +import xyz.quaver.hitomi.GalleryBlock +import xyz.quaver.hitomi.Reader +import xyz.quaver.hitomi.getGallery +import xyz.quaver.io.FileX +import xyz.quaver.io.util.getChild +import xyz.quaver.io.util.readBytes +import xyz.quaver.io.util.readText +import xyz.quaver.io.util.writeBytes +import xyz.quaver.pupil.client +import xyz.quaver.pupil.util.Preferences +import xyz.quaver.pupil.util.formatDownloadFolder + +@Serializable +data class Metadata( + var galleryBlock: GalleryBlock? = null, + var gallery: Gallery? = null, + var thumbnail: String? = null, + var reader: Reader? = null, + var imageList: MutableList? = null +) { + fun copy(): Metadata = Metadata(galleryBlock, gallery, thumbnail, reader, imageList?.let { MutableList(it.size) { i -> it[i] } }) +} + +class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) { + + companion object { + private val instances = SparseArray() + + fun getInstance(context: Context, galleryID: Int) = + instances[galleryID] ?: synchronized(this) { + instances[galleryID] ?: Cache(context, galleryID).also { instances.put(galleryID, it) } + } + } + + var metadata = kotlin.runCatching { + findFile(".metadata")?.readText()?.let { + Json.decodeFromString(it) + } + }.getOrNull() ?: Metadata() + + val downloadFolder: FileX? + get() = DownloadFolderManager.getInstance(this).getDownloadFolder(galleryID) + + val cacheFolder: FileX + get() = FileX(this, cacheDir, "imageCache/$galleryID") + + val cachedGallery: FileX + get() = DownloadFolderManager.getInstance(this).getDownloadFolder(galleryID) + ?: FileX(this, cacheDir, "imageCache/$galleryID") + + fun findFile(fileName: String): FileX? = + cacheFolder.getChild(fileName).let { + if (it.exists()) it else null + } ?: downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let { + if (it.exists()) it else null + } } + + @Synchronized + fun setMetadata(change: (Metadata) -> Unit) { + change.invoke(metadata) + + val file = cachedGallery.getChild(".metadata") + + CoroutineScope(Dispatchers.IO).launch { + file.writeText(Json.encodeToString(Metadata)) + } + } + + suspend fun getGalleryBlock(): GalleryBlock? { + val sources = listOf( + { xyz.quaver.hitomi.getGalleryBlock(galleryID) }, + { xyz.quaver.hiyobi.getGalleryBlock(galleryID) } + ) + + return metadata.galleryBlock + ?: withContext(Dispatchers.IO) { + var galleryBlock: GalleryBlock? = null + + for (source in sources) { + galleryBlock = try { + source.invoke() + } catch (e: Exception) { null } + + if (galleryBlock != null) + break + } + + galleryBlock?.also { + launch { setMetadata { metadata -> metadata.galleryBlock = it } } + } + } + } + + suspend fun getGallery(): Gallery? = + metadata.gallery + ?: withContext(Dispatchers.IO) { + kotlin.runCatching { + getGallery(galleryID) + }.getOrNull()?.also { + launch { setMetadata { metadata -> + metadata.gallery = it + + if (metadata.imageList == null) + metadata.imageList = MutableList(it.thumbnails.size) { null } + } } + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun getThumbnail(): String? = + metadata.thumbnail + ?: withContext(Dispatchers.IO) { + getGalleryBlock()?.thumbnails?.firstOrNull()?.let { thumbnail -> + kotlin.runCatching { + val request = Request.Builder() + .url(thumbnail) + .build() + + val image = client.newCall(request).execute().body()?.use { it.bytes() } + + Base64.encodeToString(image, Base64.DEFAULT) + }.getOrNull() + }?.also { + launch { setMetadata { metadata -> metadata.thumbnail = it } } + } + } + + suspend fun getReader(galleryID: Int): Reader? { + val mirrors = Preferences.get("mirrors").split('>') + + val sources = mapOf( + Code.HITOMI to { xyz.quaver.hitomi.getReader(galleryID) }, + Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) } + ).toSortedMap { o1, o2 -> mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name) } + + return metadata.reader + ?: withContext(Dispatchers.IO) { + var reader: Reader? = null + + for (source in sources) { + reader = try { withTimeoutOrNull(1000) { + source.value.invoke() + } } catch (e: Exception) { null } + + if (reader != null) + break + } + + reader?.also { + launch { setMetadata { metadata -> + metadata.reader = it + + if (metadata.imageList == null) + metadata.imageList = MutableList(reader.galleryInfo.files.size) { null } + } } + } + } + } + + fun getImage(index: Int): FileX? = + metadata.imageList?.get(index)?.let { findFile(it) } + + @Suppress("BlockingMethodInNonBlockingContext") + fun putImage(index: Int, fileName: String, data: ByteArray) = CoroutineScope(Dispatchers.IO).launch { + val file = FileX(this@Cache, cachedGallery, fileName).also { + it.createNewFile() + } + + file.writeBytes(data) + + setMetadata { metadata -> metadata.imageList!![index] = fileName } + } + + @Suppress("BlockingMethodInNonBlockingContext") + fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch { + if (downloadFolder == null) + DownloadFolderManager.getInstance(this@Cache).addDownloadFolder(galleryID, this@Cache.formatDownloadFolder()) + + metadata.imageList?.forEach { + it ?: return@forEach + + val target = downloadFolder!!.getChild(it) + val source = cacheFolder.getChild(it) + + if (!source.exists()) + return@forEach + + kotlin.runCatching { + target.createNewFile() + source.readBytes()?.let { target.writeBytes(it) } + } + } + + val cacheMetadata = cacheFolder.getChild(".metadata") + val downloadMetadata = downloadFolder!!.getChild(".metadata") + + if (cacheMetadata.exists()) { + kotlin.runCatching { + downloadMetadata.createNewFile() + cacheMetadata.readBytes()?.let { downloadMetadata.writeBytes(it) } + cacheMetadata.delete() + } + } + + cacheFolder.delete() + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadFolderManager.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadFolderManager.kt new file mode 100644 index 00000000..e179179d --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadFolderManager.kt @@ -0,0 +1,104 @@ +/* + * Pupil, Hitomi.la viewer for Android + * Copyright (C) 2020 tom5079 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xyz.quaver.pupil.util.downloader + +import android.content.Context +import android.content.ContextWrapper +import android.webkit.URLUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import xyz.quaver.io.FileX +import xyz.quaver.io.util.readText +import xyz.quaver.pupil.util.Preferences + +class DownloadFolderManager private constructor(context: Context) : ContextWrapper(context) { + + companion object { + @Volatile private var instance: DownloadFolderManager? = null + + fun getInstance(context: Context) = + instance ?: synchronized(this) { + instance ?: DownloadFolderManager(context).also { instance = it } + } + } + + val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!) + + val downloadFolder = { + val uri: String = Preferences["download_directory"] + + if (!URLUtil.isValidUrl(uri)) + Preferences["download_directory"] = defaultDownloadFolder + + kotlin.runCatching { + FileX(this, uri) + }.getOrElse { + Preferences["download_directory"] = defaultDownloadFolder + FileX(this, defaultDownloadFolder) + } + }.invoke() + + private val downloadFolderMap: MutableMap = + kotlin.runCatching { + FileX(this@DownloadFolderManager, downloadFolder, ".download").readText()?.let { + Json.decodeFromString>(it) + } + }.getOrNull() ?: mutableMapOf() + private val downloadFolderMapMutex = Mutex() + + @Synchronized + fun getDownloadFolder(galleryID: Int): FileX? = + downloadFolderMap[galleryID]?.let { FileX(this, downloadFolder, it) } + + @Synchronized + fun addDownloadFolder(galleryID: Int, name: String) { + if (downloadFolderMap.containsKey(galleryID)) + return + + if (FileX(this@DownloadFolderManager, downloadFolder, name).mkdir()) { + downloadFolderMap[galleryID] = name + + CoroutineScope(Dispatchers.IO).launch { downloadFolderMapMutex.withLock { + FileX(this@DownloadFolderManager, downloadFolder, ".download").writeText(Json.encodeToString(downloadFolderMap)) + } } + } + } + + @Synchronized + fun removeDownloadFolder(galleryID: Int) { + if (!downloadFolderMap.containsKey(galleryID)) + return + + downloadFolderMap[galleryID]?.let { + if (FileX(this@DownloadFolderManager, downloadFolder, it).delete()) { + downloadFolderMap.remove(galleryID) + + CoroutineScope(Dispatchers.IO).launch { downloadFolderMapMutex.withLock { + FileX(this@DownloadFolderManager, downloadFolder, ".download").writeText(Json.encodeToString(downloadFolderMap)) + } } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/file.kt b/app/src/main/java/xyz/quaver/pupil/util/file.kt index 2f4e1654..7f6b7e82 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/file.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/file.kt @@ -18,19 +18,15 @@ package xyz.quaver.pupil.util -import android.annotation.TargetApi import android.content.Context -import android.net.Uri -import android.os.Build import android.os.storage.StorageManager -import android.provider.DocumentsContract import androidx.core.content.ContextCompat +import androidx.core.net.toUri import java.io.File import java.io.FileOutputStream import java.lang.reflect.Array import java.net.URL - fun getCachedGallery(context: Context, galleryID: Int) = File(getDownloadDirectory(context), galleryID.toString()).let { if (it.exists()) diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt index 43cba0ab..4952021c 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -19,7 +19,14 @@ package xyz.quaver.pupil.util import android.annotation.SuppressLint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient +import xyz.quaver.pupil.util.downloader.Cache +import xyz.quaver.pupil.util.downloader.Metadata import java.util.* import kotlin.collections.ArrayList @@ -67,4 +74,20 @@ fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply { proxyInfo.authenticator()?.let { proxyAuthenticator(it) } +} + +val formatMap = mapOf (String)>( + "\$ID" to { runBlocking { it.getGalleryBlock()?.id.toString() } }, + "\$TITLE" to { runBlocking { it.getGalleryBlock()?.title.toString() } }, + // TODO +) +/** + * Formats download folder name with given Metadata + */ +fun Cache.formatDownloadFolder(): String { + return Preferences["download_folder_format", "\$ID"].apply { + formatMap.entries.forEach { (key, lambda) -> + this.replace(key, lambda.invoke(this@formatDownloadFolder)) + } + } } \ No newline at end of file