This commit is contained in:
tom5079
2021-09-15 11:15:09 +09:00
parent 5b9a83cbcc
commit 0a1e0a2dcf
46 changed files with 1262 additions and 1852 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" /> <bytecodeTargetLevel target="11" />
</component> </component>
</project> </project>

17
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_30_x86.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-09-15T00:09:20.844719Z" />
</component>
</project>

1
.idea/gradle.xml generated
View File

@@ -7,6 +7,7 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="JDK" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

View File

@@ -0,0 +1,20 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

34
.idea/misc.xml generated
View File

@@ -1,6 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="../../../../layout/compose-model-1627195341053.xml" value="0.33" />
<entry key="../../../../layout/compose-model-1627257664594.xml" value="0.4518581081081081" />
<entry key="../../../../layout/compose-model-1627353146836.xml" value="0.5" />
<entry key="../../../../layout/compose-model-1627359089674.xml" value="0.67" />
<entry key="../../../../layout/compose-model-1627469604886.xml" value="0.3684210526315789" />
<entry key="../../../../layout/compose-model-1627528986080.xml" value="0.13723644578313254" />
<entry key="../../../../layout/compose-model-1627529731737.xml" value="0.18919427710843373" />
<entry key="../../../../layout/compose-model-1627530302667.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1627605645856.xml" value="0.25" />
<entry key="../../../../layout/compose-model-1627688771576.xml" value="0.3023525994772001" />
<entry key="../../../../layout/compose-model-1627721024779.xml" value="0.3020621550972989" />
<entry key="../../../../layout/compose-model-1628033383820.xml" value="0.23796296296296296" />
<entry key="../../../../layout/compose-model-1628120781047.xml" value="0.28405460354342144" />
<entry key="../../../../layout/compose-model-1628214547556.xml" value="0.2939297124600639" />
<entry key="../../../../layout/compose-model-1628301117560.xml" value="0.18711713384072767" />
<entry key="../../../../layout/compose-model-1628301166312.xml" value="0.19250046408019306" />
<entry key="../../../../layout/compose-model-1628490334478.xml" value="0.1212177464265825" />
<entry key="../../../../layout/compose-model-1628898655628.xml" value="0.19300204727340406" />
<entry key="../../../../layout/compose-model-1628898937985.xml" value="0.19300204727340406" />
<entry key="../../../../layout/compose-model-1631666404391.xml" value="0.36203703703703705" />
<entry key="../../../../layout/custom_preview.xml" value="0.518974358974359" />
<entry key="app/src/main/res/drawable/avd_star.xml" value="0.2722222222222222" />
<entry key="app/src/main/res/layout/main_activity.xml" value="0.2953125" />
<entry key="app/src/main/res/layout/main_activity_content.xml" value="0.2953125" />
<entry key="app/src/main/res/layout/progress_card_view.xml" value="0.2953125" />
<entry key="app/src/main/res/layout/search_result_item.xml" value="0.2489868287740628" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -5,15 +5,9 @@ plugins {
id("kotlin-parcelize") id("kotlin-parcelize")
id("kotlinx-serialization") id("kotlinx-serialization")
id("com.google.android.gms.oss-licenses-plugin") id("com.google.android.gms.oss-licenses-plugin")
id("com.google.gms.google-services")
if (File("google-services.json").exists()) { id("com.google.firebase.crashlytics")
println("Firebase Enabled") id("com.google.firebase.firebase-perf")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf")
} else {
println("Firebase Disabled")
}
} }
android { android {
@@ -41,7 +35,10 @@ android {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
isCrunchPngs = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("debug")
} }
} }
buildFeatures { buildFeatures {
@@ -49,45 +46,61 @@ android {
dataBinding = true dataBinding = true
compose = true compose = true
} }
kotlinOptions { composeOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() kotlinCompilerExtensionVersion = "1.0.0"
freeCompilerArgs = listOf("-Xuse-experimental=kotlin.Experimental")
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
} }
dependencies { dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.21") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
implementation("androidx.compose.ui:ui:1.0.0-rc02") implementation("androidx.compose.ui:ui:1.0.2")
implementation("androidx.compose.ui:ui-tooling:1.0.0-rc02") implementation("androidx.compose.ui:ui-tooling:1.0.2")
implementation("androidx.compose.foundation:foundation:1.0.0-rc02") implementation("androidx.compose.foundation:foundation:1.0.2")
implementation("androidx.compose.material:material:1.0.0-rc02") implementation("androidx.compose.material:material:1.0.2")
implementation("androidx.compose.material:material-icons-core:1.0.0-rc02") implementation("androidx.compose.material:material-icons-core:1.0.2")
implementation("androidx.compose.material:material-icons-extended:1.0.0-rc02") implementation("androidx.compose.material:material-icons-extended:1.0.2")
implementation("androidx.compose.runtime:runtime-livedata:1.0.0-rc02") implementation("androidx.compose.runtime:runtime-livedata:1.0.2")
implementation("androidx.compose.material:material-icons-extended:1.0.2")
implementation("androidx.activity:activity-compose:1.3.1")
implementation("io.ktor:ktor-client-core:1.6.1") implementation("com.google.accompanist:accompanist-flowlayout:0.16.1")
implementation("io.ktor:ktor-client-okhttp:1.6.1") implementation("com.google.accompanist:accompanist-appcompat-theme:0.16.0")
implementation("io.ktor:ktor-client-serialization:1.6.1")
implementation("io.coil-kt:coil-compose:1.3.2")
implementation("io.ktor:ktor-client-core:1.6.3")
implementation("io.ktor:ktor-client-okhttp:1.6.3")
implementation("io.ktor:ktor-client-serialization:1.6.3")
implementation("androidx.appcompat:appcompat:1.3.1") implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.activity:activity-ktx:1.3.0-rc02") implementation("androidx.activity:activity-ktx:1.3.1")
implementation("androidx.fragment:fragment-ktx:1.3.6") implementation("androidx.fragment:fragment-ktx:1.3.6")
implementation("androidx.preference:preference-ktx:1.1.1") implementation("androidx.preference:preference-ktx:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.2.1") implementation("androidx.recyclerview:recyclerview:1.2.1")
implementation("androidx.constraintlayout:constraintlayout:2.0.4") implementation("androidx.constraintlayout:constraintlayout:2.1.0")
implementation("androidx.gridlayout:gridlayout:1.0.0") implementation("androidx.gridlayout:gridlayout:1.0.0")
implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.6.0-beta02") implementation("androidx.work:work-runtime-ktx:2.6.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07")
implementation("org.kodein.di:kodein-di-framework-android-x:7.6.0") implementation("androidx.room:room-runtime:2.3.0")
annotationProcessor("androidx.room:room-compiler:2.3.0")
kapt("androidx.room:room-compiler:2.3.0")
implementation("androidx.room:room-ktx:2.3.0")
implementation("org.kodein.di:kodein-di-framework-compose:7.7.0")
implementation("com.daimajia.swipelayout:library:1.2.0@aar") implementation("com.daimajia.swipelayout:library:1.2.0@aar")
@@ -104,9 +117,9 @@ dependencies {
//implementation("com.quiph.ui:recyclerviewfastscroller:0.2.1") //implementation("com.quiph.ui:recyclerviewfastscroller:0.2.1")
implementation("com.github.piasy:BigImageViewer:1.8.0") implementation("com.github.piasy:BigImageViewer:1.8.1")
implementation("com.github.piasy:FrescoImageLoader:1.8.0") implementation("com.github.piasy:FrescoImageLoader:1.8.1")
implementation("com.github.piasy:FrescoImageViewFactory:1.8.0") implementation("com.github.piasy:FrescoImageViewFactory:1.8.1")
implementation("org.jsoup:jsoup:1.14.1") implementation("org.jsoup:jsoup:1.14.1")
@@ -117,20 +130,20 @@ dependencies {
implementation("ru.noties.markwon:core:3.1.0") implementation("ru.noties.markwon:core:3.1.0")
implementation("xyz.quaver:libpupil:2.1.3") implementation("xyz.quaver:libpupil:2.1.6")
implementation("xyz.quaver:documentfilex:0.6.1") implementation("xyz.quaver:documentfilex:0.6.1")
implementation("xyz.quaver:floatingsearchview:1.1.7") implementation("xyz.quaver:floatingsearchview:1.1.7")
debugImplementation("com.orhanobut:logger:2.2.0") implementation("org.kodein.log:kodein-log:0.11.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.6") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.6")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-inline:3.11.2") testImplementation("org.mockito:mockito-inline:3.12.4")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0")
androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:runner:1.4.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.0.0-rc02") androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.0-alpha03")
} }

View File

