From 6c13a624a9ce2392ff0b1d2c0f5fe88477d5a554 Mon Sep 17 00:00:00 2001 From: tom5079 Date: Wed, 1 Dec 2021 17:18:19 +0900 Subject: [PATCH] Shows Image --- app/build.gradle.kts | 2 +- app/src/main/java/xyz/quaver/pupil/Pupil.kt | 1 + .../java/xyz/quaver/pupil/sources/Common.kt | 3 +- .../xyz/quaver/pupil/sources/hitomi/Hitomi.kt | 8 +- .../xyz/quaver/pupil/ui/ReaderActivity.kt | 108 ++++++++++++++---- .../MultipleFloatingActionButton.kt | 24 ++-- .../pupil/ui/viewmodel/ReaderViewModel.kt | 80 ++++++++++++- .../java/xyz/quaver/pupil/util/ImageCache.kt | 106 +++++++++++++++++ .../main/java/xyz/quaver/pupil/util/misc.kt | 32 ++++++ build.gradle.kts | 3 +- 10 files changed, 326 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d99ebf9..457a90bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,7 +132,7 @@ dependencies { implementation("xyz.quaver:libpupil:2.1.11") implementation("xyz.quaver:documentfilex:0.7.1") implementation("xyz.quaver:floatingsearchview:1.1.7") - implementation("xyz.quaver:subsampledimage:0.0.1-alpha01-SNAPSHOT") + implementation("xyz.quaver:subsampledimage:0.0.1-alpha09-SNAPSHOT") implementation("org.kodein.log:kodein-log:0.11.1") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7") diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 3d135de4..fc18bd43 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -58,6 +58,7 @@ class Pupil : Application(), DIAware { import(sourceModule) bind { singleton { DownloadManager(applicationContext) } } + bind { singleton { NetworkCache(applicationContext) } } bind { singleton { HttpClient(OkHttp) { diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt index d04464d2..0e683216 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt @@ -21,6 +21,7 @@ package xyz.quaver.pupil.sources import android.app.Application import android.os.Parcelable import androidx.compose.runtime.Composable +import io.ktor.http.* import kotlinx.coroutines.channels.Channel import kotlinx.parcelize.Parcelize import org.kodein.di.* @@ -61,7 +62,7 @@ abstract class Source { @Composable open fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)? = null) { } - open fun getHeadersForImage(itemID: String, url: String): Map = emptyMap() + open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { } open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) { binding.leftIcon.setImageResource(R.drawable.tag) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt index 4ab25149..e091a3ad 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt @@ -190,7 +190,7 @@ class Hitomi(app: Application) : Source(), DIAware { val reader = getGalleryInfo(galleryID) return reader.files.map { - imageUrlFromImage(galleryID, it, true) + imageUrlFromImage(galleryID, it, false) } } @@ -225,9 +225,9 @@ class Hitomi(app: Application) : Source(), DIAware { FullSearchResult(itemInfo = itemInfo) } - override fun getHeadersForImage(itemID: String, url: String) = mapOf( - "Referer" to getReferer(itemID.toInt()) - ) + override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { + append("Referer", getReferer(itemID.toInt())) + } override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) { item as TagSuggestion 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 e489af8e..571d7bab 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt @@ -24,40 +24,44 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.Image +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import coil.annotation.ExperimentalCoilApi -import coil.compose.rememberImagePainter -import coil.request.ImageRequest -import coil.transform.BlurTransformation import com.google.accompanist.appcompattheme.AppCompatTheme -import io.ktor.http.* -import okhttp3.Headers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.log.LoggerFactory import org.kodein.log.newLogger -import xyz.quaver.graphics.subsampledimage.SubSampledImage +import xyz.quaver.graphics.subsampledimage.* +import xyz.quaver.io.FileX import xyz.quaver.pupil.R import xyz.quaver.pupil.ui.composable.FloatingActionButtonState import xyz.quaver.pupil.ui.composable.MultipleFloatingActionButton import xyz.quaver.pupil.ui.composable.SubFabItem import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel +import xyz.quaver.pupil.util.FileXImageSource +import kotlin.math.abs class ReaderActivity : ComponentActivity(), DIAware { override val di by closestDI() @@ -78,10 +82,31 @@ class ReaderActivity : ComponentActivity(), DIAware { val isFullscreen by model.isFullscreen.observeAsState(false) val title by model.title.observeAsState(stringResource(R.string.reader_loading)) val sourceIcon by model.sourceIcon.observeAsState() - val images by model.images.observeAsState(emptyList()) - val source by model.sourceInstance.observeAsState() + val imageSources = remember { mutableStateListOf() } + val imageHeights = remember { mutableStateListOf() } + val states = remember { mutableStateListOf() } - logger.debug { "target: ${R.drawable.hitomi} value: $sourceIcon" } + LaunchedEffect(model.totalProgress) { + if (imageSources.isEmpty() && model.imageList.isNotEmpty()) + imageSources.addAll(List(model.imageList.size) { null }) + + if (states.isEmpty() && model.imageList.isNotEmpty()) + states.addAll(List(model.imageList.size) { SubSampledImageState(ScaleTypes.FIT_WIDTH, Bounds.FORCE_OVERLAP_OR_CENTER) }) + + if (imageHeights.isEmpty() && model.imageList.isNotEmpty()) + imageHeights.addAll(List(model.imageList.size) { null }) + + 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 { + model.error(i) + }.getOrNull() + } + } + } WindowInsetsControllerCompat(window, window.decorView).run { if (isFullscreen) { @@ -121,7 +146,7 @@ class ReaderActivity : ComponentActivity(), DIAware { icon = Icons.Default.Fullscreen, label = stringResource(id = R.string.reader_fab_fullscreen) ) { - model.isFullscreen.postValue(!isFullscreen) + model.isFullscreen.postValue(true) } ), targetState = isFABExpanded, @@ -132,14 +157,57 @@ class ReaderActivity : ComponentActivity(), DIAware { } ) { LazyColumn( - verticalArrangement = Arrangement.spacedBy(32.dp) + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - items(images) { image -> - SubSampledImage( - modifier = Modifier.fillMaxWidth().heightIn(128.dp, 1000.dp) - ) + itemsIndexed(imageSources) { i, imageSource -> + LaunchedEffect(states[i].canvasSize, states[i].imageSize) { + if (imageHeights.isNotEmpty() && imageHeights[i] == null) + states[i].canvasSize?.let { canvasSize -> + states[i].imageSize?.let { imageSize -> + imageHeights[i] = imageSize.height * canvasSize.width / imageSize.width + } } + } + + Box( + Modifier + .height( + imageHeights + .getOrNull(i) + ?.let { with(LocalDensity.current) { it.toDp() } } + ?: 500.dp) + .fillMaxWidth() + .border(1.dp, Color.Gray), + contentAlignment = Alignment.Center + ) { + if (imageSource == null) + model.progressList.getOrNull(i)?.let { progress -> + if (progress < 0f) + Icon(Icons.Filled.BrokenImage, null) + else + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + LinearProgressIndicator(progress) + Text((i + 1).toString()) + } + } + else + SubSampledImage( + modifier = Modifier.fillMaxSize(), + imageSource = imageSource, + state = states[i] + ) + } } } + + if (model.totalProgress != model.imageCount) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = model.progressList.map { abs(it) }.sum() / model.progressList.size, + color = colorResource(id = R.color.colorAccent) + ) } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt index 9bd49333..638fee8f 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt @@ -1,6 +1,8 @@ package xyz.quaver.pupil.ui.composable import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -17,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -72,16 +75,17 @@ fun MiniFloatingActionButton( } } - FloatingActionButton( - modifier = Modifier - .size(40.dp) - .scale(buttonScale), - onClick = { onClick?.invoke(item) }, - elevation = elevation, - interactionSource = interactionSource - ) { - Icon(item.icon, contentDescription = null) - } + if (buttonScale > 0f) + FloatingActionButton( + modifier = Modifier + .size(40.dp) + .scale(buttonScale), + onClick = { onClick?.invoke(item) }, + elevation = elevation, + interactionSource = interactionSource + ) { + Icon(item.icon, contentDescription = null) + } } } 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 40250d1e..237bf538 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 @@ -21,8 +21,17 @@ package xyz.quaver.pupil.ui.viewmodel import android.app.Application import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.* +import io.ktor.client.request.* +import io.ktor.http.* import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.kodein.di.DIAware import org.kodein.di.android.x.closestDI import org.kodein.di.direct @@ -33,6 +42,7 @@ import xyz.quaver.pupil.db.AppDatabase import xyz.quaver.pupil.db.Bookmark import xyz.quaver.pupil.db.History import xyz.quaver.pupil.sources.Source +import xyz.quaver.pupil.util.NetworkCache import xyz.quaver.pupil.util.source @Suppress("UNCHECKED_CAST") @@ -40,6 +50,8 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { override val di by closestDI() + private val cache: NetworkCache by instance() + private val logger = newLogger(LoggerFactory.default) val isFullscreen = MutableLiveData(false) @@ -58,8 +70,14 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { private val _title = MutableLiveData() val title = _title as LiveData - private val _images = MutableLiveData>() - val images: LiveData> = _images + private val totalProgressMutex = Mutex() + var totalProgress by mutableStateOf(0) + private set + var imageCount by mutableStateOf(0) + private set + + val imageList = mutableStateListOf() + val progressList = mutableStateListOf() val isBookmarked = Transformations.switchMap(MediatorLiveData>().apply { addSource(source) { source -> itemID.value?.let { itemID -> source to itemID } } @@ -119,12 +137,66 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { } viewModelScope.launch { - _images.postValue(withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { source.images(itemID) - }) + }.let { images -> + imageCount = images.size + + progressList.addAll(List(imageCount) { 0f }) + imageList.addAll(List(imageCount) { null }) + + images.forEachIndexed { index, image -> + when (val scheme = image.takeWhile { it != ':' }) { + "http", "https" -> { + val 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 { + totalProgress++ + } + } else { + channel.invokeOnClose { e -> + viewModelScope.launch { + if (e == null) { + imageList[index] = Uri.fromFile(file) + totalProgressMutex.withLock { + totalProgress++ + } + } else { + TODO("Handle error") + } + } + } + + launch { + kotlin.runCatching { + for (progress in channel) { + progressList[index] = progress + } + } + } + } + } + "content" -> { + progressList[index] = 1f + } + else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") + } + } + } } } + fun error(index: Int) { + progressList[index] = -1f + } + fun toggleBookmark() { val bookmark = source.value?.let { source -> itemID.value?.let { itemID -> Bookmark(source, itemID) } } ?: return diff --git a/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt b/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt new file mode 100644 index 00000000..dbb659ba --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt @@ -0,0 +1,106 @@ +/* + * 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 . + */ + +package xyz.quaver.pupil.util + +import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import io.ktor.utils.io.* +import io.ktor.utils.io.core.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import org.kodein.di.instance +import xyz.quaver.hitomi.sha256 +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import kotlin.text.toByteArray + +class NetworkCache(context: Context) : DIAware { + override val di by closestDI(context) + + private val client: HttpClient by instance() + + private val cacheDir = context.cacheDir + + private val _channels = ConcurrentHashMap>() + val channels = _channels as Map> + + private val requests = mutableMapOf() + + private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher()) + + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): 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 progressChannel = if (_channels[url]?.isClosedForSend == false) + _channels[url]!! + else + Channel(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it } + + if (file.exists()) + progressChannel.close() + else + requests[url] = networkScope.launch { + kotlin.runCatching { + file.createNewFile() + + client.request(request).execute { httpResponse -> + val responseChannel: ByteReadChannel = httpResponse.receive() + val contentLength = httpResponse.contentLength() ?: -1 + var readBytes = 0f + + file.outputStream().use { outputStream -> + while (!responseChannel.isClosedForRead) { + val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) + while (!packet.isEmpty) { + val bytes = packet.readBytes() + outputStream.write(bytes) + + readBytes += bytes.size + progressChannel.trySend(readBytes / contentLength) + } + } + } + progressChannel.close() + } + }.onFailure { + file.delete() + FirebaseCrashlytics.getInstance().recordException(it) + progressChannel.close(it) + } + } + + return@coroutineScope file + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt index 2bcf7ea1..a2f9d887 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -19,17 +19,33 @@ package xyz.quaver.pupil.util import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.BitmapRegionDecoder import android.view.MenuItem import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toAndroidRect import androidx.lifecycle.MutableLiveData import kotlinx.serialization.json.* import org.kodein.di.DIAware import org.kodein.di.DirectDIAware import org.kodein.di.direct import org.kodein.di.instance +import xyz.quaver.graphics.subsampledimage.ImageSource +import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder +import xyz.quaver.io.FileX +import xyz.quaver.io.util.inputStream import xyz.quaver.pupil.db.AppDatabase import xyz.quaver.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.SourceEntries +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.* @@ -136,4 +152,20 @@ fun View.hide() { fun View.show() { visibility = View.VISIBLE +} + +class FileXImageSource(file: FileX): ImageSource { + private val decoder = newBitmapRegionDecoder(file.inputStream()!!) + + override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) } + + override fun decodeRegion(region: Rect, sampleSize: Int): ImageBitmap = + decoder.decodeRegion(region.toAndroidRect(), BitmapFactory.Options().apply { + inSampleSize = sampleSize + }).asImageBitmap() +} + +@Composable +fun rememberFileXImageSource(file: FileX) = remember { + FileXImageSource(file) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 83b141a4..803eefd3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ buildscript { classpath("com.google.gms:google-services:4.3.10") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files - classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") + classpath("com.google.firebase:firebase-crashlytics-gradle:2.8.0") classpath("com.google.firebase:perf-plugin:1.4.0") classpath("com.google.android.gms:oss-licenses-plugin:0.10.4") } @@ -23,6 +23,7 @@ allprojects { repositories { google() mavenCentral() + mavenLocal() maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } maven { url = uri("https://jitpack.io") } }