diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 00000000..0aab68d2 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/MirrorAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/MirrorAdapter.kt deleted file mode 100644 index 06dee2c4..00000000 --- a/app/src/main/java/xyz/quaver/pupil/adapters/MirrorAdapter.kt +++ /dev/null @@ -1,86 +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 . - */ - -package xyz.quaver.pupil.adapters - -import android.annotation.SuppressLint -import android.content.Context -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import xyz.quaver.pupil.R -import xyz.quaver.pupil.databinding.MirrorsItemBinding -import xyz.quaver.pupil.util.Preferences -import java.util.* - -class MirrorAdapter(context: Context) : RecyclerView.Adapter() { - - @SuppressLint("ClickableViewAccessibility") - inner class ViewHolder(val binding: MirrorsItemBinding) : RecyclerView.ViewHolder(binding.root) { - init { - binding.mirrorButton.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) - onStartDrag?.invoke(this) - - true - } - } - fun bind(mirror: String) { - binding.mirrorName.text = mirror - } - } - - val mirrors = context.resources.getStringArray(R.array.mirrors).map { - it.split('|').let { split -> - Pair(split.first(), split.last()) - } - }.toMap() - - val list = mirrors.keys.toMutableList().apply { - Preferences.get("mirrors") - .split(">") - .asReversed() - .forEach { - if (this.contains(it)) { - this.remove(it) - this.add(0, it) - } - } - } - - val onItemMove : ((Int, Int) -> Unit) = { from, to -> - Collections.swap(list, from, to) - notifyItemMoved(from, to) - onItemMoved?.invoke(list) - } - var onStartDrag : ((ViewHolder) -> Unit)? = null - var onItemMoved : ((List) -> (Unit))? = null - - @SuppressLint("ClickableViewAccessibility") - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(mirrors[list.elementAt(position)] ?: error("")) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(MirrorsItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun getItemCount() = mirrors.size - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/SourceAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/SourceAdapter.kt deleted file mode 100644 index 94cf748c..00000000 --- a/app/src/main/java/xyz/quaver/pupil/adapters/SourceAdapter.kt +++ /dev/null @@ -1,67 +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 . - */ - -package xyz.quaver.pupil.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion -import xyz.quaver.pupil.databinding.SourceSelectDialogItemBinding -import xyz.quaver.pupil.sources.ItemInfo -import xyz.quaver.pupil.sources.Source -import xyz.quaver.pupil.sources.SourceEntries - -class SourceAdapter(sources: SourceEntries) : RecyclerView.Adapter() { - - var onSourceSelectedListener: ((String) -> Unit)? = null - var onSourceSettingsSelectedListener: ((String) -> Unit)? = null - - private val sources = sources.toList() - - inner class ViewHolder(private val binding: SourceSelectDialogItemBinding) : RecyclerView.ViewHolder(binding.root) { - lateinit var source: Source - - init { - binding.go.setOnClickListener { - onSourceSelectedListener?.invoke(source.name) - } - binding.settings.setOnClickListener { - onSourceSettingsSelectedListener?.invoke(source.name) - } - } - - fun bind(source: Source) { - this.source = source - - // TODO: save image somewhere else - binding.icon.setImageResource(source.iconResID) - binding.name.text = source.name - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(SourceSelectDialogItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(sources[position].second) - } - - override fun getItemCount(): Int = sources.size -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt similarity index 100% rename from app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt rename to app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt diff --git a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt index 316b4b7c..89c5ea65 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt @@ -87,7 +87,7 @@ class ReaderActivity : ComponentActivity(), DIAware { val imageHeights = remember { mutableStateListOf() } val states = remember { mutableStateListOf() } - LaunchedEffect(model.progressList.sum()) { + LaunchedEffect(model.imageList.count { it != null }) { if (imageSources.isEmpty() && model.imageList.isNotEmpty()) imageSources.addAll(List(model.imageList.size) { null }) @@ -97,16 +97,22 @@ class ReaderActivity : ComponentActivity(), DIAware { if (imageHeights.isEmpty() && model.imageList.isNotEmpty()) imageHeights.addAll(List(model.imageList.size) { null }) + logger.info { + "${model.imageList.count { it == null }} nulls" + } + model.imageList.forEachIndexed { i, image -> if (imageSources[i] == null && image != null) - CoroutineScope(Dispatchers.Default).launch { - imageSources[i] = kotlin.runCatching { - FileXImageSource(FileX(this@ReaderActivity, image)) - }.onFailure { - logger.warning(it) - model.error(i) - }.getOrNull() - } + imageSources[i] = kotlin.runCatching { + FileXImageSource(FileX(this@ReaderActivity, image)) + }.onFailure { + logger.warning(it) + model.error(i) + }.getOrNull() + } + + logger.info { + "${imageSources.count { it == null }} nulls" } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/dialog/SourceSelectDialog.kt b/app/src/main/java/xyz/quaver/pupil/ui/dialog/SourceSelectDialog.kt index d51c2a90..1cdf2747 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/dialog/SourceSelectDialog.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/dialog/SourceSelectDialog.kt @@ -20,15 +20,9 @@ package xyz.quaver.pupil.ui.dialog import android.app.Dialog import android.os.Bundle -import android.view.ViewGroup.LayoutParams -import android.view.Window import androidx.fragment.app.DialogFragment -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import org.kodein.di.* +import org.kodein.di.DIAware import org.kodein.di.android.x.closestDI -import xyz.quaver.pupil.adapters.SourceAdapter -import xyz.quaver.pupil.sources.* class SourceSelectDialog : DialogFragment(), DIAware { diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt index 676f40b4..e80c1135 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt @@ -76,6 +76,7 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { var imageCount by mutableStateOf(0) private set + private var images: List? = null val imageList = mutableStateListOf() val progressList = mutableStateListOf() @@ -140,6 +141,8 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { withContext(Dispatchers.IO) { source.images(itemID) }.let { images -> + this@ReaderViewModel.images = images + imageCount = images.size progressList.addAll(List(imageCount) { 0f }) @@ -151,13 +154,11 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { images.forEachIndexed { index, image -> when (val scheme = image.takeWhile { it != ':' }) { "http", "https" -> { - val file = cache.load { + val (channel, file) = cache.load { url(image) headers(source.getHeadersBuilderForImage(itemID, image)) } - val channel = cache.channels[image] ?: error("Channel is null") - if (channel.isClosedForReceive) { imageList[index] = Uri.fromFile(file) totalProgressMutex.withLock { @@ -187,6 +188,7 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { } } "content" -> { + imageList[index] = Uri.parse(image) progressList[index] = 1f } else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") @@ -211,4 +213,9 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { } } + override fun onCleared() { + cache.cleanup() + images?.let { cache.free(it) } + } + } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt similarity index 68% rename from app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt rename to app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt index 37f7df85..7c15bc0d 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt @@ -26,6 +26,7 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* +import io.ktor.util.collections.* import io.ktor.utils.io.* import io.ktor.utils.io.core.* import kotlinx.coroutines.* @@ -38,39 +39,64 @@ import org.kodein.log.LoggerFactory import org.kodein.log.newLogger import xyz.quaver.hitomi.sha256 import java.io.File +import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import kotlin.text.toByteArray +private const val CACHE_LIMIT = 100*1024*1024 // 100M + class NetworkCache(context: Context) : DIAware { override val di by closestDI(context) + private val logger = newLogger(LoggerFactory.default) + private val client: HttpClient by instance() + private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher()) private val cacheDir = context.cacheDir - private val _channels = ConcurrentHashMap>() - val channels = _channels as Map> + private val channel = ConcurrentHashMap>() + private val requests = ConcurrentHashMap() + private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap()) - private val requests = mutableMapOf() + private fun urlToFilename(url: String): String { + val hash = sha256(url.toByteArray()).joinToString("") { "%02x".format(it) } + return "$hash.${url.takeLastWhile { it != '.' }}" + } - private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher()) + fun cleanup() = CoroutineScope(Dispatchers.IO).launch { + if (cacheDir.size() > CACHE_LIMIT) + cacheDir.listFiles { file -> file.name !in activeFiles }?.forEach { it.delete() } + } - private val logger = newLogger(LoggerFactory.default) + fun free(urls: List) = urls.forEach { + requests[it]?.cancel() + channel.remove(it) + activeFiles.remove(urlToFilename(it)) + } + + fun clear() = CoroutineScope(Dispatchers.IO).launch { + requests.values.forEach { it.cancel() } + channel.clear() + activeFiles.clear() + cacheDir.listFiles()?.forEach { it.delete() } + } @OptIn(ExperimentalCoroutinesApi::class) - suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): File = coroutineScope { + suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): Pair, File> = coroutineScope { val request = HttpRequestBuilder().apply(requestBuilder) val url = request.url.buildString() - val hash = sha256(url.toByteArray()).joinToString("") { "%02x".format(it) } - val file = File(cacheDir, "$hash.${url.takeLastWhile { it != '.' }}") + val fileName = urlToFilename(url) + val file = File(cacheDir, fileName) + activeFiles.add(fileName) - val progressChannel = if (_channels[url]?.isClosedForSend == false) - _channels[url]!! + val progressChannel = if (channel[url]?.isClosedForSend == false) + channel[url]!! else - Channel(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it } + Channel(1, BufferOverflow.DROP_OLDEST).also { channel[url] = it } if (file.exists()) progressChannel.close() @@ -86,8 +112,18 @@ class NetworkCache(context: Context) : DIAware { file.outputStream().use { outputStream -> while (!responseChannel.isClosedForRead) { + if (!isActive) { + file.delete() + break + } + val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) while (!packet.isEmpty) { + if (!isActive) { + file.delete() + break + } + val bytes = packet.readBytes() outputStream.write(bytes) @@ -106,6 +142,6 @@ class NetworkCache(context: Context) : DIAware { } } - return@coroutineScope file + return@coroutineScope progressChannel to file } } \ No newline at end of file