@@ -38,8 +38,6 @@ import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.ktx.Firebase import com.google.firebase.ktx.Firebase
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.okhttp.* import io.ktor.client.engine.okhttp.*
import io.ktor.client.features.json.* import io.ktor.client.features.json.*
@@ -47,6 +45,7 @@ import io.ktor.client.features.json.serializer.*
import org.kodein.di.* import org.kodein.di.*
import org.kodein.di.android.x.androidXModule import org.kodein.di.android.x.androidXModule
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.pupil.db.databaseModule
import xyz.quaver.pupil.sources.sourceModule import xyz.quaver.pupil.sources.sourceModule
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import java.io.File import java.io.File
@@ -56,16 +55,11 @@ class Pupil : Application(), DIAware {
override val di: DI by DI.lazy { override val di: DI by DI.lazy {
import(androidXModule(this@Pupil)) import(androidXModule(this@Pupil))
import(databaseModule)
import(sourceModule) import(sourceModule)
bind { singleton { ImageCache(applicationContext) } }
bind { singleton { DownloadManager(applicationContext) } } bind { singleton { DownloadManager(applicationContext) } }
bind<SavedSourceSet>(tag = "histories") with singleton { SavedSourceSet(File(ContextCompat.getDataDir(applicationContext), "histories.json")) }
bind<SavedSourceSet>(tag = "favorites") with singleton { SavedSourceSet(File(ContextCompat.getDataDir(applicationContext), "favorites.json")) }
bind<SavedSourceSet>(tag = "favoriteTags") with singleton { SavedSourceSet(File(ContextCompat.getDataDir(applicationContext), "favoriteTags.json")) }
bind<SavedSourceSet>(tag = "searchHistory") with singleton { SavedSourceSet(File(ContextCompat.getDataDir(applicationContext), "searchHistory.json")) }
bind { singleton { bind { singleton {
HttpClient(OkHttp) { HttpClient(OkHttp) {
install(JsonFeature) { install(JsonFeature) {
@@ -90,8 +84,6 @@ class Pupil : Application(), DIAware {
firebaseAnalytics = Firebase.analytics firebaseAnalytics = Firebase.analytics
FirebaseCrashlytics.getInstance().setUserId(userID) FirebaseCrashlytics.getInstance().setUserId(userID)
Logger.addLogAdapter(AndroidLogAdapter())
try { try {
Preferences.get<String>("download_folder").also { Preferences.get<String>("download_folder").also {
if (it.startsWith("content")) if (it.startsWith("content"))

View File

@@ -1,197 +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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.adapters
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.graphics.drawable.Animatable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import com.daimajia.swipe.SwipeLayout
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.daimajia.swipe.interfaces.SwipeAdapterInterface
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.controller.BaseControllerListener
import com.facebook.imagepipeline.image.ImageInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import org.kodein.di.on
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.SearchResultItemBinding
import xyz.quaver.pupil.sources.Hitomi
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import kotlin.time.ExperimentalTime
class SearchResultsAdapter(var results: LiveData<List<ItemInfo>>) : RecyclerSwipeAdapter<SearchResultsAdapter.ViewHolder>(), SwipeAdapterInterface {
var onChipClickedHandler: ((Tag) -> Unit)? = null
var onDownloadClickedHandler: ((source: String, itemI: String) -> Unit)? = null
var onDeleteClickedHandler: ((source: String, itemID: String) -> Unit)? = null
inner class ViewHolder(private val binding: SearchResultItemBinding) : RecyclerView.ViewHolder(binding.root), DIAware {
override val di by closestDI(binding.root.context)
private val clipboardManager: ClipboardManager by di.on(itemView.context).instance()
var source: String = ""
var itemID: String = ""
init {
binding.root.binding.download.setOnClickListener {
onDownloadClickedHandler?.invoke(source, itemID)
}
binding.root.binding.delete.setOnClickListener {
onDeleteClickedHandler?.invoke(source, itemID)
}
binding.idView.setOnClickListener {
clipboardManager.setPrimaryClip(
ClipData.newPlainText("item_id", itemID)
)
Toast.makeText(itemView.context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
binding.root.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout)
binding.root.binding.download.text = itemView.context.getString(R.string.main_download)
}
override fun onOpen(layout: SwipeLayout?) {}
override fun onStartClose(layout: SwipeLayout?) {}
override fun onClose(layout: SwipeLayout?) {}
override fun onHandRelease(layout: SwipeLayout?, xvel: Float, yvel: Float) {}
override fun onUpdate(layout: SwipeLayout?, leftOffset: Int, topOffset: Int) {}
})
binding.tagGroup.onClickListener = onChipClickedHandler
}
private val controllerListener = object: BaseControllerListener<ImageInfo>() {
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
imageInfo?.let {
binding.thumbnail.aspectRatio = it.width / it.height.toFloat()
}
}
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
imageInfo?.let {
binding.thumbnail.aspectRatio = it.width / it.height.toFloat()
}
}
}
@SuppressLint("SetTextI18n")
fun bind(result: ItemInfo) {
source = result.source
itemID = result.id
binding.root.progress = 0
binding.thumbnail.controller = Fresco.newDraweeControllerBuilder()
.setUri(result.thumbnail)
.setOldController(binding.thumbnail.controller)
.setControllerListener(controllerListener)
.build()
binding.title.text = result.title
binding.idView.text = result.id
binding.artist.visibility = if (result.artists.isEmpty()) View.GONE else View.VISIBLE
binding.artist.text = result.artists
CoroutineScope(Dispatchers.Main).launch {
with (binding.tagGroup) {
tags.clear()
source = result.source
result.extra[ItemInfo.ExtraType.TAGS]?.await()?.split(", ")?.let { if (it.size == 1 && it.first().isEmpty()) emptyList() else it }?.map {
Tag.parse(it)
}?.let { tags.addAll(it) }
refresh()
}
}
val extraType = listOf(
ItemInfo.ExtraType.SERIES,
ItemInfo.ExtraType.TYPE,
ItemInfo.ExtraType.LANGUAGE
)
CoroutineScope(Dispatchers.Main).launch {
result.extra[ItemInfo.ExtraType.GROUP]?.await()?.let {
if (it.isNotEmpty())
binding.artist.text = "${result.artists} ($it)"
}
}
CoroutineScope(Dispatchers.Main).launch {
binding.extra.text =
result.extra.entries.filter { it.key in extraType && it.value.await() != null }.fold(StringBuilder()) { res, entry ->
entry.value.await()?.let {
if (it.isNotEmpty()) {
res.append(
itemView.context.getString(
ItemInfo.extraTypeMap[entry.key] ?: error(""),
if (entry.key == ItemInfo.ExtraType.LANGUAGE) Hitomi.languageMap[entry.value.await()] else entry.value.await()
)
)
res.append('\n')
}
}
res
}
}
CoroutineScope(Dispatchers.Main).launch {
binding.pagecount.text = result.extra[ItemInfo.ExtraType.PAGECOUNT]?.let {
itemView.context.getString(
ItemInfo.extraTypeMap[ItemInfo.ExtraType.PAGECOUNT] ?: error(""),
it.await()
)
} ?: "-"
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
ViewHolder(SearchResultItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
mItemManger.bindView(holder.itemView, position)
holder.bind(results.value!![position])
}
override fun getItemCount(): Int = results.value?.size ?: 0
override fun getSwipeLayoutResourceId(position: Int): Int = R.id.swipe_layout
}

View File

@@ -23,6 +23,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.databinding.SourceSelectDialogItemBinding import xyz.quaver.pupil.databinding.SourceSelectDialogItemBinding
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.sources.SourceEntries import xyz.quaver.pupil.sources.SourceEntries

View File

@@ -0,0 +1,31 @@
package xyz.quaver.pupil.db
import androidx.lifecycle.LiveData
import androidx.room.*
@Entity(primaryKeys = ["source", "itemID"])
data class Bookmark(
val source: String,
val itemID: String,
val timestamp: Long = System.currentTimeMillis()
)
@Dao
interface BookmarkDao {
@Query("SELECT * FROM bookmark")
fun getAll(): LiveData<List<Bookmark>>
@Query("SELECT itemID FROM bookmark WHERE source = :source")
fun getAll(source: String): LiveData<List<String>>
@Query("SELECT EXISTS(SELECT * FROM bookmark WHERE source = :source AND itemID = :itemID)")
fun contains(source: String, itemID: String): LiveData<Boolean>
fun contains(bookmark: Bookmark) = contains(bookmark.source, bookmark.itemID)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(bookmark: Bookmark)
@Delete
suspend fun delete(bookmark: Bookmark)
}

View File

@@ -0,0 +1,17 @@
package xyz.quaver.pupil.db
import android.app.Application
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import org.kodein.di.*
@Database(entities = [History::class, Bookmark::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun historyDao(): HistoryDao
abstract fun bookmarkDao(): BookmarkDao
}
val databaseModule = DI.Module("database") {
bind<AppDatabase>() with singleton { Room.databaseBuilder(instance<Application>(), AppDatabase::class.java, "pupil").build() }
}

View File

@@ -0,0 +1,26 @@
package xyz.quaver.pupil.db
import androidx.lifecycle.LiveData
import androidx.room.*
@Entity(primaryKeys = ["source", "itemID"])
data class History(
val source: String,
val itemID: String,
val timestamp: Long = System.currentTimeMillis()
)
@Dao
interface HistoryDao {
@Query("SELECT * FROM history")
fun getAll(): LiveData<List<History>>
@Query("SELECT itemID FROM history WHERE source = :source")
fun getAll(source: String): LiveData<List<String>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(history: History)
@Delete
suspend fun delete(history: History)
}

View File

@@ -18,101 +18,36 @@
package xyz.quaver.pupil.sources package xyz.quaver.pupil.sources
import android.app.Application
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.lifecycle.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.kodein.di.* import org.kodein.di.*
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.hitomi.Hitomi
@Serializable(with = ItemInfo.SearchResultSerializer::class) interface ItemInfo : Parcelable {
data class ItemInfo( val source: String
val source: String, val itemID: String
val id: String, val title: String
val title: String,
val thumbnail: String,
val artists: String,
val extra: Map<ExtraType, Deferred<String?>> = emptyMap()
) {
enum class ExtraType {
GROUP,
CHARACTER,
SERIES,
TYPE,
TAGS,
LANGUAGE,
PAGECOUNT,
PREVIEW,
RELATED_ITEM,
}
@Serializable
@SerialName("SearchResult")
data class ItemInfoSurrogate(
val source: String,
val id: String,
val title: String,
val thumbnail: String,
val artists: String,
val extra: Map<ExtraType, String?> = emptyMap()
)
object SearchResultSerializer : KSerializer<ItemInfo> {
override val descriptor = ItemInfoSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: ItemInfo) {
val surrogate = ItemInfoSurrogate(
value.source,
value.id,
value.title,
value.thumbnail,
value.artists,
value.extra.mapValues { runBlocking { it.value.await() } }
)
encoder.encodeSerializableValue(ItemInfoSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): ItemInfo {
val surrogate = decoder.decodeSerializableValue(ItemInfoSurrogate.serializer())
return ItemInfo(
surrogate.source,
surrogate.id,
surrogate.title,
surrogate.thumbnail,
surrogate.artists,
surrogate.extra.mapValues { CoroutineScope(Dispatchers.Unconfined).async { it.value } }
)
}
}
val isReady: Boolean
get() = extra.all { it.value.isCompleted }
suspend fun awaitAll() = extra.values.awaitAll()
companion object {
val extraTypeMap = mapOf(
ExtraType.SERIES to R.string.galleryblock_series,
ExtraType.TYPE to R.string.galleryblock_type,
ExtraType.LANGUAGE to R.string.galleryblock_language,
ExtraType.PAGECOUNT to R.string.galleryblock_pagecount
)
}
} }
@Parcelize @Parcelize
class DefaultSearchSuggestion(override val body: String) : SearchSuggestion class DefaultSearchSuggestion(override val body: String) : SearchSuggestion
interface SortModeInterface { data class SearchResultEvent(val type: Type, val payload: String) {
val ordinal: Int enum class Type {
val name: Int OPEN_READER,
OPEN_DETAILS,
NEW_QUERY,
TOGGLE_FAVORITES
}
} }
abstract class Source { abstract class Source {
@@ -121,10 +56,13 @@ abstract class Source {
abstract val preferenceID: Int abstract val preferenceID: Int
abstract val availableSortMode: List<String> abstract val availableSortMode: List<String>
abstract suspend fun search(query: String, range: IntRange, sortMode: Int) : Pair<Channel<ItemInfo>, Int> abstract suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int>
abstract suspend fun suggestion(query: String) : List<SearchSuggestion> abstract suspend fun suggestion(query: String): List<SearchSuggestion>
abstract suspend fun images(itemID: String) : List<String> abstract suspend fun images(itemID: String): List<String>
abstract suspend fun info(itemID: String) : ItemInfo abstract suspend fun info(itemID: String): ItemInfo
@Composable
open fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)? = null) { }
open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { } open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { }
@@ -135,22 +73,15 @@ abstract class Source {
typealias SourceEntry = Pair<String, Source> typealias SourceEntry = Pair<String, Source>
typealias SourceEntries = Set<SourceEntry> typealias SourceEntries = Set<SourceEntry>
typealias SourcePreferenceID = Pair<String, Int>
typealias SourcePreferenceIDs = Set<SourcePreferenceID>
@Suppress("UNCHECKED_CAST")
val sourceModule = DI.Module(name = "source") { val sourceModule = DI.Module(name = "source") {
bindSet<SourceEntry>() bindSet<SourceEntry>()
bindSet<SourcePreferenceID>()
onReady { listOf<(Application) -> (Source)>(
listOf<Source>( { Hitomi(it) }
Hitomi(instance()) ).forEach { source ->
).forEach { source -> inSet { singleton { source.invoke(instance()).let { it.name to it } } }
inSet { multiton { _: Unit -> source.name to source } }
inSet { singleton { source.name to source.preferenceID } }
}
} }
bind { factory { source: String -> History(di, source) } } bind { singleton { History(di) } }
inSet { singleton { Downloads(di).let { it.name to it as Source } } } // inSet { singleton { Downloads(di).let { it.name to it as Source } } }
} }

View File

@@ -18,6 +18,7 @@
package xyz.quaver.pupil.sources package xyz.quaver.pupil.sources
import androidx.compose.runtime.Composable
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -32,7 +33,7 @@ import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.DownloadManager import xyz.quaver.pupil.util.DownloadManager
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
/*
class Downloads(override val di: DI) : Source(), DIAware { class Downloads(override val di: DI) : Source(), DIAware {
override val name: String override val name: String
@@ -46,6 +47,8 @@ class Downloads(override val di: DI) : Source(), DIAware {
private val downloadManager: DownloadManager by instance() private val downloadManager: DownloadManager by instance()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> { override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
TODO()
/*
val downloads = downloadManager.downloads.toList() val downloads = downloadManager.downloads.toList()
val channel = Channel<ItemInfo>() val channel = Channel<ItemInfo>()
@@ -61,7 +64,7 @@ class Downloads(override val di: DI) : Source(), DIAware {
channel.close() channel.close()
} }
return Pair(channel, downloads.size) return Pair(channel, downloads.size)*/
} }
override suspend fun suggestion(query: String): List<SearchSuggestion> { override suspend fun suggestion(query: String): List<SearchSuggestion> {
@@ -75,7 +78,8 @@ class Downloads(override val di: DI) : Source(), DIAware {
} }
override suspend fun info(itemID: String): ItemInfo { override suspend fun info(itemID: String): ItemInfo {
return transform(downloadManager.downloadFolder.getChild(itemID)) TODO("Not yet implemented")
/* return transform(downloadManager.downloadFolder.getChild(itemID))*/
} }
companion object { companion object {
@@ -83,7 +87,7 @@ class Downloads(override val di: DI) : Source(), DIAware {
folder.list { _, name -> folder.list { _, name ->
name.takeLastWhile { it != '.' } in listOf("jpg", "png", "gif", "webp") name.takeLastWhile { it != '.' } in listOf("jpg", "png", "gif", "webp")
}?.toList() }?.toList()
/*
suspend fun transform(folder: FileX): ItemInfo = withContext(Dispatchers.Unconfined) { suspend fun transform(folder: FileX): ItemInfo = withContext(Dispatchers.Unconfined) {
kotlin.runCatching { kotlin.runCatching {
Json.decodeFromString<ItemInfo>(folder.getChild(".metadata").readText()) Json.decodeFromString<ItemInfo>(folder.getChild(".metadata").readText())
@@ -100,7 +104,12 @@ class Downloads(override val di: DI) : Source(), DIAware {
) )
) )
} }
} }*/
} }
} @Composable
override fun compose(itemInfo: ItemInfo) {
TODO("Not yet implemented")
}
}*/

View File

@@ -18,55 +18,63 @@
package xyz.quaver.pupil.sources package xyz.quaver.pupil.sources
import androidx.compose.runtime.Composable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.util.SavedSourceSet import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.util.database
import xyz.quaver.pupil.util.source import xyz.quaver.pupil.util.source
class History(override val di: DI, source: String) : Source(), DIAware { class History(override val di: DI) : Source(), DIAware {
private val source: Source by source(source) private val historyDao = direct.database().historyDao()
private val histories: SavedSourceSet by instance(tag = "histories")
override val name: String override val name: String
get() = source.name get() = "history"
override val iconResID: Int override val iconResID: Int
get() = source.iconResID get() = 0 //TODO
override val preferenceID: Int override val preferenceID: Int
get() = source.preferenceID get() = 0 //TODO
override val availableSortMode: List<String> = emptyList() override val availableSortMode: List<String> = emptyList()
private val history = direct.database().historyDao()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> { override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
val channel = Channel<ItemInfo>() val channel = Channel<ItemInfo>()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
histories[source.name]?.asReversed()?.forEach {
channel.send(source.info(it))
}
channel.close() channel.close()
} }
return Pair(channel, histories.map.size) throw NotImplementedError("")
//return Pair(channel, histories.map.size)
} }
override suspend fun suggestion(query: String): List<SearchSuggestion> { override suspend fun suggestion(query: String): List<SearchSuggestion> {
return source.suggestion(query) throw NotImplementedError("")
} }
override suspend fun images(itemID: String): List<String> { override suspend fun images(itemID: String): List<String> {
return source.images(itemID) throw NotImplementedError("")
} }
override suspend fun info(itemID: String): ItemInfo { override suspend fun info(itemID: String): ItemInfo {
return source.info(itemID) throw NotImplementedError("")
} }
@Composable
override fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)?) {
}
} }

View File

@@ -1,239 +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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources
import android.app.Application
import android.view.LayoutInflater
import android.widget.TextView
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.hitomi.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.ItemInfo.ExtraType
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.wordCapitalize
import kotlin.math.max
import kotlin.math.min
class Hitomi(app: Application) : Source() {
@Parcelize
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
@IgnoredOnParcel
override val body = s
/*
TODO
if (translations[s] != null)
"${translations[s]} ($s)"
else
s
*/
}
override val name: String = "hitomi.la"
override val iconResID: Int = R.drawable.hitomi
override val preferenceID: Int = R.xml.hitomi_preferences
override val availableSortMode: List<String> = app.resources.getStringArray(R.array.hitomi_sort_mode).toList()
var cachedQuery: String? = null
var cachedSortMode: Int = -1
val cache = mutableListOf<Int>()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) {
cachedQuery = null
cache.clear()
yield()
doSearch("$query ${Preferences["hitomi.default_query", ""]}", sortMode == 1).let {
yield()
cache.addAll(it)
}
cachedQuery = query
}
val channel = Channel<ItemInfo>()
val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1)
CoroutineScope(Dispatchers.IO).launch {
cache.slice(sanitizedRange).map {
async {
getGalleryBlock(it)
}
}.forEach {
channel.send(transform(name, it.await()))
}
channel.close()
}
return Pair(channel, cache.size)
}
override suspend fun suggestion(query: String) : List<TagSuggestion> {
return getSuggestionsForQuery(query.takeLastWhile { !it.isWhitespace() }).map {
TagSuggestion(it)
}
}
override suspend fun images(itemID: String): List<String> {
val galleryID = itemID.toInt()
val reader = getGalleryInfo(galleryID)
return reader.files.map {
imageUrlFromImage(galleryID, it, true)
}
}
override suspend fun info(itemID: String): ItemInfo = coroutineScope {
kotlin.runCatching {
getGallery(itemID.toInt()).let {
ItemInfo(
name,
itemID,
it.title,
it.cover,
it.artists.joinToString { it.wordCapitalize() },
mapOf(
ExtraType.TYPE to async { it.type.wordCapitalize() },
ExtraType.GROUP to async { it.groups.joinToString { it.wordCapitalize() } },
ExtraType.LANGUAGE to async { it.language },
ExtraType.SERIES to async { it.series.joinToString { it.wordCapitalize() } },
ExtraType.CHARACTER to async { it.characters.joinToString { it.wordCapitalize() } },
ExtraType.TAGS to async { it.tags.joinToString() },
ExtraType.PREVIEW to async { it.thumbnails.joinToString() },
ExtraType.RELATED_ITEM to async { it.related.joinToString() },
ExtraType.PAGECOUNT to async { it.thumbnails.size.toString() },
)
)
}
}.getOrElse {
transform(name, getGalleryBlock(itemID.toInt()))
}
}
override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = {
append("Referer", getReferer(itemID.toInt()))
}
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
item as TagSuggestion
binding.leftIcon.setImageResource(
when(item.n) {
"female" -> R.drawable.gender_female
"male" -> R.drawable.gender_male
"language" -> R.drawable.translate
"group" -> R.drawable.account_group
"character" -> R.drawable.account_star
"series" -> R.drawable.book_open
"artist" -> R.drawable.brush
else -> R.drawable.tag
}
)
if (item.t > 0) {
with (binding.root) {
val count = findViewById<TextView>(R.id.count)
if (count == null)
addView(
LayoutInflater.from(context).inflate(R.layout.suggestion_count, binding.root, false)
.apply {
this as TextView
text = item.t.toString()
}, 2
)
else
count.text = item.t.toString()
}
}
}
companion object {
val languageMap = mapOf(
"indonesian" to "Bahasa Indonesia",
"catalan" to "català",
"cebuano" to "Cebuano",
"czech" to "Čeština",
"danish" to "Dansk",
"german" to "Deutsch",
"estonian" to "eesti",
"english" to "English",
"spanish" to "Español",
"esperanto" to "Esperanto",
"french" to "Français",
"italian" to "Italiano",
"latin" to "Latina",
"hungarian" to "magyar",
"dutch" to "Nederlands",
"norwegian" to "norsk",
"polish" to "polski",
"portuguese" to "Português",
"romanian" to "română",
"albanian" to "shqip",
"slovak" to "Slovenčina",
"finnish" to "Suomi",
"swedish" to "Svenska",
"tagalog" to "Tagalog",
"vietnamese" to "tiếng việt",
"turkish" to "Türkçe",
"greek" to "Ελληνικά",
"mongolian" to "Монгол",
"russian" to "Русский",
"ukrainian" to "Українська",
"hebrew" to "עברית",
"arabic" to "العربية",
"persian" to "فارسی",
"thai" to "ไทย",
"korean" to "한국어",
"chinese" to "中文",
"japanese" to "日本語"
)
fun transform(name: String, galleryBlock: GalleryBlock) =
ItemInfo(
name,
galleryBlock.id.toString(),
galleryBlock.title,
galleryBlock.thumbnails.first(),
galleryBlock.artists.joinToString { it.wordCapitalize() },
mapOf(
ExtraType.GROUP to CoroutineScope(Dispatchers.IO).async { kotlin.runCatching {
getGallery(galleryBlock.id).groups.joinToString { it.wordCapitalize() }
}.getOrDefault("") },
ExtraType.SERIES to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.series.joinToString { it.wordCapitalize() } },
ExtraType.TYPE to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.type.wordCapitalize() },
ExtraType.LANGUAGE to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.language },
ExtraType.PAGECOUNT to CoroutineScope(Dispatchers.IO).async { kotlin.runCatching {
getGalleryInfo(galleryBlock.id).files.size.toString()
}.getOrNull() },
ExtraType.TAGS to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.relatedTags.joinToString() }
)
)
}
}

View File

@@ -35,7 +35,7 @@ import org.kodein.di.DIAware
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
/*
class ImHentai(override val di: DI) : Source(), DIAware { class ImHentai(override val di: DI) : Source(), DIAware {
private val app: Application by instance() private val app: Application by instance()
@@ -85,4 +85,4 @@ class ImHentai(override val di: DI) : Source(), DIAware {
} }
} }
} }*/

View File

@@ -0,0 +1,561 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.hitomi
import android.app.Application
import android.view.LayoutInflater
import android.widget.TextView
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Female
import androidx.compose.material.icons.filled.Male
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LiveData
import androidx.room.*
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.flowlayout.FlowRow
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.hitomi.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.db.Bookmark
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SearchResultEvent
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.wordCapitalize
import kotlin.math.max
import kotlin.math.min
@Serializable
@Parcelize
data class HitomiItemInfo(
override val itemID: String,
override val title: String,
val thumbnail: String,
val artists: List<String>,
val series: List<String>,
val type: String,
val language: String,
val tags: List<String>,
private var groups: List<String>? = null,
private var pageCount: Int? = null,
val characters: List<String>? = null,
val preview: List<String>? = null,
val relatedItem: List<String>? = null
): ItemInfo {
override val source: String
get() = "hitomi.la"
@IgnoredOnParcel
private val groupMutex = Mutex()
suspend fun getGroups() = withContext(Dispatchers.IO) {
if (groups != null) groups
else groupMutex.withLock { runCatching {
getGallery(itemID.toInt()).groups
}.getOrNull() }
}
@IgnoredOnParcel
private val pageCountMutex = Mutex()
suspend fun getPageCount() = withContext(Dispatchers.IO) {
if (pageCount != null) pageCount
else pageCountMutex.withLock { runCatching {
getGalleryInfo(itemID.toInt()).files.size.also { pageCount = it }
}.getOrNull() }
}
}
class Hitomi(app: Application) : Source(), DIAware {
override val di: DI by closestDI(app)
private val database: AppDatabase by instance()
private val bookmarkDao = database.bookmarkDao()
@Parcelize
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
@IgnoredOnParcel
override val body = s
/*
TODO
if (translations[s] != null)
"${translations[s]} ($s)"
else
s
*/
}
override val name: String = "hitomi.la"
override val iconResID: Int = R.drawable.hitomi
override val preferenceID: Int = R.xml.hitomi_preferences
override val availableSortMode: List<String> = app.resources.getStringArray(R.array.hitomi_sort_mode).toList()
var cachedQuery: String? = null
var cachedSortMode: Int = -1
private val cache = mutableListOf<Int>()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) {
cachedQuery = null
cache.clear()
yield()
doSearch("$query ${Preferences["hitomi.default_query", ""]}", sortMode == 1).let {
yield()
cache.addAll(it)
}
cachedQuery = query
}
val channel = Channel<ItemInfo>()
val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1)
CoroutineScope(Dispatchers.IO).launch {
cache.slice(sanitizedRange).map {
async {
getGalleryBlock(it)
}
}.forEach {
channel.send(transform(it.await()))
}
channel.close()
}
return Pair(channel, cache.size)
}
override suspend fun suggestion(query: String) : List<TagSuggestion> {
return getSuggestionsForQuery(query.takeLastWhile { !it.isWhitespace() }).map {
TagSuggestion(it)
}
}
override suspend fun images(itemID: String): List<String> {
val galleryID = itemID.toInt()
val reader = getGalleryInfo(galleryID)
return reader.files.map {
imageUrlFromImage(galleryID, it, true)
}
}
override suspend fun info(itemID: String): HitomiItemInfo = withContext(Dispatchers.IO) {
kotlin.runCatching {
getGallery(itemID.toInt()).let {
HitomiItemInfo(
itemID,
it.title,
it.cover,
it.artists,
it.series,
it.type,
it.language,
it.tags,
it.groups,
it.thumbnails.size,
it.characters,
it.thumbnails,
it.related.map { it.toString() }
)
}
}.getOrElse {
transform(getGalleryBlock(itemID.toInt()))
}
}
@Composable
override fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)?) {
itemInfo as HitomiItemInfo
FullSearchResult(itemInfo = itemInfo)
}
override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = {
append("Referer", getReferer(itemID.toInt()))
}
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
item as TagSuggestion
binding.leftIcon.setImageResource(
when(item.n) {
"female" -> R.drawable.gender_female
"male" -> R.drawable.gender_male
"language" -> R.drawable.translate
"group" -> R.drawable.account_group
"character" -> R.drawable.account_star
"series" -> R.drawable.book_open
"artist" -> R.drawable.brush
else -> R.drawable.tag
}
)
if (item.t > 0) {
with (binding.root) {
val count = findViewById<TextView>(R.id.count)
if (count == null)
addView(
LayoutInflater.from(context).inflate(R.layout.suggestion_count, binding.root, false)
.apply {
this as TextView
text = item.t.toString()
}, 2
)
else
count.text = item.t.toString()
}
}
}
companion object {
val languageMap = mapOf(
"indonesian" to "Bahasa Indonesia",
"catalan" to "català",
"cebuano" to "Cebuano",
"czech" to "Čeština",
"danish" to "Dansk",
"german" to "Deutsch",
"estonian" to "eesti",
"english" to "English",
"spanish" to "Español",
"esperanto" to "Esperanto",
"french" to "Français",
"italian" to "Italiano",
"latin" to "Latina",
"hungarian" to "magyar",
"dutch" to "Nederlands",
"norwegian" to "norsk",
"polish" to "polski",
"portuguese" to "Português",
"romanian" to "română",
"albanian" to "shqip",
"slovak" to "Slovenčina",
"finnish" to "Suomi",
"swedish" to "Svenska",
"tagalog" to "Tagalog",
"vietnamese" to "tiếng việt",
"turkish" to "Türkçe",
"greek" to "Ελληνικά",
"mongolian" to "Монгол",
"russian" to "Русский",
"ukrainian" to "Українська",
"hebrew" to "עברית",
"arabic" to "العربية",
"persian" to "فارسی",
"thai" to "ไทย",
"korean" to "한국어",
"chinese" to "中文",
"japanese" to "日本語"
)
fun transform(galleryBlock: GalleryBlock) =
HitomiItemInfo(
galleryBlock.id.toString(),
galleryBlock.title,
galleryBlock.thumbnails.first(),
galleryBlock.artists,
galleryBlock.series,
galleryBlock.type,
galleryBlock.language,
galleryBlock.relatedTags
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TagChip(tag: String, isFavorite: Boolean, onClick: ((String) -> Unit)? = null, onFavoriteClick: ((String) -> Unit)? = null) {
val tagParts = tag.split(":", limit = 2).let {
if (it.size == 1) listOf("", it.first())
else it
}
val icon = when (tagParts[0]) {
"male" -> Icons.Filled.Male
"female" -> Icons.Filled.Female
else -> null
}
val (surfaceColor, textTint) = when {
isFavorite -> Pair(colorResource(id = R.color.material_orange_500), Color.White)
else -> when (tagParts[0]) {
"male" -> Pair(colorResource(id = R.color.material_blue_700), Color.White)
"female" -> Pair(colorResource(id = R.color.material_pink_600), Color.White)
else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
}
}
val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
Surface(
modifier = Modifier.padding(2.dp),
onClick = { onClick?.invoke(tag) },
shape = RoundedCornerShape(16.dp),
color = surfaceColor,
elevation = 2.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null)
Icon(
icon,
contentDescription = "Icon",
modifier = Modifier
.padding(4.dp)
.size(24.dp),
tint = Color.White
)
else
Box(Modifier.size(16.dp))
Text(
tagParts[1],
color = textTint,
style = MaterialTheme.typography.body2
)
Icon(
starIcon,
contentDescription = "Favorites",
modifier = Modifier
.padding(8.dp)
.size(16.dp)
.clip(CircleShape)
.clickable { onFavoriteClick?.invoke(tag) },
tint = textTint
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TagGroup(tags: List<String>) {
var isFolded by remember { mutableStateOf(true) }
val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
val bookmarkedTagsInList = bookmarkedTags.toSet() intersect tags
FlowRow(Modifier.padding(0.dp, 16.dp)) {
tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
TagChip(
tag = tag,
isFavorite = bookmarkedTagsInList.contains(tag),
onFavoriteClick = { tag ->
val bookmarkTag = Bookmark(name, tag)
CoroutineScope(Dispatchers.IO).launch {
if (bookmarkedTagsInList.contains(tag))
bookmarkDao.delete(bookmarkTag)
else
bookmarkDao.insert(bookmarkTag)
}
}
)
}
if (isFolded && tags.size > 10)
Surface(
modifier = Modifier.padding(2.dp),
color = MaterialTheme.colors.background,
shape = RoundedCornerShape(16.dp),
elevation = 2.dp,
onClick = { isFolded = false }
) {
Text(
"",
modifier = Modifier.padding(16.dp, 8.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2
)
}
}
}
@OptIn(ExperimentalCoilApi::class)
@Composable
fun FullSearchResult(itemInfo: HitomiItemInfo) {
var group by remember { mutableStateOf(emptyList<String>()) }
var pageCount by remember { mutableStateOf("-") }
LaunchedEffect(itemInfo) {
launch {
itemInfo.getPageCount()?.run {
pageCount = "${this}P"
}
}
launch {
itemInfo.getGroups()?.run {
group = this
}
}
}
val painter = rememberImagePainter(itemInfo.thumbnail)
Column {
Row {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.requiredWidth(150.dp)
.aspectRatio(
with(painter.intrinsicSize) { if (this == Size.Companion.Unspecified) 1f else width / height },
true
)
.padding(0.dp, 0.dp, 8.dp, 0.dp)
.align(Alignment.CenterVertically),
contentScale = ContentScale.FillWidth
)
Column {
Text(
itemInfo.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface
)
val artistStringBuilder = StringBuilder()
with (itemInfo.artists) {
if (this.isNotEmpty())
artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
}
if (group.isNotEmpty()) {
if (artistStringBuilder.isNotEmpty()) artistStringBuilder.append(" ")
artistStringBuilder.append("(")
artistStringBuilder.append(group.joinToString(", ") { it.wordCapitalize() })
artistStringBuilder.append(")")
}
if (artistStringBuilder.isNotEmpty())
Text(
artistStringBuilder.toString(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
if (itemInfo.series.isNotEmpty())
Text(
stringResource(
id = R.string.galleryblock_series,
itemInfo.series.joinToString { it.wordCapitalize() }
),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
Text(
stringResource(id = R.string.galleryblock_type, itemInfo.type),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
languageMap[itemInfo.language]?.run {
Text(
stringResource(id = R.string.galleryblock_language, this),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
}
TagGroup(tags = itemInfo.tags)
}
}
Divider(
thickness = 1.dp,
modifier = Modifier.padding(0.dp, 8.dp)
)
Box(
Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
Text(
itemInfo.itemID,
color = MaterialTheme.colors.onSurface,
modifier = Modifier
.padding(8.dp)
.align(Alignment.CenterStart)
)
Text(
pageCount,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
Image(
painterResource(id = R.drawable.ic_star_empty),
contentDescription = "Favorite",
modifier = Modifier
.size(32.dp)
.padding(4.dp)
.align(Alignment.CenterEnd)
)
}
}
}
}

View File

@@ -18,46 +18,51 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.text.util.Linkify
import android.view.KeyEvent import android.view.KeyEvent
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.animation.DecelerateInterpolator
import android.widget.EditText import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.orhanobut.logger.Logger
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import xyz.quaver.floatingsearchview.FloatingSearchView import xyz.quaver.floatingsearchview.FloatingSearchView
import xyz.quaver.pupil.* import xyz.quaver.pupil.*
import xyz.quaver.pupil.adapters.SearchResultsAdapter import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.MainActivityBinding import xyz.quaver.pupil.databinding.MainActivityBinding
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.types.* import xyz.quaver.pupil.types.*
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialogFragment import xyz.quaver.pupil.ui.dialog.DownloadLocationDialogFragment
import xyz.quaver.pupil.ui.dialog.GalleryDialogFragment
import xyz.quaver.pupil.ui.dialog.SourceSelectDialog import xyz.quaver.pupil.ui.dialog.SourceSelectDialog
import xyz.quaver.pupil.ui.view.ProgressCardView import xyz.quaver.pupil.ui.view.ProgressCardView
import xyz.quaver.pupil.ui.view.SwipePageTurnView
import xyz.quaver.pupil.ui.viewmodel.MainViewModel import xyz.quaver.pupil.ui.viewmodel.MainViewModel
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import java.util.regex.Pattern
import kotlin.math.* import kotlin.math.*
class MainActivity : class MainActivity :
@@ -70,11 +75,63 @@ class MainActivity :
private lateinit var binding: MainActivityBinding private lateinit var binding: MainActivityBinding
private val model: MainViewModel by viewModels() private val model: MainViewModel by viewModels()
private var refreshOnResume = false private var refreshOnResume = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = MainActivityBinding.inflate(layoutInflater) binding = MainActivityBinding.inflate(layoutInflater)
binding.contents.composeView.setContent {
val searchResults: List<ItemInfo> by model.searchResults.observeAsState(emptyList())
val source: Source? by model.source.observeAsState(null)
val loading: Boolean by model.loading.observeAsState(false)
val listState = rememberLazyListState()
LaunchedEffect(listState) {
var lastOffset = 0
val querySectionHeight = binding.contents.searchview.binding.querySection.root.height.toFloat()
snapshotFlow { listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { newOffset ->
val dy = newOffset - lastOffset
lastOffset = newOffset
binding.contents.searchview.apply {
translationY = (translationY - dy).coerceIn(-querySectionHeight .. 0f)
}
}
}
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(0.dp, 64.dp, 0.dp, 0.dp)) {
item(searchResults) {
searchResults.forEach { itemInfo ->
ProgressCardView(
progress = 0.5f,
onClick = {
startActivity(
Intent(
this@MainActivity,
ReaderActivity::class.java
).apply {
putExtra("source", model.source.value!!.name)
putExtra("id", itemInfo.itemID)
})
}
) {
source?.SearchResult(itemInfo = itemInfo)
}
}
}
}
if (loading)
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
setContentView(binding.root) setContentView(binding.root)
if (Preferences["download_folder", ""].isEmpty()) if (Preferences["download_folder", ""].isEmpty())
@@ -92,7 +149,7 @@ class MainActivity :
model.availableSortMode.observe(this) { model.availableSortMode.observe(this) {
binding.contents.searchview.post { binding.contents.searchview.post {
binding.contents.searchview.binding.querySection.menuView.menuItems.findMenu(R.id.sort).subMenu.apply { binding.contents.searchview.binding.querySection.menuView.menuItems.findMenu(R.id.sort)?.subMenu?.apply {
clear() clear()
it.forEachIndexed { index, sortMode -> it.forEachIndexed { index, sortMode ->
@@ -126,38 +183,7 @@ class MainActivity :
} }
} }
model.searchResults.observe(this) {
binding.contents.recyclerview.post {
if (model.loading) {
if (it.isEmpty()) {
binding.contents.noresult.hide()
binding.contents.progressbar.show()
(binding.contents.recyclerview.adapter as RecyclerSwipeAdapter).run {
mItemManger.closeAllItems()
notifyDataSetChanged()
}
ViewCompat.animate(binding.contents.searchview)
.setDuration(100)
.setInterpolator(DecelerateInterpolator())
.translationY(0F)
}
} else {
binding.contents.progressbar.hide()
if (it.isEmpty()) {
binding.contents.recyclerview.adapter?.notifyDataSetChanged()
binding.contents.noresult.show()
} else {
binding.contents.recyclerview.adapter?.notifyItemInserted(it.size-1)
}
}
}
}
model.suggestions.observe(this) { runOnUiThread { model.suggestions.observe(this) { runOnUiThread {
Logger.d(it)
binding.contents.searchview.swapSuggestions( binding.contents.searchview.swapSuggestions(
if (it.isEmpty()) listOf(NoResultSuggestion(getString(R.string.main_no_result))) else it if (it.isEmpty()) listOf(NoResultSuggestion(getString(R.string.main_no_result))) else it
) )
@@ -173,12 +199,6 @@ class MainActivity :
} }
} }
override fun onDestroy() {
super.onDestroy()
binding.contents.recyclerview.adapter = null
}
@OptIn(ExperimentalStdlibApi::class)
override fun onBackPressed() { override fun onBackPressed() {
if (binding.drawer.isDrawerOpen(GravityCompat.START)) if (binding.drawer.isDrawerOpen(GravityCompat.START))
binding.drawer.closeDrawer(GravityCompat.START) binding.drawer.closeDrawer(GravityCompat.START)
@@ -213,25 +233,6 @@ class MainActivity :
} }
private fun initView() { private fun initView() {
binding.contents.recyclerview.addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
// -height of the search view < translationY < 0
binding.contents.searchview.translationY =
min(
max(
binding.contents.searchview.translationY - dy,
-binding.contents.searchview.binding.querySection.root.height.toFloat()
), 0F)
if (dy > 0)
binding.contents.fab.hideMenuButton(true)
else if (dy < 0)
binding.contents.fab.showMenuButton(true)
}
})
Linkify.addLinks(binding.contents.noresult, Pattern.compile(getString(R.string.https_text)), null, null, { _, _ -> getString(R.string.https) })
//NavigationView //NavigationView
binding.navView.setNavigationItemSelectedListener(this) binding.navView.setNavigationItemSelectedListener(this)
@@ -266,15 +267,15 @@ class MainActivity :
with (binding.contents.randomFab) { with (binding.contents.randomFab) {
setOnClickListener { setOnClickListener {
setImageDrawable(CircularProgressDrawable(context)) setImageDrawable(CircularProgressDrawable(context))
/*
model.random { runOnUiThread { model.random { runOnUiThread {
GalleryDialogFragment(model.source.value!!.name, it.id).apply { GalleryDialogFragment(model.source.value!!.name, it.itemID).apply {
onChipClickedHandler.add { onChipClickedHandler.add {
model.setQueryAndSearch(it.toQuery()) model.setQueryAndSearch(it.toQuery())
dismiss() dismiss()
} }
}.show(supportFragmentManager, "GalleryDialogFragment") }.show(supportFragmentManager, "GalleryDialogFragment")
} } } } */
} }
} }
@@ -290,112 +291,24 @@ class MainActivity :
setPositiveButton(android.R.string.ok) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
val galleryID = editText.text.toString() val galleryID = editText.text.toString()
/*
GalleryDialogFragment(model.source.value!!.name, galleryID).apply { GalleryDialogFragment(model.source.value!!.name, galleryID).apply {
onChipClickedHandler.add { onChipClickedHandler.add {
model.setQueryAndSearch(it.toQuery()) model.setQueryAndSearch(it.toQuery())
dismiss() dismiss()
} }
}.show(supportFragmentManager, "GalleryDialogFragment") }.show(supportFragmentManager, "GalleryDialogFragment")*/
} }
}.show() }.show()
} }
} }
with (binding.contents.swipePageTurnView) {
setOnPageTurnListener(object: SwipePageTurnView.OnPageTurnListener {
override fun onPrev(page: Int) {
model.prevPage()
// disable pageturn until the contents are loaded
setCurrentPage(1, false)
model.query()
}
override fun onNext(page: Int) {
model.nextPage()
// disable pageturn until the contents are loaded
setCurrentPage(1, false)
ViewCompat.animate(binding.contents.searchview)
.setDuration(100)
.setInterpolator(DecelerateInterpolator())
.translationY(0F)
model.query()
}
})
}
setupSearchBar() setupSearchBar()
setupRecyclerView()
// TODO: Save recent source // TODO: Save recent source
} }
@SuppressLint("ClickableViewAccessibility")
private fun setupRecyclerView() {
with (binding.contents.recyclerview) {
adapter = SearchResultsAdapter(model.searchResults).apply {
onChipClickedHandler = {
model.setQueryAndSearch(it.toQuery())
}
onDownloadClickedHandler = { source, itemID ->
closeAllItems()
}
onDeleteClickedHandler = { source, itemID ->
closeAllItems()
}
}
ItemClickSupport.addTo(this).apply {
onItemClickListener = listener@{ _, position, v ->
if (v !is ProgressCardView)
return@listener
val intent = Intent(this@MainActivity, ReaderActivity::class.java).apply {
putExtra("source", model.source.value!!.name)
putExtra("id", model.searchResults.value!![position].id)
}
//TODO: Maybe sprinkling some transitions will be nice :D
startActivity(intent)
}
onItemLongClickListener = listener@{ _, position, v ->
if (v !is ProgressCardView)
return@listener false
val result = model.searchResults.value!!.getOrNull(position) ?: return@listener true
GalleryDialogFragment(model.source.value!!.name, result.id).apply {
onChipClickedHandler.add {
model.setQueryAndSearch(it.toQuery())
dismiss()
}
}.show(supportFragmentManager, "GalleryDialogFragment")
true
}
}
}
}
private fun setupSearchBar() { private fun setupSearchBar() {
with (binding.contents.searchview) { with (binding.contents.searchview) {
onMenuStatusChangeListener = object: FloatingSearchView.OnMenuStatusChangeListener {
override fun onMenuOpened() {
(this@MainActivity.binding.contents.recyclerview.adapter as SearchResultsAdapter).closeAllItems()
}
override fun onMenuClosed() {
//Do Nothing
}
}
onMenuItemClickListener = { onMenuItemClickListener = {
onActionMenuItemSelected(it) onActionMenuItemSelected(it)
} }

View File

@@ -19,294 +19,48 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.content.ContextCompat import androidx.compose.material.Scaffold
import androidx.core.view.forEach import androidx.compose.material.TopAppBar
import androidx.compose.material.Text
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import com.google.accompanist.appcompattheme.AppCompatTheme
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.orhanobut.logger.Logger
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.di.android.di
import org.kodein.di.instance
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.ReaderAdapter
import xyz.quaver.pupil.databinding.ReaderActivityBinding import xyz.quaver.pupil.databinding.ReaderActivityBinding
import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.SavedSourceSet
import xyz.quaver.pupil.util.source
class ReaderActivity : BaseActivity(), DIAware { class ReaderActivity : BaseActivity(), DIAware {
override val di by closestDI() override val di by closestDI()
private var source = ""
private var itemID = ""
private var currentPage = 0
private var isScroll = true
private var isFullscreen = false
set(value) {
field = value
}
private val snapHelper = PagerSnapHelper()
private var menu: Menu? = null private var menu: Menu? = null
private lateinit var binding: ReaderActivityBinding private lateinit var binding: ReaderActivityBinding
private val model: ReaderViewModel by viewModels() private val model: ReaderViewModel by viewModels()
private val favorites: SavedSourceSet by instance(tag = "favorites")
private val histories: SavedSourceSet by instance(tag = "histories")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.reader_loading) setContent {
supportActionBar?.setDisplayHomeAsUpEnabled(false) AppCompatTheme {
Scaffold(
topBar = {
TopAppBar() {
Text("Reader")
}
}
) {
handleIntent(intent) }
if (itemID.isEmpty()) {
onBackPressed()
return
}
histories.add(source, itemID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
Logger.d(histories)
model.readerItems.observe(this) {
(binding.recyclerview.adapter as ReaderAdapter).submitList(it.toMutableList())
binding.downloadProgressbar.apply {
max = it.size
progress = it.count { it.image != null }
visibility =
if (progress == max)
View.GONE
else
View.VISIBLE
} }
} }
model.title.observe(this) {
title = it
}
model.load(source, itemID)
initView()
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) model.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) {
source = uri.host ?: ""
itemID = when (uri.host) {
"hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: ""
"hiyobi.me" -> lastPathSegment
"e-hentai.org" -> uri.pathSegments[1]
else -> ""
}
}
} else {
source = intent.getStringExtra("source") ?: ""
itemID = intent.getStringExtra("id") ?: ""
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.reader, menu)
menu?.forEach {
when (it.itemId) {
R.id.reader_menu_favorite -> {
if (favorites[source]?.contains(itemID) == true)
(it.icon as Animatable).start()
}
R.id.source -> {
it.setIcon(source(source).value.iconResID)
}
}
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) {
R.id.reader_menu_favorite -> {
val id = itemID
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
if (favorites[source]?.contains(id) == true) {
favorites.remove(source, id)
favorite.icon = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_star)
} else {
favorites.add(source, id)
(favorite.icon as Animatable).start()
}
}
}
return true
}
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 {
return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage+1, 0)
true
}
else -> super.onKeyDown(keyCode, event)
}
}
private fun initView() {
with (binding.recyclerview) {
adapter = ReaderAdapter().apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false)
fullscreen(true)
} else {
binding.recyclerview.layoutManager?.scrollToPosition(currentPage+1) // 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()
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "${currentPage+1}/${recyclerView.adapter!!.itemCount}"
}
})
itemAnimator = null
}
with (binding.retryFab) {
setImageResource(R.drawable.refresh)
setOnClickListener {
}
}
with (binding.fullscreenFab) {
setImageResource(R.drawable.ic_fullscreen)
setOnClickListener {
isFullscreen = true
fullscreen(isFullscreen)
binding.fab.close(true)
}
}
}
private fun fullscreen(isFullscreen: Boolean) {
(binding.recyclerview.adapter as ReaderAdapter).fullscreen = isFullscreen
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[0] = 10
extraLayoutSpace[1] = 10
}
}
}
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0)
} }
} }

