NetworkCache
This commit is contained in:
17
.idea/deploymentTargetDropDown.xml
generated
Normal file
17
.idea/deploymentTargetDropDown.xml
generated
Normal 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>
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user