DetailedGalleryInfo card

This commit is contained in:
tom5079
2024-03-24 17:50:31 -07:00
parent d1381b8700
commit e648b6dfee
5 changed files with 257 additions and 77 deletions

View File

@@ -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"

View File

@@ -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.** { *; }
-keep class xyz.quaver.pupil.** { *; }

View File

@@ -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()
}

View File

@@ -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<Int, Int> = 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<T>(
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<Suggestion> {
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<String> = 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<Set<Int>> = runCatching {
when (query) {
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()

View File

@@ -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<GalleryTag>) {
}
}
@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<String?>(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