View File

@@ -0,0 +1,52 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Column
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
enum class FloatingActionButtonState(val isExpanded: Boolean) {
COLLAPSED(false), EXPANDED(true);
operator fun not() = lookupTable[!this.isExpanded]!!
companion object {
private val lookupTable = mapOf(
false to COLLAPSED,
true to EXPANDED
)
}
}
data class SubFabItem(
val icon: ImageVector,
val label: String? = null
)
@Preview
@Composable
fun MultipleFloatingActionButton(
items: List<SubFabItem>,
fabIcon: ImageVector = Icons.Default.Add,
onItemClicked: () -> Unit = { },
targetState: FloatingActionButtonState = FloatingActionButtonState.COLLAPSED,
onStateChanged: ((FloatingActionButtonState) -> Unit)? = null
) {
val transition = updateTransition(targetState = targetState, label = "expand")
Column {
FloatingActionButton(onClick = {
onStateChanged?.invoke(!targetState)
}) {
items.forEach {
}
Icon(imageVector = fabIcon, contentDescription = null)
}
}
}

View File

@@ -27,7 +27,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding
import xyz.quaver.pupil.sources.Hitomi import xyz.quaver.pupil.sources.hitomi.Hitomi
import xyz.quaver.pupil.types.Tags import xyz.quaver.pupil.types.Tags
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences

