From e648b6dfee0cec66b00aac7c37aa3c740d9ddeb1 Mon Sep 17 00:00:00 2001 From: tom5079 <7948651+tom5079@users.noreply.github.com> Date: Sun, 24 Mar 2024 17:50:31 -0700 Subject: [PATCH] DetailedGalleryInfo card --- app/build.gradle | 8 +- app/proguard-rules.pro | 5 +- app/src/main/java/xyz/quaver/pupil/Pupil.kt | 14 +- .../pupil/networking/HitomiHttpClient.kt | 112 ++++++++-- .../xyz/quaver/pupil/ui/composable/Gallery.kt | 195 +++++++++++++----- 5 files changed, 257 insertions(+), 77 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7f510b36..797e4c52 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,7 +25,6 @@ android { versionCode 69 versionName "6.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables.useSupportLibrary = true } buildTypes { debug { @@ -52,6 +51,7 @@ android { buildConfig true } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -64,6 +64,8 @@ android { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"]) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" @@ -80,7 +82,7 @@ dependencies { implementation "androidx.biometric:biometric:1.1.0" implementation "androidx.work:work-runtime-ktx:2.9.0" - implementation platform("androidx.compose:compose-bom:2024.02.01") + implementation platform("androidx.compose:compose-bom:2024.02.02") implementation "androidx.compose.material3:material3" implementation "androidx.compose.material3:material3-window-size-class" @@ -112,7 +114,7 @@ dependencies { implementation "com.google.android.material:material:1.11.0" - implementation platform('com.google.firebase:firebase-bom:32.7.0') + implementation platform('com.google.firebase:firebase-bom:32.7.4') implementation "com.google.firebase:firebase-analytics-ktx" implementation "com.google.firebase:firebase-crashlytics-ktx" implementation "com.google.firebase:firebase-perf-ktx" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bf7e0248..a26be727 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -31,7 +31,4 @@ -keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's kotlinx.serialization.KSerializer serializer(...); } --keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment --keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment --keep class xyz.quaver.pupil.** { *; } --keep class app.cash.zipline.** { *; } \ No newline at end of file +-keep class xyz.quaver.pupil.** { *; } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index e2755159..e6e3ba3f 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -30,6 +30,8 @@ import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager +import coil.ImageLoader +import coil.ImageLoaderFactory import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.fresco.FrescoImageLoader @@ -44,6 +46,7 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response import xyz.quaver.io.FileX +import xyz.quaver.pupil.networking.SSLSettings import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.util.* import java.io.File @@ -74,7 +77,7 @@ val client: OkHttpClient clientHolder = it } -class Pupil : Application() { +class Pupil : Application(), ImageLoaderFactory { companion object { lateinit var instance: Pupil private set @@ -207,4 +210,13 @@ class Pupil : Application() { super.onCreate() } + override fun newImageLoader() = ImageLoader + .Builder(this) + .okHttpClient { + OkHttpClient + .Builder() + .sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!) + .build() + }.build() + } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt index 3c04a125..2efb6176 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt @@ -10,13 +10,18 @@ import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock.System.now +import kotlinx.datetime.Instant import kotlinx.serialization.json.Json import xyz.quaver.pupil.hitomi.max_node_size import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.IntBuffer -import java.nio.charset.Charset +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes const val domain = "ltn.hitomi.la" const val galleryBlockExtension = ".html" @@ -51,6 +56,54 @@ private val json = Json { ignoreUnknownKeys = true } +class ImagePathResolver(ggjs: String) { + private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt() + private val prefixMap: Map = buildMap { + val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt() + + Regex("case (\\d+):").findAll(ggjs).forEach { + val case = it.groupValues[1].toInt() + put(case, o) + } + } + + private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1] + + fun decodeSubdomain(hash: String, thumbnail: Boolean): String { + val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16) + val base = if (thumbnail) "tn" else "a" + + return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base" + } + + fun decodeImagePath(hash: String, thumbnail: Boolean): String { + val key = hash.last() to hash.dropLast(1).takeLast(2) + + return if (thumbnail) { + "${key.first}/${key.second}/$hash" + } else { + "$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash" + } + } +} + +class ExpirableEntry( + private val expiryDuration: Duration, + private val action: suspend () -> T +) { + private var value: T? = null + private var expiresAt: Instant = now() + + private val mutex = Mutex() + + suspend fun getValue(): T = mutex.withLock { + value?.let { if (expiresAt > now()) value else null } ?: action().also { + expiresAt = now() + expiryDuration + value = it + } + } +} + object HitomiHttpClient { private val httpClient = HttpClient(OkHttp) { engine { @@ -60,17 +113,12 @@ object HitomiHttpClient { } } - private var _tagIndexVersion: String? = null - private suspend fun getTagIndexVersion(): String = - _tagIndexVersion ?: getIndexVersion("tagindex").also { - _tagIndexVersion = it - } + private var imagePathResolver = ExpirableEntry(1.minutes) { + ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText()) + } - private var _galleriesIndexVersion: String? = null - private suspend fun getGalleriesIndexVersion(): String = - _galleriesIndexVersion ?: getIndexVersion("galleriesindex").also { - _galleriesIndexVersion = it - } + private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") } + private val galleriesIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") } private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) { httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText() @@ -90,10 +138,10 @@ object HitomiHttpClient { private suspend fun getNodeAtAddress(field: String, address: Long): Node { val url = when (field) { - "galleries" -> "https://$domain/$galleriesIndexDir/galleries.${getGalleriesIndexVersion()}.index" - "languages" -> "https://$domain/$galleriesIndexDir/languages.${getGalleriesIndexVersion()}.index" - "nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${getGalleriesIndexVersion()}.index" - else -> "https://$domain/$indexDir/$field.${getTagIndexVersion()}.index" + "galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index" + "languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index" + "nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index" + else -> "https://$domain/$indexDir/$field.${HitomiHttpClient.tagIndexVersion.getValue()}.index" } return Node.decodeNode( @@ -123,7 +171,7 @@ object HitomiHttpClient { } private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer { - val url = "https://$domain/$galleriesIndexDir/galleries.${getGalleriesIndexVersion()}.data" + val url = "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data" if (length > 100000000 || length <= 0) { error("length $length is too long") } @@ -132,7 +180,7 @@ object HitomiHttpClient { } private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List { - val url = "https://$domain/$indexDir/$field.${getTagIndexVersion()}.data" + val url = "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.data" val (offset, length) = data check(data.length in 1..10000) { "Invalid length ${data.length}" } @@ -214,6 +262,36 @@ object HitomiHttpClient { } } + suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List = buildList { + val imagePathResolver = imagePathResolver.getValue() + + listOf("webp", "avif", "jxl").forEach { type -> + val available = when { + thumbnail && type != "jxl" -> true + type == "webp" -> galleryFile.hasWebP != 0 + type == "avif" -> galleryFile.hasAVIF != 0 + !thumbnail && type == "jxl" -> galleryFile.hasJXL != 0 + else -> false + } + + if (!available) return@forEach + + val url = buildString { + append("https://") + append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail)) + append(".hitomi.la/") + append(type) + if (thumbnail) append("bigtn") + append('/') + append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail)) + append('.') + append(type) + } + + add(url) + } + } + suspend fun search(query: SearchQuery?): Result> = runCatching { when (query) { is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet() diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt index 929c3422..79d1cf69 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -17,24 +18,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.StarOutline import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.Artist import xyz.quaver.pupil.networking.Character @@ -42,6 +48,7 @@ import xyz.quaver.pupil.networking.GalleryFile import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.GalleryTag import xyz.quaver.pupil.networking.Group +import xyz.quaver.pupil.networking.HitomiHttpClient import xyz.quaver.pupil.networking.Language import xyz.quaver.pupil.networking.Series import xyz.quaver.pupil.networking.joinToCapitalizedString @@ -239,63 +246,142 @@ fun TagGroup(tags: List) { } } +@Composable +fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { + val thumbnailFile = galleryInfo.files.firstOrNull() + if (thumbnailFile?.let { it.width > it.height } == true) { + Column { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUrl) + .setHeader("Referer", "https://hitomi.la/") + .build(), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(thumbnailFile.let { it.width / it.height.toFloat() }) + .clip(RoundedCornerShape(8.dp)), + loading = { CircularProgressIndicator() }, + error = { Image(painter= painterResource(R.drawable.thumbnail), contentDescription = null) }, + contentDescription = "Thumbnail" + ) + Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall) + val artistsAndGroups = buildString { + if (!galleryInfo.artists.isNullOrEmpty()) + append(galleryInfo.artists.joinToCapitalizedString()) + + if (!galleryInfo.groups.isNullOrEmpty()) { + if (this.isNotEmpty()) append(' ') + append('(') + append(galleryInfo.groups.joinToCapitalizedString()) + append(')') + } + } + + Text( + artistsAndGroups, + style = MaterialTheme.typography.labelLarge + ) + + Spacer(Modifier.height(8.dp)) + + if (galleryInfo.series?.isNotEmpty() == true) + Text( + "Series: ${galleryInfo.series.joinToCapitalizedString()}", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + "Type: ${galleryInfo.type}", + style = MaterialTheme.typography.bodyMedium + ) + + languageMap[galleryInfo.language]?.let { + Text( + "Language: $it", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUrl) + .setHeader("Referer", "https://hitomi.la/") + .build(), + modifier = Modifier + .height(200.dp) + .clip(RoundedCornerShape(8.dp)), + loading = { CircularProgressIndicator() }, + error = { Image(painter= painterResource(R.drawable.thumbnail), contentDescription = null) }, + contentDescription = "Thumbnail" + ) + Column(Modifier.heightIn(min = 200.dp)) { + Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall) + val artistsAndGroups = buildString { + if (!galleryInfo.artists.isNullOrEmpty()) + append(galleryInfo.artists.joinToCapitalizedString()) + + if (!galleryInfo.groups.isNullOrEmpty()) { + if (this.isNotEmpty()) append(' ') + append('(') + append(galleryInfo.groups.joinToCapitalizedString()) + append(')') + } + } + + Text( + artistsAndGroups, + style = MaterialTheme.typography.labelLarge + ) + + Spacer( + Modifier + .weight(1f) + .heightIn(min = 8.dp)) + + if (galleryInfo.series?.isNotEmpty() == true) + Text( + "Series: ${galleryInfo.series.joinToCapitalizedString()}", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + "Type: ${galleryInfo.type}", + style = MaterialTheme.typography.bodyMedium + ) + + languageMap[galleryInfo.language]?.let { + Text( + "Language: $it", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + @Preview @Composable fun DetailedGalleryInfo( @PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo, modifier: Modifier = Modifier, ) { + var thumbnailUrl by remember { mutableStateOf(null) } + + LaunchedEffect(galleryInfo) { + thumbnailUrl = galleryInfo.files.firstOrNull()?.let { + HitomiHttpClient.getImageURL(it, true).firstOrNull() + } ?: "" + } + Card(modifier) { Column(Modifier.padding(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Image( - modifier = Modifier.height(200.dp), - painter = painterResource(R.drawable.thumbnail), - contentDescription = "Icon" - ) - Column(Modifier.heightIn(min = 200.dp)) { - Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall) - val artistsAndGroups = buildString { - if (!galleryInfo.artists.isNullOrEmpty()) - append(galleryInfo.artists.joinToCapitalizedString()) - - if (!galleryInfo.groups.isNullOrEmpty()) { - if (this.isNotEmpty()) append(' ') - append('(') - append(galleryInfo.groups.joinToCapitalizedString()) - append(')') - } - } - - Text( - artistsAndGroups, - style = MaterialTheme.typography.labelLarge - ) - - Spacer( - Modifier - .weight(1f) - .heightIn(min = 8.dp)) - - if (galleryInfo.series?.isNotEmpty() == true) - Text( - "Series: ${galleryInfo.series.joinToCapitalizedString()}", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - "Type: ${galleryInfo.type}", - style = MaterialTheme.typography.bodyMedium - ) - - languageMap[galleryInfo.language]?.let { - Text( - "Language: $it", - style = MaterialTheme.typography.bodyMedium - ) - } - } - } + DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl) if (galleryInfo.tags?.isNotEmpty() == true) { TagGroup(galleryInfo.tags) @@ -303,7 +389,10 @@ fun DetailedGalleryInfo( HorizontalDivider(Modifier.padding(4.dp)) - Box(Modifier.fillMaxWidth().padding(4.dp)) { + Box( + Modifier + .fillMaxWidth() + .padding(4.dp)) { Text( modifier = Modifier.align(Alignment.CenterStart), text = galleryInfo.id, @@ -315,7 +404,9 @@ fun DetailedGalleryInfo( style = MaterialTheme.typography.bodyMedium ) Icon( - modifier = Modifier.align(Alignment.CenterEnd).size(32.dp), + modifier = Modifier + .align(Alignment.CenterEnd) + .size(32.dp), imageVector = Icons.Default.StarOutline, contentDescription = null, tint = Yellow500