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 imageHeights = remember { mutableStateListOf<Float?>() }
|
||||||
val states = remember { mutableStateListOf<SubSampledImageState>() }
|
val states = remember { mutableStateListOf<SubSampledImageState>() }
|
||||||
|
|
||||||
LaunchedEffect(model.progressList.sum()) {
|
LaunchedEffect(model.imageList.count { it != null }) {
|
||||||
if (imageSources.isEmpty() && model.imageList.isNotEmpty())
|
if (imageSources.isEmpty() && model.imageList.isNotEmpty())
|
||||||
imageSources.addAll(List(model.imageList.size) { null })
|
imageSources.addAll(List(model.imageList.size) { null })
|
||||||
|
|
||||||
@@ -97,16 +97,22 @@ class ReaderActivity : ComponentActivity(), DIAware {
|
|||||||
if (imageHeights.isEmpty() && model.imageList.isNotEmpty())
|
if (imageHeights.isEmpty() && model.imageList.isNotEmpty())
|
||||||
imageHeights.addAll(List(model.imageList.size) { null })
|
imageHeights.addAll(List(model.imageList.size) { null })
|
||||||
|
|
||||||
|
logger.info {
|
||||||
|
"${model.imageList.count { it == null }} nulls"
|
||||||
|
}
|
||||||
|
|
||||||
model.imageList.forEachIndexed { i, image ->
|
model.imageList.forEachIndexed { i, image ->
|
||||||
if (imageSources[i] == null && image != null)
|
if (imageSources[i] == null && image != null)
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
imageSources[i] = kotlin.runCatching {
|
||||||
imageSources[i] = kotlin.runCatching {
|
FileXImageSource(FileX(this@ReaderActivity, image))
|
||||||
FileXImageSource(FileX(this@ReaderActivity, image))
|
}.onFailure {
|
||||||
}.onFailure {
|
logger.warning(it)
|
||||||
logger.warning(it)
|
model.error(i)
|
||||||
model.error(i)
|
}.getOrNull()
|
||||||
}.getOrNull()
|
}
|
||||||
}
|
|
||||||
|
logger.info {
|
||||||
|
"${imageSources.count { it == null }} nulls"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,9 @@ package xyz.quaver.pupil.ui.dialog
|
|||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.ViewGroup.LayoutParams
|
|
||||||
import android.view.Window
|
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import org.kodein.di.DIAware
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.kodein.di.*
|
|
||||||
import org.kodein.di.android.x.closestDI
|
import org.kodein.di.android.x.closestDI
|
||||||
import xyz.quaver.pupil.adapters.SourceAdapter
|
|
||||||
import xyz.quaver.pupil.sources.*
|
|
||||||
|
|
||||||
class SourceSelectDialog : DialogFragment(), DIAware {
|
class SourceSelectDialog : DialogFragment(), DIAware {
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
|||||||
var imageCount by mutableStateOf(0)
|
var imageCount by mutableStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var images: List<String>? = null
|
||||||
val imageList = mutableStateListOf<Uri?>()
|
val imageList = mutableStateListOf<Uri?>()
|
||||||
val progressList = mutableStateListOf<Float>()
|
val progressList = mutableStateListOf<Float>()
|
||||||
|
|
||||||
@@ -140,6 +141,8 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
source.images(itemID)
|
source.images(itemID)
|
||||||
}.let { images ->
|
}.let { images ->
|
||||||
|
this@ReaderViewModel.images = images
|
||||||
|
|
||||||
imageCount = images.size
|
imageCount = images.size
|
||||||
|
|
||||||
progressList.addAll(List(imageCount) { 0f })
|
progressList.addAll(List(imageCount) { 0f })
|
||||||
@@ -151,13 +154,11 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
|||||||
images.forEachIndexed { index, image ->
|
images.forEachIndexed { index, image ->
|
||||||
when (val scheme = image.takeWhile { it != ':' }) {
|
when (val scheme = image.takeWhile { it != ':' }) {
|
||||||
"http", "https" -> {
|
"http", "https" -> {
|
||||||
val file = cache.load {
|
val (channel, file) = cache.load {
|
||||||
url(image)
|
url(image)
|
||||||
headers(source.getHeadersBuilderForImage(itemID, image))
|
headers(source.getHeadersBuilderForImage(itemID, image))
|
||||||
}
|
}
|
||||||
|
|
||||||
val channel = cache.channels[image] ?: error("Channel is null")
|
|
||||||
|
|
||||||
if (channel.isClosedForReceive) {
|
if (channel.isClosedForReceive) {
|
||||||
imageList[index] = Uri.fromFile(file)
|
imageList[index] = Uri.fromFile(file)
|
||||||
totalProgressMutex.withLock {
|
totalProgressMutex.withLock {
|
||||||
@@ -187,6 +188,7 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"content" -> {
|
"content" -> {
|
||||||
|
imageList[index] = Uri.parse(image)
|
||||||
progressList[index] = 1f
|
progressList[index] = 1f
|
||||||
}
|
}
|
||||||
else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
|
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.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.util.*
|
import io.ktor.util.*
|
||||||
|
import io.ktor.util.collections.*
|
||||||
import io.ktor.utils.io.*
|
import io.ktor.utils.io.*
|
||||||
import io.ktor.utils.io.core.*
|
import io.ktor.utils.io.core.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -38,39 +39,64 @@ import org.kodein.log.LoggerFactory
|
|||||||
import org.kodein.log.newLogger
|
import org.kodein.log.newLogger
|
||||||
import xyz.quaver.hitomi.sha256
|
import xyz.quaver.hitomi.sha256
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import kotlin.text.toByteArray
|
import kotlin.text.toByteArray
|
||||||
|
|
||||||
|
private const val CACHE_LIMIT = 100*1024*1024 // 100M
|
||||||
|
|
||||||
class NetworkCache(context: Context) : DIAware {
|
class NetworkCache(context: Context) : DIAware {
|
||||||
override val di by closestDI(context)
|
override val di by closestDI(context)
|
||||||
|
|
||||||
|
private val logger = newLogger(LoggerFactory.default)
|
||||||
|
|
||||||
private val client: HttpClient by instance()
|
private val client: HttpClient by instance()
|
||||||
|
private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher())
|
||||||
|
|
||||||
private val cacheDir = context.cacheDir
|
private val cacheDir = context.cacheDir
|
||||||
|
|
||||||
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
|
private val channel = ConcurrentHashMap<String, Channel<Float>>()
|
||||||
val channels = _channels as Map<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)
|
@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 request = HttpRequestBuilder().apply(requestBuilder)
|
||||||
|
|
||||||
val url = request.url.buildString()
|
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)
|
val progressChannel = if (channel[url]?.isClosedForSend == false)
|
||||||
_channels[url]!!
|
channel[url]!!
|
||||||
else
|
else
|
||||||
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it }
|
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { channel[url] = it }
|
||||||
|
|
||||||
if (file.exists())
|
if (file.exists())
|
||||||
progressChannel.close()
|
progressChannel.close()
|
||||||
@@ -86,8 +112,18 @@ class NetworkCache(context: Context) : DIAware {
|
|||||||
|
|
||||||
file.outputStream().use { outputStream ->
|
file.outputStream().use { outputStream ->
|
||||||
while (!responseChannel.isClosedForRead) {
|
while (!responseChannel.isClosedForRead) {
|
||||||
|
if (!isActive) {
|
||||||
|
file.delete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||||
while (!packet.isEmpty) {
|
while (!packet.isEmpty) {
|
||||||
|
if (!isActive) {
|
||||||
|
file.delete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
val bytes = packet.readBytes()
|
val bytes = packet.readBytes()
|
||||||
outputStream.write(bytes)
|
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