View File

@@ -18,50 +18,14 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.app.Dialog
import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout.LayoutParams
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.controller.BaseControllerListener
import com.facebook.imagepipeline.image.ImageInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.instance
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.SearchResultsAdapter
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
import xyz.quaver.pupil.databinding.*
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip
import xyz.quaver.pupil.ui.viewmodel.GalleryDialogViewModel
import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.SavedSourceSet
import xyz.quaver.pupil.util.wordCapitalize
import java.util.*
import kotlin.collections.ArrayList
class GalleryDialogFragment(private val source: String, private val itemID: String) : DialogFragment(), DIAware { class GalleryDialogFragment(private val source: String, private val itemID: String) : DialogFragment(), DIAware {
override val di by closestDI() override val di by closestDI()
/*
private val favoriteTags: SavedSourceSet by instance(tag = "favoriteTags") private val favoriteTags: SavedSourceSet by instance(tag = "favoriteTags")
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>() val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
@@ -111,7 +75,7 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
.build() .build()
MainScope().launch { MainScope().launch {
binding.type.text = it.extra[ItemInfo.ExtraType.TYPE]?.await()?.wordCapitalize() binding.type.text = it.extra[ItemInfo.ExtraType.TYPE]?.wordCapitalize()
addDetails(it) addDetails(it)
addPreviews(it) addPreviews(it)
@@ -150,11 +114,11 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
).zip( ).zip(
listOf( listOf(
info.artists.split(", ").map { Tag("artist", it) }, info.artists.split(", ").map { Tag("artist", it) },
info.extra[ItemInfo.ExtraType.GROUP]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("group", it) }, info.extra[ItemInfo.ExtraType.GROUP]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("group", it) },
info.extra[ItemInfo.ExtraType.LANGUAGE]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("language", it) }, info.extra[ItemInfo.ExtraType.LANGUAGE]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("language", it) },
info.extra[ItemInfo.ExtraType.SERIES]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("series", it) }, info.extra[ItemInfo.ExtraType.SERIES]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("series", it) },
info.extra[ItemInfo.ExtraType.CHARACTER]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("character", it) }, info.extra[ItemInfo.ExtraType.CHARACTER]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("character", it) },
info.extra[ItemInfo.ExtraType.TAGS]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.sortedBy { info.extra[ItemInfo.ExtraType.TAGS]?.split(", ")?.filterNot { it.isEmpty() }?.sortedBy {
val tag = Tag.parse(it) val tag = Tag.parse(it)
if (favoriteTags.map[source]?.contains(tag.toString()) == true) if (favoriteTags.map[source]?.contains(tag.toString()) == true)
@@ -197,7 +161,7 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
} }
private suspend fun addPreviews(info: ItemInfo) { private suspend fun addPreviews(info: ItemInfo) {
val previews = info.extra[ItemInfo.ExtraType.PREVIEW]?.await()?.split(", ") ?: return val previews = info.extra[ItemInfo.ExtraType.PREVIEW]?.split(", ") ?: return
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply { GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_thumbnails) type.setText(R.string.gallery_thumbnails)
@@ -257,5 +221,5 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
binding.contents.forEach { if (it is RecyclerView) ItemClickSupport.removeFrom(it) } binding.contents.forEach { if (it is RecyclerView) ItemClickSupport.removeFrom(it) }
super.onDestroyView() super.onDestroyView()
} }
*/
} }

