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"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
<bytecodeTargetLevel target="11" />
</component>
</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="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="JDK" />
<option name="modules">
<set>
<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"?>
<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" />
</component>
<component name="ProjectType">

View File

@@ -5,15 +5,9 @@ plugins {
id("kotlin-parcelize")
id("kotlinx-serialization")
id("com.google.android.gms.oss-licenses-plugin")
if (File("google-services.json").exists()) {
println("Firebase Enabled")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf")
} else {
println("Firebase Disabled")
}
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf")
}
android {
@@ -41,7 +35,10 @@ android {
isMinifyEnabled = true
isShrinkResources = true
isCrunchPngs = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("debug")
}
}
buildFeatures {
@@ -49,45 +46,61 @@ android {
dataBinding = true
compose = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf("-Xuse-experimental=kotlin.Experimental")
composeOptions {
kotlinCompilerExtensionVersion = "1.0.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.21")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.1")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
implementation("androidx.compose.ui:ui:1.0.0-rc02")
implementation("androidx.compose.ui:ui-tooling:1.0.0-rc02")
implementation("androidx.compose.foundation:foundation:1.0.0-rc02")
implementation("androidx.compose.material:material:1.0.0-rc02")
implementation("androidx.compose.material:material-icons-core:1.0.0-rc02")
implementation("androidx.compose.material:material-icons-extended:1.0.0-rc02")
implementation("androidx.compose.runtime:runtime-livedata:1.0.0-rc02")
implementation("androidx.compose.ui:ui:1.0.2")
implementation("androidx.compose.ui:ui-tooling:1.0.2")
implementation("androidx.compose.foundation:foundation:1.0.2")
implementation("androidx.compose.material:material:1.0.2")
implementation("androidx.compose.material:material-icons-core:1.0.2")
implementation("androidx.compose.material:material-icons-extended:1.0.2")
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("io.ktor:ktor-client-okhttp:1.6.1")
implementation("io.ktor:ktor-client-serialization:1.6.1")
implementation("com.google.accompanist:accompanist-flowlayout:0.16.1")
implementation("com.google.accompanist:accompanist-appcompat-theme:0.16.0")
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.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.preference:preference-ktx:1.1.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.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")
@@ -104,9 +117,9 @@ dependencies {
//implementation("com.quiph.ui:recyclerviewfastscroller:0.2.1")
implementation("com.github.piasy:BigImageViewer:1.8.0")
implementation("com.github.piasy:FrescoImageLoader:1.8.0")
implementation("com.github.piasy:FrescoImageViewFactory:1.8.0")
implementation("com.github.piasy:BigImageViewer:1.8.1")
implementation("com.github.piasy:FrescoImageLoader:1.8.1")
implementation("com.github.piasy:FrescoImageViewFactory:1.8.1")
implementation("org.jsoup:jsoup:1.14.1")
@@ -117,20 +130,20 @@ dependencies {
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: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")
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:rules:1.4.0")
androidTestImplementation("androidx.test:runner:1.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.crashlytics.FirebaseCrashlytics
import com.google.firebase.ktx.Firebase
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
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.android.x.androidXModule
import xyz.quaver.io.FileX
import xyz.quaver.pupil.db.databaseModule
import xyz.quaver.pupil.sources.sourceModule
import xyz.quaver.pupil.util.*
import java.io.File
@@ -56,16 +55,11 @@ class Pupil : Application(), DIAware {
override val di: DI by DI.lazy {
import(androidXModule(this@Pupil))
import(databaseModule)
import(sourceModule)
bind { singleton { ImageCache(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 {
HttpClient(OkHttp) {
install(JsonFeature) {
@@ -90,8 +84,6 @@ class Pupil : Application(), DIAware {
firebaseAnalytics = Firebase.analytics
FirebaseCrashlytics.getInstance().setUserId(userID)
Logger.addLogAdapter(AndroidLogAdapter())
try {
Preferences.get<String>("download_folder").also {
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 xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.databinding.SourceSelectDialogItemBinding
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
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
import android.app.Application
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.lifecycle.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
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 xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.hitomi.Hitomi
@Serializable(with = ItemInfo.SearchResultSerializer::class)
data class ItemInfo(
val source: String,
val id: 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
)
}
interface ItemInfo : Parcelable {
val source: String
val itemID: String
val title: String
}
@Parcelize
class DefaultSearchSuggestion(override val body: String) : SearchSuggestion
interface SortModeInterface {
val ordinal: Int
val name: Int
data class SearchResultEvent(val type: Type, val payload: String) {
enum class Type {
OPEN_READER,
OPEN_DETAILS,
NEW_QUERY,
TOGGLE_FAVORITES
}
}
abstract class Source {
@@ -121,10 +56,13 @@ abstract class Source {
abstract val preferenceID: Int
abstract val availableSortMode: List<String>
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 images(itemID: String) : List<String>
abstract suspend fun info(itemID: String) : ItemInfo
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 images(itemID: String): List<String>
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 = { }
@@ -135,22 +73,15 @@ abstract class Source {
typealias SourceEntry = Pair<String, Source>
typealias SourceEntries = Set<SourceEntry>
typealias SourcePreferenceID = Pair<String, Int>
typealias SourcePreferenceIDs = Set<SourcePreferenceID>
@Suppress("UNCHECKED_CAST")
val sourceModule = DI.Module(name = "source") {
bindSet<SourceEntry>()
bindSet<SourcePreferenceID>()
onReady {
listOf<Source>(
Hitomi(instance())
).forEach { source ->
inSet { multiton { _: Unit -> source.name to source } }
inSet { singleton { source.name to source.preferenceID } }
}
listOf<(Application) -> (Source)>(
{ Hitomi(it) }
).forEach { source ->
inSet { singleton { source.invoke(instance()).let { it.name to it } } }
}
bind { factory { source: String -> History(di, source) } }
inSet { singleton { Downloads(di).let { it.name to it as Source } } }
bind { singleton { History(di) } }
// inSet { singleton { Downloads(di).let { it.name to it as Source } } }
}

View File

@@ -18,6 +18,7 @@
package xyz.quaver.pupil.sources
import androidx.compose.runtime.Composable
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.decodeFromString
@@ -32,7 +33,7 @@ import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.DownloadManager
import kotlin.math.max
import kotlin.math.min
/*
class Downloads(override val di: DI) : Source(), DIAware {
override val name: String
@@ -46,6 +47,8 @@ class Downloads(override val di: DI) : Source(), DIAware {
private val downloadManager: DownloadManager by instance()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
TODO()
/*
val downloads = downloadManager.downloads.toList()
val channel = Channel<ItemInfo>()
@@ -61,7 +64,7 @@ class Downloads(override val di: DI) : Source(), DIAware {
channel.close()
}
return Pair(channel, downloads.size)
return Pair(channel, downloads.size)*/
}
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 {
return transform(downloadManager.downloadFolder.getChild(itemID))
TODO("Not yet implemented")
/* return transform(downloadManager.downloadFolder.getChild(itemID))*/
}
companion object {
@@ -83,7 +87,7 @@ class Downloads(override val di: DI) : Source(), DIAware {
folder.list { _, name ->
name.takeLastWhile { it != '.' } in listOf("jpg", "png", "gif", "webp")
}?.toList()
/*
suspend fun transform(folder: FileX): ItemInfo = withContext(Dispatchers.Unconfined) {
kotlin.runCatching {
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
import androidx.compose.runtime.Composable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.direct
import org.kodein.di.instance
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
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 histories: SavedSourceSet by instance(tag = "histories")
private val historyDao = direct.database().historyDao()
override val name: String
get() = source.name
get() = "history"
override val iconResID: Int
get() = source.iconResID
get() = 0 //TODO
override val preferenceID: Int
get() = source.preferenceID
get() = 0 //TODO
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> {
val channel = Channel<ItemInfo>()
CoroutineScope(Dispatchers.IO).launch {
histories[source.name]?.asReversed()?.forEach {
channel.send(source.info(it))
}
channel.close()
}
return Pair(channel, histories.map.size)
throw NotImplementedError("")
//return Pair(channel, histories.map.size)
}
override suspend fun suggestion(query: String): List<SearchSuggestion> {
return source.suggestion(query)
throw NotImplementedError("")
}
override suspend fun images(itemID: String): List<String> {
return source.images(itemID)
throw NotImplementedError("")
}
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 xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R
/*
class ImHentai(override val di: DI) : Source(), DIAware {
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
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.InputType
import android.text.util.Linkify
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.animation.DecelerateInterpolator
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.viewModels
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.ViewCompat
import androidx.core.view.children
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.google.android.material.navigation.NavigationView
import com.orhanobut.logger.Logger
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import xyz.quaver.floatingsearchview.FloatingSearchView
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.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.types.*
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.view.ProgressCardView
import xyz.quaver.pupil.ui.view.SwipePageTurnView
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
import xyz.quaver.pupil.util.*
import java.util.regex.Pattern
import kotlin.math.*
class MainActivity :
@@ -70,11 +75,63 @@ class MainActivity :
private lateinit var binding: MainActivityBinding
private val model: MainViewModel by viewModels()
private var refreshOnResume = false
private var refreshOnResume = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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)
if (Preferences["download_folder", ""].isEmpty())
@@ -92,7 +149,7 @@ class MainActivity :
model.availableSortMode.observe(this) {
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()
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 {
Logger.d(it)
binding.contents.searchview.swapSuggestions(
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() {
if (binding.drawer.isDrawerOpen(GravityCompat.START))
binding.drawer.closeDrawer(GravityCompat.START)
@@ -213,25 +233,6 @@ class MainActivity :
}
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
binding.navView.setNavigationItemSelectedListener(this)
@@ -266,15 +267,15 @@ class MainActivity :
with (binding.contents.randomFab) {
setOnClickListener {
setImageDrawable(CircularProgressDrawable(context))
/*
model.random { runOnUiThread {
GalleryDialogFragment(model.source.value!!.name, it.id).apply {
GalleryDialogFragment(model.source.value!!.name, it.itemID).apply {
onChipClickedHandler.add {
model.setQueryAndSearch(it.toQuery())
dismiss()
}
}.show(supportFragmentManager, "GalleryDialogFragment")
} }
} } */
}
}
@@ -290,112 +291,24 @@ class MainActivity :
setPositiveButton(android.R.string.ok) { _, _ ->
val galleryID = editText.text.toString()
/*
GalleryDialogFragment(model.source.value!!.name, galleryID).apply {
onChipClickedHandler.add {
model.setQueryAndSearch(it.toQuery())
dismiss()
}
}.show(supportFragmentManager, "GalleryDialogFragment")
}.show(supportFragmentManager, "GalleryDialogFragment")*/
}
}.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()
setupRecyclerView()
// 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() {
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 = {
onActionMenuItemSelected(it)
}

View File

@@ -19,294 +19,48 @@
package xyz.quaver.pupil.ui
import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.view.*
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import androidx.compose.material.Scaffold
import androidx.compose.material.TopAppBar
import androidx.compose.material.Text
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
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 com.google.accompanist.appcompattheme.AppCompatTheme
import org.kodein.di.DIAware
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.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 {
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 lateinit var binding: ReaderActivityBinding
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?) {
super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.reader_loading)
supportActionBar?.setDisplayHomeAsUpEnabled(false)
setContent {
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) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data
val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) {
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)
model.handleIntent(intent)
}
}

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 xyz.quaver.pupil.R
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.util.Preferences

View File

@@ -18,50 +18,14 @@
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.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.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 {
override val di by closestDI()
/*
private val favoriteTags: SavedSourceSet by instance(tag = "favoriteTags")
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
@@ -111,7 +75,7 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
.build()
MainScope().launch {
binding.type.text = it.extra[ItemInfo.ExtraType.TYPE]?.await()?.wordCapitalize()
binding.type.text = it.extra[ItemInfo.ExtraType.TYPE]?.wordCapitalize()
addDetails(it)
addPreviews(it)
@@ -150,11 +114,11 @@ class GalleryDialogFragment(private val source: String, private val itemID: Stri
).zip(
listOf(
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.LANGUAGE]?.await()?.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.CHARACTER]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("character", it) },
info.extra[ItemInfo.ExtraType.TAGS]?.await()?.split(", ")?.filterNot { it.isEmpty() }?.sortedBy {
info.extra[ItemInfo.ExtraType.GROUP]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("group", it) },
info.extra[ItemInfo.ExtraType.LANGUAGE]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("language", it) },
info.extra[ItemInfo.ExtraType.SERIES]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("series", it) },
info.extra[ItemInfo.ExtraType.CHARACTER]?.split(", ")?.filterNot { it.isEmpty() }?.map { Tag("character", it) },
info.extra[ItemInfo.ExtraType.TAGS]?.split(", ")?.filterNot { it.isEmpty() }?.sortedBy {
val tag = Tag.parse(it)
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) {
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 {
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) }
super.onDestroyView()
}
*/
}

View File

@@ -37,7 +37,7 @@ class SourceSelectDialog : DialogFragment(), DIAware {
var onSourceSelectedListener: ((String) -> Unit)? = null
var onSourceSettingsSelectedListener: ((String) -> Unit)? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {/*
return Dialog(requireContext()).apply {
window?.requestFeature(Window.FEATURE_NO_TITLE)
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 xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.SavedSourceSet
import java.io.IOException
/*
class ManageFavoritesFragment : PreferenceFragmentCompat(), DIAware {
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 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?) {
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
@@ -54,9 +51,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
this ?: return false
when (key) {
"delete_cache" -> {
val cache: ImageCache by instance()
"delete_cache" -> {/*
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message)
@@ -72,7 +67,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}.show()*/
}
"delete_downloads" -> {
val dir = downloadManager.downloadFolder
@@ -114,6 +109,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}
/*
"clear_history" -> {
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
@@ -124,7 +120,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}
}*/
else -> return false
}
}
@@ -135,12 +131,12 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
private fun initPreferences() {
val context = context ?: return
with (findPreference<Preference>("delete_cache")) {
with (findPreference<Preference>("delete_cache")) {/*
this ?: return@with
summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
onPreferenceClickListener = this@ManageStorageFragment
}
*/}
with (findPreference<Preference>("delete_downloads")) {
this ?: return@with
@@ -163,14 +159,14 @@ class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.On
onPreferenceClickListener = this@ManageStorageFragment
}
/*
with (findPreference<Preference>("clear_history")) {
this ?: return@with
summary = context.getString(R.string.settings_clear_history_summary, histories.map.values.sumOf { it.size })
onPreferenceClickListener = this@ManageStorageFragment
}
}*/
}
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.direct
import org.kodein.di.instance
import xyz.quaver.pupil.sources.SourcePreferenceIDs
import xyz.quaver.pupil.ui.dialog.DefaultQueryDialogFragment
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.getAvailableLanguages
@@ -49,7 +48,7 @@ class SourceSettingsFragment(private val source: String) :
private val client: HttpClient by instance()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(direct.instance<SourcePreferenceIDs>().toMap()[source]!!, rootKey)
/*setPreferencesFromResource(direct.instance<SourcePreferenceIDs>().toMap()[source]!!, rootKey)*/
initPreferences()
}

View File

@@ -33,12 +33,11 @@ import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.floatingsearchview.util.view.SearchInputView
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.HistorySuggestion
import xyz.quaver.pupil.types.LoadingSuggestion
import xyz.quaver.pupil.types.NoResultSuggestion
import java.util.*
class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
xyz.quaver.floatingsearchview.FloatingSearchView(context, attrs),

View File

@@ -6,10 +6,41 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.graphics.drawable.DrawableCompat
import xyz.quaver.pupil.R
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) {

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

View File

@@ -29,7 +29,6 @@ import org.kodein.di.instance
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.sources.*
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.notify
import xyz.quaver.pupil.util.source
import kotlin.math.ceil
import kotlin.math.roundToInt
@@ -39,10 +38,11 @@ import kotlin.random.Random
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI()
private val _searchResults = MutableLiveData<MutableList<ItemInfo>>()
private val _searchResults = MutableLiveData<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 suggestionJob: Job? = null
@@ -111,7 +111,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
setSourceAndReset(
when {
mode == MainMode.DOWNLOADS -> "downloads"
source.value is Downloads -> "hitomi.la"
//source.value is Downloads -> "hitomi.la"
else -> source.value!!.name
}
)
@@ -126,7 +126,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
suggestionJob?.cancel()
queryJob?.cancel()
loading = true
_loading.value = true
val results = mutableListOf<ItemInfo>()
_searchResults.value = results
@@ -146,11 +146,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
for (result in channel) {
yield()
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
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.orhanobut.logger.Logger
import androidx.constraintlayout.widget.ConstraintSet
import androidx.lifecycle.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.util.*
import kotlinx.coroutines.*
import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI
import org.kodein.di.instance
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.util.ImageCache
import xyz.quaver.pupil.util.notify
import xyz.quaver.pupil.util.source
@@ -44,7 +42,16 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
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>()
val title = _title as LiveData<String>
@@ -55,9 +62,56 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
private var _readerItems = MutableLiveData<MutableList<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)
fun load(sourceName: String, itemID: String) {
val source: Source by source(sourceName)
fun load() {
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 {
_title.value = withContext(Dispatchers.IO) {
@@ -66,66 +120,20 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
}
viewModelScope.launch {
withContext(Dispatchers.IO) {
_images.postValue(withContext(Dispatchers.IO) {
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 {
cache.cleanup()
images.value?.let { cache.free(it) }
if (bookmarkDao.contains(bookmark).value ?: return@launch)
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 xyz.quaver.io.FileX
import xyz.quaver.io.util.*
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
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)
}
const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
private const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@Serializable
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.direct
import org.kodein.di.instance
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SourceEntries
import java.io.InputStream
@@ -40,7 +41,7 @@ fun String.wordCapitalize() : String {
@SuppressLint("DefaultLocale")
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(" ")
}
@@ -73,9 +74,8 @@ fun byteToString(byte: Long, precision : Int = 1) : String {
fun Int.normalizeID() = this.and(0xFFFF)
val formatMap = mapOf<String, ItemInfo.() -> (String)>(
"-id-" to { id },
"-id-" to { itemID },
"-title-" to { title },
"-artist-" to { artists }
// TODO
)
/**
@@ -103,8 +103,8 @@ operator fun JsonElement.get(tag: String) =
val JsonElement.content
get() = this.jsonPrimitive.contentOrNull
fun List<MenuItem>.findMenu(itemID: Int): MenuItem {
return first { it.itemId == itemID }
fun List<MenuItem>.findMenu(itemID: Int): MenuItem? {
return firstOrNull { it.itemId == itemID }
}
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 DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[source]!!
fun DIAware.database() = lazy { direct.database() }
fun DirectDIAware.database() = instance<AppDatabase>()
fun View.hide() {
visibility = View.INVISIBLE
}

View File

@@ -24,52 +24,10 @@
android:layout_height="match_parent"
tools:context=".ui.MainActivity">
<xyz.quaver.pupil.ui.view.SwipePageTurnView
android:id="@+id/swipe_page_turn_view"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="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"/>
android:layout_height="match_parent"/>
<com.github.clans.fab.FloatingActionMenu
android:id="@+id/fab"

View File

@@ -30,112 +30,9 @@
app:cardUseCompatPadding="true"
tools:ignore="RtlHardcoded">
<androidx.constraintlayout.widget.ConstraintLayout
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
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>
android:layout_height="wrap_content" />
</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).
*/
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import org.junit.Test
@@ -38,9 +40,9 @@ class ExampleUnitTest {
@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()
}
dependencies {
classpath("com.android.tools.build:gradle:7.0.0-rc01")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21")
classpath("com.android.tools.build:gradle:7.0.2")
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-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
// in the individual module build.gradle files
classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1")
@@ -23,7 +23,6 @@ allprojects {
repositories {
google()
mavenCentral()
mavenLocal()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
maven { url = uri("https://jitpack.io") }
}

View File

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