WIP
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
* Copyright (C) 2021 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
|
||||
@@ -16,35 +16,29 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util.downloader
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.di
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.*
|
||||
import xyz.quaver.pupil.sources.sources
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
import xyz.quaver.pupil.util.formatDownloadFolder
|
||||
import xyz.quaver.pupil.sources.AnySource
|
||||
|
||||
class DownloadManager private constructor(context: Context) : ContextWrapper(context) {
|
||||
class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware {
|
||||
|
||||
companion object {
|
||||
@Volatile private var instance: DownloadManager? = null
|
||||
override val di by di(context)
|
||||
|
||||
fun getInstance(context: Context) =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: DownloadManager(context).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
|
||||
private val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
|
||||
|
||||
val downloadFolder: FileX
|
||||
get() = {
|
||||
@@ -58,7 +52,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
|
||||
|
||||
private var prevDownloadFolder: FileX? = null
|
||||
private var downloadFolderMapInstance: MutableMap<String, String>? = null
|
||||
val downloadFolderMap: MutableMap<String, String>
|
||||
private val downloadFolderMap: MutableMap<String, String>
|
||||
@Synchronized
|
||||
get() {
|
||||
if (prevDownloadFolder != downloadFolder) {
|
||||
@@ -88,8 +82,12 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
|
||||
downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) }
|
||||
|
||||
@Synchronized
|
||||
fun addDownloadFolder(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
|
||||
val name = "A" // TODO
|
||||
fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
|
||||
val source: AnySource by instance(tag = source)
|
||||
val info = async { source.info(itemID) }
|
||||
val images = async { source.images(itemID) }
|
||||
|
||||
val name = info.await().formatDownloadFolder()
|
||||
|
||||
val folder = downloadFolder.getChild("$source/$name")
|
||||
|
||||
@@ -105,7 +103,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteDownloadFolder(source: String, itemID: String) {
|
||||
fun delete(source: String, itemID: String) {
|
||||
downloadFolderMap["$source/$itemID"]?.let {
|
||||
kotlin.runCatching {
|
||||
downloadFolder.getChild(it).deleteRecursively()
|
||||
126
app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt
Normal file
126
app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import okhttp3.*
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.di
|
||||
import org.kodein.di.instance
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class ImageCache(context: Context) : DIAware {
|
||||
override val di by di(context)
|
||||
|
||||
private val client: OkHttpClient by instance()
|
||||
|
||||
val cacheFolder = File(context.cacheDir, "imageCache")
|
||||
val cache = SavedMap(File(cacheFolder, ".cache"), "", "")
|
||||
|
||||
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
|
||||
val channels = _channels as Map<String, Channel<Float>>
|
||||
|
||||
@Synchronized
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun cleanup() = coroutineScope {
|
||||
val LIMIT = 100*1024*1024
|
||||
|
||||
cacheFolder.listFiles { it -> it.canonicalPath !in cache }?.forEach { it.delete() }
|
||||
|
||||
if (cacheFolder.size() > LIMIT)
|
||||
do {
|
||||
cache.entries.firstOrNull { !channels.containsKey(it.key) }?.let {
|
||||
File(it.value).delete()
|
||||
cache.remove(it.key)
|
||||
}
|
||||
} while (cacheFolder.size() > LIMIT / 2)
|
||||
}
|
||||
|
||||
fun free(images: List<String>) {
|
||||
client.dispatcher().let { it.queuedCalls() + it.runningCalls() }
|
||||
.filter { it.request().url().toString() in images }
|
||||
.forEach { it.cancel() }
|
||||
|
||||
images.forEach { _channels.remove(it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
suspend fun clear() = coroutineScope {
|
||||
client.dispatcher().queuedCalls().forEach { it.cancel() }
|
||||
|
||||
cacheFolder.listFiles()?.forEach { it.delete() }
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun load(request: Request): File {
|
||||
val key = request.url().toString()
|
||||
|
||||
val channel = if (_channels[key]?.isClosedForSend == false)
|
||||
_channels[key]!!
|
||||
else
|
||||
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[key] = it }
|
||||
|
||||
return cache[key]?.let {
|
||||
channel.close()
|
||||
File(it)
|
||||
} ?: File(cacheFolder, "${UUID.randomUUID()}.${key.takeLastWhile { it != '.' }}").also { file ->
|
||||
client.newCall(request).enqueue(object: Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
file.delete()
|
||||
cache.remove(call.request().url().toString())
|
||||
|
||||
FirebaseCrashlytics.getInstance().recordException(e)
|
||||
channel.close(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (response.code() != 200) {
|
||||
file.delete()
|
||||
cache.remove(call.request().url().toString())
|
||||
|
||||
channel.close(IOException("HTTP Response code is not 200"))
|
||||
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
|
||||
response.body()?.use { body ->
|
||||
if (!file.exists())
|
||||
file.createNewFile()
|
||||
|
||||
body.byteStream().copyTo(file.outputStream()) { bytes, _ ->
|
||||
channel.sendBlocking(bytes / body.contentLength().toFloat() * 100)
|
||||
}
|
||||
}
|
||||
|
||||
channel.close()
|
||||
}
|
||||
})
|
||||
}.also { cache[key] = it.canonicalPath }
|
||||
}
|
||||
}
|
||||
170
app/src/main/java/xyz/quaver/pupil/util/SavedCollections.kt
Normal file
170
app/src/main/java/xyz/quaver/pupil/util/SavedCollections.kt
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.SetSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import java.io.File
|
||||
|
||||
class SavedSet <T: Any> (private val file: File, any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val serializer: KSerializer<Set<T>> = SetSerializer(serializer(any::class.java) as KSerializer<T>)
|
||||
|
||||
init {
|
||||
if (!file.exists()) {
|
||||
file.parentFile?.mkdirs()
|
||||
save()
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun load() {
|
||||
set.clear()
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString(serializer, file.readText())
|
||||
}.onSuccess {
|
||||
set.addAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save() {
|
||||
if (!file.exists())
|
||||
file.createNewFile()
|
||||
|
||||
file.writeText(Json.encodeToString(serializer, set))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun add(element: T): Boolean {
|
||||
set.remove(element)
|
||||
|
||||
return set.add(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addAll(elements: Collection<T>): Boolean {
|
||||
set.removeAll(elements)
|
||||
|
||||
return set.addAll(elements).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun remove(element: T): Boolean {
|
||||
load()
|
||||
|
||||
return set.remove(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun clear() {
|
||||
set.clear()
|
||||
save()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SavedMap <K: Any, V: Any> (private val file: File, anyKey: K, anyValue: V, private val map: MutableMap<K, V> = mutableMapOf()) : MutableMap<K, V> by map {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val serializer: KSerializer<Map<K, V>> = MapSerializer(serializer(anyKey::class.java) as KSerializer<K>, serializer(anyValue::class.java) as KSerializer<V>)
|
||||
|
||||
init {
|
||||
if (!file.exists()) {
|
||||
file.parentFile?.mkdirs()
|
||||
save()
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun load() {
|
||||
map.clear()
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString(serializer, file.readText())
|
||||
}.onSuccess {
|
||||
map.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save() {
|
||||
if (!file.exists())
|
||||
file.createNewFile()
|
||||
|
||||
file.writeText(Json.encodeToString(serializer, map))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun put(key: K, value: V): V? {
|
||||
map.remove(key)
|
||||
|
||||
return map.put(key, value).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun putAll(from: Map<out K, V>) {
|
||||
for (key in from.keys) {
|
||||
map.remove(key)
|
||||
}
|
||||
|
||||
map.putAll(from)
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun remove(key: K): V? {
|
||||
return map.remove(key).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@RequiresApi(24)
|
||||
override fun remove(key: K, value: V): Boolean {
|
||||
return map.remove(key, value).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun clear() {
|
||||
map.clear()
|
||||
save()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val serializer: KSerializer<List<T>>
|
||||
get() = ListSerializer(serializer(any::class.java) as KSerializer<T>)
|
||||
|
||||
init {
|
||||
if (!file.exists()) {
|
||||
file.parentFile?.mkdirs()
|
||||
save()
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun load() {
|
||||
set.clear()
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString(serializer, file.readText())
|
||||
}.onSuccess {
|
||||
set.addAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun save() {
|
||||
file.writeText(Json.encodeToString(serializer, set.toList()))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun add(element: T): Boolean {
|
||||
load()
|
||||
|
||||
set.remove(element)
|
||||
|
||||
return set.add(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addAll(elements: Collection<T>): Boolean {
|
||||
load()
|
||||
|
||||
set.removeAll(elements)
|
||||
|
||||
return set.addAll(elements).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun remove(element: T): Boolean {
|
||||
load()
|
||||
|
||||
return set.remove(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun clear() {
|
||||
set.clear()
|
||||
save()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util.downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.deleteRecursively
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.io.util.outputStream
|
||||
import xyz.quaver.io.util.writeText
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
import xyz.quaver.pupil.sources.sources
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Serializable
|
||||
data class Metadata(
|
||||
var itemInfo: ItemInfo? = null,
|
||||
var imageList: MutableList<String?>? = null
|
||||
) {
|
||||
fun copy(): Metadata = Metadata(itemInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
|
||||
}
|
||||
|
||||
class Cache private constructor(context: Context, source: String, private val itemID: String) : ContextWrapper(context) {
|
||||
|
||||
companion object {
|
||||
val instances = ConcurrentHashMap<String, Cache>()
|
||||
|
||||
fun getInstance(context: Context, source: String, itemID: String): Cache {
|
||||
val key = "$source/$itemID"
|
||||
return instances[key] ?: synchronized(this) {
|
||||
instances[key] ?: Cache(context, source, itemID).also { instances[key] = it }
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun delete(source: String, itemID: String) {
|
||||
val key = "$source/$itemID"
|
||||
|
||||
instances[key]?.cacheFolder?.deleteRecursively()
|
||||
instances.remove("$source/$itemID")
|
||||
}
|
||||
}
|
||||
|
||||
val source = sources[source]!!
|
||||
|
||||
val downloadFolder: FileX?
|
||||
get() = DownloadManager.getInstance(this).getDownloadFolder(source.name, itemID)
|
||||
|
||||
val cacheFolder: FileX
|
||||
get() = FileX(this, cacheDir, "imageCache/$source/$itemID").also {
|
||||
if (!it.exists())
|
||||
it.mkdirs()
|
||||
}
|
||||
|
||||
val metadata: Metadata = kotlin.runCatching {
|
||||
Json.decodeFromString<Metadata>(findFile(".metadata")!!.readText())
|
||||
}.getOrDefault(Metadata())
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
fun setMetadata(change: (Metadata) -> Unit) {
|
||||
change.invoke(metadata)
|
||||
|
||||
val file = cacheFolder.getChild(".metadata")
|
||||
|
||||
kotlin.runCatching {
|
||||
if (!file.exists()) {
|
||||
file.createNewFile()
|
||||
}
|
||||
file.writeText(Json.encodeToString(metadata))
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFile(fileName: String): FileX? =
|
||||
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
|
||||
if (it.exists()) it else null
|
||||
} } ?: cacheFolder.getChild(fileName).let {
|
||||
if (it.exists()) it else null
|
||||
}
|
||||
|
||||
fun putImage(index: Int, name: String, `is`: InputStream) {
|
||||
cacheFolder.getChild(name).also {
|
||||
if (!it.exists())
|
||||
it.createNewFile()
|
||||
}.outputStream()?.use {
|
||||
it.channel.truncate(0L)
|
||||
`is`.copyTo(it)
|
||||
}
|
||||
|
||||
setMetadata { metadata -> metadata.imageList!![index] = name }
|
||||
}
|
||||
|
||||
fun getImage(index: Int): FileX? {
|
||||
return metadata.imageList?.get(index)?.let { findFile(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util.downloader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.*
|
||||
import okio.*
|
||||
import xyz.quaver.pupil.PupilInterceptor
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.client
|
||||
import xyz.quaver.pupil.interceptors
|
||||
import xyz.quaver.pupil.services.DownloadService
|
||||
import xyz.quaver.pupil.sources.sources
|
||||
import xyz.quaver.pupil.ui.ReaderActivity
|
||||
import xyz.quaver.pupil.util.cleanCache
|
||||
import xyz.quaver.pupil.util.normalizeID
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private typealias ProgressListener = (Downloader.Tag, Long, Long, Boolean) -> Unit
|
||||
class Downloader private constructor(private val context: Context) {
|
||||
|
||||
data class Tag(val source: String, val itemID: String, val index: Int)
|
||||
|
||||
companion object {
|
||||
var instance: Downloader? = null
|
||||
|
||||
fun getInstance(context: Context): Downloader {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Downloader(context).also {
|
||||
interceptors[Tag::class] = it.interceptor
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//region Notification
|
||||
private val notificationManager by lazy {
|
||||
NotificationManagerCompat.from(context)
|
||||
}
|
||||
|
||||
private val serviceNotification by lazy {
|
||||
NotificationCompat.Builder(context, "downloader")
|
||||
.setContentTitle(context.getString(R.string.downloader_running))
|
||||
.setProgress(0, 0, false)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
}
|
||||
|
||||
private val notification = ConcurrentHashMap<String, NotificationCompat.Builder?>()
|
||||
|
||||
private fun initNotification(source: String, itemID: String) {
|
||||
val key = "$source-$itemID"
|
||||
|
||||
val intent = Intent(context, ReaderActivity::class.java)
|
||||
.putExtra("source", source)
|
||||
.putExtra("itemID", itemID)
|
||||
|
||||
val pendingIntent = TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(itemID.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
val action =
|
||||
NotificationCompat.Action.Builder(0, context.getText(android.R.string.cancel),
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
R.id.notification_download_cancel_action.normalizeID(),
|
||||
Intent(context, DownloadService::class.java)
|
||||
.putExtra(DownloadService.KEY_COMMAND, DownloadService.COMMAND_CANCEL)
|
||||
.putExtra(DownloadService.KEY_ID, itemID),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT),
|
||||
).build()
|
||||
|
||||
notification[key] = NotificationCompat.Builder(context, "download").apply {
|
||||
setContentTitle(context.getString(R.string.reader_loading))
|
||||
setContentText(context.getString(R.string.reader_notification_text))
|
||||
setSmallIcon(R.drawable.ic_notification)
|
||||
setContentIntent(pendingIntent)
|
||||
addAction(action)
|
||||
setProgress(0, 0, true)
|
||||
setOngoing(true)
|
||||
}
|
||||
|
||||
notify(source, itemID)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun notify(source: String, itemID: String) {
|
||||
val key = "$source-$itemID"
|
||||
val max = progress[key]?.size ?: 0
|
||||
val progress = progress[key]?.count { it == Float.POSITIVE_INFINITY } ?: 0
|
||||
|
||||
val notification = notification[key] ?: return
|
||||
|
||||
if (isCompleted(source, itemID)) {
|
||||
notification
|
||||
.setContentText(context.getString(R.string.reader_notification_complete))
|
||||
.setProgress(0, 0, false)
|
||||
.setOngoing(false)
|
||||
.mActions.clear()
|
||||
|
||||
notificationManager.cancel(key.hashCode())
|
||||
} else
|
||||
notification
|
||||
.setProgress(max, progress, false)
|
||||
.setContentText("$progress/$max")
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region ProgressListener
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private val progressListener: ProgressListener = { (source, itemID, index), bytesRead, contentLength, done ->
|
||||
if (!done && progress["$source-$itemID"]?.get(index)?.isFinite() == true)
|
||||
progress["$source-$itemID"]?.set(index, bytesRead * 100F / contentLength)
|
||||
}
|
||||
|
||||
private class ProgressResponseBody(
|
||||
val tag: Any?,
|
||||
val responseBody: ResponseBody,
|
||||
val progressListener : ProgressListener
|
||||
) : ResponseBody() {
|
||||
private var bufferedSource : BufferedSource? = null
|
||||
|
||||
override fun contentLength() = responseBody.contentLength()
|
||||
override fun contentType() = responseBody.contentType()
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
if (bufferedSource == null)
|
||||
bufferedSource = Okio.buffer(source(responseBody.source()))
|
||||
|
||||
return bufferedSource!!
|
||||
}
|
||||
|
||||
private fun source(source: Source) = object: ForwardingSource(source) {
|
||||
var totalBytesRead = 0L
|
||||
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
|
||||
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
|
||||
progressListener.invoke(tag as Tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val interceptor: PupilInterceptor = { chain ->
|
||||
val request = chain.request()
|
||||
var response = chain.proceed(request)
|
||||
|
||||
var retry = 5
|
||||
while (!response.isSuccessful && retry > 0) {
|
||||
response = chain.proceed(request)
|
||||
retry--
|
||||
}
|
||||
|
||||
response.newBuilder()
|
||||
.body(response.body()?.let {
|
||||
ProgressResponseBody(request.tag(), it, progressListener)
|
||||
}).build()
|
||||
}
|
||||
//endregion
|
||||
|
||||
private val callback = object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
val (source, itemID, index) = call.request().tag() as Tag
|
||||
|
||||
FirebaseCrashlytics.getInstance().recordException(e)
|
||||
|
||||
progress["$source-$itemID"]?.set(index, Float.NEGATIVE_INFINITY)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val (source, itemID, index) = call.request().tag() as Tag
|
||||
val ext = call.request().url().encodedPath().takeLastWhile { it != '.' }
|
||||
|
||||
if (response.code() != 200)
|
||||
throw IOException()
|
||||
|
||||
response.body()?.use {
|
||||
Cache.getInstance(context, source, itemID).putImage(index, "$index.$ext", it.byteStream())
|
||||
}
|
||||
progress["$source-$itemID"]?.set(index, Float.POSITIVE_INFINITY)
|
||||
}
|
||||
}
|
||||
|
||||
private val progress = ConcurrentHashMap<String, MutableList<Float>>()
|
||||
fun getProgress(source: String, itemID: String): List<Float>? {
|
||||
return progress["$source-$itemID"]
|
||||
}
|
||||
|
||||
fun isCompleted(source: String, itemID: String) = progress["$source-$itemID"]?.all { it == Float.POSITIVE_INFINITY } == true
|
||||
|
||||
fun cancel() {
|
||||
client.dispatcher().queuedCalls().filter {
|
||||
it.request().tag() is Tag
|
||||
}.forEach {
|
||||
it.cancel()
|
||||
}
|
||||
client.dispatcher().runningCalls().filter {
|
||||
it.request().tag() is Tag
|
||||
}.forEach {
|
||||
it.cancel()
|
||||
}
|
||||
|
||||
progress.clear()
|
||||
}
|
||||
|
||||
fun cancel(source: String, itemID: String) {
|
||||
client.dispatcher().queuedCalls().filter {
|
||||
(it.request().tag() as? Tag)?.let { tag ->
|
||||
tag.source == source && tag.itemID == itemID
|
||||
} == true
|
||||
}.forEach {
|
||||
it.cancel()
|
||||
}
|
||||
client.dispatcher().runningCalls().filter {
|
||||
(it.request().tag() as? Tag)?.let { tag ->
|
||||
tag.source == source && tag.itemID == itemID
|
||||
} == true
|
||||
}.forEach {
|
||||
it.cancel()
|
||||
}
|
||||
|
||||
progress.remove("$source-$itemID")
|
||||
}
|
||||
|
||||
fun retry(source: String, itemID: String) {
|
||||
cancel(source, itemID)
|
||||
download(source, itemID)
|
||||
}
|
||||
|
||||
var onImageListLoadedCallback: ((List<String>) -> Unit)? = null
|
||||
fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
|
||||
if (isDownloading(source, itemID))
|
||||
return@launch
|
||||
|
||||
initNotification(source, itemID)
|
||||
cleanCache(context)
|
||||
|
||||
val source = sources[source] ?: return@launch
|
||||
val cache = Cache.getInstance(context, source.name, itemID)
|
||||
|
||||
source.images(itemID).also {
|
||||
progress["${source.name}-$itemID"] = MutableList(it.size) { i ->
|
||||
if (cache.metadata.imageList?.get(i) == null) 0F else Float.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
if (cache.metadata.imageList == null)
|
||||
cache.metadata.imageList = MutableList(it.size) { null }
|
||||
|
||||
onImageListLoadedCallback?.invoke(it)
|
||||
}.forEachIndexed { index, url ->
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.tag(Tag(source.name, itemID, index))
|
||||
.url(url)
|
||||
.headers(Headers.of(source.getHeadersForImage(itemID, url)))
|
||||
.build()
|
||||
).enqueue(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun isDownloading(source: String, itemID: String): Boolean {
|
||||
return (client.dispatcher().queuedCalls() + client.dispatcher().runningCalls()).any {
|
||||
(it.request().tag() as? Tag)?.let { tag ->
|
||||
tag.source == source && tag.itemID == itemID
|
||||
} == true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,50 +18,7 @@
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import xyz.quaver.pupil.histories
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||
import java.io.File
|
||||
|
||||
val mutex = Mutex()
|
||||
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
|
||||
if (mutex.isLocked) return@launch
|
||||
|
||||
mutex.withLock {
|
||||
val cacheFolder = File(context.cacheDir, "imageCache")
|
||||
val downloadManager = DownloadManager.getInstance(context)
|
||||
|
||||
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
|
||||
|
||||
if (limit == 0L) return@withLock
|
||||
|
||||
val cacheSize = {
|
||||
var size = 0L
|
||||
|
||||
cacheFolder.walk().forEach {
|
||||
size += it.length()
|
||||
}
|
||||
|
||||
size
|
||||
}
|
||||
|
||||
if (cacheSize.invoke() > limit)
|
||||
while (cacheSize.invoke() > limit/2) {
|
||||
val caches = cacheFolder.list() ?: return@withLock
|
||||
|
||||
synchronized(histories) {
|
||||
(histories.firstOrNull {
|
||||
TODO()
|
||||
} ?: return@withLock).let {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun File.size(): Long =
|
||||
this.walk().fold(0L) { size, file -> size + file.length() }
|
||||
@@ -20,6 +20,7 @@ package xyz.quaver.pupil.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MenuItem
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -27,6 +28,8 @@ import xyz.quaver.hitomi.GalleryInfo
|
||||
import xyz.quaver.hitomi.getReferer
|
||||
import xyz.quaver.hitomi.imageUrlFromImage
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@@ -84,47 +87,13 @@ val formatMap = mapOf<String, ItemInfo.() -> (String)>(
|
||||
/**
|
||||
* Formats download folder name with given Metadata
|
||||
*/
|
||||
fun ItemInfo.formatDownloadFolder(): String =
|
||||
Preferences["download_folder_name", "[-id-] -title-"].let {
|
||||
formatMap.entries.fold(it) { str, (k, v) ->
|
||||
str.replace(k, v.invoke(this), true)
|
||||
}
|
||||
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
|
||||
|
||||
fun ItemInfo.formatDownloadFolderTest(format: String): String =
|
||||
fun ItemInfo.formatDownloadFolder(format: String = Preferences["download_folder_name", "[-id-] -title-"]): String =
|
||||
format.let {
|
||||
formatMap.entries.fold(it) { str, (k, v) ->
|
||||
str.replace(k, v.invoke(this), true)
|
||||
}
|
||||
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
|
||||
|
||||
val GalleryInfo.requestBuilders: List<Request.Builder>
|
||||
get() {
|
||||
val galleryID = this.id ?: 0
|
||||
val lowQuality = Preferences["low_quality", true]
|
||||
|
||||
return this.files.map {
|
||||
Request.Builder()
|
||||
.url(imageUrlFromImage(galleryID, it, !lowQuality))
|
||||
.header("Referer", getReferer(galleryID))
|
||||
}
|
||||
/*
|
||||
return when(code) {
|
||||
Code.HITOMI -> {
|
||||
this.galleryInfo.files.map {
|
||||
Request.Builder()
|
||||
.url(imageUrlFromImage(galleryID, it, !lowQuality))
|
||||
.header("Referer", getReferer(galleryID))
|
||||
}
|
||||
}
|
||||
Code.HIYOBI -> {
|
||||
createImgList(galleryID, this, lowQuality).map {
|
||||
Request.Builder()
|
||||
.url(it.path)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
fun String.ellipsize(n: Int): String =
|
||||
if (this.length > n)
|
||||
this.slice(0 until n) + "…"
|
||||
@@ -142,4 +111,21 @@ val JsonElement.content
|
||||
|
||||
fun List<MenuItem>.findMenu(itemID: Int): MenuItem {
|
||||
return first { it.itemId == itemID }
|
||||
}
|
||||
|
||||
fun <E> MutableLiveData<MutableList<E>>.notify() {
|
||||
this.value = this.value
|
||||
}
|
||||
|
||||
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long, bytesJustCopied: Int) -> Any): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
onCopy(bytesCopied, bytes)
|
||||
bytes = read(buffer)
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
@@ -45,21 +45,10 @@ import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import ru.noties.markwon.Markwon
|
||||
import xyz.quaver.hitomi.GalleryBlock
|
||||
import xyz.quaver.hitomi.getGalleryBlock
|
||||
import xyz.quaver.hitomi.getReader
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.io.util.readText
|
||||
import xyz.quaver.io.util.writeBytes
|
||||
import xyz.quaver.io.util.writeText
|
||||
import xyz.quaver.pupil.BuildConfig
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.client
|
||||
import xyz.quaver.pupil.favorites
|
||||
import xyz.quaver.pupil.services.DownloadService
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import xyz.quaver.pupil.util.downloader.Metadata
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
|
||||
Reference in New Issue
Block a user