View File

@@ -37,7 +37,7 @@ class SourceSelectDialog : DialogFragment(), DIAware {
var onSourceSelectedListener: ((String) -> Unit)? = null var onSourceSelectedListener: ((String) -> Unit)? = null
var onSourceSettingsSelectedListener: ((String) -> Unit)? = null var onSourceSettingsSelectedListener: ((String) -> Unit)? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {/*
return Dialog(requireContext()).apply { return Dialog(requireContext()).apply {
window?.requestFeature(Window.FEATURE_NO_TITLE) window?.requestFeature(Window.FEATURE_NO_TITLE)
window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
@@ -55,6 +55,6 @@ class SourceSelectDialog : DialogFragment(), DIAware {
} }
}) })
} }
} */return super.onCreateDialog(savedInstanceState)}
} }

View File

@@ -45,9 +45,8 @@ import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.pupil.Pupil import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.SavedSourceSet
import java.io.IOException import java.io.IOException
/*
class ManageFavoritesFragment : PreferenceFragmentCompat(), DIAware { class ManageFavoritesFragment : PreferenceFragmentCompat(), DIAware {
private lateinit var progressDrawable: CircularProgressDrawable private lateinit var progressDrawable: CircularProgressDrawable
@@ -155,4 +154,4 @@ class ManageFavoritesFragment : PreferenceFragmentCompat(), DIAware {
} }
} }
} }*/

