From 067a263336d2a6004c22389ad1ab1a5a35a5e790 Mon Sep 17 00:00:00 2001 From: tom5079 <7948651+tom5079@users.noreply.github.com> Date: Sat, 6 Apr 2024 18:08:22 -0700 Subject: [PATCH] wip --- app/build.gradle | 11 +- app/src/main/AndroidManifest.xml | 115 +--- app/src/main/java/xyz/quaver/pupil/Pupil.kt | 50 +- .../quaver/pupil/adapters/ReaderAdapter.kt | 250 ------- .../xyz/quaver/pupil/di/SingletonModule.kt | 24 + .../java/xyz/quaver/pupil/hitomi/common.kt | 273 -------- .../java/xyz/quaver/pupil/hitomi/galleries.kt | 54 -- .../xyz/quaver/pupil/hitomi/galleryblock.kt | 90 --- .../java/xyz/quaver/pupil/hitomi/reader.kt | 38 -- .../java/xyz/quaver/pupil/hitomi/results.kt | 87 --- .../java/xyz/quaver/pupil/hitomi/search.kt | 328 ---------- .../quaver/pupil/networking/GalleryInfo.kt | 30 +- .../pupil/networking/HitomiHttpClient.kt | 82 ++- .../xyz/quaver/pupil/networking/ImageCache.kt | 128 ++++ .../quaver/pupil/services/DownloadService.kt | 445 ------------- .../pupil/services/ImageCacheService.kt | 57 ++ .../java/xyz/quaver/pupil/ui/MainActivity.kt | 3 +- .../xyz/quaver/pupil/ui/ReaderActivity.kt | 619 ------------------ .../xyz/quaver/pupil/ui/composable/Gallery.kt | 187 ++++-- .../xyz/quaver/pupil/ui/composable/MainApp.kt | 45 +- .../ui/composable/MainNavigationActions.kt | 7 +- .../pupil/ui/composable/SearchScreen.kt | 146 ++++- .../xyz/quaver/pupil/ui/reader/ImageViewer.kt | 2 + .../pupil/ui/viewmodel/MainViewModel.kt | 12 +- .../xyz/quaver/pupil/util/downloader/Cache.kt | 297 --------- .../pupil/util/downloader/DownloadManager.kt | 123 ---- app/src/main/res/layout/reader_activity.xml | 105 --- app/src/main/res/layout/reader_eye_card.xml | 67 -- app/src/main/res/layout/reader_item.xml | 72 -- app/src/main/res/values-ja/strings.xml | 7 +- app/src/main/res/values-ko/strings.xml | 7 +- app/src/main/res/values/strings.xml | 8 +- build.gradle | 1 + 33 files changed, 610 insertions(+), 3160 deletions(-) delete mode 100644 app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/common.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/reader.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/results.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/hitomi/search.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/services/ImageCacheService.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/ui/reader/ImageViewer.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt delete mode 100644 app/src/main/res/layout/reader_activity.xml delete mode 100644 app/src/main/res/layout/reader_eye_card.xml delete mode 100644 app/src/main/res/layout/reader_item.xml diff --git a/app/build.gradle b/app/build.gradle index 797e4c52..07ff5e6b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,7 @@ apply plugin: "kotlin-parcelize" apply plugin: "kotlinx-serialization" apply plugin: "com.google.android.gms.oss-licenses-plugin" apply plugin: "com.google.devtools.ksp" - +apply plugin: "com.google.dagger.hilt.android" if (file("google-services.json").exists()) { logger.lifecycle("Firebase Enabled") @@ -82,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.02") + implementation platform("androidx.compose:compose-bom:2024.03.00") implementation "androidx.compose.material3:material3" implementation "androidx.compose.material3:material3-window-size-class" @@ -90,7 +90,7 @@ dependencies { implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-tooling-preview' debugImplementation 'androidx.compose.ui:ui-tooling' - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.6.2' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.6.4' debugImplementation 'androidx.compose.ui:ui-test-manifest' implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.activity:activity-compose:1.8.2' @@ -112,9 +112,12 @@ dependencies { implementation "io.coil-kt:coil-compose:2.6.0" + implementation "com.google.dagger:hilt-android:2.44" + ksp "com.google.dagger:hilt-compiler:2.44" + implementation "com.google.android.material:material:1.11.0" - implementation platform('com.google.firebase:firebase-bom:32.7.4') + implementation platform('com.google.firebase:firebase-bom:32.8.0') 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/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47e0af21..d54999b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,8 +11,8 @@ - + @@ -28,7 +28,8 @@ android:theme="@style/AppTheme" android:networkSecurityConfig="@xml/network_security_config" tools:replace="android:theme" - tools:ignore="UnusedAttribute"> + tools:ignore="UnusedAttribute" + android:dataExtractionRules="@xml/data_extraction_rules"> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -179,7 +77,6 @@ - \ 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 4b645d6f..8a764239 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -40,6 +40,7 @@ import com.google.android.gms.common.GooglePlayServicesRepairableException import com.google.android.gms.security.ProviderInstaller import com.google.firebase.FirebaseApp import com.google.firebase.crashlytics.FirebaseCrashlytics +import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.* import okhttp3.Dispatcher import okhttp3.Interceptor @@ -58,15 +59,6 @@ import kotlin.reflect.KClass typealias PupilInterceptor = (Interceptor.Chain) -> Response -lateinit var histories: SavedSet - private set -lateinit var favorites: SavedSet - private set -lateinit var favoriteTags: SavedSet - private set -lateinit var searchHistory: SavedSet - private set - val interceptors = mutableMapOf, PupilInterceptor>() lateinit var clientBuilder: OkHttpClient.Builder @@ -77,22 +69,13 @@ val client: OkHttpClient clientHolder = it } -class Pupil : Application(), ImageLoaderFactory { - companion object { - lateinit var instance: Pupil - private set - } - +@HiltAndroidApp +class Pupil : Application() { override fun onCreate() { - instance = this - - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - preferences = PreferenceManager.getDefaultSharedPreferences(this) val userID = Preferences["user_id", ""].let { userID -> - if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it } - else userID + userID.ifEmpty { UUID.randomUUID().toString().also { Preferences["user_id"] = it } } } FirebaseApp.initializeApp(this) @@ -101,7 +84,6 @@ class Pupil : Application(), ImageLoaderFactory { val proxyInfo = getProxyInfo() clientBuilder = OkHttpClient.Builder() -// .connectTimeout(0, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) .proxyInfo(proxyInfo) .addInterceptor { chain -> @@ -140,19 +122,6 @@ class Pupil : Application(), ImageLoaderFactory { Preferences["reset_secure"] = true } - histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0) - favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0) - favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse("")) - searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "") - - favoriteTags.filter { it.tag.contains('_') }.forEach { - favoriteTags.remove(it) - } - - /* - if (BuildConfig.DEBUG) - FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/ - try { ProviderInstaller.installIfNeeded(this) } catch (e: GooglePlayServicesRepairableException) { @@ -209,15 +178,4 @@ class Pupil : Application(), ImageLoaderFactory { super.onCreate() } - - override fun newImageLoader() = ImageLoader - .Builder(this) - .okHttpClient { - OkHttpClient - .Builder() - .sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!) - .build() - }.memoryCache(null) - .build() - } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt deleted file mode 100644 index 93892729..00000000 --- a/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2019 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.adapters - -import android.content.Context -import android.graphics.drawable.Animatable -import android.net.Uri -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.ContextCompat -import androidx.core.view.updateLayoutParams -import androidx.recyclerview.widget.RecyclerView -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.drawee.controller.BaseControllerListener -import com.facebook.drawee.drawable.ScalingUtils -import com.facebook.drawee.interfaces.DraweeController -import com.facebook.drawee.view.SimpleDraweeView -import com.facebook.imagepipeline.image.ImageInfo -import com.github.piasy.biv.view.BigImageView -import com.github.piasy.biv.view.ImageShownCallback -import com.github.piasy.biv.view.ImageViewFactory -import kotlinx.coroutines.* -import xyz.quaver.pupil.R -import xyz.quaver.pupil.databinding.ReaderItemBinding -import xyz.quaver.pupil.hitomi.GalleryInfo -import xyz.quaver.pupil.ui.ReaderActivity -import xyz.quaver.pupil.util.downloader.Cache -import java.io.File -import kotlin.math.roundToInt - -class ReaderAdapter( - private val activity: ReaderActivity, - private val galleryID: Int -) : RecyclerView.Adapter() { - var galleryInfo: GalleryInfo? = null - - var isFullScreen = false - - var onItemClickListener : (() -> (Unit))? = null - - inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) { - init { - with (binding.image) { - setImageViewFactory(FrescoImageViewFactory().apply { - updateView = { imageInfo -> - binding.image.updateLayoutParams { - dimensionRatio = "${imageInfo.width}:${imageInfo.height}" - } - } - }) - setImageShownCallback(object : ImageShownCallback { - override fun onMainImageShown() { - binding.image.mainView.let { v -> - when (v) { - is SubsamplingScaleImageView -> - if (!isFullScreen) binding.image.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - } - } - } - - override fun onThumbnailShown() {} - }) - - setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant)) - setOnClickListener { - onItemClickListener?.invoke() - } - } - - binding.root.setOnClickListener { - onItemClickListener?.invoke() - } - } - - fun bind(position: Int) { - if (cache == null) - cache = Cache.getInstance(itemView.context, galleryID) - - if (!isFullScreen) { - binding.root.setBackgroundResource(R.drawable.reader_item_boundary) - binding.image.updateLayoutParams { - height = 0 - dimensionRatio = - "${galleryInfo!!.files[position].width}:${galleryInfo!!.files[position].height}" - } - } else { - binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.image.updateLayoutParams { - height = ConstraintLayout.LayoutParams.MATCH_PARENT - dimensionRatio = null - } - binding.root.background = null - } - - binding.readerIndex.text = (position+1).toString() - - val image = cache!!.getImage(position) - val progress = activity.downloader?.progress?.get(galleryID)?.get(position) - - if (progress?.isInfinite() == true && image != null) { - binding.progressGroup.visibility = View.INVISIBLE - binding.image.showImage(image.uri) - } else { - binding.progressGroup.visibility = View.VISIBLE - binding.readerItemProgressbar.progress = - if (progress?.isInfinite() == true) - 100 - else - progress?.roundToInt() ?: 0 - - clear() - - CoroutineScope(Dispatchers.Main).launch { - delay(1000) - notifyItemChanged(position) - } - } - } - - fun clear() { - binding.image.mainView.let { - when (it) { - is SubsamplingScaleImageView -> - it.recycle() - is SimpleDraweeView -> - it.controller = null - } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - private var cache: Cache? = null - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(position) - } - - override fun getItemCount() = galleryInfo?.files?.size ?: 0 - - override fun onViewRecycled(holder: ViewHolder) { - holder.clear() - } - -} - -class FrescoImageViewFactory : ImageViewFactory() { - var updateView: ((ImageInfo) -> Unit)? = null - - override fun createAnimatedImageView( - context: Context, imageType: Int, - initScaleType: Int - ): View { - val view = SimpleDraweeView(context) - view.hierarchy.actualImageScaleType = scaleType(initScaleType) - return view - } - - override fun loadAnimatedContent( - view: View, imageType: Int, - imageFile: File - ) { - if (view is SimpleDraweeView) { - val controller: DraweeController = Fresco.newDraweeControllerBuilder() - .setUri(Uri.parse("file://" + imageFile.absolutePath)) - .setAutoPlayAnimations(true) - .setControllerListener(object: BaseControllerListener() { - override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) { - imageInfo?.let { updateView?.invoke(it) } - } - - override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) { - imageInfo?.let { updateView?.invoke(it) } - } - }) - .build() - view.controller = controller - } - } - - override fun createThumbnailView( - context: Context, - scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean - ): View { - return if (willLoadFromNetwork) { - val thumbnailView = SimpleDraweeView(context) - thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType) - thumbnailView - } else { - super.createThumbnailView(context, scaleType, false) - } - } - - override fun loadThumbnailContent(view: View, thumbnail: Uri) { - if (view is SimpleDraweeView) { - val controller: DraweeController = Fresco.newDraweeControllerBuilder() - .setUri(thumbnail) - .build() - view.controller = controller - } - } - - private fun scaleType(value: Int): ScalingUtils.ScaleType { - return when (value) { - BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER - BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP - BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE - BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END - BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START - BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY - BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER - else -> ScalingUtils.ScaleType.FIT_CENTER - } - } - - private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType { - return when (scaleType) { - ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER - ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP - ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE - ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END - ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START - ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY - ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER - else -> ScalingUtils.ScaleType.FIT_CENTER - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt b/app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt new file mode 100644 index 00000000..86284f5c --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt @@ -0,0 +1,24 @@ +package xyz.quaver.pupil.di + +import android.content.Context +import com.google.android.datatransport.runtime.dagger.Provides +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import xyz.quaver.pupil.networking.FileImageCache +import xyz.quaver.pupil.networking.ImageCache +import java.io.File +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SingletonModule { + @Singleton + @Provides + fun provideImageCache( + @ApplicationContext context: Context + ): ImageCache { + return FileImageCache(File(context.cacheDir, "image_cache")) + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/common.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/common.kt deleted file mode 100644 index 13d92e0e..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/common.kt +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.datetime.Clock.System.now -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Request -import okhttp3.Response -import xyz.quaver.pupil.client -import java.io.IOException -import java.net.URL -import java.util.concurrent.Executors -import kotlin.coroutines.resumeWithException -import kotlin.time.Duration.Companion.minutes -import kotlin.time.ExperimentalTime - -const val protocol = "https:" - -@Serializable -data class Artist( - val artist: String, - val url: String -) - -@Serializable -data class Group( - val group: String, - val url: String -) - -@Serializable -data class Parody( - val parody: String, - val url: String -) - -@Serializable -data class Character( - val character: String, - val url: String -) - -@Serializable -data class Tag( - val tag: String, - val url: String, - val female: String? = null, - val male: String? = null -) - -@Serializable -data class Language( - val galleryid: String, - val url: String, - val language_localname: String, - val name: String -) - -@Serializable -data class GalleryInfo( - val id: String, - val title: String, - val japanese_title: String? = null, - val language: String? = null, - val type: String, - val date: String, - val artists: List? = null, - val groups: List? = null, - val parodys: List? = null, - val tags: List? = null, - val related: List = emptyList(), - val languages: List = emptyList(), - val characters: List? = null, - val scene_indexes: List? = emptyList(), - val files: List = emptyList() -) - -val json = Json { - isLenient = true - ignoreUnknownKeys = true - allowSpecialFloatingPointValues = true - useArrayPolymorphism = true -} - -typealias HeaderSetter = (Request.Builder) -> Request.Builder -fun URL.readText(settings: HeaderSetter? = null): String { - val request = Request.Builder() - .url(this).let { - settings?.invoke(it) ?: it - }.build() - - return client.newCall(request).execute().also{ if (it.code != 200) throw IOException("CODE ${it.code}") }.body?.use { it.string() } ?: throw IOException() -} - -fun URL.readBytes(settings: HeaderSetter? = null): ByteArray { - val request = Request.Builder() - .url(this).let { - settings?.invoke(it) ?: it - }.build() - - return client.newCall(request).execute().also { if (it.code != 200) throw IOException("CODE ${it.code}") }.body?.use { it.bytes() } ?: throw IOException() -} - -@Suppress("EXPERIMENTAL_API_USAGE") -fun getGalleryInfo(galleryID: Int) = - json.decodeFromString( - URL("$protocol//$domain/galleries/$galleryID.js").readText() - .replace("var galleryinfo = ", "") - ) - -//common.js -const val domain = "ltn.hitomi.la" -const val galleryblockextension = ".html" -const val galleryblockdir = "galleryblock" -const val nozomiextension = ".nozomi" - -val evaluationContext = Dispatchers.Main + Job() - -object gg { - private var lastRetrieval: Long? = null - - private val mutex = Mutex() - - private var mDefault = 0 - private val mMap = mutableMapOf() - - private var b = "" - - @OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) - private suspend fun refresh() = withContext(Dispatchers.IO) { - mutex.withLock { - if (lastRetrieval == null || (lastRetrieval!! + 60000) < System.currentTimeMillis()) { - val ggjs: String = suspendCancellableCoroutine { continuation -> - val call = client.newCall(Request.Builder().url("https://ltn.hitomi.la/gg.js").build()) - - call.enqueue(object: Callback { - override fun onFailure(call: Call, e: IOException) { - if (continuation.isCancelled) return - continuation.resumeWithException(e) - } - - override fun onResponse(call: Call, response: Response) { - if (!call.isCanceled()) { - response.body?.use { - continuation.resume(it.string()) { - call.cancel() - } - } - } - } - }) - - continuation.invokeOnCancellation { - call.cancel() - } - } - - mDefault = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt() - val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt() - - mMap.clear() - Regex("case (\\d+):").findAll(ggjs).forEach { - val case = it.groupValues[1].toInt() - mMap[case] = o - } - - b = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1] - - lastRetrieval = System.currentTimeMillis() - } - } - } - - suspend fun m(g: Int): Int { - refresh() - - return mMap[g] ?: mDefault - } - - suspend fun b(): String { - refresh() - return b - } - fun s(h: String): String { - val m = Regex("(..)(.)$").find(h) - return m!!.groupValues.let { it[2]+it[1] }.toInt(16).toString(10) - } -} - -suspend fun subdomainFromURL(url: String, base: String? = null) : String { - var retval = "b" - - if (!base.isNullOrBlank()) - retval = base - - val b = 16 - - val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""") - val m = r.find(url) ?: return "a" - - val g = m.groupValues.let { it[2]+it[1] }.toIntOrNull(b) - - if (g != null) { - retval = (97+ gg.m(g)).toChar().toString() + retval - } - - return retval -} - -suspend fun urlFromUrl(url: String, base: String? = null) : String { - return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/") -} - -suspend fun fullPathFromHash(hash: String) : String = - "${gg.b()}${gg.s(hash)}/$hash" - -fun realFullPathFromHash(hash: String): String = - hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash") - -suspend fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String { - val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' } - val dir = dir ?: "images" - return "https://a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext" -} - -suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) = - if (base == "tn") - urlFromUrl("https://a.hitomi.la/$dir/${realFullPathFromHash(image.hash)}.$ext", base) - else - urlFromUrl(urlFromHash(galleryID, image, dir, ext), base) - -suspend fun rewriteTnPaths(html: String) { - html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url -> - runBlocking { - urlFromUrl(url.value, "tn") - } - } -} - -suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String { - return urlFromUrlFromHash(galleryID, image, "webp", null, "a") -// return when { -// noWebp -> -// urlFromUrlFromHash(galleryID, image) -//// image.hasavif != 0 -> -//// urlFromUrlFromHash(galleryID, image, "avif", null, "a") -// image.haswebp != 0 -> -// urlFromUrlFromHash(galleryID, image, "webp", null, "a") -// else -> -// urlFromUrlFromHash(galleryID, image) -// } -} diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt deleted file mode 100644 index 6920c666..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import kotlinx.serialization.Serializable - -@Serializable -data class Gallery( - val related: List, - val langList: List>, - val cover: String, - val title: String, - val artists: List, - val groups: List, - val type: String, - val language: String, - val series: List, - val characters: List, - val tags: List, - val thumbnails: List -) - -suspend fun getGallery(galleryID: Int) : Gallery { - val info = getGalleryInfo(galleryID) - - return Gallery( - info.related, - info.languages.map { it.name to it.galleryid }, - urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn"), - info.title, - info.artists?.map { it.artist }.orEmpty(), - info.groups?.map { it.group }.orEmpty(), - info.type, - info.language.orEmpty(), - info.parodys?.map { it.parody }.orEmpty(), - info.characters?.map { it.character }.orEmpty(), - info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(), - info.files.map { urlFromUrlFromHash(galleryID, it, "webpsmalltn", "webp", "tn") } - ) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt deleted file mode 100644 index dbe19b53..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import kotlinx.serialization.Serializable -import java.net.URL -import java.net.URLDecoder -import java.nio.ByteBuffer -import java.nio.ByteOrder -import javax.net.ssl.HttpsURLConnection - -//galleryblock.js -fun fetchNozomi(area: String? = null, tag: String = "index", language: String = "all", start: Int = -1, count: Int = -1) : Pair, Int> { - val url = when(area) { - null -> "$protocol//$domain/$tag-$language$nozomiextension" - else -> "$protocol//$domain/$area/$tag-$language$nozomiextension" - } - - with(URL(url).openConnection() as HttpsURLConnection) { - requestMethod = "GET" - - if (start != -1 && count != -1) { - val startByte = start*4 - val endByte = (start+count)*4-1 - - setRequestProperty("Range", "bytes=$startByte-$endByte") - } - - connect() - - val totalItems = getHeaderField("Content-Range") - .replace(Regex("^[Bb]ytes \\d+-\\d+/"), "").toInt() / 4 - - val nozomi = ArrayList() - - val arrayBuffer = ByteBuffer - .wrap(inputStream.readBytes()) - .order(ByteOrder.BIG_ENDIAN) - - while (arrayBuffer.hasRemaining()) - nozomi.add(arrayBuffer.int) - - return Pair(nozomi, totalItems) - } -} - -@Serializable -data class GalleryBlock( - val id: Int, - val galleryUrl: String, - val thumbnails: List, - val title: String, - val artists: List, - val series: List, - val type: String, - val language: String, - val relatedTags: List, - val groups: List = emptyList() -) - -suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock { - val info = getGalleryInfo(galleryID) - - return GalleryBlock( - galleryID, - "", - listOf(urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn")), - info.title, - info.artists?.map { it.artist }.orEmpty(), - info.parodys?.map { it.parody }.orEmpty(), - info.type, - info.language.orEmpty(), - info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(), - info.groups?.map { it.group }.orEmpty() - ) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/reader.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/reader.kt deleted file mode 100644 index af8db372..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/reader.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import kotlinx.serialization.Serializable -import xyz.quaver.pupil.hitomi.GalleryInfo -import xyz.quaver.pupil.hitomi.getGalleryInfo - -@Serializable -data class GalleryFiles( - val width: Int, - val hash: String, - val haswebp: Int = 0, - val name: String, - val height: Int, - val hasavif: Int = 0, - val hasavifsmalltn: Int? = 0 -) - -//Set header `Referer` to reader url to avoid 403 error -@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo")) -fun getReader(galleryID: Int) : GalleryInfo { - return getGalleryInfo(galleryID) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/results.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/results.kt deleted file mode 100644 index 23f78778..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/results.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import java.util.* - -suspend fun doSearch(query: String, sortByPopularity: Boolean = false) : Set = coroutineScope { - val terms = query - .trim() - .replace(Regex("""^\?"""), "") - .lowercase() - .split(Regex("\\s+")) - .map { - it.replace('_', ' ') - } - - val positiveTerms = LinkedList() - val negativeTerms = LinkedList() - - for (term in terms) { - if (term.matches(Regex("^-.+"))) - negativeTerms.push(term.replace(Regex("^-"), "")) - else if (term.isNotBlank()) - positiveTerms.push(term) - } - - val positiveResults = positiveTerms.map { - async { - runCatching { - getGalleryIDsForQuery(it) - }.getOrElse { emptySet() } - } - } - - val negativeResults = negativeTerms.mapIndexed { index, it -> - async { - runCatching { - getGalleryIDsForQuery(it) - }.getOrElse { emptySet() } - } - } - - val results = when { - sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all") - positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all") - else -> emptySet() - }.toMutableSet() - - fun filterPositive(newResults: Set) { - when { - results.isEmpty() -> results.addAll(newResults) - else -> results.retainAll(newResults) - } - } - - fun filterNegative(newResults: Set) { - results.removeAll(newResults) - } - - //positive results - positiveResults.forEach { - filterPositive(it.await()) - } - - //negative results - negativeResults.forEachIndexed { index, it -> - filterNegative(it.await()) - } - - results -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/hitomi/search.kt b/app/src/main/java/xyz/quaver/pupil/hitomi/search.kt deleted file mode 100644 index ef622bce..00000000 --- a/app/src/main/java/xyz/quaver/pupil/hitomi/search.kt +++ /dev/null @@ -1,328 +0,0 @@ -/* - * Copyright 2019 tom5079 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.quaver.pupil.hitomi - -import okhttp3.Request -import xyz.quaver.pupil.client -import java.net.URL -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest -import kotlin.math.min - -//searchlib.js -const val separator = "-" -const val extension = ".html" -const val index_dir = "tagindex" -const val galleries_index_dir = "galleriesindex" -const val max_node_size = 464 -const val B = 16 -const val compressed_nozomi_prefix = "n" - -val tag_index_version: String by lazy { getIndexVersion("tagindex") } -val galleries_index_version: String by lazy { getIndexVersion("galleriesindex") } - -fun sha256(data: ByteArray) : ByteArray { - return MessageDigest.getInstance("SHA-256").digest(data) -} - -@OptIn(ExperimentalUnsignedTypes::class) -fun hashTerm(term: String) : UByteArray { - return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4) -} - -fun sanitize(input: String) : String { - return input.replace(Regex("[/#]"), "") -} - -fun getIndexVersion(name: String) = - URL("$protocol//${xyz.quaver.pupil.networking.domain}/$name/version?_=${System.currentTimeMillis()}").readText() - -//search.js -fun getGalleryIDsForQuery(query: String) : Set { - query.replace("_", " ").let { - if (it.indexOf(':') > -1) { - val sides = it.split(":") - val ns = sides[0] - var tag = sides[1] - - var area : String? = ns - var language = "all" - when (ns) { - "female", "male" -> { - area = "tag" - tag = it - } - "language" -> { - area = null - language = tag - tag = "index" - } - } - - return getGalleryIDsFromNozomi(area, tag, language) - } - - val key = hashTerm(it) - val field = "galleries" - - val node = getNodeAtAddress(field, 0) ?: return emptySet() - - val data = bSearch(field, key, node) - - if (data != null) - return getGalleryIDsFromData(data) - - return emptySet() - } -} - -fun getSuggestionsForQuery(query: String) : List { - query.replace('_', ' ').let { - var field = "global" - var term = it - - if (term.indexOf(':') > -1) { - val sides = it.split(':') - field = sides[0] - term = sides[1] - } - - val key = hashTerm(term) - val node = getNodeAtAddress(field, 0) ?: return emptyList() - val data = bSearch(field, key, node) - - if (data != null) - return getSuggestionsFromData(field, data) - - return emptyList() - } -} - -data class Suggestion(val s: String, val t: Int, val u: String, val n: String) -fun getSuggestionsFromData(field: String, data: Pair) : List { - val url = "$protocol//${xyz.quaver.pupil.networking.domain}/$index_dir/$field.$tag_index_version.data" - val (offset, length) = data - if (length > 10000 || length <= 0) - throw Exception("length $length is too long") - - val inbuf = getURLAtRange(url, offset.until(offset+length)) - - val suggestions = ArrayList() - - val buffer = ByteBuffer - .wrap(inbuf) - .order(ByteOrder.BIG_ENDIAN) - val numberOfSuggestions = buffer.int - - if (numberOfSuggestions > 100 || numberOfSuggestions <= 0) - throw Exception("number of suggestions $numberOfSuggestions is too long") - - for (i in 0.until(numberOfSuggestions)) { - var top = buffer.int - - val ns = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8")) - buffer.position(buffer.position()+top) - - top = buffer.int - - val tag = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8")) - buffer.position(buffer.position()+top) - - val count = buffer.int - - val tagname = sanitize(tag) - val u = - when(ns) { - "female", "male" -> "/tag/$ns:$tagname${separator}1$extension" - "language" -> "/index-$tagname${separator}1$extension" - else -> "/$ns/$tagname${separator}all${separator}1$extension" - } - - suggestions.add(Suggestion(tag, count, u, ns)) - } - - return suggestions -} - -fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set { - val nozomiAddress = - when(area) { - null -> "$protocol//${xyz.quaver.pupil.networking.domain}/$compressed_nozomi_prefix/$tag-$language$nozomiextension" - else -> "$protocol//${xyz.quaver.pupil.networking.domain}/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension" - } - - val bytes = try { - URL(nozomiAddress).readBytes() - } catch (e: Exception) { - return emptySet() - } - - val nozomi = mutableSetOf() - - val arrayBuffer = ByteBuffer - .wrap(bytes) - .order(ByteOrder.BIG_ENDIAN) - - while (arrayBuffer.hasRemaining()) - nozomi.add(arrayBuffer.int) - - return nozomi -} - -fun getGalleryIDsFromData(data: Pair) : Set { - val url = "$protocol//${xyz.quaver.pupil.networking.domain}/$galleries_index_dir/galleries.$galleries_index_version.data" - val (offset, length) = data - if (length > 100000000 || length <= 0) - throw Exception("length $length is too long") - - val inbuf = getURLAtRange(url, offset.until(offset+length)) - - val galleryIDs = mutableSetOf() - - val buffer = ByteBuffer - .wrap(inbuf) - .order(ByteOrder.BIG_ENDIAN) - - val numberOfGalleryIDs = buffer.int - - val expectedLength = numberOfGalleryIDs*4+4 - - if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0) - throw Exception("number_of_galleryids $numberOfGalleryIDs is too long") - else if (inbuf.size != expectedLength) - throw Exception("inbuf.byteLength ${inbuf.size} != expected_length $expectedLength") - - for (i in 0.until(numberOfGalleryIDs)) - galleryIDs.add(buffer.int) - - return galleryIDs -} - -fun getNodeAtAddress(field: String, address: Long) : Node? { - val url = - when(field) { - "galleries" -> "$protocol//${xyz.quaver.pupil.networking.domain}/$galleries_index_dir/galleries.$galleries_index_version.index" - "languages" -> "$protocol//${xyz.quaver.pupil.networking.domain}/$galleries_index_dir/languages.$galleries_index_version.index" - "nozomiurl" -> "$protocol//${xyz.quaver.pupil.networking.domain}/$galleries_index_dir/nozomiurl.$galleries_index_version.index" - else -> "$protocol//${xyz.quaver.pupil.networking.domain}/$index_dir/$field.$tag_index_version.index" - } - - val nodedata = getURLAtRange(url, address.until(address+ max_node_size)) - - return decodeNode(nodedata) -} - -fun getURLAtRange(url: String, range: LongRange) : ByteArray { - val request = Request.Builder() - .url(url) - .header("Range", "bytes=${range.first}-${range.last}") - .build() - - return client.newCall(request).execute().body?.use { it.bytes() } ?: byteArrayOf() -} - -@OptIn(ExperimentalUnsignedTypes::class) -data class Node(val keys: List, val datas: List>, val subNodeAddresses: List) -@OptIn(ExperimentalUnsignedTypes::class) -fun decodeNode(data: ByteArray) : Node { - val buffer = ByteBuffer - .wrap(data) - .order(ByteOrder.BIG_ENDIAN) - - val uData = data.toUByteArray() - - val numberOfKeys = buffer.int - val keys = ArrayList() - - for (i in 0.until(numberOfKeys)) { - val keySize = buffer.int - - if (keySize == 0 || keySize > 32) - throw Exception("fatal: !keySize || keySize > 32") - - keys.add(uData.sliceArray(buffer.position().until(buffer.position()+keySize))) - buffer.position(buffer.position()+keySize) - } - - val numberOfDatas = buffer.int - val datas = ArrayList>() - - for (i in 0.until(numberOfDatas)) { - val offset = buffer.long - val length = buffer.int - - datas.add(Pair(offset, length)) - } - - val numberOfSubNodeAddresses = B +1 - val subNodeAddresses = ArrayList() - - for (i in 0.until(numberOfSubNodeAddresses)) { - val subNodeAddress = buffer.long - subNodeAddresses.add(subNodeAddress) - } - - return Node(keys, datas, subNodeAddresses) -} - -@OptIn(ExperimentalUnsignedTypes::class) -fun bSearch(field: String, key: UByteArray, node: Node) : Pair? { - fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int { - val top = min(dv1.size, dv2.size) - - for (i in 0.until(top)) { - if (dv1[i] < dv2[i]) - return -1 - else if (dv1[i] > dv2[i]) - return 1 - } - - return 0 - } - - fun locateKey(key: UByteArray, node: Node) : Pair { - for (i in node.keys.indices) { - val cmpResult = compareArrayBuffers(key, node.keys[i]) - - if (cmpResult <= 0) - return Pair(cmpResult==0, i) - } - - return Pair(false, node.keys.size) - } - - fun isLeaf(node: Node) : Boolean { - for (subnode in node.subNodeAddresses) - if (subnode != 0L) - return false - - return true - } - - if (node.keys.isEmpty()) - return null - - val (there, where) = locateKey(key, node) - if (there) - return node.datas[where] - else if (isLeaf(node)) - return null - - val nextNode = getNodeAtAddress(field, node.subNodeAddresses[where]) ?: return null - return bSearch(field, key, nextNode) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt b/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt index e2278294..32a748c8 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt @@ -1,5 +1,6 @@ package xyz.quaver.pupil.networking +import android.os.BaseBundle import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -60,7 +61,29 @@ data class GalleryFile( val width: Int, val hash: String, val name: String, -) +) { + fun writeToBundle(bundle: BaseBundle) { + bundle.putInt("hasWebP", hasWebP) + bundle.putInt("hasAVIF", hasAVIF) + bundle.putInt("hasJXL", hasJXL) + bundle.putInt("height", height) + bundle.putInt("width", width) + bundle.putString("hash", hash) + bundle.putString("name", name) + } + + companion object { + fun fromBundle(bundle: BaseBundle) = GalleryFile( + bundle.getInt("hasWebP"), + bundle.getInt("hasAVIF"), + bundle.getInt("hasJXL"), + bundle.getInt("height"), + bundle.getInt("width"), + bundle.getString("hash")!!, + bundle.getString("name")!! + ) + } +} @Serializable data class GalleryInfo( @@ -81,10 +104,7 @@ data class GalleryInfo( val files: List = emptyList() ) - @JvmName("joinToCapitalizedStringArtist") fun List.joinToCapitalizedString() = joinToString { it.artist.replaceFirstChar(Char::titlecase) } @JvmName("joinToCapitalizedStringGroup") -fun List.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) } -@JvmName("joinToCapitalizedStringParody") -fun List.joinToCapitalizedString() = joinToString { it.series.replaceFirstChar(Char::titlecase) } \ No newline at end of file +fun List.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) } \ 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 2efb6176..835dad68 100644 --- a/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt +++ b/app/src/main/java/xyz/quaver/pupil/networking/HitomiHttpClient.kt @@ -3,10 +3,12 @@ package xyz.quaver.pupil.networking import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.onDownload import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText +import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -16,7 +18,6 @@ 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 @@ -32,6 +33,7 @@ const val compressedNozomiPrefix = "n" const val B = 16 const val indexDir = "tagindex" +const val maxNodeSize = 464 const val galleriesIndexDir = "galleriesindex" const val languagesIndexDir = "languagesindex" const val nozomiURLIndexDir = "nozomiurlindex" @@ -145,7 +147,7 @@ object HitomiHttpClient { } return Node.decodeNode( - getURLAtRange(url, address ..< address+max_node_size) + getURLAtRange(url, address ..< address + maxNodeSize) ) } @@ -262,36 +264,6 @@ 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() @@ -352,4 +324,50 @@ object HitomiHttpClient { null -> getGalleryIDsFromNozomi(null, "index", "all").toSet() } } + + 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 loadImage( + galleryFile: GalleryFile, + thumbnail: Boolean = false, + acceptImage: (String) -> Boolean = { true }, + onDownload: (bytesSentTotal: Long, contentLength: Long) -> Unit = { _, _ -> } + ): Result> { + return runCatching { + withContext(Dispatchers.IO) { + val url = getImageURL(galleryFile, thumbnail).firstOrNull(acceptImage) ?: error("No available image") + val channel: ByteReadChannel = httpClient.get(url) { onDownload(onDownload) }.body() + Pair(channel, url) + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt b/app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt new file mode 100644 index 00000000..bcc0b561 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt @@ -0,0 +1,128 @@ +package xyz.quaver.pupil.networking + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.ktor.util.cio.writeChannel +import io.ktor.utils.io.copyAndClose +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +sealed class ImageLoadProgress { + data object NotStarted : ImageLoadProgress() + data class Progress(val bytesSent: Long, val contentLength: Long) : ImageLoadProgress() + data class Finished(val file: File) : ImageLoadProgress() + data class Error(val exception: Throwable) : ImageLoadProgress() +} + +interface ImageCache { + suspend fun load(galleryFile: GalleryFile, forceDownload: Boolean = false): StateFlow + suspend fun free(vararg files: GalleryFile) + suspend fun clear() +} + +class FileImageCache( + private val cacheDir: File, + private val cacheLimit: Long = 128 * 1024 * 1024 // 128MB +) : ImageCache { + private val mutex = Mutex() + + private val requests = mutableMapOf>>() + private val activeFiles = mutableMapOf() + + private suspend fun cleanup() = withContext(Dispatchers.IO) { + mutex.withLock { + val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0 + + if (size > cacheLimit) { + cacheDir.listFiles { file -> + file.nameWithoutExtension !in activeFiles + }?.forEach { file -> + file.delete() + } + } + } + } + + override suspend fun free(vararg files: GalleryFile) = withContext(Dispatchers.IO) { + mutex.withLock { + files.forEach { file -> + val hash = file.hash + + requests[hash]?.let { (job, _) -> + job.cancel() + } + + requests.remove(hash) + activeFiles.remove(hash) + } + } + } + + override suspend fun clear(): Unit = withContext(Dispatchers.IO) { + mutex.withLock { + requests.forEach { _, (job, _) -> job.cancel() } + activeFiles.clear() + cacheDir.deleteRecursively() + } + } + + override suspend fun load(galleryFile: GalleryFile, forceDownload: Boolean): StateFlow { + val hash = galleryFile.hash + + mutex.withLock { + val file = activeFiles[hash] + if (!forceDownload && file != null) { + return MutableStateFlow(ImageLoadProgress.Finished(file)) + } + } + + cleanup() + + mutex.withLock { + requests[hash]?.first?.cancelAndJoin() + activeFiles[hash]?.delete() + + val flow = MutableStateFlow(ImageLoadProgress.NotStarted) + val job = coroutineScope { + launch { + runCatching { + val (channel, url) = HitomiHttpClient.loadImage(galleryFile) { sent, total -> + flow.value = ImageLoadProgress.Progress(sent, total) + }.onFailure { + FirebaseCrashlytics.getInstance().recordException(it) + flow.value = ImageLoadProgress.Error(it) + }.getOrThrow() + + val file = File(cacheDir, "$hash.${url.substringAfterLast('.')}") + + mutex.withLock { + activeFiles.put(hash, file) + } + + channel.copyAndClose(file.writeChannel()) + + file + }.onSuccess { file -> + flow.value = ImageLoadProgress.Finished(file) + }.onFailure { + activeFiles.remove(hash) + FirebaseCrashlytics.getInstance().recordException(it) + flow.value = ImageLoadProgress.Error(it) + } + } + } + + requests[hash] = job to flow + + return flow + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt deleted file mode 100644 index 77f27cc6..00000000 --- a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt +++ /dev/null @@ -1,445 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2020 tom5079 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package xyz.quaver.pupil.services - -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.TaskStackBuilder -import androidx.core.content.ContextCompat -import com.google.firebase.crashlytics.FirebaseCrashlytics -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Response -import okhttp3.ResponseBody -import okio.* -import xyz.quaver.pupil.* -import xyz.quaver.pupil.ui.ReaderActivity -import xyz.quaver.pupil.util.* -import xyz.quaver.pupil.util.downloader.Cache -import xyz.quaver.pupil.util.downloader.DownloadManager -import java.io.IOException -import java.util.concurrent.ConcurrentHashMap -import kotlin.math.ceil -import kotlin.math.log10 - -private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit -class DownloadService : Service() { - data class Tag(val galleryID: Int, val index: Int, val startId: Int? = null) - - //region Notification - private val notificationManager by lazy { - NotificationManagerCompat.from(this) - } - - private val serviceNotification by lazy { - NotificationCompat.Builder(this, "downloader") - .setContentTitle(getString(R.string.downloader_running)) - .setProgress(0, 0, false) - .setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - } - - private val notification = ConcurrentHashMap() - - private fun initNotification(galleryID: Int) { - val intent = Intent(this, ReaderActivity::class.java) - .putExtra("galleryID", galleryID) - - val pendingIntent = TaskStackBuilder.create(this).run { - addNextIntentWithParentStack(intent) - getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0) - } - val action = - NotificationCompat.Action.Builder(0, getText(android.R.string.cancel), - PendingIntent.getService( - this, - R.id.notification_download_cancel_action.normalizeID(), - Intent(this, DownloadService::class.java) - .putExtra(KEY_COMMAND, COMMAND_CANCEL) - .putExtra(KEY_ID, galleryID), - PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0), - ).build() - - notification[galleryID] = NotificationCompat.Builder(this, "download").apply { - setContentTitle(getString(R.string.reader_loading)) - setContentText(getString(R.string.reader_notification_text)) - setSmallIcon(R.drawable.ic_notification) - setContentIntent(pendingIntent) - addAction(action) - setProgress(0, 0, true) - setOngoing(true) - } - - notify(galleryID) - } - - @SuppressLint("RestrictedApi", "MissingPermission") - private fun notify(galleryID: Int) { - val max = progress[galleryID]?.size ?: 0 - val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0 - - val notification = notification[galleryID] ?: return - - if (!checkNotificationEnabled(this)) return - - if (isCompleted(galleryID)) { - notification - .setContentText(getString(R.string.reader_notification_complete)) - .setProgress(0, 0, false) - .setOngoing(false) - .mActions.clear() - - notificationManager.cancel(galleryID) - } else - notification - .setProgress(max, progress, false) - .setContentText("$progress/$max") - - if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null || galleryID == priority) - notification.let { notificationManager.notify(galleryID, it.build()) } - else - notificationManager.cancel(galleryID) - } - //endregion - - //region ProgressListener - @Suppress("UNCHECKED_CAST") - private val progressListener: ProgressListener = { (galleryID, index), bytesRead, contentLength, done -> - if (!done && progress[galleryID]?.get(index)?.isFinite() == true) - progress[galleryID]?.set(index, bytesRead * 100F / contentLength) - } - - private class ProgressResponseBody( - val tag: Any?, - val responseBody: ResponseBody, - val progressListener : ProgressListener - ) : ResponseBody() { - private var bufferedSource : BufferedSource? = null - - override fun contentLength() = responseBody.contentLength() - override fun contentType() = responseBody.contentType() - - override fun source(): BufferedSource { - if (bufferedSource == null) - bufferedSource = source(responseBody.source()).buffer() - - return bufferedSource!! - } - - private fun source(source: Source) = object: ForwardingSource(source) { - var totalBytesRead = 0L - - override fun read(sink: Buffer, byteCount: Long): Long { - val bytesRead = super.read(sink, byteCount) - - totalBytesRead += if (bytesRead == -1L) 0L else bytesRead - progressListener.invoke(tag as Tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L) - - return bytesRead - } - } - } - - private val interceptor: PupilInterceptor = { chain -> - val request = chain.request() - - var response = kotlin.runCatching { - chain.proceed(request) - }.getOrNull() - var limit = 10 - - while (response?.isSuccessful != true) { - if (response?.code == 503) { - Thread.sleep(200) - } else if (--limit < 0) - break - - response = kotlin.runCatching { - chain.proceed(request) - }.getOrNull() - } - - if (response == null) - response = chain.proceed(request) - - response!!.newBuilder() - .body(response.body?.let { - ProgressResponseBody(request.tag(), it, progressListener) - }).build() - } - //endregion - - //region Downloader - /** - * KEY - * primary galleryID - * secondary index - * PRIMARY VALUE - * MutableList -> Download in progress - * null -> Loading / Gallery doesn't exist - * SECONDARY VALUE - * 0 <= value < 100 -> Download in progress - * Float.POSITIVE_INFINITY -> Download completed - */ - val progress = ConcurrentHashMap>() - var priority = 0 - - fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true - - private val callback = object: Callback { - - override fun onFailure(call: Call, e: IOException) { - Log.d("PUPILD", "ONFAILURE ${call.request().tag()}, ${e}") - FirebaseCrashlytics.getInstance().recordException(e) - - if (e.message?.contains("cancel", true) == false) { - val galleryID = (call.request().tag() as Tag).galleryID - } - } - - override fun onResponse(call: Call, response: Response) { - Log.d("PUPILD", "ONRESPONSE ${call.request().tag()}") - val (galleryID, index, startId) = call.request().tag() as Tag - val ext = call.request().url.encodedPath.split('.').last() - - CoroutineScope(Dispatchers.IO).launch { - runCatching { - val image = response.also { if (it.code != 200) throw IOException( "$galleryID $index ${response.request.url} CODE ${it.code}" ) }.body?.use { it.bytes() } ?: throw Exception("Response null") - val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt() - - Cache.getInstance(this@DownloadService, galleryID) - .putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image) - - progress[galleryID]?.set(index, Float.POSITIVE_INFINITY) - notify(galleryID) - - if (isCompleted(galleryID)) { - if (DownloadManager.getInstance(this@DownloadService) - .getDownloadFolder(galleryID) != null - ) - Cache.getInstance(this@DownloadService, galleryID).moveToDownload() - - startId?.let { stopSelf(it) } - } - }.onFailure { - FirebaseCrashlytics.getInstance().recordException(it) - } - } - } - } - - fun cancel(startId: Int? = null) { - client.dispatcher.queuedCalls().filter { - it.request().tag() is Tag - }.forEach { - (it.request().tag() as? Tag)?.startId?.let { stopSelf(it) } - it.cancel() - } - client.dispatcher.runningCalls().filter { - it.request().tag() is Tag - }.forEach { - (it.request().tag() as? Tag)?.startId?.let { stopSelf(it) } - it.cancel() - } - - progress.clear() - notification.clear() - notificationManager.cancelAll() - - startId?.let { stopSelf(it) } - } - - fun cancel(galleryID: Int, startId: Int? = null) { - client.dispatcher.queuedCalls().filter { - (it.request().tag() as? Tag)?.galleryID == galleryID - }.forEach { - (it.request().tag() as? Tag)?.startId?.let { stopSelf(it) } - it.cancel() - } - client.dispatcher.runningCalls().filter { - (it.request().tag() as? Tag)?.galleryID == galleryID - }.forEach { - (it.request().tag() as? Tag)?.startId?.let { stopSelf(it) } - it.cancel() - } - - progress.remove(galleryID) - notification.remove(galleryID) - notificationManager.cancel(galleryID) - - startId?.let { stopSelf(it) } - } - - fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch { - cancel(galleryID) - DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID) - Cache.delete(this@DownloadService, galleryID) - - startId?.let { stopSelf(it) } - } - - fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch { - if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID)) - return@launch - - cleanCache(this@DownloadService) - - val cache = Cache.getInstance(this@DownloadService, galleryID) - - initNotification(galleryID) - - val galleryInfo = cache.getGalleryInfo() - - // Gallery doesn't exist - if (galleryInfo == null) { - delete(galleryID) - progress[galleryID] = mutableListOf() - return@launch - } - - histories.add(galleryID) - - progress[galleryID] = MutableList(galleryInfo.files.size) { 0F } - - cache.metadata.imageList?.let { - it.forEachIndexed { index, image -> - progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F) - } - } - - if (isCompleted(galleryID)) { - if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null) - Cache.getInstance(this@DownloadService, galleryID).moveToDownload() - - notificationManager.cancel(galleryID) - startId?.let { stopSelf(it) } - return@launch - } - - notification[galleryID]?.setContentTitle(galleryInfo.title?.ellipsize(30)) - notify(galleryID) - - val queued = mutableSetOf() - - if (priority) { - client.dispatcher.queuedCalls().forEach { - val queuedID = (it.request().tag() as? Tag)?.galleryID ?: return@forEach - - if (queued.add(queuedID)) - cancel(queuedID) - } - } - - galleryInfo.getRequestBuilders().forEachIndexed { index, it -> - if (progress[galleryID]?.get(index)?.isInfinite() == false) { - val request = it.tag(Tag(galleryID, index, startId)).build() - client.newCall(request).enqueue(callback) - } - } - - queued.forEach { download(it) } - } - //endregion - - companion object { - const val KEY_COMMAND = "COMMAND" // String - const val KEY_ID = "ID" // Int - const val KEY_PRIORITY = "PRIORITY" // Boolean - - const val COMMAND_DOWNLOAD = "DOWNLOAD" - const val COMMAND_CANCEL = "CANCEL" - const val COMMAND_DELETE = "DELETE" - - private fun command(context: Context, extras: Intent.() -> Unit) { - ContextCompat.startForegroundService(context, Intent(context, DownloadService::class.java).apply(extras)) - } - - fun download(context: Context, galleryID: Int, priority: Boolean = false) { - command(context) { - putExtra(KEY_COMMAND, COMMAND_DOWNLOAD) - putExtra(KEY_PRIORITY, priority) - putExtra(KEY_ID, galleryID) - } - } - - fun cancel(context: Context, galleryID: Int? = null) { - command(context) { - putExtra(KEY_COMMAND, COMMAND_CANCEL) - galleryID?.let { putExtra(KEY_ID, it) } - } - } - - fun delete(context: Context, galleryID: Int) { - command(context) { - putExtra(KEY_COMMAND, COMMAND_DELETE) - putExtra(KEY_ID, galleryID) - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) { - startForeground(R.id.downloader_notification_id, serviceNotification.build()) - } else { - startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - } - - when (intent?.getStringExtra(KEY_COMMAND)) { - COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) - download(it, intent.getBooleanExtra(KEY_PRIORITY, false), startId) - } - COMMAND_CANCEL -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) cancel(it, startId) else cancel(startId = startId) } - COMMAND_DELETE -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) delete(it, startId) } - } - - return START_NOT_STICKY - } - - inner class Binder : android.os.Binder() { - val service = this@DownloadService - } - - private val binder = Binder() - override fun onBind(p0: Intent?) = binder - - override fun onCreate() { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) { - startForeground(R.id.downloader_notification_id, serviceNotification.build()) - } else { - startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - } - interceptors[Tag::class] = interceptor - } - - override fun onDestroy() { - interceptors.remove(Tag::class) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/services/ImageCacheService.kt b/app/src/main/java/xyz/quaver/pupil/services/ImageCacheService.kt new file mode 100644 index 00000000..7893c32a --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/services/ImageCacheService.kt @@ -0,0 +1,57 @@ +/* + * Pupil, Hitomi.la viewer for Android + * Copyright (C) 2020 tom5079 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xyz.quaver.pupil.services + +import android.annotation.SuppressLint +import android.app.job.JobParameters +import android.app.job.JobService +import com.google.firebase.crashlytics.FirebaseCrashlytics +import dagger.hilt.android.AndroidEntryPoint +import io.ktor.util.cio.writeChannel +import io.ktor.util.collections.ConcurrentMap +import io.ktor.util.collections.ConcurrentSet +import io.ktor.utils.io.copyAndClose +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import xyz.quaver.pupil.Pupil +import xyz.quaver.pupil.networking.GalleryFile +import xyz.quaver.pupil.networking.HitomiHttpClient +import java.io.File + +@SuppressLint("SpecifyJobSchedulerIdRange") +@AndroidEntryPoint +class ImageCacheService : JobService() { + override fun onStartJob(params: JobParameters?): Boolean { + return false + } + + override fun onStopJob(params: JobParameters?): Boolean { + return false + } +} \ No newline at end of file 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 37dc5a25..7e32a32c 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -57,7 +57,8 @@ class MainActivity : BaseActivity() { displayFeatures = displayFeatures, uiState = uiState, navController = navController, - closeDetailScreen = viewModel::closeDetailScreen, + openGalleryDetails = viewModel::openGalleryDetails, + closeGalleryDetails = viewModel::closeGalleryDetails, onQueryChange = viewModel::onQueryChange, loadSearchResult = viewModel::loadSearchResult ) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt deleted file mode 100644 index bfb7c3c9..00000000 --- a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt +++ /dev/null @@ -1,619 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2019 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.ui - -import android.Manifest -import android.content.ComponentName -import android.content.Intent -import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.graphics.drawable.Animatable -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.view.* -import android.view.animation.Animation -import android.view.animation.AnticipateInterpolator -import android.view.animation.OvershootInterpolator -import android.view.animation.TranslateAnimation -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.PagerSnapHelper -import androidx.recyclerview.widget.RecyclerView -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.google.android.material.snackbar.Snackbar -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.google.mlkit.vision.face.Face -import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import xyz.quaver.pupil.R -import xyz.quaver.pupil.adapters.ReaderAdapter -import xyz.quaver.pupil.databinding.NumberpickerDialogBinding -import xyz.quaver.pupil.databinding.ReaderActivityBinding -import xyz.quaver.pupil.favorites -import xyz.quaver.pupil.services.DownloadService -import xyz.quaver.pupil.util.Preferences -import xyz.quaver.pupil.util.camera -import xyz.quaver.pupil.util.checkNotificationEnabled -import xyz.quaver.pupil.util.closeCamera -import xyz.quaver.pupil.util.downloader.Cache -import xyz.quaver.pupil.util.downloader.DownloadManager -import xyz.quaver.pupil.util.requestNotificationPermission -import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog -import xyz.quaver.pupil.util.startCamera - -class ReaderActivity : BaseActivity() { - - private var galleryID = 0 - private var currentPage = 0 - - private var isScroll = true - private var isFullscreen = false - set(value) { - field = value - - (binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value - } - - private lateinit var cache: Cache - var downloader: DownloadService? = null - private val conn = object: ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - downloader = (service as DownloadService.Binder).service.also { - it.priority = 0 - - if (!it.progress.containsKey(galleryID)) - DownloadService.download(this@ReaderActivity, galleryID, true) - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - downloader = null - } - } - - private val snapHelper = PagerSnapHelper() - private var menu: Menu? = null - - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) - toggleCamera() - else - AlertDialog.Builder(this) - .setTitle(R.string.error) - .setMessage(R.string.camera_denied) - .setPositiveButton(android.R.string.ok) { _, _ ->} - .show() - } - - enum class Eye { - LEFT, - RIGHT - } - - private var cameraEnabled = false - private var eyeType: Eye? = null - private var eyeTime: Long = 0L - - private lateinit var binding: ReaderActivityBinding - - private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (!isGranted) { - showNotificationPermissionExplanationDialog(this) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ReaderActivityBinding.inflate(layoutInflater) - setContentView(binding.root) - - title = getString(R.string.reader_loading) - supportActionBar?.setDisplayHomeAsUpEnabled(false) - - handleIntent(intent) - cache = Cache.getInstance(this, galleryID) - FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID) - - if (galleryID == 0) { - onBackPressed() - return - } - - initDownloadListener() - initView() - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleIntent(intent) - } - - private fun handleIntent(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW) { - val uri = intent.data - val lastPathSegment = uri?.lastPathSegment - if (uri != null && lastPathSegment != null) { - galleryID = if (uri.host?.endsWith("hasha.in") == true) { - lastPathSegment?.toInt() ?: 0 - } else when (uri.host) { - "hitomi.la" -> - Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0 - "e-hentai.org" -> uri.pathSegments[1].toInt() - else -> 0 - } - } - } else { - galleryID = intent.getIntExtra("galleryID", 0) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.reader, menu) - - with(menu.findItem(R.id.reader_menu_favorite)) { - if (favorites.contains(galleryID)) - (icon as Animatable).start() - } - - this.menu = menu - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.reader_menu_page_indicator -> { - // TODO: Switch to DialogFragment - val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false) - - with(binding.numberPicker) { - minValue = 1 - maxValue = cache.metadata.galleryInfo?.files?.size ?: 0 - value = currentPage - } - val dialog = AlertDialog.Builder(this).apply { - setView(binding.root) - }.create() - binding.okButton.setOnClickListener { - (this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0) - dialog.dismiss() - } - - dialog.show() - } - R.id.reader_menu_favorite -> { - val id = galleryID - val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true - - if (favorites.contains(id)) { - favorites.remove(id) - favorite.icon = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_star) - } else { - favorites.add(id) - (favorite.icon as Animatable).start() - } - } - } - - return true - } - - override fun onResume() { - super.onResume() - - bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE) - - if (cameraEnabled) - startCamera(this, cameraCallback) - } - - override fun onPause() { - super.onPause() - closeCamera() - - if (downloader != null) - unbindService(conn) - - downloader?.priority = galleryID - } - - override fun onDestroy() { - super.onDestroy() - - update = false - - if (!DownloadManager.getInstance(this).isDownloading(galleryID)) - DownloadService.cancel(this, galleryID) - } - - override fun onBackPressed() { - if (isScroll and !isFullscreen) - super.onBackPressed() - - if (isFullscreen) { - isFullscreen = false - fullscreen(false) - } - - if (!isScroll) { - isScroll = true - scrollMode(true) - } - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - //currentPage is 1-based - return when(keyCode) { - KeyEvent.KEYCODE_VOLUME_UP -> { - (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0) - - true - } - KeyEvent.KEYCODE_VOLUME_DOWN -> { - (binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0) - - true - } - else -> super.onKeyDown(keyCode, event) - } - } - - private var update = true - private fun initDownloadListener() { - CoroutineScope(Dispatchers.Main).launch { - while (update) { - delay(1000) - - val downloader = downloader ?: continue - - if (!downloader.progress.containsKey(galleryID)) //loading - continue - - if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found - update = false - Snackbar - .make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE) - .show() - - return@launch - } - - binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0 - binding.downloadProgressbar.progress = - downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0 - - if (title == getString(R.string.reader_loading)) { - val galleryInfo = cache.metadata.galleryInfo - - if (galleryInfo != null) { - with(binding.recyclerview.adapter as ReaderAdapter) { - this.galleryInfo = galleryInfo - notifyDataSetChanged() - } - - title = galleryInfo.title - menu?.findItem(R.id.reader_menu_page_indicator)?.title = - "$currentPage/${galleryInfo.files.size}" - - menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi) - } - } - - if (downloader.isCompleted(galleryID)) { //Download finished - binding.downloadProgressbar.visibility = View.GONE - - animateDownloadFAB(false) - } - } - } - } - - private fun initView() { - with(binding.recyclerview) { - adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply { - onItemClickListener = { - if (isScroll) { - isScroll = false - isFullscreen = true - - scrollMode(false) - fullscreen(true) - } else { - (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing - } - } - } - - addOnScrollListener(object: RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - - if (dy < 0) - binding.fab.showMenuButton(true) - else if (dy > 0) - binding.fab.hideMenuButton(true) - - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - - if (layoutManager.findFirstVisibleItemPosition() == -1) - return - currentPage = layoutManager.findFirstVisibleItemPosition()+1 - menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}" - } - }) - } - - with(binding.downloadFab) { - animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button - - setOnClickListener { - requestNotificationPermission( - this@ReaderActivity, - requestNotificationPermssionLauncher - ) { - val downloadManager = DownloadManager.getInstance(this@ReaderActivity) - - if (downloadManager.isDownloading(galleryID)) { - downloadManager.deleteDownloadFolder(galleryID) - animateDownloadFAB(false) - } else { - downloadManager.addDownloadFolder(galleryID) - DownloadService.download(context, galleryID, true) - animateDownloadFAB(true) - } - } - } - } - - with(binding.retryFab) { - setImageResource(R.drawable.refresh) - setOnClickListener { - DownloadService.download(context, galleryID) - } - } - - with(binding.autoFab) { - setImageResource(R.drawable.eye_white) - setOnClickListener { - when { - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> { - toggleCamera() - } - Build.VERSION.SDK_INT >= 23 && shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> { - AlertDialog.Builder(this@ReaderActivity) - .setTitle(R.string.warning) - .setMessage(R.string.camera_denied) - .setPositiveButton(android.R.string.ok) { _, _ ->} - .show() - } - else -> - requestPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - } - - with(binding.fullscreenFab) { - setImageResource(R.drawable.ic_fullscreen) - setOnClickListener { - isFullscreen = true - fullscreen(isFullscreen) - - binding.fab.close(true) - } - } - } - - private fun fullscreen(isFullscreen: Boolean) { - with(window.attributes) { - if (isFullscreen) { - flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN - supportActionBar?.hide() - binding.fab.visibility = View.INVISIBLE - binding.scroller.let { - it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_height) - it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_width) - it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb_horizontal) - it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.HORIZONTAL - } - } else { - flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv() - supportActionBar?.show() - binding.fab.visibility = View.VISIBLE - binding.scroller.let { - it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_width) - it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_height) - it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb) - it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.VERTICAL - } - } - - window.attributes = this - } - - binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw - } - - private fun scrollMode(isScroll: Boolean) { - if (isScroll) { - snapHelper.attachToRecyclerView(null) - binding.recyclerview.layoutManager = LinearLayoutManager(this) - } else { - snapHelper.attachToRecyclerView(binding.recyclerview) - binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) { - override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { - extraLayoutSpace.fill(600) - } - } - } - - (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0) - } - - private fun animateDownloadFAB(animate: Boolean) { - with(binding.downloadFab) { - if (animate) { - val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading) - - icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating - post { - setImageResource(R.drawable.ic_download) - labelText = getString(R.string.reader_fab_download_cancel) - } - else // Or continue animate - post { - icon.start() - labelText = getString(R.string.reader_fab_download_cancel) - } - } - }) - - setImageDrawable(icon) - icon?.start() - } else { - setImageResource(R.drawable.ic_download) - labelText = getString(R.string.reader_fab_download) - } - } - } - - val cameraCallback: (List) -> Unit = callback@{ faces -> - binding.eyeCard.dot.let { - it.visibility = View.VISIBLE - CoroutineScope(Dispatchers.Main).launch { - delay(50) - it.visibility = View.INVISIBLE - } - } - - if (faces.size != 1) - ContextCompat.getDrawable(this, R.drawable.eye_off).let { - with(binding.eyeCard) { - leftEye.setImageDrawable(it) - rightEye.setImageDrawable(it) - } - - return@callback - } - - val (left, right) = Pair( - faces[0].rightEyeOpenProbability?.let { it > 0.4 } == true, - faces[0].leftEyeOpenProbability?.let { it > 0.4 } == true - ) - - with(binding.eyeCard) { - leftEye.setImageDrawable( - ContextCompat.getDrawable( - leftEye.context, - if (left) R.drawable.eye else R.drawable.eye_closed - ) - ) - rightEye.setImageDrawable( - ContextCompat.getDrawable( - rightEye.context, - if (right) R.drawable.eye else R.drawable.eye_closed - ) - ) - } - - when { - // Both closed / opened - !left.xor(right) -> { - eyeType = null - eyeTime = 0L - } - !left -> { - if (eyeType != Eye.LEFT) { - eyeType = Eye.LEFT - eyeTime = System.currentTimeMillis() - } - } - !right -> { - if (eyeType != Eye.RIGHT) { - eyeType = Eye.RIGHT - eyeTime = System.currentTimeMillis() - } - } - } - - if (eyeType != null && System.currentTimeMillis() - eyeTime > 100) { - (binding.recyclerview.layoutManager as LinearLayoutManager).let { - it.scrollToPositionWithOffset(when(eyeType!!) { - Eye.RIGHT -> { - if (it.reverseLayout) currentPage - 2 else currentPage - } - Eye.LEFT -> { - if (it.reverseLayout) currentPage else currentPage - 2 - } - }, 0) - } - - eyeTime = System.currentTimeMillis() + 500 - } - } - - private fun toggleCamera() { - val eyes = binding.eyeCard.root - when (camera) { - null -> { - binding.autoFab.labelText = getString(R.string.reader_fab_auto_cancel) - binding.autoFab.setImageResource(R.drawable.eye_off_white) - eyes.apply { - visibility = View.VISIBLE - TranslateAnimation(0F, 0F, -100F, 0F).apply { - duration = 500 - fillAfter = false - interpolator = OvershootInterpolator() - }.let { startAnimation(it) } - } - startCamera(this, cameraCallback) - cameraEnabled = true - } - else -> { - binding.autoFab.labelText = getString(R.string.reader_fab_auto) - binding.autoFab.setImageResource(R.drawable.eye_white) - eyes.apply { - TranslateAnimation(0F, 0F, 0F, -100F).apply { - duration = 500 - fillAfter = false - interpolator = AnticipateInterpolator() - setAnimationListener(object: Animation.AnimationListener { - override fun onAnimationStart(p0: Animation?) {} - override fun onAnimationRepeat(p0: Animation?) {} - - override fun onAnimationEnd(p0: Animation?) { - eyes.visibility = View.GONE - } - }) - }.let { startAnimation(it) } - } - closeCamera() - cameraEnabled = false - } - } - } -} \ No newline at end of file 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 2ae4754c..51bb3886 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 @@ -1,6 +1,7 @@ package xyz.quaver.pupil.ui.composable import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,13 +10,18 @@ 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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.filled.StarOutline import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -33,12 +39,18 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString 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 androidx.compose.ui.unit.sp import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import xyz.quaver.pupil.R @@ -50,8 +62,13 @@ 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.SearchQuery import xyz.quaver.pupil.networking.Series import xyz.quaver.pupil.networking.joinToCapitalizedString +import xyz.quaver.pupil.ui.theme.Blue500 +import xyz.quaver.pupil.ui.theme.Green500 +import xyz.quaver.pupil.ui.theme.Purple500 +import xyz.quaver.pupil.ui.theme.Red500 import xyz.quaver.pupil.ui.theme.Yellow500 private val languageMap = mapOf( @@ -94,6 +111,22 @@ private val languageMap = mapOf( "japanese" to "日本語" ) +private val galleryTypeStringMap = mapOf( + "doujinshi" to R.string.doujinshi, + "manga" to R.string.manga, + "artistcg" to R.string.artist_cg, + "gamecg" to R.string.game_cg, + "imageset" to R.string.image_set +) + +private val galleryTypeColorMap = mapOf( + "doujinshi" to Red500, + "manga" to Yellow500, + "artistcg" to Purple500, + "gamecg" to Green500, + "imageset" to Blue500 +) + class GalleryInfoProvider: PreviewParameterProvider { override val values = sequenceOf( GalleryInfo( @@ -211,18 +244,19 @@ class GalleryInfoProvider: PreviewParameterProvider { @OptIn(ExperimentalLayoutApi::class) @Composable -fun TagGroup(tags: List) { - var isFolded by remember { mutableStateOf(true) } +fun TagGroup(tags: List, folded: Boolean = false) { + var isFolded by remember { mutableStateOf(folded) } FlowRow( - Modifier.padding(0.dp, 16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { tags.sortedBy { - if (!it.female.isNullOrEmpty()) 1 - else if (!it.female.isNullOrEmpty()) 2 - else 3 + when(it.namespace) { + "female" -> 1 + "male" -> 2 + else -> 3 + } }.let { if (isFolded) it.take(10) else it }.forEach { tag -> @@ -246,6 +280,59 @@ fun TagGroup(tags: List) { } } +@Composable +fun GalleryTypeIndicator(galleryType: String) { + Surface( + modifier = Modifier.height(32.dp), + color = galleryTypeColorMap[galleryType] ?: MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Box(Modifier.fillMaxHeight()) { + Text( + galleryTypeStringMap[galleryType]?.let { stringResource(it) } ?: galleryType, + modifier = Modifier + .padding(horizontal = 16.dp) + .align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + } + } +} + +@Composable +fun LanguageTitle(title: String, language: String?) { + val icon = languageIconMap[language] + + if (icon != null) { + Text( + buildAnnotatedString { + appendInlineContent("language", "") + append(' ') + append(title) + }, + style = MaterialTheme.typography.headlineSmall, + inlineContent = mapOf( + "language" to InlineTextContent( + Placeholder( + width = 20.sp, + height = 20.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = Color.Unspecified + ) + } + ) + ) + } else { + Text(title, style = MaterialTheme.typography.headlineSmall) + } +} + @Composable fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { val thumbnailFile = galleryInfo.files.first() @@ -263,7 +350,7 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { .fillMaxWidth() .aspectRatio(aspectRatio) .clip(RoundedCornerShape(8.dp)), - loading = { CircularProgressIndicator(Modifier.size(32.dp)) }, + loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) }, error = { Image( painter = painterResource(R.drawable.thumbnail), @@ -273,11 +360,18 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { contentDescription = "Thumbnail" ) } else { - Box(Modifier.fillMaxWidth().aspectRatio(aspectRatio)) { - CircularProgressIndicator(Modifier.size(32.dp)) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio)) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) } } - Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(8.dp)) + + LanguageTitle(galleryInfo.title, galleryInfo.language) + val artistsAndGroups = buildString { if (!galleryInfo.artists.isNullOrEmpty()) append(galleryInfo.artists.joinToCapitalizedString()) @@ -290,34 +384,16 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { } } - Text( - artistsAndGroups, - style = MaterialTheme.typography.labelLarge - ) - - Spacer(Modifier.height(8.dp)) - - if (galleryInfo.series?.isNotEmpty() == true) + if (artistsAndGroups.isNotEmpty()) { 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 + artistsAndGroups, + style = MaterialTheme.typography.labelLarge, ) } } } else { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { if (thumbnailUrl != null) { @@ -330,7 +406,7 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { .height(200.dp) .aspectRatio(aspectRatio) .clip(RoundedCornerShape(8.dp)), - loading = { CircularProgressIndicator(Modifier.size(32.dp)) }, + loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) }, error = { Image( painter = painterResource(R.drawable.thumbnail), @@ -340,12 +416,16 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { contentDescription = "Thumbnail" ) } else { - Box(Modifier.height(200.dp).aspectRatio(aspectRatio)) { - CircularProgressIndicator(Modifier.size(32.dp)) + Box( + Modifier + .height(200.dp) + .aspectRatio(aspectRatio)) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) } } Column(Modifier.heightIn(min = 200.dp)) { - Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall) + LanguageTitle(galleryInfo.title, galleryInfo.language) + val artistsAndGroups = buildString { if (!galleryInfo.artists.isNullOrEmpty()) append(galleryInfo.artists.joinToCapitalizedString()) @@ -358,31 +438,10 @@ fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) { } } - Text( - artistsAndGroups, - style = MaterialTheme.typography.labelLarge - ) - - Spacer( - Modifier - .weight(1f) - .heightIn(min = 8.dp)) - - if (galleryInfo.series?.isNotEmpty() == true) + if (artistsAndGroups.isNotEmpty()) { 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 + artistsAndGroups, + style = MaterialTheme.typography.labelLarge ) } } @@ -405,14 +464,16 @@ fun DetailedGalleryInfo( } Card(modifier) { - Column(Modifier.padding(8.dp)) { + Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl) + GalleryTypeIndicator(galleryInfo.type) + if (galleryInfo.tags?.isNotEmpty() == true) { - TagGroup(galleryInfo.tags) + TagGroup(galleryInfo.tags.map { it.toTag() }, folded = true) } - HorizontalDivider(Modifier.padding(4.dp)) + HorizontalDivider() Box( Modifier diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt index 2d752c99..0878b345 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt @@ -2,8 +2,6 @@ package xyz.quaver.pupil.ui.composable import android.util.Log import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,8 +38,8 @@ import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import kotlinx.coroutines.launch import xyz.quaver.pupil.R +import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.SearchQuery -import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.SettingsActivity import xyz.quaver.pupil.ui.viewmodel.SearchState @@ -51,9 +49,10 @@ fun MainApp( displayFeatures: List, uiState: SearchState, navController: NavHostController, - closeDetailScreen: () -> Unit, + openGalleryDetails: (GalleryInfo) -> Unit, + closeGalleryDetails: () -> Unit, onQueryChange: (SearchQuery?) -> Unit, - loadSearchResult: (IntRange) -> Unit + loadSearchResult: (IntRange) -> Unit, ) { val navigationType: NavigationType val contentType: ContentType @@ -106,7 +105,8 @@ fun MainApp( navigationContentPosition, uiState, navController, - closeDetailScreen = closeDetailScreen, + openGalleryDetails = openGalleryDetails, + closeGalleryDetails = closeGalleryDetails, onQueryChange = onQueryChange, loadSearchResult = loadSearchResult ) @@ -120,7 +120,8 @@ private fun MainNavigationWrapper( navigationContentPosition: NavigationContentPosition, uiState: SearchState, navController: NavHostController, - closeDetailScreen: () -> Unit, + openGalleryDetails: (GalleryInfo) -> Unit, + closeGalleryDetails: () -> Unit, onQueryChange: (SearchQuery?) -> Unit, loadSearchResult: (IntRange) -> Unit ) { @@ -156,9 +157,10 @@ private fun MainNavigationWrapper( uiState = uiState, navController = navController, onDrawerClicked = openDrawer, - closeDetailScreen = closeDetailScreen, + openGalleryDetails = openGalleryDetails, + closeGalleryDetails = closeGalleryDetails, onQueryChange = onQueryChange, - loadSearchResult = loadSearchResult + loadSearchResult = loadSearchResult, ) } } else { @@ -187,9 +189,10 @@ private fun MainNavigationWrapper( uiState = uiState, navController = navController, onDrawerClicked = openDrawer, - closeDetailScreen = closeDetailScreen, + openGalleryDetails = openGalleryDetails, + closeGalleryDetails = closeGalleryDetails, onQueryChange = onQueryChange, - loadSearchResult = loadSearchResult + loadSearchResult = loadSearchResult, ) } } @@ -217,9 +220,10 @@ fun MainContent( uiState: SearchState, navController: NavHostController, onDrawerClicked: () -> Unit, - closeDetailScreen: () -> Unit, + openGalleryDetails: (GalleryInfo) -> Unit, + closeGalleryDetails: () -> Unit, onQueryChange: (SearchQuery?) -> Unit, - loadSearchResult: (IntRange) -> Unit + loadSearchResult: (IntRange) -> Unit, ) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -261,9 +265,16 @@ fun MainContent( contentType = contentType, displayFeatures = displayFeatures, uiState = uiState, - closeDetailScreen = closeDetailScreen, + openGalleryDetails = openGalleryDetails, + closeGalleryDetails = closeGalleryDetails, onQueryChange = onQueryChange, - loadSearchResult = loadSearchResult + loadSearchResult = loadSearchResult, + openGallery = { + Log.d("PUPILD", "openGallery: ${it.id}") + navController.navigate(MainDestination.ImageViewer(it.id).route) { + launchSingleTop = true + } + } ) } composable(MainDestination.History.route) { @@ -278,8 +289,8 @@ fun MainContent( activity(MainDestination.Settings.route) { activityClass = SettingsActivity::class } - activity(MainDestination.ImageViewer.route) { - activityClass = ReaderActivity::class + activity(MainDestination.ImageViewer.commonRoute) { +// argument("galleryID") { type = NavType.IntType } } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt index 1446d0da..a4c6c666 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainNavigationActions.kt @@ -47,11 +47,14 @@ sealed interface MainDestination { override val textId = R.string.main_destination_settings } - data object ImageViewer: MainDestination { - override val route = "image_viewer" + class ImageViewer(galleryID: String): MainDestination { + override val route = "image_viewer/$galleryID" override val icon = Icons.AutoMirrored.Filled.MenuBook override val textId = R.string.main_destination_image_viewer + companion object { + val commonRoute = "image_viewer/{galleryID}" + } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt index cc1cc583..2af11d12 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt @@ -35,27 +35,30 @@ import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Female import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.Male import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -92,6 +95,7 @@ import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.GalleryInfo +import xyz.quaver.pupil.networking.HitomiHttpClient import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.ui.theme.Blue600 import xyz.quaver.pupil.ui.theme.Pink600 @@ -108,10 +112,10 @@ private val iconMap = mapOf( "series" to Icons.Default.Book, "type" to Icons.Default.Folder, "language" to Icons.Default.Translate, - "tag" to Icons.Default.LocalOffer, + "tag" to Icons.AutoMirrored.Filled.Label, ) -private val languageMap = mapOf( +val languageIconMap = mapOf( "indonesian" to R.drawable.language_indonesian, "javanese" to R.drawable.language_javanese, "catalan" to R.drawable.language_catalan, @@ -155,9 +159,9 @@ fun TagChipIcon(tag: SearchQuery.Tag) { val icon = iconMap[tag.namespace] if (icon != null) { - if (tag.namespace == "language" && languageMap.contains(tag.tag)) { + if (tag.namespace == "language" && languageIconMap.contains(tag.tag)) { Icon( - painter = painterResource(languageMap[tag.tag]!!), + painter = painterResource(languageIconMap[tag.tag]!!), contentDescription = "icon", modifier = Modifier .padding(4.dp) @@ -360,7 +364,8 @@ fun SearchBar( indication = null ) { focused = true - }.onGloballyPositioned { + } + .onGloballyPositioned { onSearchBarPositioned(it.positionInRoot().y.roundToInt() + it.size.height) } .absoluteOffset { IntOffset(0, topOffset) }, @@ -414,6 +419,7 @@ fun GalleryList( error: Boolean = false, onPageChange: (Int) -> Unit, onQueryChange: (SearchQuery?) -> Unit = {}, + openGalleryDetails: (GalleryInfo) -> Unit, ) { val listState = rememberLazyListState() var topOffset by remember { mutableIntStateOf(0) } @@ -480,7 +486,8 @@ fun GalleryList( DetailedGalleryInfo( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp), + .padding(horizontal = 4.dp) + .clickable { openGalleryDetails(galleryInfo) }, galleryInfo = galleryInfo ) } @@ -490,21 +497,76 @@ fun GalleryList( } } +@Composable +fun DetailScreen( + galleryInfo: GalleryInfo, + closeGalleryDetails: () -> Unit = { }, + openGallery: (GalleryInfo) -> Unit = { } +) { + var thumbnailUrl by remember { mutableStateOf(null) } + + LaunchedEffect(galleryInfo) { + thumbnailUrl = galleryInfo.files.firstOrNull()?.let { + HitomiHttpClient.getImageURL(it, true).firstOrNull() + } ?: "" + } + + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars)) + IconButton(onClick = closeGalleryDetails) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Detail") + } + + DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl) + + Row(Modifier.fillMaxWidth()) { + FilledTonalButton( + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), + onClick = { /*TODO*/ } + ) { + Text(stringResource(R.string.download)) + } + + Button( + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), + onClick = { openGallery(galleryInfo) } + ) { + Text("Open") + } + } + + GalleryTypeIndicator(galleryInfo.type) + + if (galleryInfo.series?.isNotEmpty() == true) { + TagGroup(galleryInfo.series.map { it.toTag() }) + } + + if (galleryInfo.characters?.isNotEmpty() == true) { + TagGroup(galleryInfo.characters.map { it.toTag() }) + } + + if (galleryInfo.tags?.isNotEmpty() == true) { + TagGroup(galleryInfo.tags.map { it.toTag() }) + } + } +} + @Composable fun SearchScreen( contentType: ContentType, displayFeatures: List, uiState: SearchState, - closeDetailScreen: () -> Unit, + openGalleryDetails: (GalleryInfo) -> Unit, + closeGalleryDetails: () -> Unit, onQueryChange: (SearchQuery?) -> Unit, - loadSearchResult: (IntRange) -> Unit + loadSearchResult: (IntRange) -> Unit, + openGallery: (GalleryInfo) -> Unit ) { - LaunchedEffect(contentType) { - if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) { - closeDetailScreen() - } - } - val itemsPerPage by remember { mutableIntStateOf(20) } val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {{ page -> @@ -527,7 +589,19 @@ fun SearchScreen( loadSearchResult(pageToRange(page)) }} - LaunchedEffect(uiState.query, currentPage) { loadSearchResult(pageToRange(currentPage)) } + LaunchedEffect(uiState.query) { loadSearchResult(pageToRange(currentPage)) } + + LaunchedEffect(contentType) { + if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) { + closeGalleryDetails() + } + } + + if (contentType == ContentType.SINGLE_PANE && uiState.isDetailOnlyOpen) { + BackHandler { + closeGalleryDetails() + } + } if (contentType == ContentType.DUAL_PANE) { TwoPane( @@ -541,7 +615,8 @@ fun SearchScreen( loading = uiState.loading, error = uiState.error, onQueryChange = onQueryChange, - onPageChange = loadResult + onPageChange = loadResult, + openGalleryDetails = openGalleryDetails ) }, second = { @@ -551,17 +626,30 @@ fun SearchScreen( displayFeatures = displayFeatures ) } else { - GalleryList( - contentType = contentType, - galleries = uiState.galleries, - query = uiState.query, - currentPage = currentPage, - maxPage = maxPage, - loading = uiState.loading, - error = uiState.error, - onQueryChange = onQueryChange, - onPageChange = loadResult - ) + val detailGallery = uiState.openedGallery + AnimatedVisibility(!uiState.isDetailOnlyOpen || detailGallery == null) { + GalleryList( + contentType = contentType, + galleries = uiState.galleries, + query = uiState.query, + currentPage = currentPage, + maxPage = maxPage, + loading = uiState.loading, + error = uiState.error, + onQueryChange = onQueryChange, + onPageChange = loadResult, + openGalleryDetails = openGalleryDetails + ) + } + AnimatedVisibility(uiState.isDetailOnlyOpen && detailGallery != null) { + if (detailGallery != null) { + DetailScreen( + galleryInfo = detailGallery, + closeGalleryDetails = closeGalleryDetails, + openGallery = openGallery + ) + } + } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/reader/ImageViewer.kt b/app/src/main/java/xyz/quaver/pupil/ui/reader/ImageViewer.kt new file mode 100644 index 00000000..b7a8a540 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/ui/reader/ImageViewer.kt @@ -0,0 +1,2 @@ +package xyz.quaver.pupil.ui.reader + diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt index af277092..67e0933b 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt @@ -1,5 +1,6 @@ package xyz.quaver.pupil.ui.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job @@ -18,7 +19,14 @@ class MainViewModel : ViewModel() { private var searchSource: GallerySearchSource = GallerySearchSource(null) private var job: Job? = null - fun closeDetailScreen() { + fun openGalleryDetails(galleryInfo: GalleryInfo) { + _uiState.value = _uiState.value.copy( + openedGallery = galleryInfo, + isDetailOnlyOpen = true + ) + } + + fun closeGalleryDetails() { _uiState.value = _uiState.value.copy( isDetailOnlyOpen = false ) @@ -35,11 +43,13 @@ class MainViewModel : ViewModel() { } fun loadSearchResult(range: IntRange) { + Thread.dumpStack() job?.cancel() job = viewModelScope.launch { val sanitizedRange = max(range.first, 0) .. min(range.last, searchState.value.galleryCount ?: Int.MAX_VALUE) _uiState.value = _uiState.value.copy( loading = true, + error = false, currentRange = sanitizedRange ) diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt deleted file mode 100644 index 0bcba433..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2020 tom5079 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package xyz.quaver.pupil.util.downloader - -import android.content.Context -import android.content.ContextWrapper -import android.net.Uri -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.Request -import xyz.quaver.io.FileX -import xyz.quaver.io.util.* -import xyz.quaver.pupil.client -import xyz.quaver.pupil.hitomi.* -import java.io.File -import java.io.IOException -import java.util.concurrent.ConcurrentHashMap - -@Serializable -data class OldReader( - val code: String, - val galleryInfo: OldGalleryInfo -) - -@Serializable -data class OldGalleryInfo( - val language_localname: String? = null, - val language: String? = null, - val date: String? = null, - val files: List, - val id: Int? = null, - val type: String? = null, - val title: String? = null -) - -@Serializable -data class OldGalleryFiles( - val width: Int, - val hash: String, - val haswebp: Int = 0, - val name: String, - val height: Int, - val hasavif: Int = 0, - val hasavifsmalltn: Int? = 0 -) - -@Serializable -data class OldMetadata( - var galleryBlock: GalleryBlock? = null, - var reader: OldReader? = null, - var imageList: MutableList? = null -) { - fun copy(): OldMetadata = OldMetadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } }) -} - -@Serializable -data class Metadata( - var galleryBlock: GalleryBlock? = null, - var galleryInfo: GalleryInfo? = null, - var imageList: MutableList? = null -) { - constructor(old: OldMetadata) : this( - old.galleryBlock, - old.reader?.galleryInfo?.let { oldGalleryInfo -> - GalleryInfo( - oldGalleryInfo.id.toString(), - oldGalleryInfo.title ?: "", - null, - oldGalleryInfo.language, - oldGalleryInfo.type ?: "", - oldGalleryInfo.date ?: "", - files = oldGalleryInfo.files.map { - GalleryFiles( - it.width, - it.hash, - it.haswebp, - it.name, - it.height, - it.hasavif, - it.hasavifsmalltn - ) - } - ) - }, - old.imageList - ) - - fun copy(): Metadata = Metadata(galleryBlock, galleryInfo, imageList?.let { MutableList(it.size) { i -> it[i] } }) -} - -class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) { - companion object { - val instances = ConcurrentHashMap() - - fun getInstance(context: Context, galleryID: Int) = - instances[galleryID] ?: synchronized(this) { - instances[galleryID] ?: Cache(context, galleryID).also { instances.put(galleryID, it) } - } - - @Synchronized - fun delete(context: Context, galleryID: Int) { - File(context.cacheDir, "imageCache/$galleryID").deleteRecursively() - instances.remove(galleryID) - } - } - - init { - cacheFolder.mkdirs() - } - - var metadata = kotlin.runCatching { - findFile(".metadata")?.readText()?.let { metadata -> - kotlin.runCatching { - Json.decodeFromString(metadata) - }.getOrElse { - Metadata(json.decodeFromString(metadata)) - } - } - }.onFailure { it.printStackTrace() }.getOrNull() ?: Metadata() - - val downloadFolder: FileX? - get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID) - - val cacheFolder: FileX - get() = FileX(this, cacheDir, "imageCache/$galleryID").also { - if (!it.exists()) - it.mkdirs() - } - - fun findFile(fileName: String): FileX? = - downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let { - if (it.exists()) it else null - } } ?: cacheFolder.getChild(fileName).let { - if (it.exists()) it else null - } - - @Suppress("BlockingMethodInNonBlockingContext") - fun setMetadata(change: (Metadata) -> Unit) { - change.invoke(metadata) - - val file = cacheFolder.getChild(".metadata") - - kotlin.runCatching { - if (!file.exists()) { - file.createNewFile() - } - file.writeText(Json.encodeToString(metadata)) - } - } - - suspend fun getGalleryBlock(): GalleryBlock? { - return metadata.galleryBlock - ?: withContext(Dispatchers.IO) { - try { - getGalleryBlock(galleryID).also { - setMetadata { metadata -> metadata.galleryBlock = it } - } - } catch (e: Exception) { return@withContext null } - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun getThumbnail(): Uri = - findFile(".thumbnail")?.uri - ?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) { - kotlin.runCatching { - val request = Request.Builder() - .url(it) - .header("Referer", "https://hitomi.la/") - .build() - - client.newCall(request).execute().also { if (it.code != 200) throw IOException() }.body?.use { it.bytes() } - }.getOrNull()?.let { thumbnail -> kotlin.runCatching { - cacheFolder.getChild(".thumbnail").also { - if (!it.exists()) - it.createNewFile() - - it.writeBytes(thumbnail) - } - }.getOrNull()?.uri } - } } ?: Uri.EMPTY - - suspend fun getGalleryInfo(): GalleryInfo? { - - return metadata.galleryInfo - ?: withContext(Dispatchers.IO) { - try { - getGalleryInfo(galleryID).also { - setMetadata { metadata -> - metadata.galleryInfo = it - - if (metadata.imageList == null) - metadata.imageList = MutableList(it.files.size) { null } - } - } - } catch (e: Exception) { - null - } - } - } - - fun getImage(index: Int): FileX? = - metadata.imageList?.getOrNull(index)?.let { findFile(it) } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun putImage(index: Int, fileName: String, data: ByteArray) = coroutineScope { - val file = cacheFolder.getChild(fileName) - - if (!file.exists()) - file.createNewFile() - - file.writeBytes(data) - setMetadata { metadata -> metadata.imageList!![index] = fileName } - } - - private val lock = ConcurrentHashMap() - @Suppress("BlockingMethodInNonBlockingContext") - fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch { - val downloadFolder = downloadFolder ?: return@launch - - if (lock[galleryID]?.isLocked == true) - return@launch - - (lock[galleryID] ?: Mutex().also { lock[galleryID] = it }).withLock { - val cacheMetadata = cacheFolder.getChild(".metadata") - val downloadMetadata = downloadFolder.getChild(".metadata") - - if (!cacheMetadata.exists()) - return@launch - - if (cacheMetadata.exists()) { - kotlin.runCatching { - if (!downloadMetadata.exists()) - downloadMetadata.createNewFile() - - downloadMetadata.writeText(Json.encodeToString(metadata)) - } - } - - val cacheThumbnail = cacheFolder.getChild(".thumbnail") - val downloadThumbnail = downloadFolder.getChild(".thumbnail") - - if (cacheThumbnail.exists()) { - kotlin.runCatching { - if (!downloadThumbnail.exists()) - downloadThumbnail.createNewFile() - - downloadThumbnail.outputStream()?.use { target -> target.channel.truncate(0L); cacheThumbnail.inputStream()?.use { source -> - source.copyTo(target) - } } - cacheThumbnail.delete() - } - } - - metadata.imageList?.forEach { imageName -> - imageName ?: return@forEach - val target = downloadFolder.getChild(imageName) - val source = cacheFolder.getChild(imageName) - - if (!source.exists()) - return@forEach - - kotlin.runCatching { - if (!target.exists()) - target.createNewFile() - - target.outputStream()?.use { target -> target.channel.truncate(0L); source.inputStream()?.use { source -> - source.copyTo(target) - } } - } - } - - cacheFolder.deleteRecursively() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt deleted file mode 100644 index 77349b4b..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2020 tom5079 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package xyz.quaver.pupil.util.downloader - -import android.content.Context -import android.content.ContextWrapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.Call -import xyz.quaver.io.FileX -import xyz.quaver.io.util.* -import xyz.quaver.pupil.client -import xyz.quaver.pupil.services.DownloadService -import xyz.quaver.pupil.util.Preferences -import xyz.quaver.pupil.util.formatDownloadFolder - -class DownloadManager private constructor(context: Context) : ContextWrapper(context) { - - companion object { - @Volatile private var instance: DownloadManager? = null - - fun getInstance(context: Context) = - instance ?: synchronized(this) { - instance ?: DownloadManager(context).also { instance = it } - } - } - - val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!) - - val downloadFolder: FileX - get() = kotlin.runCatching { - FileX(this, Preferences.get("download_folder")) - }.getOrElse { - Preferences["download_folder"] = defaultDownloadFolder.uri.toString() - defaultDownloadFolder - } - - private var prevDownloadFolder: FileX? = null - private var downloadFolderMapInstance: MutableMap? = null - val downloadFolderMap: MutableMap - @Synchronized - get() { - if (prevDownloadFolder != downloadFolder) { - prevDownloadFolder = downloadFolder - downloadFolderMapInstance = run { - val file = downloadFolder.getChild(".download") - val data = if (file.exists()) - kotlin.runCatching { - file.readText()?.let{ Json.decodeFromString>(it) } - }.onFailure { file.delete() }.getOrNull() - else - null - data ?: run { - file.createNewFile() - mutableMapOf() - } - } - } - - return downloadFolderMapInstance ?: mutableMapOf() - } - - - @Synchronized - fun isDownloading(galleryID: Int): Boolean { - val isThisGallery: (Call) -> Boolean = { !it.isCanceled() && (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID } - - return downloadFolderMap.containsKey(galleryID) - && client.dispatcher.let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) } - } - - @Synchronized - fun getDownloadFolder(galleryID: Int): FileX? = - downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) } - - fun addDownloadFolder(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch { - val name = Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock() - ?.formatDownloadFolder() ?: return@launch - - val folder = downloadFolder.getChild(name) - - downloadFolderMap[galleryID] = name - - downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() } - downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap)) - - if (folder.exists()) return@launch - folder.mkdir() - } - - @Synchronized - fun deleteDownloadFolder(galleryID: Int) { - downloadFolderMap[galleryID]?.let { - kotlin.runCatching { - downloadFolder.getChild(it).deleteRecursively() - downloadFolderMap.remove(galleryID) - - downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() } - downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap)) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/reader_activity.xml b/app/src/main/res/layout/reader_activity.xml deleted file mode 100644 index 71d51f57..00000000 --- a/app/src/main/res/layout/reader_activity.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/reader_eye_card.xml b/app/src/main/res/layout/reader_eye_card.xml deleted file mode 100644 index 11343d53..00000000 --- a/app/src/main/res/layout/reader_eye_card.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/reader_item.xml b/app/src/main/res/layout/reader_item.xml deleted file mode 100644 index 0cee0987..00000000 --- a/app/src/main/res/layout/reader_item.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1ef173db..ab8f1ce3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -14,6 +14,11 @@ 検索設定 設定 アップデートダウンロード中 + 同人誌 + 漫画 + アーティストCG + ゲームCG + イメージまとめ 新しいアップデートがあります 注意 その他 @@ -92,7 +97,7 @@ おすすめ イメージを隠す 削除 - ダウンロード + ダウンロード ブックマークバックアップ ブックマーク復元 バックアップファイルを作成しました diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c97a0323..2b01e60b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -13,6 +13,11 @@ 검색 설정 설정 업데이트 다운로드중… + 동인지 + 만화 + 아티스트 CG + 게임 CG + 이미지 모음 업데이트가 있습니다! 경고 결과 없음\n해결법 @@ -91,7 +96,7 @@ 미리보기 이미지 숨기기 삭제 - 다운로드 + 다운로드 즐겨찾기 백업 즐겨찾기 복원 백업 파일을 생성하였습니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb70ccb8..44f6410a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,9 +83,15 @@ Move to page %1$d - DOWNLOAD + Download DELETE + Doujinshi + Manga + Artist CG + Game CG + Image Set + Update available Download Completed Click here to update diff --git a/build.gradle b/build.gradle index 2eb3321f..5010f45b 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,7 @@ buildscript { plugins { id 'com.google.devtools.ksp' version '1.9.22-1.0.17' apply false + id 'com.google.dagger.hilt.android' version '2.44' apply false } allprojects {