From f6f0ed40c1c2c1d7c72f01e6f19e42ce6bdeff7d Mon Sep 17 00:00:00 2001 From: tom5079 Date: Mon, 20 Dec 2021 18:04:29 +0900 Subject: [PATCH] Reader bug fix --- .idea/deploymentTargetDropDown.xml | 8 +- app/build.gradle.kts | 2 +- app/src/main/java/xyz/quaver/pupil/Pupil.kt | 7 +- .../pupil/sources/composable/ReaderBase.kt | 284 +++++++----------- .../xyz/quaver/pupil/sources/hitomi/Hitomi.kt | 101 +++++-- .../quaver/pupil/sources/manatoki/Manatoki.kt | 255 +++++++++++----- .../composable/MangaListingBottomSheet.kt | 25 +- .../xyz/quaver/pupil/sources/manatoki/util.kt | 18 +- .../manatoki/viewmodel/MainViewModel.kt | 4 +- .../java/xyz/quaver/pupil/ui/MainActivity.kt | 2 +- .../xyz/quaver/pupil/util/NetworkCache.kt | 116 ++++--- .../main/java/xyz/quaver/pupil/util/misc.kt | 6 +- .../java/xyz/quaver/pupil/ExampleUnitTest.kt | 37 +-- 13 files changed, 497 insertions(+), 368 deletions(-) diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 8a4d8845..4ed21896 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -1,9 +1,9 @@ - + - + @@ -11,7 +11,7 @@ - - + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d36af219..7161bf0f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,7 +136,7 @@ dependencies { implementation("ru.noties.markwon:core:3.1.0") implementation("xyz.quaver:documentfilex:0.7.1") - implementation("xyz.quaver:subsampledimage:0.0.1-alpha11-SNAPSHOT") + implementation("xyz.quaver:subsampledimage:0.0.1-alpha13-SNAPSHOT") implementation("com.google.guava:guava:31.0.1-android") diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 325cf7b8..72d1fe71 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -38,6 +38,7 @@ import com.google.firebase.ktx.Firebase import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.features.* +import io.ktor.client.features.cache.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import okhttp3.Protocol @@ -73,8 +74,12 @@ class Pupil : Application(), DIAware { socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS connectTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS } + install(HttpCache) - BrowserUserAgent() + install(UserAgent) { + agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36" + } + //BrowserUserAgent() } } } } diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt index 7e05f5aa..d5bfc82f 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt @@ -29,9 +29,6 @@ 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.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarOutline import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,7 +36,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -47,19 +43,22 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.navigationBarsPadding import com.google.accompanist.insets.rememberInsetsPaddingValues -import com.google.accompanist.insets.ui.Scaffold -import com.google.accompanist.insets.ui.TopAppBar import io.ktor.client.request.* import io.ktor.http.* import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.di.instance +import org.kodein.log.LoggerFactory +import org.kodein.log.newLogger import xyz.quaver.graphics.subsampledimage.* import xyz.quaver.io.FileX import xyz.quaver.pupil.R @@ -68,33 +67,47 @@ import xyz.quaver.pupil.ui.theme.Orange500 import xyz.quaver.pupil.util.NetworkCache import xyz.quaver.pupil.util.activity import xyz.quaver.pupil.util.rememberFileXImageSource +import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAware { override val di by closestDI(app) + private val logger = newLogger(LoggerFactory.default) + private val cache: NetworkCache by instance() - var isFullscreen by mutableStateOf(false) + var fullscreen by mutableStateOf(false) private val database: AppDatabase by instance() var error by mutableStateOf(false) - var title by mutableStateOf(null) - var imageCount by mutableStateOf(0) val imageList = mutableStateListOf() val progressList = mutableStateListOf() + private val progressCollectJobs = ConcurrentHashMap() + private val totalProgressMutex = Mutex() var totalProgress by mutableStateOf(0) private set + private var urls: List? = null + + var loadJob: Job? = null @OptIn(ExperimentalCoroutinesApi::class) fun load(urls: List, headerBuilder: HeadersBuilder.() -> Unit = { }) { + this.urls = urls viewModelScope.launch { + loadJob?.cancelAndJoin() + progressList.clear() + imageList.clear() + totalProgressMutex.withLock { + totalProgress = 0 + } + imageCount = urls.size progressList.addAll(List(imageCount) { 0f }) @@ -103,79 +116,61 @@ open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAwar totalProgress = 0 } - urls.forEachIndexed { index, url -> - when (val scheme = url.takeWhile { it != ':' }) { - "http", "https" -> { - val (channel, file) = cache.load { - url(url) - headers(headerBuilder) - } + loadJob = launch { + urls.forEachIndexed { index, url -> + when (val scheme = url.takeWhile { it != ':' }) { + "http", "https" -> { + val (flow, file) = cache.load { + url(url) + headers(headerBuilder) + } - 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) - - } else { - error(index) - } - imageList[index] = Uri.fromFile(file) - totalProgressMutex.withLock { - totalProgress++ - } + progressCollectJobs[index] = launch { + flow.takeWhile { it.isFinite() }.collect { + progressList[index] = it } - } - launch { - kotlin.runCatching { - for (progress in channel) { - progressList[index] = progress - } - } + progressList[index] = flow.value } } + "content" -> { + imageList[index] = Uri.parse(url) + progressList[index] = Float.POSITIVE_INFINITY + } + else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") } - "content" -> { - imageList[index] = Uri.parse(url) - progressList[index] = 1f - } - else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") } } } } fun error(index: Int) { - progressList[index] = -1f + progressList[index] = Float.NEGATIVE_INFINITY + } + + override fun onCleared() { + urls?.let { cache.free(it) } + cache.cleanup() } } @OptIn(ExperimentalFoundationApi::class) @Composable fun ReaderBase( - model: ReaderBaseViewModel, - icon: @Composable () -> Unit = { }, - bookmark: Boolean = false, - onToggleBookmark: () -> Unit = { } + modifier: Modifier = Modifier, + model: ReaderBaseViewModel ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current - var isFABExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) } - val scaffoldState = rememberScaffoldState() val snackbarCoroutineScope = rememberCoroutineScope() - LaunchedEffect(model.isFullscreen) { + LaunchedEffect(model.fullscreen) { context.activity?.window?.let { window -> ViewCompat.getWindowInsetsController(window.decorView)?.let { - if (model.isFullscreen) { + if (model.fullscreen) { it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.hide(WindowInsetsCompat.Type.systemBars()) @@ -195,130 +190,79 @@ fun ReaderBase( } } - Scaffold( - topBar = { - if (!model.isFullscreen) - TopAppBar( - title = { - Text( - model.title ?: stringResource(R.string.reader_loading), - color = MaterialTheme.colors.onSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actions = { - IconButton(onClick = { }) { - icon() - } + Box(modifier) { + LazyColumn( + Modifier + .fillMaxSize() + .align(Alignment.TopStart), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars) + ) { + itemsIndexed(model.imageList) { i, uri -> + val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH) - IconButton(onClick = onToggleBookmark) { - Icon( - if (bookmark) Icons.Default.Star else Icons.Default.StarOutline, - contentDescription = null, - tint = Orange500 - ) - } - }, - contentPadding = rememberInsetsPaddingValues( - LocalWindowInsets.current.statusBars, - applyBottom = false - ) - ) - }, - floatingActionButton = { - if (!model.isFullscreen) - MultipleFloatingActionButton( - modifier = Modifier.navigationBarsPadding(), - items = listOf( - SubFabItem( - icon = Icons.Default.Fullscreen, - label = stringResource(id = R.string.reader_fab_fullscreen) + Box( + Modifier + .wrapContentHeight(state, 500.dp) + .fillMaxWidth() + .border(1.dp, Color.Gray), + contentAlignment = Alignment.Center + ) { + val progress = model.progressList.getOrNull(i) ?: 0f + + if (progress == Float.NEGATIVE_INFINITY) + Icon(Icons.Filled.BrokenImage, null, tint = Orange500) + else if (progress.isFinite()) + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - model.isFullscreen = true + LinearProgressIndicator(progress) + Text((i + 1).toString()) } - ), - targetState = isFABExpanded, - onStateChanged = { - isFABExpanded = it - } - ) - }, - scaffoldState = scaffoldState, - snackbarHost = { scaffoldState.snackbarHostState } - ) { contentPadding -> - Box(Modifier.padding(contentPadding)) { - LazyColumn( - Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars) - ) { - itemsIndexed(model.imageList) { i, uri -> - val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH) + else if (uri != null && progress == Float.POSITIVE_INFINITY) { + val imageSource = kotlin.runCatching { + rememberFileXImageSource(FileX(context, uri)) + }.getOrNull() - Box( - Modifier - .wrapContentHeight(state, 500.dp) - .fillMaxWidth() - .border(1.dp, Color.Gray), - contentAlignment = Alignment.Center - ) { - if (uri == 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 { - val imageSource = kotlin.runCatching { - rememberFileXImageSource(FileX(context, uri)) - }.getOrNull() + if (imageSource != null) + SubSampledImage( + modifier = Modifier + .fillMaxSize() + .run { + if (model.fullscreen) + doubleClickCycleZoom(state, 2f) + else + combinedClickable( + onLongClick = { - if (imageSource == null) - Icon(Icons.Default.BrokenImage, contentDescription = null) - else - SubSampledImage( - modifier = Modifier - .fillMaxSize() - .run { - if (model.isFullscreen) - doubleClickCycleZoom(state, 2f) - else - combinedClickable( - onLongClick = { - - } - ) { - model.isFullscreen = true } - }, - imageSource = imageSource, - state = state - ) - } + ) { + model.fullscreen = true + } + }, + imageSource = imageSource, + state = state, + onError = { + model.error(i) + } + ) } } } - - if (model.totalProgress != model.imageCount) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter), - progress = model.progressList.map { abs(it) }.sum() / model.progressList.size, - color = MaterialTheme.colors.secondary - ) - - SnackbarHost( - scaffoldState.snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) } + + if (model.progressList.any { it.isFinite() }) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + progress = model.progressList.map { if (it.isInfinite()) 1f else abs(it) }.sum() / model.progressList.size, + color = MaterialTheme.colors.secondary + ) + + SnackbarHost( + scaffoldState.snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } \ 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/Hitomi.kt index 3a24a881..8594ac4f 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 @@ -19,27 +19,32 @@ package xyz.quaver.pupil.sources.hitomi import android.app.Application +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import com.google.accompanist.insets.LocalWindowInsets +import com.google.accompanist.insets.rememberInsetsPaddingValues +import com.google.accompanist.insets.ui.Scaffold +import com.google.accompanist.insets.ui.TopAppBar import io.ktor.client.* import kotlinx.coroutines.launch import org.kodein.di.DIAware @@ -53,9 +58,11 @@ import xyz.quaver.pupil.db.AppDatabase import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.sources.composable.* import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult +import xyz.quaver.pupil.sources.hitomi.lib.GalleryInfo import xyz.quaver.pupil.sources.hitomi.lib.getGalleryInfo import xyz.quaver.pupil.sources.hitomi.lib.getReferer import xyz.quaver.pupil.sources.hitomi.lib.imageUrlFromImage +import xyz.quaver.pupil.ui.theme.Orange500 class Hitomi(app: Application) : Source(), DIAware { override val di by closestDI(app) @@ -195,44 +202,76 @@ class Hitomi(app: Application) : Source(), DIAware { val coroutineScope = rememberCoroutineScope() - val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID") ?: "" + val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID") - if (itemID.isEmpty()) model.error = true + if (itemID == null) model.error = true - val bookmark by bookmarkDao.contains(name, itemID).observeAsState(false) - - LaunchedEffect(itemID) { + val bookmark by bookmarkDao.contains(name, itemID ?: "").observeAsState(false) + val galleryInfo by produceState(null) { runCatching { - val galleryID = itemID.toInt() + val galleryID = itemID!!.toInt() - val galleryInfo = getGalleryInfo(client, galleryID) - - model.title = galleryInfo.title - - model.load(galleryInfo.files.map { imageUrlFromImage(galleryID, it, false) }) { - append("Referer", getReferer(galleryID)) + value = getGalleryInfo(client, galleryID).also { + model.load(it.files.map { imageUrlFromImage(galleryID, it, false) }) { + append("Referer", getReferer(galleryID)) + } } }.onFailure { model.error = true } } - ReaderBase( - model, - icon = { - Image( - painter = painterResource(R.drawable.hitomi), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - }, - bookmark = bookmark, - onToggleBookmark = { - coroutineScope.launch { - if (itemID.isEmpty() || bookmark) bookmarkDao.delete(name, itemID) - else bookmarkDao.insert(name, itemID) - } + BackHandler { + if (model.fullscreen) model.fullscreen = false + else navController.popBackStack() + } + + Scaffold( + topBar = { + if (!model.fullscreen) + TopAppBar( + title = { + Text( + galleryInfo?.title ?: stringResource(R.string.reader_loading), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + IconButton({ }) { + Image( + painter = painterResource(R.drawable.hitomi), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = { + itemID?.let { + coroutineScope.launch { + if (bookmark) bookmarkDao.delete(name, it) + else bookmarkDao.insert(name, it) + } + } + }) { + Icon( + if (bookmark) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null, + tint = Orange500 + ) + } + }, + contentPadding = rememberInsetsPaddingValues( + LocalWindowInsets.current.statusBars, + applyBottom = false + ) + ) } - ) + ) { contentPadding -> + ReaderBase( + Modifier.padding(contentPadding), + model + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt index a3decdf4..047b4b73 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt @@ -18,24 +18,27 @@ package xyz.quaver.pupil.sources.manatoki import android.app.Application +import android.util.LruCache import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarOutline import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -50,11 +53,13 @@ import com.google.accompanist.insets.rememberInsetsPaddingValues import com.google.accompanist.insets.ui.Scaffold import com.google.accompanist.insets.ui.TopAppBar import io.ktor.client.* -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.di.compose.rememberInstance +import org.kodein.di.instance import org.kodein.log.LoggerFactory import org.kodein.log.newLogger import xyz.quaver.pupil.R @@ -67,17 +72,20 @@ import xyz.quaver.pupil.sources.manatoki.composable.BoardButton import xyz.quaver.pupil.sources.manatoki.composable.MangaListingBottomSheet import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel -import java.util.concurrent.ConcurrentHashMap +import xyz.quaver.pupil.ui.theme.Orange500 class Manatoki(app: Application) : Source(), DIAware { override val di by closestDI(app) private val logger = newLogger(LoggerFactory.default) + private val client: HttpClient by instance() + override val name = "manatoki.net" override val iconResID = R.drawable.manatoki - private val readerInfoChannel = ConcurrentHashMap>() + private val readerInfoMutex = Mutex() + private val readerInfoCache = LruCache(25) override fun NavGraphBuilder.navGraph(navController: NavController) { navigation(route = name, startDestination = "manatoki.net/") { @@ -91,8 +99,6 @@ class Manatoki(app: Application) : Source(), DIAware { fun Main(navController: NavController) { val model: MainViewModel = viewModel() - val client: HttpClient by rememberInstance() - val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) } @@ -100,22 +106,16 @@ class Manatoki(app: Application) : Source(), DIAware { val onListing: (MangaListing) -> Unit = { mangaListing = it - logger.info { - it.toString() - } - coroutineScope.launch { - sheetState.show() - } } val onReader: (ReaderInfo) -> Unit = { readerInfo -> - val channel = Channel() - readerInfoChannel[readerInfo.itemID] = channel - coroutineScope.launch { - channel.send(readerInfo) + readerInfoMutex.withLock { + readerInfoCache.put(readerInfo.itemID, readerInfo) + } + sheetState.snapTo(ModalBottomSheetValue.Hidden) + navController.navigate("manatoki.net/reader/${readerInfo.itemID}") } - navController.navigate("manatoki.net/reader/${readerInfo.itemID}") } var sourceSelectDialog by remember { mutableStateOf(false) } @@ -124,11 +124,6 @@ class Manatoki(app: Application) : Source(), DIAware { SourceSelectDialog(navController, name) { sourceSelectDialog = false } LaunchedEffect(Unit) { - navController.backQueue.forEach { - logger.info { - it.destination.route.toString() - } - } model.load() } @@ -198,6 +193,10 @@ class Manatoki(app: Application) : Source(), DIAware { ) { items(model.recentUpload) { item -> Thumbnail(item) { + coroutineScope.launch { + mangaListing = null + sheetState.show() + } coroutineScope.launch { client.getItem(it, onListing, onReader) } @@ -237,6 +236,10 @@ class Manatoki(app: Application) : Source(), DIAware { ) { items(model.mangaList) { item -> Thumbnail(item) { + coroutineScope.launch { + mangaListing = null + sheetState.show() + } coroutineScope.launch { client.getItem(it, onListing, onReader) } @@ -250,29 +253,49 @@ class Manatoki(app: Application) : Source(), DIAware { verticalArrangement = Arrangement.spacedBy(8.dp) ) { model.topWeekly.forEachIndexed { index, item -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + Card( + modifier = Modifier.clickable { + coroutineScope.launch { + mangaListing = null + sheetState.show() + } + + coroutineScope.launch { + client.getItem(item.itemID, onListing, onReader) + } + } ) { - Text( - (index + 1).toString(), - modifier = Modifier - .background(Color(0xFF64C3F5)) - .width(24.dp), - color = Color.White, - textAlign = TextAlign.Center - ) + Row( + modifier = Modifier.height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .background(Color(0xFF64C3F5)) + .width(24.dp) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Text( + (index + 1).toString(), + color = Color.White, + textAlign = TextAlign.Center + ) + } - Text( - item.title, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + item.title, + modifier = Modifier.weight(1f).padding(0.dp, 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - Text( - item.count, - color = Color(0xFFFF4500) - ) + Text( + item.count, + color = Color(0xFFFF4500) + ) + } } } } @@ -284,6 +307,7 @@ class Manatoki(app: Application) : Source(), DIAware { } } + @OptIn(ExperimentalMaterialApi::class) @Composable fun Reader(navController: NavController) { val model: ReaderBaseViewModel = viewModel() @@ -294,46 +318,127 @@ class Manatoki(app: Application) : Source(), DIAware { val coroutineScope = rememberCoroutineScope() val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID") + var readerInfo: ReaderInfo? by rememberSaveable { mutableStateOf(null) } - LaunchedEffect(Unit) { - val channel = itemID?.let { readerInfoChannel.remove(it) } - - if (channel == null) - model.error = true - else { - val readerInfo = channel.receive() - - model.title = readerInfo.title - model.load(readerInfo.urls) - } + LaunchedEffect(itemID) { + if (itemID != null) + readerInfoMutex.withLock { + readerInfoCache.get(itemID)?.let { + readerInfo = it + model.load(it.urls) + } ?: run { + model.error = true + } + } } val bookmark by bookmarkDao.contains(name, itemID ?: "").observeAsState(false) + val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) } + BackHandler { - if (model.isFullscreen) - model.isFullscreen = false - else - navController.popBackStack() + when { + sheetState.isVisible -> coroutineScope.launch { sheetState.hide() } + model.fullscreen -> model.fullscreen = false + else -> navController.popBackStack() + } } - ReaderBase( - model, - icon = { - Image( - painter = painterResource(R.drawable.manatoki), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - }, - bookmark = bookmark, - onToggleBookmark = { - if (itemID != null) + ModalBottomSheetLayout( + sheetState = sheetState, + sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp), + sheetContent = { + MangaListingBottomSheet(mangaListing) { coroutineScope.launch { - if (bookmark) bookmarkDao.delete(name, itemID) - else bookmarkDao.insert(name, itemID) + client.getItem( + it, + onReader = { + coroutineScope.launch { + readerInfoMutex.withLock { + readerInfoCache.put(it.itemID, it) + } + navController.navigate("manatoki.net/reader/${it.itemID}") { + popUpTo("manatoki.net/reader/$itemID") { inclusive = true } + } + } + } + ) } + } } - ) + ) { + Scaffold( + topBar = { + if (!model.fullscreen) + TopAppBar( + title = { + Text( + readerInfo?.title ?: stringResource(R.string.reader_loading), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + IconButton({ }) { + Image( + painter = painterResource(R.drawable.manatoki), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = { + itemID?.let { + coroutineScope.launch { + if (bookmark) bookmarkDao.delete(name, it) + else bookmarkDao.insert(name, it) + } + } + }) { + Icon( + if (bookmark) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null, + tint = Orange500 + ) + } + }, + contentPadding = rememberInsetsPaddingValues( + LocalWindowInsets.current.statusBars, + applyBottom = false + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + modifier = Modifier.navigationBarsPadding(), + onClick = { + readerInfo?.let { + coroutineScope.launch { + sheetState.show() + } + + coroutineScope.launch { + if (mangaListing?.itemID != it.listingItemID) + client.getItem(it.listingItemID, onListing = { + mangaListing = it + }) + } + } + } + ) { + Icon( + Icons.Default.List, + contentDescription = null + ) + } + } + ) { contentPadding -> + ReaderBase( + Modifier.padding(contentPadding), + model + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt index 4bc30ec9..c7e90ec0 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt @@ -98,12 +98,16 @@ fun MangaListingBottomSheet( Box( modifier = Modifier.fillMaxWidth() ) { - mangaListing?.run { + if (mangaListing == null) + CircularProgressIndicator(Modifier.navigationBarsPadding().padding(16.dp).align(Alignment.Center)) + else MangaListingBottomSheetLayout( floatingActionButton = { ExtendedFloatingActionButton( text = { Text("첫화보기") }, - onClick = { entries.lastOrNull()?.let { onOpenItem(it.itemID) } } + onClick = { + mangaListing.entries.lastOrNull()?.let { onOpenItem(it.itemID) } + } ) }, top = { @@ -114,7 +118,7 @@ fun MangaListingBottomSheet( .padding(0.dp, 0.dp, 0.dp, 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - val painter = rememberImagePainter(thumbnail) + val painter = rememberImagePainter(mangaListing.thumbnail) Image( modifier = Modifier @@ -135,13 +139,13 @@ fun MangaListingBottomSheet( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - title, + mangaListing.title, style = MaterialTheme.typography.h5, modifier = Modifier.weight(1f) ) CompositionLocalProvider(LocalContentAlpha provides 0.7f) { - Text("작가: $author") + Text("작가: ${mangaListing.author}") Row(verticalAlignment = Alignment.CenterVertically) { Text("분류: ") @@ -151,7 +155,7 @@ fun MangaListingBottomSheet( modifier = Modifier.weight(1f), mainAxisSpacing = 8.dp ) { - tags.forEach { + mangaListing.tags.forEach { Card( elevation = 4.dp ) { @@ -166,7 +170,7 @@ fun MangaListingBottomSheet( } } - Text("발행구분: $type") + Text("발행구분: ${mangaListing.type}") } } } @@ -177,7 +181,7 @@ fun MangaListingBottomSheet( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars) ) { - items(entries) { entry -> + items(mangaListing.entries) { entry -> Row( modifier = Modifier .clickable { @@ -200,10 +204,5 @@ fun MangaListingBottomSheet( } } ) - } ?: run { - CircularProgressIndicator( - Modifier.align(Alignment.Center).navigationBarsPadding().padding(16.dp) - ) - } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt index 9c20f9b4..854f3f71 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt @@ -71,13 +71,14 @@ data class MangaListing( data class ReaderInfo( val itemID: String, val title: String, - val urls: List + val urls: List, + val listingItemID: String ): Parcelable suspend fun HttpClient.getItem( itemID: String, - onListing: (MangaListing) -> Unit, - onReader: (ReaderInfo) -> Unit + onListing: (MangaListing) -> Unit = { }, + onReader: (ReaderInfo) -> Unit = { } ) = coroutineScope { waitForRateLimit() val content: String = get("https://manatoki116.net/comic/$itemID") @@ -110,7 +111,16 @@ suspend fun HttpClient.getItem( val title = doc.getElementsByClass("toon-title").first()!!.ownText() - onReader(ReaderInfo(itemID, title, urls)) + val listingItemID = doc.select("a:contains(전체목록)").first()!!.attr("href").takeLastWhile { it != '/' } + + onReader( + ReaderInfo( + itemID, + title, + urls, + listingItemID + ) + ) } else { val titleBlock = doc.selectFirst("div.view-title")!! diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt index befdd738..c5af8f31 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt @@ -86,7 +86,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { } misoPostGallery[1] - .select(".post-image > a").also { logger.info { it.size.toString() } } + .select(".post-image > a") .forEach { entry -> val itemID = entry.attr("href").takeLastWhile { it != '/' } val title = entry.selectFirst("div.in-subject")!!.ownText() @@ -99,7 +99,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { val misoPostList = doc.select(".miso-post-list") misoPostList[4] - .select(".post-row > a") + .select(".post-row > a").also { logger.info { it.size.toString() } } .forEach { entry -> yield() val itemID = entry.attr("href").takeLastWhile { it != '/' } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index d70c97cf..26a62379 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -75,7 +75,7 @@ class MainActivity : ComponentActivity(), DIAware { LaunchedEffect(Unit) { if (!launched) { - val source = it.arguments?.getString("source") ?: "hitomi.la" + val source = it.arguments?.getString("source") ?: "manatoki.net" navController.navigate(source) launched = true } else { diff --git a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt index 805b55c5..49973fa9 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt @@ -19,6 +19,7 @@ package xyz.quaver.pupil.util import android.content.Context +import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics import io.ktor.client.* import io.ktor.client.call.* @@ -30,14 +31,17 @@ import io.ktor.util.collections.* 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.di.instance import org.kodein.log.LoggerFactory import org.kodein.log.newLogger import java.io.File +import java.io.IOException import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -55,8 +59,13 @@ class NetworkCache(context: Context) : DIAware { private val cacheDir = File(context.cacheDir, "networkcache") - private val channel = ConcurrentHashMap>() + private val flowMutex = Mutex() + private val flow = ConcurrentHashMap>() + + private val requestsMutex = Mutex() private val requests = ConcurrentHashMap() + + private val activeFilesMutex = Mutex() private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap()) private fun urlToFilename(url: String): String { @@ -69,21 +78,33 @@ class NetworkCache(context: Context) : DIAware { cacheDir.listFiles { file -> file.name !in activeFiles }?.forEach { it.delete() } } - fun free(urls: List) = urls.forEach { - requests[it]?.cancel() - channel.remove(it) - activeFiles.remove(urlToFilename(it)) + fun free(urls: List) = CoroutineScope(Dispatchers.IO).launch { + requestsMutex.withLock { + urls.forEach { + requests[it]?.cancel() + } + } + flowMutex.withLock { + urls.forEach { + flow.remove(it) + } + } + activeFilesMutex.withLock { + urls.forEach { + activeFiles.remove(urlToFilename(it)) + } + } } fun clear() = CoroutineScope(Dispatchers.IO).launch { requests.values.forEach { it.cancel() } - channel.clear() + flow.clear() activeFiles.clear() cacheDir.listFiles()?.forEach { it.delete() } } @OptIn(ExperimentalCoroutinesApi::class) - suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): Pair, File> = coroutineScope { + suspend fun load(force: Boolean = false, requestBuilder: HttpRequestBuilder.() -> Unit): Pair, File> = coroutineScope { val request = HttpRequestBuilder().apply(requestBuilder) val url = request.url.buildString() @@ -92,56 +113,65 @@ class NetworkCache(context: Context) : DIAware { val file = File(cacheDir, fileName) activeFiles.add(fileName) - val progressChannel = if (channel[url]?.isClosedForSend == false) - channel[url]!! - else - Channel(1, BufferOverflow.DROP_OLDEST).also { channel[url] = it } + val progressFlow = flowMutex.withLock { + if (flow.contains(url)) { + flow[url]!! + } else MutableStateFlow(0f).also { flow[url] = it } + } - if (file.exists()) - progressChannel.close() - else - requests[url] = networkScope.launch { - kotlin.runCatching { - cacheDir.mkdirs() - file.createNewFile() + requestsMutex.withLock { + if (!requests.contains(url) || force) { + if (force) requests[url]?.cancelAndJoin() - client.request(request).execute { httpResponse -> - val responseChannel: ByteReadChannel = httpResponse.receive() - val contentLength = httpResponse.contentLength() ?: -1 - var readBytes = 0f + requests[url] = networkScope.launch { + runCatching { + cacheDir.mkdirs() + file.createNewFile() - file.outputStream().use { outputStream -> - while (!responseChannel.isClosedForRead) { - if (!isActive) { - file.delete() - break - } + client.request(request).execute { httpResponse -> + if (!httpResponse.status.isSuccess()) throw IOException("${request.url} failed with code ${httpResponse.status.value}") + val responseChannel: ByteReadChannel = httpResponse.receive() + val contentLength = httpResponse.contentLength() ?: -1 + var readBytes = 0f - val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) - while (!packet.isEmpty) { + file.outputStream().use { outputStream -> + outputStream.channel.truncate(0) + while (!responseChannel.isClosedForRead) { if (!isActive) { file.delete() break } - val bytes = packet.readBytes() - outputStream.write(bytes) + val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) + while (!packet.isEmpty) { + if (!isActive) { + file.delete() + break + } - readBytes += bytes.size - progressChannel.trySend(readBytes / contentLength) + val bytes = packet.readBytes() + outputStream.write(bytes) + + readBytes += bytes.size + progressFlow.emit(readBytes / contentLength) + } } } + progressFlow.emit(Float.POSITIVE_INFINITY) + } + }.onFailure { + Log.d("PUPILD-NC", it.message.toString()) + file.delete() + FirebaseCrashlytics.getInstance().recordException(it) + progressFlow.emit(Float.NEGATIVE_INFINITY) + requestsMutex.withLock { + requests.remove(url) } - progressChannel.close() } - }.onFailure { - logger.warning(it) - file.delete() - FirebaseCrashlytics.getInstance().recordException(it) - progressChannel.close(it) } } + } - return@coroutineScope progressChannel to file + return@coroutineScope progressFlow to 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 ffc9cf62..f23e8407 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -67,7 +67,11 @@ fun View.show() { } class FileXImageSource(val file: FileX): ImageSource { - private val decoder = newBitmapRegionDecoder(file.inputStream()!!) + private val decoder by lazy { + file.inputStream()!!.use { + newBitmapRegionDecoder(it) + } + } override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) } diff --git a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt b/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt index 9e0c0674..233e4a8f 100644 --- a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt +++ b/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt @@ -20,40 +20,33 @@ package xyz.quaver.pupil +import io.ktor.client.* +import kotlinx.coroutines.runBlocking +import org.junit.Test +import xyz.quaver.pupil.sources.manatoki.getItem + /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.* -import kotlinx.serialization.json.Json -import org.junit.Test -import xyz.quaver.hitomi.getGalleryInfo -import xyz.quaver.hitomi.imageUrlFromImage -import xyz.quaver.pupil.sources.Hiyobi_io -import java.lang.reflect.ParameterizedType -import kotlin.reflect.KClass -import kotlin.reflect.KType -import kotlin.reflect.typeOf - class ExampleUnitTest { @Test fun test() { - val galleryID = 479010 - val files = getGalleryInfo(galleryID).files + val itemID = 232566 - files.forEachIndexed { i, it -> - println("$i: ${imageUrlFromImage(galleryID, it, true)}") + val client = HttpClient() + + runBlocking { + client.getItem( + itemID.toString(), + onReader = { + print(it) + } + ) } } - @Test - fun test2() { - print(Hiyobi_io.parseQuery("female:loli female:big_breast tag:group")) - } - }