View File

@@ -37,9 +37,6 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
private var job: Job? = null private var job: Job? = null
private val downloadManager: DownloadManager by instance() private val downloadManager: DownloadManager by instance()
private val cache: ImageCache by instance()
private val histories: SavedSourceSet by instance(tag = "histories")
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey) setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
@@ -54,9 +51,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
this ?: return false this ?: return false
when (key) { when (key) {
"delete_cache" -> { "delete_cache" -> {/*
val cache: ImageCache by instance()
AlertDialog.Builder(context).apply { AlertDialog.Builder(context).apply {
setTitle(R.string.warning) setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message) setMessage(R.string.settings_clear_cache_alert_message)
@@ -72,7 +67,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
} }
} }
setNegativeButton(android.R.string.cancel) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()*/
} }
"delete_downloads" -> { "delete_downloads" -> {
val dir = downloadManager.downloadFolder val dir = downloadManager.downloadFolder
@@ -114,6 +109,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
setNegativeButton(android.R.string.cancel) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()
} }
/*
"clear_history" -> { "clear_history" -> {
AlertDialog.Builder(context).apply { AlertDialog.Builder(context).apply {
setTitle(R.string.warning) setTitle(R.string.warning)
@@ -124,7 +120,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
} }
setNegativeButton(android.R.string.cancel) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()
} }*/
else -> return false else -> return false
} }
} }
@@ -135,12 +131,12 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
private fun initPreferences() { private fun initPreferences() {
val context = context ?: return val context = context ?: return
with (findPreference<Preference>("delete_cache")) { with (findPreference<Preference>("delete_cache")) {/*
this ?: return@with this ?: return@with
summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size())) summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
onPreferenceClickListener = this@ManageStorageFragment onPreferenceClickListener = this@ManageStorageFragment
} */}
with (findPreference<Preference>("delete_downloads")) { with (findPreference<Preference>("delete_downloads")) {
this ?: return@with this ?: return@with
@@ -163,14 +159,14 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
onPreferenceClickListener = this@ManageStorageFragment onPreferenceClickListener = this@ManageStorageFragment
} }
/*
with (findPreference<Preference>("clear_history")) { with (findPreference<Preference>("clear_history")) {
this ?: return@with this ?: return@with
summary = context.getString(R.string.settings_clear_history_summary, histories.map.values.sumOf { it.size }) summary = context.getString(R.string.settings_clear_history_summary, histories.map.values.sumOf { it.size })
onPreferenceClickListener = this@ManageStorageFragment onPreferenceClickListener = this@ManageStorageFragment
} }*/
} }
override fun onDestroy() { override fun onDestroy() {

View File

@@ -31,7 +31,6 @@ import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.direct import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.pupil.sources.SourcePreferenceIDs
import xyz.quaver.pupil.ui.dialog.DefaultQueryDialogFragment import xyz.quaver.pupil.ui.dialog.DefaultQueryDialogFragment
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.getAvailableLanguages import xyz.quaver.pupil.util.getAvailableLanguages
@@ -49,7 +48,7 @@ class SourceSettingsFragment(private val source: String) :
private val client: HttpClient by instance() private val client: HttpClient by instance()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(direct.instance<SourcePreferenceIDs>().toMap()[source]!!, rootKey) /*setPreferencesFromResource(direct.instance<SourcePreferenceIDs>().toMap()[source]!!, rootKey)*/
initPreferences() initPreferences()
} }

View File

@@ -33,12 +33,11 @@ import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.floatingsearchview.util.view.SearchInputView import xyz.quaver.floatingsearchview.util.view.SearchInputView
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.Hitomi import xyz.quaver.pupil.sources.hitomi.Hitomi
import xyz.quaver.pupil.types.FavoriteHistorySwitch import xyz.quaver.pupil.types.FavoriteHistorySwitch
import xyz.quaver.pupil.types.HistorySuggestion import xyz.quaver.pupil.types.HistorySuggestion
import xyz.quaver.pupil.types.LoadingSuggestion import xyz.quaver.pupil.types.LoadingSuggestion
import xyz.quaver.pupil.types.NoResultSuggestion import xyz.quaver.pupil.types.NoResultSuggestion
import java.util.*
class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
xyz.quaver.floatingsearchview.FloatingSearchView(context, attrs), xyz.quaver.floatingsearchview.FloatingSearchView(context, attrs),

View File

@@ -6,10 +6,41 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.ProgressCardViewBinding import xyz.quaver.pupil.databinding.ProgressCardViewBinding
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProgressCardView(progress: Float? = null, onLongClick: (() -> Unit)? = null, onClick: () -> Unit, content: @Composable () -> Unit) {
MaterialTheme(if (isSystemInDarkTheme()) darkColors() else lightColors()) {
Card(
modifier = Modifier
.padding(8.dp)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
shape = RoundedCornerShape(4.dp),
elevation = 4.dp
) {
Column {
progress?.run { LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth()) }
content.invoke()
}
}
}
}
class ProgressCardView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, defStyle: Int = R.attr.cardViewStyle) : CardView(context, attr, defStyle) { class ProgressCardView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, defStyle: Int = R.attr.cardViewStyle) : CardView(context, attr, defStyle) {

View File

@@ -1,103 +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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.view
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.content.ContextCompat
import com.google.android.material.chip.Chip
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.Hitomi
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.SavedSourceSet
import xyz.quaver.pupil.util.wordCapitalize
@SuppressLint("ViewConstructor")
class TagChip(context: Context, private val source: String, _tag: Tag) : Chip(context), DIAware {
override val di by closestDI(context)
private val favoriteTags: SavedSourceSet by instance(tag = "favoriteTags")
// TODO private val translations: Map<String, String> by instance()
val tag: Tag =
_tag.let {
when {
it.area != null -> it
else -> Tag("tag", _tag.tag)
}
}
init {
when(tag.area) {
"male" -> {
setChipBackgroundColorResource(R.color.material_blue_700)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
setCloseIconTintResource(android.R.color.white)
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_male_white)
}
"female" -> {
setChipBackgroundColorResource(R.color.material_pink_600)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
setCloseIconTintResource(android.R.color.white)
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_female_white)
}
}
if (favoriteTags.map[source]?.contains(tag.toString()) == true)
setChipBackgroundColorResource(R.color.material_orange_500)
isCloseIconVisible = true
closeIcon = ContextCompat.getDrawable(context,
if (favoriteTags.map[source]?.contains(tag.toString()) == true)
R.drawable.ic_star_filled
else
R.drawable.ic_star_empty
)
setOnCloseIconClickListener {
if (favoriteTags[source]?.contains(tag.toString()) == true) {
favoriteTags.remove(source, tag.toString())
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_empty)
when(tag.area) {
"male" -> setChipBackgroundColorResource(R.color.material_blue_700)
"female" -> setChipBackgroundColorResource(R.color.material_pink_600)
else -> chipBackgroundColor = null
}
} else {
favoriteTags.add(source, tag.toString())
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_filled)
setChipBackgroundColorResource(R.color.material_orange_500)
}
}
text = when (tag.area) {
// TODO languageMap
"language" -> Hitomi.languageMap[tag.tag]
else -> /*(translations[tag.tag] ?: */tag.tag.wordCapitalize()
}
setEnsureMinTouchTargetSize(false)
}
}

