NetworkCache

This commit is contained in:
tom5079
2021-12-16 12:19:32 +09:00
parent b690d01243
commit 78ba11ca5f
8 changed files with 91 additions and 184 deletions

17
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_30_x86.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-12-16T03:12:12.593009Z" />
</component>
</project>

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<MirrorAdapter.ViewHolder>() {
@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<String>("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<String>) -> (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
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SourceAdapter.ViewHolder>() {
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
}

View File

@@ -87,7 +87,7 @@ class ReaderActivity : ComponentActivity(), DIAware {
val imageHeights = remember { mutableStateListOf<Float?>() }
val states = remember { mutableStateListOf<SubSampledImageState>() }
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"
}
}

View File

@@ -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 {

View File

@@ -76,6 +76,7 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
var imageCount by mutableStateOf(0)
private set
private var images: List<String>? = null
val imageList = mutableStateListOf<Uri?>()
val progressList = mutableStateListOf<Float>()
@@ -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) }
}
}

View File

@@ -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<String, Channel<Float>>()
val channels = _channels as Map<String, Channel<Float>>
private val channel = ConcurrentHashMap<String, Channel<Float>>()
private val requests = ConcurrentHashMap<String, Job>()
private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val requests = mutableMapOf<String, Job>()
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<String>) = 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<Channel<Float>, 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<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it }
Channel<Float>(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
}
}