View File

@@ -1,100 +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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.view
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.util.Log
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import kotlinx.coroutines.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.types.Tags
class TagChipGroup @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, attrStyle: Int = R.attr.chipGroupStyle, var source: String = "hitomi.la", val tags: Tags = Tags()) : ChipGroup(context, attr, attrStyle), MutableSet<Tag> by tags {
object Defaults {
const val maxChipSize = 10
}
var maxChipSize: Int = Defaults.maxChipSize
set(value) {
field = value
refresh()
}
private val moreView = Chip(context).apply {
text = ""
setEnsureMinTouchTargetSize(false)
setOnClickListener {
removeView(this)
for (i in maxChipSize until tags.size) {
val tag = tags.elementAt(i)
addView(TagChip(context, source, tag).apply {
setOnClickListener {
onClickListener?.invoke(tag)
}
})
}
}
}
var onClickListener: ((Tag) -> Unit)? = null
private fun applyAttributes(attr: TypedArray) {
maxChipSize = attr.getInt(R.styleable.TagChipGroup_maxTag, Defaults.maxChipSize)
}
private var refreshJob: Job? = null
fun refresh() {
refreshJob?.cancel()
this.removeAllViews()
refreshJob = CoroutineScope(Dispatchers.Main).launch {
tags.take(maxChipSize).map {
CoroutineScope(Dispatchers.Default).async {
TagChip(context, source, it).apply {
setOnClickListener {
onClickListener?.invoke(this.tag)
}
}
}
}.forEach {
addView(it.await())
}
if (maxChipSize > 0 && tags.size > maxChipSize)
addView(moreView)
}
}
init {
applyAttributes(context.obtainStyledAttributes(attr, R.styleable.TagChipGroup))
refresh()
}
}

View File

@@ -40,12 +40,12 @@ class GalleryDialogViewModel(app: Application) : AndroidViewModel(app), DIAware
private val _related = MutableLiveData<List<ItemInfo>>() private val _related = MutableLiveData<List<ItemInfo>>()
val related: LiveData<List<ItemInfo>> = _related val related: LiveData<List<ItemInfo>> = _related
fun load(source: String, itemID: String) { fun load(source: String, itemID: String) {/*
val source: Source by source(source) val source: Source by source(source)
viewModelScope.launch { viewModelScope.launch {
_info.value = withContext(Dispatchers.IO) { _info.value = withContext(Dispatchers.IO) {
source.info(itemID).also { it.awaitAll() } source.info(itemID)
}.also { }.also {
_related.value = it.extra[ItemInfo.ExtraType.RELATED_ITEM]?.await()?.split(", ")?.map { related -> _related.value = it.extra[ItemInfo.ExtraType.RELATED_ITEM]?.await()?.split(", ")?.map { related ->
async(Dispatchers.IO) { async(Dispatchers.IO) {
@@ -53,7 +53,7 @@ class GalleryDialogViewModel(app: Application) : AndroidViewModel(app), DIAware
} }
}?.awaitAll() }?.awaitAll()
} }
} }*/
} }
} }

View File

@@ -29,7 +29,6 @@ import org.kodein.di.instance
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.sources.* import xyz.quaver.pupil.sources.*
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.notify
import xyz.quaver.pupil.util.source import xyz.quaver.pupil.util.source
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -39,10 +38,11 @@ import kotlin.random.Random
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI() override val di by closestDI()
private val _searchResults = MutableLiveData<MutableList<ItemInfo>>() private val _searchResults = MutableLiveData<List<ItemInfo>>()
val searchResults = _searchResults as LiveData<List<ItemInfo>> val searchResults = _searchResults as LiveData<List<ItemInfo>>
var loading = false
private set private val _loading = MutableLiveData(false)
val loading = _loading as LiveData<Boolean>
private var queryJob: Job? = null private var queryJob: Job? = null
private var suggestionJob: Job? = null private var suggestionJob: Job? = null
@@ -111,7 +111,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
setSourceAndReset( setSourceAndReset(
when { when {
mode == MainMode.DOWNLOADS -> "downloads" mode == MainMode.DOWNLOADS -> "downloads"
source.value is Downloads -> "hitomi.la" //source.value is Downloads -> "hitomi.la"
else -> source.value!!.name else -> source.value!!.name
} }
) )
@@ -126,7 +126,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
suggestionJob?.cancel() suggestionJob?.cancel()
queryJob?.cancel() queryJob?.cancel()
loading = true _loading.value = true
val results = mutableListOf<ItemInfo>() val results = mutableListOf<ItemInfo>()
_searchResults.value = results _searchResults.value = results
@@ -146,11 +146,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
for (result in channel) { for (result in channel) {
yield() yield()
results.add(result) results.add(result)
_searchResults.notify() _searchResults.value = results.toList()
} }
_searchResults.notify()
loading = false _loading.value = false
} }
} }

View File

@@ -20,22 +20,20 @@
package xyz.quaver.pupil.ui.viewmodel package xyz.quaver.pupil.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.constraintlayout.widget.ConstraintSet
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.orhanobut.logger.Logger
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.pupil.adapters.ReaderItem import xyz.quaver.pupil.adapters.ReaderItem
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.db.Bookmark
import xyz.quaver.pupil.db.History
import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.util.ImageCache
import xyz.quaver.pupil.util.notify import xyz.quaver.pupil.util.notify
import xyz.quaver.pupil.util.source import xyz.quaver.pupil.util.source
@@ -44,7 +42,16 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI() override val di by closestDI()
private val cache: ImageCache by instance() private val database: AppDatabase by instance()
private val historyDao = database.historyDao()
private val bookmarkDao = database.bookmarkDao()
private val _source = MutableLiveData<String>()
val source = _source as LiveData<String>
private val _itemID = MutableLiveData<String>()
val itemID = _itemID as LiveData<String>
private val _title = MutableLiveData<String>() private val _title = MutableLiveData<String>()
val title = _title as LiveData<String> val title = _title as LiveData<String>
@@ -55,9 +62,56 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
private var _readerItems = MutableLiveData<MutableList<ReaderItem>>() private var _readerItems = MutableLiveData<MutableList<ReaderItem>>()
val readerItems = _readerItems as LiveData<List<ReaderItem>> val readerItems = _readerItems as LiveData<List<ReaderItem>>
val isBookmarked = Transformations.switchMap(MediatorLiveData<Pair<Source, String>>().apply {
addSource(source) { source -> itemID.value?.let { itemID -> source to itemID } }
addSource(itemID) { itemID -> source.value?.let { source -> source to itemID } }
}) { (source, itemID) ->
bookmarkDao.contains(source.name, itemID)
}
val sourceInstance = Transformations.map(source) {
source(it)
}
val sourceIcon = Transformations.map(sourceInstance) {
it.value.iconResID
}
/**
* Parses source and itemID from the intent
*
* @throws IllegalStateException when the intent has no recognizable source and/or itemID
*/
fun handleIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data
val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) {
_source.postValue(uri.host ?: error("Source cannot be null"))
_itemID.postValue(when (uri.host) {
"hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: error("Invalid itemID")
"hiyobi.me" -> lastPathSegment
"e-hentai.org" -> uri.pathSegments[1]
else -> error("Invalid host")
})
}
} else {
_source.postValue(intent.getStringExtra("source") ?: error("Invalid source"))
_itemID.postValue(intent.getStringExtra("id") ?: error("Invalid itemID"))
}
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun load(sourceName: String, itemID: String) { fun load() {
val source: Source by source(sourceName) val source: Source by source(source.value ?: return)
val itemID = itemID.value ?: return
viewModelScope.launch {
launch(Dispatchers.IO) {
historyDao.insert(History(source.name, itemID))
}
}
viewModelScope.launch { viewModelScope.launch {
_title.value = withContext(Dispatchers.IO) { _title.value = withContext(Dispatchers.IO) {
@@ -66,66 +120,20 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
} }
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { _images.postValue(withContext(Dispatchers.IO) {
source.images(itemID) source.images(itemID)
}.let { images -> })
_readerItems.value = MutableList(images.size) { ReaderItem(0F, null) }
_images.value = images
images.forEachIndexed { index, image ->
when (val scheme = image.takeWhile { it != ':' }) {
"http", "https" -> {
val file = cache.load {
url(image)
headers(source.getHeadersBuilderForImage(itemID, image))
}
val channel = cache.channels[image] ?: error("Channel is null")
if (channel.isClosedForReceive) {
_readerItems.value!![index] =
ReaderItem(_readerItems.value!![index].progress, Uri.fromFile(file))
_readerItems.notify()
} else {
channel.invokeOnClose { e ->
viewModelScope.launch {
if (e == null) {
_readerItems.value!![index] =
ReaderItem(_readerItems.value!![index].progress, Uri.fromFile(file))
_readerItems.notify()
} else {
Logger.e(index.toString())
Logger.e(e, e.message ?: "")
}
}
}
launch {
kotlin.runCatching {
for (progress in channel) {
_readerItems.value!![index] =
ReaderItem(progress, _readerItems.value!![index].image)
_readerItems.notify()
}
}
}
}
}
"content" -> {
_readerItems.value!![index] = ReaderItem(100f, Uri.parse(image))
_readerItems.notify()
}
else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
}
}
}
} }
} }
override fun onCleared() { fun toggleBookmark() {
val bookmark = source.value?.let { source -> itemID.value?.let { itemID -> Bookmark(source, itemID) } } ?: return
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
cache.cleanup() if (bookmarkDao.contains(bookmark).value ?: return@launch)
images.value?.let { cache.free(it) } bookmarkDao.delete(bookmark)
else
bookmarkDao.insert(bookmark)
} }
} }

View File

@@ -31,6 +31,7 @@ import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.* import xyz.quaver.io.util.*
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.sources.Source
class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware { class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware {

View File

@@ -1,136 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import xyz.quaver.io.FileX
import xyz.quaver.pupil.Pupil
import java.io.File
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class ImageCache(context: Context) : DIAware {
override val di by closestDI(context)
private val applicationContext: Pupil by instance()
private val client: HttpClient by instance()
val cacheFolder = File(context.cacheDir, "imageCache")
val cache = SavedMap(File(cacheFolder, ".cache"), "", "")
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
val channels = _channels as Map<String, Channel<Float>>
private val requests = mutableMapOf<String, Job>()
@Synchronized
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun cleanup() = coroutineScope {
val LIMIT = 100*1024*1024
cacheFolder.listFiles { it -> it.canonicalPath !in cache.values || it.name == ".cache" }?.forEach { it.delete() }
if (cacheFolder.size() > LIMIT)
do {
cache.entries.firstOrNull { !channels.containsKey(it.key) }?.let {
File(it.value).delete()
cache.remove(it.key)
}
} while (cacheFolder.size() > LIMIT / 2)
}
fun free(images: List<String>) {
images.forEach {
requests[it]?.cancel()
}
images.forEach { _channels.remove(it) }
}
@Synchronized
suspend fun clear() = coroutineScope {
requests.values.forEach { it.cancel() }
cacheFolder.listFiles()?.forEach { it.delete() }
cache.clear()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun load(requestBuilder: HttpRequestBuilder.() -> Unit): File {
val request = HttpRequestBuilder().apply(requestBuilder)
val key = request.url.buildString()
val progressChannel = if (_channels[key]?.isClosedForSend == false)
_channels[key]!!
else
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[key] = it }
return cache[key]?.let {
progressChannel.close()
File(it)
} ?: File(cacheFolder, "${UUID.randomUUID()}.${key.takeLastWhile { it != '.' }}").also { file ->
if (!file.exists())
file.createNewFile()
cache[key] = file.canonicalPath
requests[key] = CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
client.get<HttpStatement>(request).execute { httpResponse ->
val responseChannel: ByteReadChannel = httpResponse.receive()
val contentLength = httpResponse.contentLength() ?: -1
var readBytes = 0F
while (!responseChannel.isClosedForRead) {
val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
file.appendBytes(bytes)
readBytes += bytes.size
progressChannel.trySend(readBytes / contentLength)
}
}
progressChannel.close()
}
}.onFailure {
file.delete()
cache.remove(key)
FirebaseCrashlytics.getInstance().recordException(it)
progressChannel.close(it)
}
}
}
}
}

View File

@@ -1,175 +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 <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import androidx.annotation.RequiresApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.Json.Default.decodeFromString
import kotlinx.serialization.serializer
import java.io.File
class SavedMap <K: Any, V: Any> (private val file: File, anyKey: K, anyValue: V, private val map: MutableMap<K, V> = mutableMapOf()) : MutableMap<K, V> by map {
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalSerializationApi::class)
val serializer: KSerializer<Map<K, V>> = MapSerializer(serializer(anyKey::class.java) as KSerializer<K>, serializer(anyValue::class.java) as KSerializer<V>)
init {
if (!file.exists()) {
save()
}
load()
}
@Synchronized
fun load() {
map.clear()
kotlin.runCatching {
decodeFromString(serializer, file.readText())
}.onSuccess {
map.putAll(it)
}
}
@Synchronized
fun save() {
file.parentFile?.mkdirs()
if (!file.exists())
file.createNewFile()
file.writeText(Json.encodeToString(serializer, map))
}
@Synchronized
override fun put(key: K, value: V): V? {
map.remove(key)
return map.put(key, value).also {
save()
}
}
@Synchronized
override fun putAll(from: Map<out K, V>) {
for (key in from.keys) {
map.remove(key)
}
map.putAll(from)
save()
}
@Synchronized
override fun remove(key: K): V? {
return map.remove(key).also {
save()
}
}
@Synchronized
@RequiresApi(24)
override fun remove(key: K, value: V): Boolean {
return map.remove(key, value).also {
save()
}
}
@Synchronized
override fun clear() {
map.clear()
save()
}
}
class SavedSourceSet(private val file: File) {
private val _map = mutableMapOf<String, MutableList<String>>()
val map: Map<String, List<String>> = _map
private val serializer = MapSerializer(String.serializer(), ListSerializer(String.serializer()))
@Synchronized
fun load() {
_map.clear()
kotlin.runCatching {
decodeFromString(serializer, file.readText())
}.onSuccess {
it.forEach { (k, v) ->
_map[k] = v.toMutableList()
}
}
}
@Synchronized
fun save() {
file.parentFile?.mkdirs()
if (!file.exists())
file.createNewFile()
file.writeText(Json.encodeToString(serializer, _map))
}
operator fun get(key: String) = _map[key]
@Synchronized
fun add(source: String, value: String) {
_map[source]?.remove(value)
if (!_map.containsKey(source))
_map[source] = mutableListOf(value)
else
_map[source]!!.add(value)
save()
}
@Synchronized
fun addAll(from: Map<String, Set<String>>) {
for (source in from.keys) {
if (_map.containsKey(source)) {
_map[source]!!.removeAll(from[source]!!)
_map[source]!!.addAll(from[source]!!)
} else {
_map[source] = from[source]!!.toMutableList()
}
}
save()
}
@Synchronized
fun remove(source: String, value: String): Boolean {
return (_map[source]?.remove(value) ?: false).also {
save()
}
}
@Synchronized
fun clear() {
_map.clear()
save()
}
}

View File

@@ -43,7 +43,7 @@ fun hashWithSalt(password: String): Pair<String, String> {
return Pair(hash(password+salt), salt) return Pair(hash(password+salt), salt)
} }
const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" private const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@Serializable @Serializable
data class Lock(val type: Type, val hash: String, val salt: String) { data class Lock(val type: Type, val hash: String, val salt: String) {

View File

@@ -27,6 +27,7 @@ import org.kodein.di.DIAware
import org.kodein.di.DirectDIAware import org.kodein.di.DirectDIAware
import org.kodein.di.direct import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SourceEntries import xyz.quaver.pupil.sources.SourceEntries
import java.io.InputStream import java.io.InputStream
@@ -40,7 +41,7 @@ fun String.wordCapitalize() : String {
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
for (word in this.split(" ")) for (word in this.split(" "))
result.add(word.capitalize(Locale.US)) result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() })
return result.joinToString(" ") return result.joinToString(" ")
} }
@@ -73,9 +74,8 @@ fun byteToString(byte: Long, precision : Int = 1) : String {
fun Int.normalizeID() = this.and(0xFFFF) fun Int.normalizeID() = this.and(0xFFFF)
val formatMap = mapOf<String, ItemInfo.() -> (String)>( val formatMap = mapOf<String, ItemInfo.() -> (String)>(
"-id-" to { id }, "-id-" to { itemID },
"-title-" to { title }, "-title-" to { title },
"-artist-" to { artists }
// TODO // TODO
) )
/** /**
@@ -103,8 +103,8 @@ operator fun JsonElement.get(tag: String) =
val JsonElement.content val JsonElement.content
get() = this.jsonPrimitive.contentOrNull get() = this.jsonPrimitive.contentOrNull
fun List<MenuItem>.findMenu(itemID: Int): MenuItem { fun List<MenuItem>.findMenu(itemID: Int): MenuItem? {
return first { it.itemId == itemID } return firstOrNull { it.itemId == itemID }
} }
fun <E> MutableLiveData<MutableList<E>>.notify() { fun <E> MutableLiveData<MutableList<E>>.notify() {
@@ -127,6 +127,9 @@ fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long, bytes
fun DIAware.source(source: String) = lazy { direct.source(source) } fun DIAware.source(source: String) = lazy { direct.source(source) }
fun DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[source]!! fun DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[source]!!
fun DIAware.database() = lazy { direct.database() }
fun DirectDIAware.database() = instance<AppDatabase>()
fun View.hide() { fun View.hide() {
visibility = View.INVISIBLE visibility = View.INVISIBLE
} }

View File

@@ -24,52 +24,10 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.MainActivity"> tools:context=".ui.MainActivity">
<xyz.quaver.pupil.ui.view.SwipePageTurnView <androidx.compose.ui.platform.ComposeView
android:id="@+id/swipe_page_turn_view" android:id="@+id/compose_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"/>
<com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
android:layout_width="match_parent"
android:layout_height="match_parent"
app:handleDrawable="@drawable/thumb"
app:handleHasFixedSize="true"
app:handleHeight="72dp"
app:handleWidth="24dp"
app:disableTrack="true"
app:hideHandleAfter="1000"
app:trackMarginStart="64dp"
app:addLastItemPadding="true"
app:popupDrawable="@android:color/transparent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="64dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller>
</xyz.quaver.pupil.ui.view.SwipePageTurnView>
<androidx.core.widget.ContentLoadingProgressBar
style="?android:attr/progressBarStyle"
android:id="@+id/progressbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"/>
<TextView
android:id="@+id/noresult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/main_no_result"
android:linksClickable="true"
android:visibility="invisible"/>
<com.github.clans.fab.FloatingActionMenu <com.github.clans.fab.FloatingActionMenu
android:id="@+id/fab" android:id="@+id/fab"

View File

@@ -30,112 +30,9 @@
app:cardUseCompatPadding="true" app:cardUseCompatPadding="true"
tools:ignore="RtlHardcoded"> tools:ignore="RtlHardcoded">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/thumbnail"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:contentDescription="@string/galleryblock_thumbnail_description"
android:adjustViewBounds="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/barrier"/>
<TextView
style="@style/TextAppearance.AppCompat.Headline"
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
app:layout_constraintLeft_toRightOf="@id/thumbnail"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
style="@style/TextAppearance.AppCompat.Medium"
android:id="@+id/artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
app:layout_constraintLeft_toRightOf="@id/thumbnail"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<TextView
android:id="@+id/extra"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
app:layout_constraintTop_toBottomOf="@id/artist"
app:layout_constraintLeft_toRightOf="@id/thumbnail"/>
<xyz.quaver.pupil.ui.view.TagChipGroup
android:id="@+id/tag_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:chipSpacing="4dp"
app:layout_constraintTop_toBottomOf="@id/extra"
app:layout_constraintLeft_toRightOf="@id/thumbnail"
app:layout_constraintRight_toRightOf="parent"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="thumbnail, tag_group"/>
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
app:layout_constraintTop_toBottomOf="@id/barrier"
android:layout_margin="8dp"/>
<TextView
android:id="@+id/id_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="150dp"
android:ellipsize="end"
android:maxLines="1"
android:layout_margin="8dp"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"/>
<TextView
android:id="@+id/pagecount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ImageView
android:id="@+id/favorite"
android:contentDescription="@string/app_name"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginRight="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:srcCompat="@drawable/ic_star_empty"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</xyz.quaver.pupil.ui.view.ProgressCardView> </xyz.quaver.pupil.ui.view.ProgressCardView>

View File

@@ -26,6 +26,8 @@ package xyz.quaver.pupil
* See [testing documentation](http://d.android.com/tools/testing). * See [testing documentation](http://d.android.com/tools/testing).
*/ */
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Test import org.junit.Test
@@ -38,9 +40,9 @@ class ExampleUnitTest {
@Test @Test
fun test() { fun test() {
val a = mutableSetOf<Int>() runBlocking {
print(a::class.java.methods.firstOrNull { it.name == "add" }?.genericParameterTypes?.firstOrNull() as? ParameterizedType) }
} }
} }

View File

@@ -6,11 +6,11 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:7.0.0-rc01") classpath("com.android.tools.build:gradle:7.0.2")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10")
classpath("org.jetbrains.kotlin:kotlin-android-extensions:1.5.21") classpath("org.jetbrains.kotlin:kotlin-android-extensions:1.5.21")
classpath("org.jetbrains.kotlin:kotlin-serialization:1.5.21") classpath("org.jetbrains.kotlin:kotlin-serialization:1.5.21")
classpath("com.google.gms:google-services:4.3.8") classpath("com.google.gms:google-services:4.3.10")
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1")
@@ -23,7 +23,6 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
mavenLocal()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
maven { url = uri("https://jitpack.io") } maven { url = uri("https://jitpack.io") }
} }

View File

@@ -18,5 +18,4 @@ org.gradle.configureondemand=true
org.gradle.caching=true org.gradle.caching=true
kotlin.code.style=official kotlin.code.style=official
android.enableJetifier=true android.enableJetifier=true
android.useAndroidX=true android.useAndroidX=true
android.enableBuildCache=true