diff --git a/.gitignore b/.gitignore index 2968239c3..a8c7b78f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /local.properties /.idea/caches /.idea/libraries +/.idea/dictionaries /.idea/modules.xml /.idea/misc.xml /.idea/workspace.xml diff --git a/.idea/dictionaries/admin.xml b/.idea/dictionaries/admin.xml deleted file mode 100644 index 64cf6888c..000000000 --- a/.idea/dictionaries/admin.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - amoled - chucker - desu - failsafe - koin - kotatsu - manga - snackbar - upsert - webtoon - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 35ffc652f..a0de2a152 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -14,7 +14,6 @@ - diff --git a/app/build.gradle b/app/build.gradle index 0574ceaac..25140cfc3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,15 +6,16 @@ plugins { } android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion '30.0.3' + namespace 'org.koitharu.kotatsu' defaultConfig { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 - targetSdkVersion 30 - versionCode 369 - versionName '2.0-b1' + targetSdkVersion 31 + versionCode 380 + versionName '2.1.4' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -24,10 +25,6 @@ android { } } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } buildTypes { debug { applicationIdSuffix = '.debug' @@ -45,76 +42,79 @@ android { sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } - lintOptions { - disable 'MissingTranslation' + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + freeCompilerArgs += [ + '-Xjvm-default=enable', + '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-Xopt-in=kotlinx.coroutines.FlowPreview', + '-Xopt-in=kotlin.contracts.ExperimentalContracts', + ] + } + lint { abortOnError false + disable 'MissingTranslation' } testOptions { unitTests.includeAndroidResources = true unitTests.returnDefaultValues = false } } -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - freeCompilerArgs += [ - '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', - '-Xopt-in=kotlinx.coroutines.FlowPreview', - '-Xopt-in=org.koin.core.component.KoinApiExtension' - ] - } -} dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.activity:activity-ktx:1.3.1' - implementation 'androidx.fragment:fragment-ktx:1.3.6' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-service:2.3.1' - implementation 'androidx.lifecycle:lifecycle-process:2.3.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.activity:activity-ktx:1.4.0' + implementation 'androidx.fragment:fragment-ktx:1.4.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-service:2.4.1' + implementation 'androidx.lifecycle:lifecycle-process:2.4.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' - implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'androidx.work:work-runtime-ktx:2.6.0' - implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'com.google.android.material:material:1.6.0-alpha02' //noinspection LifecycleAnnotationProcessorWithJava8 - kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1' + kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' - implementation 'androidx.room:room-runtime:2.3.0' - implementation 'androidx.room:room-ktx:2.3.0' - kapt 'androidx.room:room-compiler:2.3.0' + implementation 'androidx.room:room-runtime:2.4.1' + implementation 'androidx.room:room-ktx:2.4.1' + kapt 'androidx.room:room-compiler:2.4.1' - implementation 'com.squareup.okhttp3:okhttp:4.9.1' - implementation 'com.squareup.okio:okio:2.10.0' - implementation 'org.jsoup:jsoup:1.14.2' + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + implementation 'com.squareup.okio:okio:3.0.0' + implementation 'org.jsoup:jsoup:1.14.3' - implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0' - implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0' + implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1' + implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1' - implementation 'io.insert-koin:koin-android:3.1.2' - implementation 'io.coil-kt:coil-base:1.3.2' + implementation 'io.insert-koin:koin-android:3.1.5' + implementation 'io.coil-kt:coil-base:1.4.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' - implementation 'com.github.solkin:disk-lru-cache:1.3' + implementation 'com.github.solkin:disk-lru-cache:1.4' - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' testImplementation 'junit:junit:4.13.2' testImplementation 'com.google.truth:truth:1.1.3' - testImplementation 'org.json:json:20210307' - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' - testImplementation 'io.insert-koin:koin-test-junit4:3.1.2' + testImplementation 'org.json:json:20211205' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + testImplementation 'io.insert-koin:koin-test-junit4:3.1.5' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' - androidTestImplementation 'androidx.room:room-testing:2.3.0' + androidTestImplementation 'androidx.room:room-testing:2.4.1' androidTestImplementation 'com.google.truth:truth:1.1.3' } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 57fe6234e..fb3509dc2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,3 +1,4 @@ +-optimizationpasses 8 -dontobfuscate -assumenosideeffects class kotlin.jvm.internal.Intrinsics { public static void checkExpressionValueIsNotNull(...); @@ -5,9 +6,8 @@ public static void checkReturnedValueIsNotNull(...); public static void checkFieldIsNotNull(...); public static void checkParameterIsNotNull(...); + public static void checkNotNullParameter(...); } +-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep class org.koitharu.kotatsu.core.db.entity.* { *; } --keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository { - public (...); -} -dontwarn okhttp3.internal.platform.ConscryptPlatform \ No newline at end of file diff --git a/app/src/debug/res/menu/opt_settings.xml b/app/src/debug/res/menu/opt_settings.xml new file mode 100644 index 000000000..f54e09cc0 --- /dev/null +++ b/app/src/debug/res/menu/opt_settings.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/debug/res/values/bools.xml b/app/src/debug/res/values/bools.xml new file mode 100644 index 000000000..fc8a7be3d --- /dev/null +++ b/app/src/debug/res/values/bools.xml @@ -0,0 +1,4 @@ + + + false + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e18e81392..a666a295e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -20,8 +19,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme" - android:usesCleartextTraffic="true" + android:theme="@style/Theme.Kotatsu" + android:networkSecurityConfig="@xml/network_security_config" tools:ignore="UnusedAttribute"> + android:value="org.koitharu.kotatsu.ui.search.SearchActivity" /> + + form: Map, ): Response { val body = FormBody.Builder() form.forEach { (k, v) -> @@ -38,7 +44,7 @@ open class MangaLoaderContext( suspend fun httpPost( url: String, - payload: String + payload: String, ): Response { val body = FormBody.Builder() payload.split('&').forEach { @@ -55,10 +61,24 @@ open class MangaLoaderContext( return okHttp.newCall(request.build()).await() } - open fun getSettings(source: MangaSource) = SourceSettings(get(), source) - - private companion object { - - private const val SCHEME_HTTP = "http" + suspend fun graphQLQuery(endpoint: String, query: String): JSONObject { + val body = JSONObject() + body.put("operationName", null) + body.put("variables", JSONObject()) + body.put("query", "{${query}}") + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toString().toRequestBody(mediaType) + val request = Request.Builder() + .post(requestBody) + .url(endpoint) + val json = okHttp.newCall(request.build()).await().parseJson() + json.optJSONArray("errors")?.let { + if (it.length() != 0) { + throw GraphQLException(it) + } + } + return json } + + open fun getSettings(source: MangaSource) = SourceSettings(get(), source) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt index 12771e5ea..0223031d9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt @@ -8,7 +8,6 @@ object MangaProviderFactory { fun getSources(settings: AppSettings, includeHidden: Boolean): List { val list = MangaSource.values().toList() - MangaSource.LOCAL val order = settings.sourcesOrder - val hidden = settings.hiddenSources val sorted = list.sortedBy { x -> val e = order.indexOf(x.ordinal) if (e == -1) order.size + x.ordinal else e @@ -16,6 +15,7 @@ object MangaProviderFactory { return if (includeHidden) { sorted } else { + val hidden = settings.hiddenSources sorted.filterNot { x -> x.name in hidden } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt index 83bd5dc25..25b0ac52f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt @@ -3,7 +3,9 @@ package org.koitharu.kotatsu.base.domain import android.graphics.BitmapFactory import android.net.Uri import android.util.Size -import androidx.annotation.WorkerThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.koin.core.component.KoinComponent @@ -11,6 +13,7 @@ import org.koin.core.component.get import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.medianOrNull @@ -23,18 +26,18 @@ object MangaUtils : KoinComponent { * Automatic determine type of manga by page size * @return ReaderMode.WEBTOON if page is wide */ - @WorkerThread - @Suppress("BlockingMethodInNonBlockingContext") suspend fun determineMangaIsWebtoon(pages: List): Boolean? { try { val page = pages.medianOrNull() ?: return null - val url = page.source.repository.getPageUrl(page) + val url = MangaRepository(page.source).getPageUrl(page) val uri = Uri.parse(url) val size = if (uri.scheme == "cbz") { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - getBitmapSize(it) + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + getBitmapSize(it) + } } } else { val client = get() @@ -45,7 +48,9 @@ object MangaUtils : KoinComponent { .cacheControl(CacheUtils.CONTROL_DISABLED) .build() client.newCall(request).await().use { - getBitmapSize(it.body?.byteStream()) + withContext(Dispatchers.IO) { + getBitmapSize(it.body?.byteStream()) + } } } return size.width * 2 < size.height diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt index 080aa9d91..8077ea928 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt @@ -5,9 +5,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.CallSuper -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder abstract class AlertDialogFragment : DialogFragment() { @@ -20,7 +20,7 @@ abstract class AlertDialogFragment : DialogFragment() { val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext()) val binding = onInflateView(inflater, null) viewBinding = binding - return AlertDialog.Builder(requireContext(), theme) + return MaterialAlertDialogBuilder(requireContext(), theme) .setView(binding.root) .also(::onBuildDialog) .create() @@ -38,7 +38,7 @@ abstract class AlertDialogFragment : DialogFragment() { super.onDestroyView() } - open fun onBuildDialog(builder: AlertDialog.Builder) = Unit + open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit protected fun bindingOrNull(): B? = viewBinding diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt index 5e91880a1..32ff25748 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt @@ -35,8 +35,9 @@ abstract class BaseActivity : AppCompatActivity(), OnApplyWindo private var lastInsets: Insets = Insets.NONE override fun onCreate(savedInstanceState: Bundle?) { - if (get().isAmoledTheme) { - setTheme(R.style.AppTheme_AMOLED) + when { + get().isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED) + get().isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet) } super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt index ef059eff7..c452bd1ce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.databinding.DialogCheckboxBinding class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) : @@ -17,7 +18,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context)) - private val delegate = AlertDialog.Builder(context) + private val delegate = MaterialAlertDialogBuilder(context) .setView(binding.root) fun setTitle(@StringRes titleResId: Int): Builder { diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt index 74a019a82..a72672272 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt @@ -13,7 +13,6 @@ import org.koitharu.kotatsu.databinding.ItemStorageBinding import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.inflate -import org.koitharu.kotatsu.utils.ext.longHashCode import java.io.File class StorageSelectDialog private constructor(private val delegate: AlertDialog) : @@ -24,16 +23,16 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog) class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) { private val adapter = VolumesAdapter(context) - private val delegate = AlertDialog.Builder(context) + private val delegate = MaterialAlertDialogBuilder(context) init { if (adapter.isEmpty) { delegate.setMessage(R.string.cannot_find_available_storage) } else { - val checked = adapter.volumes.indexOfFirst { + adapter.selectedItemPosition = adapter.volumes.indexOfFirst { it.first.canonicalPath == defaultValue?.canonicalPath } - delegate.setSingleChoiceItems(adapter, checked) { d, i -> + delegate.setAdapter(adapter) { d, i -> listener.onStorageSelected(adapter.getItem(i).first) d.dismiss() } @@ -60,12 +59,16 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog) private class VolumesAdapter(context: Context) : BaseAdapter() { + var selectedItemPosition: Int = -1 val volumes = getAvailableVolumes(context) override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: parent.inflate(R.layout.item_storage) + val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also { + view.tag = it + } val item = volumes[position] - val binding = ItemStorageBinding.bind(view) + binding.imageViewIndicator.isChecked = selectedItemPosition == position binding.textViewTitle.text = item.second binding.textViewSubtitle.text = item.first.path return view @@ -73,23 +76,21 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog) override fun getItem(position: Int): Pair = volumes[position] - override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode() + override fun getItemId(position: Int) = position.toLong() override fun getCount() = volumes.size + override fun hasStableIds() = true + + private fun getAvailableVolumes(context: Context): List> { + return LocalMangaRepository.getAvailableStorageDirs(context).map { + it to it.getStorageName(context) + } + } } fun interface OnStorageSelectListener { fun onStorageSelected(file: File) } - - private companion object { - - fun getAvailableVolumes(context: Context): List> { - return LocalMangaRepository.getAvailableStorageDirs(context).map { - it to it.getStorageName(context) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt index b645d8cd8..4b5c02ca6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt @@ -10,7 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.databinding.DialogInputBinding class TextInputDialog private constructor( - private val delegate: AlertDialog + private val delegate: AlertDialog, ) : DialogInterface by delegate { fun show() = delegate.show() @@ -19,7 +19,7 @@ class TextInputDialog private constructor( private val binding = DialogInputBinding.inflate(LayoutInflater.from(context)) - private val delegate = AlertDialog.Builder(context) + private val delegate = MaterialAlertDialogBuilder(context) .setView(binding.root) fun setTitle(@StringRes titleResId: Int): Builder { @@ -33,7 +33,7 @@ class TextInputDialog private constructor( } fun setHint(@StringRes hintResId: Int): Builder { - binding.inputLayout.hint = binding.root.context.getString(hintResId) + binding.inputEdit.hint = binding.root.context.getString(hintResId) return this } @@ -64,7 +64,7 @@ class TextInputDialog private constructor( listener: (DialogInterface, String) -> Unit ): Builder { delegate.setPositiveButton(textId) { dialog, _ -> - listener(dialog, binding.inputEdit.text.toString().orEmpty()) + listener(dialog, binding.inputEdit.text?.toString().orEmpty()) } return this } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt deleted file mode 100644 index 9e238487a..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.koitharu.kotatsu.base.ui.list - -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import java.util.* - -@Deprecated("") -class AdapterUpdater(oldList: List, newList: List, getId: (T) -> Long) { - - private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - getId(oldList[oldItemPosition]) == getId(newList[newItemPosition]) - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - Objects.equals(oldList[oldItemPosition], newList[newItemPosition]) - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - }) - - operator fun invoke(adapter: RecyclerView.Adapter<*>) { - diff.dispatchUpdatesTo(adapter) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt deleted file mode 100644 index b878524ca..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.base.ui.list - -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding -import org.koin.core.component.KoinComponent - -@Deprecated("") -abstract class BaseViewHolder protected constructor(val binding: B) : - RecyclerView.ViewHolder(binding.root), KoinComponent { - - var boundData: T? = null - private set - - val context get() = itemView.context!! - - fun bind(data: T, extra: E) { - boundData = data - onBind(data, extra) - } - - fun requireData(): T { - return boundData ?: throw IllegalStateException("Calling requireData() before bind()") - } - - open fun onRecycled() = Unit - - abstract fun onBind(data: T, extra: E) -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt new file mode 100644 index 000000000..2d91e71c7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt @@ -0,0 +1,87 @@ +package org.koitharu.kotatsu.base.ui.list.decor + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import androidx.core.content.res.getColorOrThrow +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R as materialR + +@SuppressLint("PrivateResource") +abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val bounds = Rect() + private val thickness: Int + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + init { + paint.style = Paint.Style.FILL + val ta = context.obtainStyledAttributes( + null, + materialR.styleable.MaterialDivider, + materialR.attr.materialDividerStyle, + materialR.style.Widget_Material3_MaterialDivider, + ) + paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor) + thickness = ta.getDimensionPixelSize( + materialR.styleable.MaterialDivider_dividerThickness, + context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness), + ) + ta.recycle() + } + + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + outRect.set(0, thickness, 0, 0) + } + + // TODO implement for horizontal lists on demand + override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) { + if (parent.layoutManager == null || thickness == 0) { + return + } + canvas.save() + val left: Float + val right: Float + if (parent.clipToPadding) { + left = parent.paddingLeft.toFloat() + right = (parent.width - parent.paddingRight).toFloat() + canvas.clipRect( + left, + parent.paddingTop.toFloat(), + right, + (parent.height - parent.paddingBottom).toFloat() + ) + } else { + left = 0f + right = parent.width.toFloat() + } + + var previous: RecyclerView.ViewHolder? = null + for (child in parent.children) { + val holder = parent.getChildViewHolder(child) + if (previous != null && shouldDrawDivider(previous, holder)) { + parent.getDecoratedBoundsWithMargins(child, bounds) + val top: Float = bounds.top + child.translationY + val bottom: Float = top + thickness + canvas.drawRect(left, top, right, bottom, paint) + } + previous = holder + } + canvas.restore() + } + + protected abstract fun shouldDrawDivider( + above: RecyclerView.ViewHolder, + below: RecyclerView.ViewHolder, + ): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/ItemTypeDividerDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/ItemTypeDividerDecoration.kt deleted file mode 100644 index a887d8311..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/ItemTypeDividerDecoration.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.base.ui.list.decor - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Rect -import android.view.View -import androidx.core.view.children -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.utils.ext.getThemeDrawable -import kotlin.math.roundToInt - -class ItemTypeDividerDecoration(context: Context) : RecyclerView.ItemDecoration() { - - private val divider = context.getThemeDrawable(android.R.attr.listDivider) - private val bounds = Rect() - - override fun getItemOffsets( - outRect: Rect, view: View, - parent: RecyclerView, state: RecyclerView.State - ) { - outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0) - } - - override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) { - if (parent.layoutManager == null || divider == null) { - return - } - val adapter = parent.adapter ?: return - canvas.save() - val left: Int - val right: Int - if (parent.clipToPadding) { - left = parent.paddingLeft - right = parent.width - parent.paddingRight - canvas.clipRect( - left, parent.paddingTop, right, - parent.height - parent.paddingBottom - ) - } else { - left = 0 - right = parent.width - } - - var lastItemType = -1 - for (child in parent.children) { - val itemType = adapter.getItemViewType(parent.getChildAdapterPosition(child)) - if (lastItemType != -1 && itemType != lastItemType) { - parent.getDecoratedBoundsWithMargins(child, bounds) - val top: Int = bounds.top + child.translationY.roundToInt() - val bottom: Int = top + divider.intrinsicHeight - divider.setBounds(left, top, right, bottom) - divider.draw(canvas) - } - lastItemType = itemType - } - canvas.restore() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt deleted file mode 100644 index d8181e5c7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt +++ /dev/null @@ -1,96 +0,0 @@ -package org.koitharu.kotatsu.base.ui.list.decor - -import android.graphics.Canvas -import android.graphics.Rect -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.core.view.children -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.utils.ext.inflate -import kotlin.math.max - -/** - * https://github.com/paetztm/recycler_view_headers - */ -class SectionItemDecoration( - private val isSticky: Boolean, - private val callback: Callback -) : RecyclerView.ItemDecoration() { - - private var headerView: TextView? = null - private var headerOffset: Int = 0 - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - if (headerOffset == 0) { - headerOffset = parent.resources.getDimensionPixelSize(R.dimen.header_height) - } - val pos = parent.getChildAdapterPosition(view) - outRect.set(0, if (callback.isSection(pos)) headerOffset else 0, 0, 0) - } - - override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - super.onDrawOver(c, parent, state) - val textView = headerView ?: parent.inflate(R.layout.item_filter_header).also { - headerView = it - } - fixLayoutSize(textView, parent) - - for (child in parent.children) { - val pos = parent.getChildAdapterPosition(child) - if (callback.isSection(pos)) { - textView.text = callback.getSectionTitle(pos) ?: continue - c.save() - if (isSticky) { - c.translate( - 0f, - max(0f, (child.top - textView.height).toFloat()) - ) - } else { - c.translate( - 0f, - (child.top - textView.height).toFloat() - ) - } - textView.draw(c) - c.restore() - } - } - } - - /** - * Measures the header view to make sure its size is greater than 0 and will be drawn - * https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations - */ - private fun fixLayoutSize(view: View, parent: ViewGroup) { - val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) - val heightSpec = - View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) - - val childWidth = ViewGroup.getChildMeasureSpec( - widthSpec, - parent.paddingLeft + parent.paddingRight, - view.layoutParams.width - ) - val childHeight = ViewGroup.getChildMeasureSpec( - heightSpec, - parent.paddingTop + parent.paddingBottom, - view.layoutParams.height - ) - view.measure(childWidth, childHeight) - view.layout(0, 0, view.measuredWidth, view.measuredHeight) - } - - interface Callback { - - fun isSection(position: Int): Boolean - - fun getSectionTitle(position: Int): CharSequence? - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt new file mode 100644 index 000000000..77d4acc2c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.annotation.AttrRes +import androidx.annotation.IdRes +import androidx.core.view.children +import com.google.android.material.button.MaterialButton + +class CheckableButtonGroup @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener { + + var onCheckedChangeListener: OnCheckedChangeListener? = null + + override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) { + if (child is MaterialButton) { + child.setOnClickListener(this) + } + super.addView(child, index, params) + } + + override fun onClick(v: View) { + setCheckedId(v.id) + } + + fun setCheckedId(@IdRes viewRes: Int) { + children.forEach { + (it as? MaterialButton)?.isChecked = it.id == viewRes + } + onCheckedChangeListener?.onCheckedChanged(this, viewRes) + } + + fun interface OnCheckedChangeListener { + fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt index 472f95a78..9c8366293 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt @@ -1,12 +1,19 @@ package org.koitharu.kotatsu.base.ui.widgets import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator import android.util.AttributeSet import android.widget.Checkable +import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageView +import androidx.core.os.ParcelCompat class CheckableImageView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, ) : AppCompatImageView(context, attrs, defStyleAttr), Checkable { private var isCheckedInternal = false @@ -14,20 +21,6 @@ class CheckableImageView @JvmOverloads constructor( var onCheckedChangeListener: OnCheckedChangeListener? = null - init { - setOnClickListener { - toggle() - } - } - - fun setOnCheckedChangeListener(listener: (Boolean) -> Unit) { - onCheckedChangeListener = object : OnCheckedChangeListener { - override fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) { - listener(isChecked) - } - } - } - override fun isChecked() = isCheckedInternal override fun toggle() { @@ -49,18 +42,54 @@ class CheckableImageView @JvmOverloads constructor( override fun onCreateDrawableState(extraSpace: Int): IntArray { val state = super.onCreateDrawableState(extraSpace + 1) if (isCheckedInternal) { - mergeDrawableStates(state, CHECKED_STATE_SET) + mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked)) } return state } + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() ?: return null + return SavedState(superState, isChecked) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + isChecked = state.isChecked + } else { + super.onRestoreInstanceState(state) + } + } + fun interface OnCheckedChangeListener { fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) } - private companion object { + private class SavedState : BaseSavedState { - private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked) + val isChecked: Boolean + + constructor(superState: Parcelable, checked: Boolean) : super(superState) { + isChecked = checked + } + + constructor(source: Parcel) : super(source) { + isChecked = ParcelCompat.readBoolean(source) + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + ParcelCompat.writeBoolean(out, isChecked) + } + + companion object { + @JvmField + val CREATOR: Creator = object : Creator { + override fun createFromParcel(`in`: Parcel) = SavedState(`in`) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt index eb867ec0f..7d8730c33 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt @@ -4,7 +4,6 @@ import android.content.Context import android.util.AttributeSet import android.view.View.OnClickListener import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat import androidx.core.view.children import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable @@ -77,7 +76,6 @@ class ChipsView @JvmOverloads constructor( val chip = Chip(context) val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) chip.setChipDrawable(drawable) - chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary)) chip.isCloseIconVisible = onChipCloseClickListener != null chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setEnsureMinTouchTargetSize(false) @@ -96,11 +94,32 @@ class ChipsView @JvmOverloads constructor( } } - data class ChipModel( + class ChipModel( @DrawableRes val icon: Int, val title: CharSequence, val data: Any? = null - ) + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChipModel + + if (icon != other.icon) return false + if (title != other.title) return false + if (data != other.data) return false + + return true + } + + override fun hashCode(): Int { + var result = icon + result = 31 * result + title.hashCode() + result = 31 * result + data.hashCode() + return result + } + } fun interface OnChipClickListener { diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt index 049452f4f..a4e7bf748 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt @@ -6,40 +6,33 @@ import android.widget.LinearLayout import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.withStyledAttributes import org.koitharu.kotatsu.R +import kotlin.math.roundToInt class CoverImageView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : AppCompatImageView(context, attrs, defStyleAttr) { private var orientation: Int = HORIZONTAL init { context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) { - orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL) + orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val desiredWidth: Int + val desiredHeight: Int if (orientation == VERTICAL) { - val originalHeight = MeasureSpec.getSize(heightMeasureSpec) - super.onMeasure( - MeasureSpec.makeMeasureSpec( - (originalHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt(), - MeasureSpec.EXACTLY - ), - MeasureSpec.makeMeasureSpec(originalHeight, MeasureSpec.EXACTLY) - ) + desiredHeight = measuredHeight + desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt() } else { - val originalWidth = MeasureSpec.getSize(widthMeasureSpec) - super.onMeasure( - MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec( - (originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt(), - MeasureSpec.EXACTLY - ) - ) + desiredWidth = measuredWidth + desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt() } + setMeasuredDimension(desiredWidth, desiredHeight) } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt new file mode 100644 index 000000000..8f41c695d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.postDelayed +import org.koitharu.kotatsu.R + +/** + * A custom snackbar implementation allowing more control over placement and entry/exit animations. + * + * Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google. + * + * https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt + */ +class FadingSnackbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val message: TextView + private val action: Button + + init { + val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true) + message = view.findViewById(R.id.snackbar_text) + action = view.findViewById(R.id.snackbar_action) + } + + fun dismiss() { + if (visibility == VISIBLE && alpha == 1f) { + animate() + .alpha(0f) + .withEndAction { visibility = GONE } + .duration = EXIT_DURATION + } + } + + fun show( + messageText: CharSequence? = null, + @StringRes actionId: Int? = null, + longDuration: Boolean = true, + actionClick: () -> Unit = { dismiss() }, + dismissListener: () -> Unit = { } + ) { + message.text = messageText + if (actionId != null) { + action.run { + visibility = VISIBLE + text = context.getString(actionId) + setOnClickListener { + actionClick() + } + } + } else { + action.visibility = GONE + } + alpha = 0f + visibility = VISIBLE + animate() + .alpha(1f) + .duration = ENTER_DURATION + val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION + postDelayed(showDuration) { + dismiss() + dismissListener() + } + } + + companion object { + private const val ENTER_DURATION = 300L + private const val EXIT_DURATION = 200L + private const val SHORT_DURATION = 1_500L + private const val LONG_DURATION = 2_750L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt index e5be6808c..497d18499 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -92,8 +92,16 @@ class BrowserActivity : BaseActivity(), BrowserCallback } override fun onWindowInsetsChanged(insets: Insets) { - binding.appbar.updatePadding(top = insets.top) - binding.webView.updatePadding(bottom = insets.bottom) + binding.appbar.updatePadding( + top = insets.top, + left = insets.left, + right = insets.right, + ) + binding.root.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt index 641a17a6a..8c1de2625 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt @@ -8,9 +8,9 @@ import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebSettings -import androidx.appcompat.app.AlertDialog import androidx.core.view.isInvisible import androidx.fragment.app.setFragmentResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koin.android.ext.android.get import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.core.network.UserAgentInterceptor @@ -52,7 +52,7 @@ class CloudFlareDialog : AlertDialogFragment(), Cloud super.onDestroyView() } - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setNegativeButton(android.R.string.cancel, null) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt index c0af8d788..d0ec8a8cb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.backup import android.content.Context import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.json.JSONArray import org.koitharu.kotatsu.R @@ -33,8 +34,7 @@ class BackupArchive(file: File) : MutableZipFile(file) { private const val DIR_BACKUPS = "backups" - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) { + suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) { val dir = context.run { getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt index 5de7d7e01..407365d3c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONArray -data class BackupEntry( +class BackupEntry( val name: String, val data: JSONArray ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index d20b3b979..25ec915a3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState import org.koitharu.kotatsu.core.model.MangaTag @Entity(tableName = "manga") -data class MangaEntity( +class MangaEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "title") val title: String, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt index 7eb68a0ef..99e94b25a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt @@ -14,7 +14,7 @@ import androidx.room.PrimaryKey onDelete = ForeignKey.CASCADE )] ) -data class MangaPrefsEntity( +class MangaPrefsEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "mode") val mode: Int diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt index b96ff5d83..ea7f0b3d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt @@ -20,7 +20,7 @@ import androidx.room.ForeignKey ) ] ) -data class MangaTagsEntity( +class MangaTagsEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "tag_id", index = true) val tagId: Long ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt index 950fbdf33..ad80b0beb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt @@ -5,7 +5,7 @@ import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.utils.ext.mapToSet -data class MangaWithTags( +class MangaWithTags( @Embedded val manga: MangaEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 1af937d06..3e25b0bed 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.utils.ext.longHashCode @Entity(tableName = "tags") -data class TagEntity( +class TagEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "tag_id") val id: Long, @ColumnInfo(name = "title") val title: String, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt index 57a01888f..1b5c41492 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt @@ -15,7 +15,7 @@ import androidx.room.PrimaryKey ) ] ) -data class TrackEntity( +class TrackEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "chapters_total") val totalChapters: Int, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt index e73d34d74..a4f608c4d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt @@ -15,7 +15,7 @@ import androidx.room.PrimaryKey ) ] ) -data class TrackLogEntity( +class TrackLogEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt index e32bcde6a..263e91938 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem import org.koitharu.kotatsu.utils.ext.mapToSet import java.util.* -data class TrackLogWithManga( +class TrackLogWithManga( @Embedded val trackLog: TrackLogEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt new file mode 100644 index 000000000..92cdb325f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.core.exceptions + +import org.json.JSONArray +import org.koitharu.kotatsu.utils.ext.map + +class GraphQLException(private val errors: JSONArray) : RuntimeException() { + + val messages = errors.map { + it.getString("message") + } + + override val message: String + get() = messages.joinToString("\n") +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt index 758dd5f46..09557cb47 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt @@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github import java.util.* -data class VersionId( +class VersionId( val major: Int, val minor: Int, val build: Int, val variantType: String, - val variantNumber: Int + val variantNumber: Int, ) : Comparable { override fun compareTo(other: VersionId): Int { @@ -30,10 +30,34 @@ data class VersionId( return variantNumber.compareTo(other.variantNumber) } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VersionId + + if (major != other.major) return false + if (minor != other.minor) return false + if (build != other.build) return false + if (variantType != other.variantType) return false + if (variantNumber != other.variantNumber) return false + + return true + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + build + result = 31 * result + variantType.hashCode() + result = 31 * result + variantNumber + return result + } + companion object { private fun variantWeight(variantType: String) = - when (variantType.toLowerCase(Locale.ROOT)) { + when (variantType.lowercase(Locale.ROOT)) { "a", "alpha" -> 1 "b", "beta" -> 2 "rc" -> 4 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt index 0682eadc6..9b0533722 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt @@ -9,6 +9,13 @@ data class MangaChapter( val name: String, val number: Int, val url: String, - val branch: String? = null, - val source: MangaSource -) : Parcelable \ No newline at end of file + val scanlator: String?, + val uploadDate: Long, + val branch: String?, + val source: MangaSource, +) : Parcelable, Comparable { + + override fun compareTo(other: MangaChapter): Int { + return number.compareTo(other.number) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt index eaf04e6c7..95f736f2b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt @@ -10,5 +10,5 @@ data class MangaHistory( val updatedAt: Date, val chapterId: Long, val page: Int, - val scroll: Int + val scroll: Int, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt index a85dc14bc..7576e2da4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt @@ -8,6 +8,6 @@ data class MangaPage( val id: Long, val url: String, val referer: String, - val preview: String? = null, - val source: MangaSource + val preview: String?, + val source: MangaSource, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt index f881a59f0..a9fc84da5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -2,48 +2,38 @@ package org.koitharu.kotatsu.core.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import org.koin.core.context.GlobalContext -import org.koin.core.error.NoBeanDefFoundException -import org.koin.core.qualifier.named -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.site.* -import org.koitharu.kotatsu.local.domain.LocalMangaRepository @Suppress("SpellCheckingInspection") @Parcelize enum class MangaSource( val title: String, val locale: String?, - val cls: Class ) : Parcelable { - LOCAL("Local", null, LocalMangaRepository::class.java), - READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java), - MINTMANGA("MintManga", "ru", MintMangaRepository::class.java), - SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java), - MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java), - DESUME("Desu.me", "ru", DesuMeRepository::class.java), - HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java), - YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java), - MANGATOWN("MangaTown", "en", MangaTownRepository::class.java), - MANGALIB("MangaLib", "ru", MangaLibRepository::class.java), + LOCAL("Local", null), + READMANGA_RU("ReadManga", "ru"), + MINTMANGA("MintManga", "ru"), + SELFMANGA("SelfManga", "ru"), + MANGACHAN("Манга-тян", "ru"), + DESUME("Desu.me", "ru"), + HENCHAN("Хентай-тян", "ru"), + YAOICHAN("Яой-тян", "ru"), + MANGATOWN("MangaTown", "en"), + MANGALIB("MangaLib", "ru"), // NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java), - MANGAREAD("MangaRead", "en", MangareadRepository::class.java), - REMANGA("Remanga", "ru", RemangaRepository::class.java), - HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java), - ANIBEL("Anibel", "be", AnibelRepository::class.java), - NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java), - NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java), - NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java), - NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java), - NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java), - NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java), - NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java), - EXHENTAI("ExHentai", null, ExHentaiRepository::class.java) + MANGAREAD("MangaRead", "en"), + REMANGA("Remanga", "ru"), + HENTAILIB("HentaiLib", "ru"), + ANIBEL("Anibel", "be"), + NINEMANGA_EN("NineManga English", "en"), + NINEMANGA_ES("NineManga Español", "es"), + NINEMANGA_RU("NineManga Русский", "ru"), + NINEMANGA_DE("NineManga Deutsch", "de"), + NINEMANGA_IT("NineManga Italiano", "it"), + NINEMANGA_BR("NineManga Brasil", "pt"), + NINEMANGA_FR("NineManga Français", "fr"), + EXHENTAI("ExHentai", null), + MANGAOWL("MangaOwl", "en"), + MANGADEX("MangaDex", null), ; - - @get:Throws(NoBeanDefFoundException::class) - @Deprecated("") - val repository: MangaRepository - get() = GlobalContext.get().get(named(this)) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt index be5aa63d2..fe10cb690 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt @@ -7,5 +7,5 @@ import kotlinx.parcelize.Parcelize data class MangaTag( val title: String, val key: String, - val source: MangaSource + val source: MangaSource, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index afd162a81..18e5bbf55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -6,4 +6,5 @@ object CommonHeaders { const val USER_AGENT = "User-Agent" const val ACCEPT = "Accept" const val CONTENT_DISPOSITION = "Content-Disposition" + const val COOKIE = "Cookie" } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt index 4d5c26d3b..5627e8637 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -7,7 +7,9 @@ import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.utils.CacheUtils +import org.koitharu.kotatsu.utils.DownloadManagerHelper import java.util.concurrent.TimeUnit val networkModule @@ -28,4 +30,6 @@ val networkModule } }.build() } + factory { DownloadManagerHelper(get(), get()) } + single { MangaLoaderContext(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt new file mode 100644 index 000000000..44f1ca040 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.core.parser + +import android.net.Uri +import coil.map.Mapper +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.core.model.MangaSource + +class FaviconMapper() : Mapper { + + override fun map(data: Uri): HttpUrl { + val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) + val repo = MangaRepository(mangaSource) as RemoteMangaRepository + return repo.getFaviconUrl().toHttpUrl() + } + + override fun handles(data: Uri) = data.scheme == "favicon" +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt index c8904b2a8..45103954f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.* interface MangaRepository { + val source: MangaSource + val sortOrders: Set suspend fun getList2( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt index 361bdfa61..944bd1ac6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt @@ -2,15 +2,12 @@ package org.koitharu.kotatsu.core.parser import org.koin.core.qualifier.named import org.koin.dsl.module -import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.site.* val parserModule get() = module { - single { MangaLoaderContext(get(), get()) } - factory(named(MangaSource.READMANGA_RU)) { ReadmangaRepository(get()) } factory(named(MangaSource.MINTMANGA)) { MintMangaRepository(get()) } factory(named(MangaSource.SELFMANGA)) { SelfMangaRepository(get()) } @@ -33,4 +30,6 @@ val parserModule factory(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) } factory(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) } factory(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) } + factory(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) } + factory(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index ef4c55817..3505c53b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.MangaPage -import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.prefs.SourceSettings @@ -12,8 +11,6 @@ abstract class RemoteMangaRepository( protected val loaderContext: MangaLoaderContext ) : MangaRepository { - protected abstract val source: MangaSource - protected abstract val defaultDomain: String private val conf by lazy { @@ -29,6 +26,8 @@ abstract class RemoteMangaRepository( override suspend fun getTags(): Set = emptySet() + open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico" + open fun onCreatePreferences(map: MutableMap) { map[SourceSettings.KEY_DOMAIN] = defaultDomain } @@ -53,8 +52,10 @@ abstract class RemoteMangaRepository( if (subdomain != null) { append(subdomain) append('.') + append(conf.getDomain(defaultDomain).removePrefix("www.")) + } else { + append(conf.getDomain(defaultDomain)) } - append(conf.getDomain(defaultDomain)) append(this@withDomain) } else -> this diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt index bb3622afc..e71378eec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt @@ -1,9 +1,14 @@ package org.koitharu.kotatsu.core.parser.site +import androidx.collection.ArraySet +import org.json.JSONArray +import org.json.JSONObject import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.mapIndexed +import org.koitharu.kotatsu.utils.ext.stringIterator import java.util.* class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { @@ -16,163 +21,243 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor SortOrder.NEWEST ) + override fun getFaviconUrl(): String { + return "https://cdn.${getDomain()}/favicons/favicon.png" + } + override suspend fun getList2( offset: Int, query: String?, tags: Set?, - sortOrder: SortOrder? + sortOrder: SortOrder?, ): List { if (!query.isNullOrEmpty()) { - return if (offset == 0) search(query) else emptyList() + return if (offset == 0) { + search(query) + } else { + emptyList() + } } - val page = (offset / 12f).toIntUp().inc() - val link = when { - tags.isNullOrEmpty() -> "/manga?page=$page".withDomain() - else -> tags.joinToString( - prefix = "/manga?", - postfix = "&page=$page", - separator = "&", - ) { tag -> "genre[]=${tag.key}" }.withDomain() - } - val doc = loaderContext.httpGet(link).parseHtml() - val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root") - val items = root.select("div.anime-card") - return items.mapNotNull { card -> - val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null - val status = card.select("tr")[2].text() - val fullTitle = card.selectFirst("h1.anime-card-title")?.text() - ?.substringBeforeLast('[') ?: return@mapNotNull null - val titleParts = fullTitle.splitTwoParts('/') + val filters = tags?.takeUnless { it.isEmpty() }?.joinToString( + separator = ",", + prefix = "genres: [", + postfix = "]" + ) { "\"it.key\"" }.orEmpty() + val array = apiCall( + """ + getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) { + docs { + mediaId + title { + be + alt + } + rating + poster + genres + slug + mediaType + status + } + } + """.trimIndent() + ).getJSONObject("getMediaList").getJSONArray("docs") + return array.map { jo -> + val mediaId = jo.getString("mediaId") + val title = jo.getJSONObject("title") + val href = "${jo.getString("mediaType")}/${jo.getString("slug")}" Manga( - id = generateUid(href), - title = titleParts?.first?.trim() ?: fullTitle, - coverUrl = card.selectFirst("img")?.attr("data-src") - ?.withDomain().orEmpty(), - altTitle = titleParts?.second?.trim(), + id = generateUid(mediaId), + title = title.getString("be"), + coverUrl = jo.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + "?width=200&height=280", + altTitle = title.getString("alt").takeUnless(String::isEmpty), author = null, - rating = Manga.NO_RATING, + rating = jo.getDouble("rating").toFloat() / 10f, url = href, - publicUrl = href.withDomain(), - tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x -> - MangaTag( - title = x.text(), - key = x.attr("href").ifEmpty { - return@mapNotNull null - }.substringAfterLast("="), - source = source - ) - }, - state = when (status) { - "выпускаецца" -> MangaState.ONGOING - "завершанае" -> MangaState.FINISHED + publicUrl = "https://${getDomain()}/${href}", + tags = jo.getJSONArray("genres").mapToTags(), + state = when (jo.getString("status")) { + "ongoing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED else -> null }, - source = source + source = source, ) } } override suspend fun getDetails(manga: Manga): Manga { - val doc = loaderContext.httpGet(manga.publicUrl).parseHtml() - val root = doc.body().select("div.container") ?: parseFailed("Cannot find root") + val (type, slug) = manga.url.split('/') + val details = apiCall( + """ + media(mediaType: $type, slug: "$slug") { + mediaId + title { + be + alt + } + description { + be + } + status + poster + rating + genres + } + """.trimIndent() + ).getJSONObject("media") + val title = details.getJSONObject("title") + val poster = details.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + val chapters = apiCall( + """ + chapters(mediaId: "${details.getString("mediaId")}") { + id + chapter + released + } + """.trimIndent() + ).getJSONArray("chapters") return manga.copy( - description = root.select("div.manga-block.grid-12")[2].select("p").text(), - chapters = root.select("ul.series").flatMap { table -> - table.select("li") - }.map { it.selectFirst("a") }.mapIndexedNotNull { i, a -> - val href = a?.select("a")?.first()?.attr("href") - ?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null + title = title.getString("be"), + altTitle = title.getString("alt"), + coverUrl = "$poster?width=200&height=280", + largeCoverUrl = poster, + description = details.getJSONObject("description").getString("be"), + rating = details.getDouble("rating").toFloat() / 10f, + tags = details.getJSONArray("genres").mapToTags(), + state = when (details.getString("status")) { + "ongoing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED + else -> null + }, + chapters = chapters.map { jo -> + val number = jo.getInt("chapter") MangaChapter( - id = generateUid(href), - name = a.selectFirst("a")?.text().orEmpty(), - number = i + 1, - url = href, - source = source + id = generateUid(jo.getString("id")), + name = "Глава $number", + number = number, + url = "${manga.url}/read/$number", + scanlator = null, + uploadDate = jo.getLong("released"), + branch = null, + source = source, ) } ) } override suspend fun getPages(chapter: MangaChapter): List { - val fullUrl = chapter.url.withDomain() - val doc = loaderContext.httpGet(fullUrl).parseHtml() - val scripts = doc.select("script") - for (script in scripts) { - val data = script.html() - val pos = data.indexOf("dataSource") - if (pos == -1) { - continue - } - val json = data.substring(pos).substringAfter('[').substringBefore(']') - val domain = getDomain() - return json.split(",").mapNotNull { - it.trim() - .removeSurrounding('"', '\'') - .toRelativeUrl(domain) - .takeUnless(String::isBlank) - }.map { url -> - MangaPage( - id = generateUid(url), - url = url, - referer = fullUrl, - source = source - ) + val (_, slug, _, number) = chapter.url.split('/') + val chapterJson = apiCall( + """ + chapter(slug: "$slug", chapter: $number) { + id + images { + large + thumbnail + } } + """.trimIndent() + ).getJSONObject("chapter") + val pages = chapterJson.getJSONArray("images") + val chapterUrl = "https://${getDomain()}/${chapter.url}" + return pages.mapIndexed { i, jo -> + MangaPage( + id = generateUid("${chapter.url}/$i"), + url = jo.getString("large"), + referer = chapterUrl, + preview = jo.getString("thumbnail"), + source = source, + ) } - parseFailed("Pages list not found at ${chapter.url.withDomain()}") } override suspend fun getTags(): Set { - val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml() - val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums") - return root.select("p.menu-tags.tupe").mapToSet { p -> - val a = p.selectFirst("a") ?: parseFailed("a is null") - MangaTag( - title = a.text().toCamelCase(), - key = a.attr("data-name"), - source = source - ) - } + val json = apiCall( + """ + getFilters(mediaType: manga) { + genres + } + """.trimIndent() + ) + val array = json.getJSONObject("getFilters").getJSONArray("genres") + return array.mapToTags() } private suspend fun search(query: String): List { - val domain = getDomain() - val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml() - val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root") - val items = root.select("div.anime-card") - return items.mapNotNull { card -> - val href = card.select("a").attr("href") - val status = card.select("tr")[2].text() - val fullTitle = card.selectFirst("h1.anime-card-title")?.text() - ?.substringBeforeLast('[') ?: return@mapNotNull null - val titleParts = fullTitle.splitTwoParts('/') + val json = apiCall( + """ + search(query: "$query", limit: 40) { + id + title { + be + en + } + poster + url + type + } + """.trimIndent() + ) + val array = json.getJSONArray("search") + return array.map { jo -> + val mediaId = jo.getString("id") + val title = jo.getJSONObject("title") + val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}" Manga( - id = generateUid(href), - title = titleParts?.first?.trim() ?: fullTitle, - coverUrl = card.selectFirst("img")?.attr("src") - ?.withDomain().orEmpty(), - altTitle = titleParts?.second?.trim(), + id = generateUid(mediaId), + title = title.getString("be"), + coverUrl = jo.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + "?width=200&height=280", + altTitle = title.getString("en").takeUnless(String::isEmpty), author = null, rating = Manga.NO_RATING, url = href, - publicUrl = href.withDomain(), - tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x -> - MangaTag( - title = x.text(), - key = x.attr("href").ifEmpty { - return@mapNotNull null - }.substringAfterLast("="), - source = source - ) - }, - state = when (status) { - "выпускаецца" -> MangaState.ONGOING - "завершанае" -> MangaState.FINISHED - else -> null - }, - source = source + publicUrl = "https://${getDomain()}/${href}", + tags = emptySet(), + state = null, + source = source, ) } } + private suspend fun apiCall(request: String): JSONObject { + return loaderContext.graphQLQuery("https://api.${getDomain()}/graphql", request) + .getJSONObject("data") + } + + private fun JSONArray.mapToTags(): Set { + + fun toTitle(slug: String): String { + val builder = StringBuilder(slug) + var capitalize = true + for ((i, c) in builder.withIndex()) { + when { + c == '-' -> { + builder.setCharAt(i, ' ') + capitalize = true + } + capitalize -> { + builder.setCharAt(i, c.uppercaseChar()) + capitalize = false + } + } + } + return builder.toString() + } + + val result = ArraySet(length()) + stringIterator().forEach { + result.add( + MangaTag( + title = toTitle(it), + key = it, + source = source, + ) + ) + } + return result + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt index 629082010..82a0a3268 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt @@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository( @@ -76,19 +77,21 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() val root = doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root") + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) return manga.copy( description = root.getElementById("description")?.html()?.substringBeforeLast(" - table.select("div.manga2") - }.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a -> - val href = a?.relUrl("href") ?: return@mapIndexedNotNull null + chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr -> + val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null MangaChapter( id = generateUid(href), - name = a.text().trim(), + name = tr.selectFirst("a")?.text().orEmpty(), number = i + 1, url = href, - source = source + scanlator = null, + branch = null, + uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()), + source = source, ) } ) @@ -116,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe MangaPage( id = generateUid(url), url = url, + preview = null, referer = fullUrl, - source = source + source = source, ) } } @@ -154,4 +158,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe SortOrder.NEWEST -> "datedesc" else -> "favdesc" } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt index 73b223b82..0b5fd9b65 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt @@ -93,12 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor description = json.getString("description"), chapters = chaptersList.mapIndexed { i, it -> val chid = it.getLong("id") + val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0") + val title = it.optString("title", "null").takeUnless { it == "null" } MangaChapter( id = generateUid(chid), source = manga.source, url = "$baseChapterUrl$chid", - name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}", - number = totalChapters - i + uploadDate = it.getLong("date") * 1000, + name = if (title.isNullOrEmpty()) volChap else "$volChap: $title", + number = totalChapters - i, + scanlator = null, + branch = null, ) }.reversed() ) @@ -113,8 +118,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor MangaPage( id = generateUid(jo.getLong("id")), referer = fullUrl, + preview = null, source = chapter.source, - url = jo.getString("img") + url = jo.getString("img"), ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt index bef2af960..41b86750e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt @@ -141,8 +141,10 @@ class ExHentaiRepository( name = "${manga.title} #$i", number = i, url = url, - branch = null, + uploadDate = 0L, source = source, + scanlator = null, + branch = null, ) } chapters diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt index 2d35111e8..598a43bf0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.parser.site +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Response import org.koitharu.kotatsu.base.domain.MangaLoaderContext @@ -7,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class GroupleRepository(loaderContext: MangaLoaderContext) : @@ -39,14 +41,14 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : getSortKey( sortOrder ) - }&offset=${offset upBy PAGE_SIZE}" + }&offset=${offset upBy PAGE_SIZE}", HEADER ) tags.size == 1 -> loaderContext.httpGet( "https://$domain/list/genre/${tags.first().key}?sortType=${ getSortKey( sortOrder ) - }&offset=${offset upBy PAGE_SIZE}" + }&offset=${offset upBy PAGE_SIZE}", HEADER ) offset > 0 -> return emptyList() else -> advancedSearch(domain, tags) @@ -104,14 +106,15 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : } override suspend fun getDetails(manga: Manga): Manga { - val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() + val doc = loaderContext.httpGet(manga.url.withDomain(), HEADER).parseHtml() val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") ?: throw ParseException("Cannot find root") + val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US) + val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img") return manga.copy( description = root.selectFirst("div.manga-description")?.html(), - largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr( - "data-full" - ), + largeCoverUrl = coverImg?.attr("data-full"), + coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl, tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ") .mapNotNull { val a = it.selectFirst("a.element-link") ?: return@mapNotNull null @@ -122,21 +125,32 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : ) }, chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") - ?.select("a")?.asReversed()?.mapIndexed { i, a -> + ?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr -> + val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null val href = a.relUrl("href") + var translators = "" + val translatorElement = a.attr("title") + if (!translatorElement.isNullOrBlank()) { + translators = translatorElement + .replace("(Переводчик),", "&") + .removeSuffix(" (Переводчик)") + } MangaChapter( id = generateUid(href), - name = a.ownText().removePrefix(manga.title).trim(), + name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(), number = i + 1, url = href, - source = source + uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()), + scanlator = translators, + source = source, + branch = null, ) } ) } override suspend fun getPages(chapter: MangaChapter): List { - val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1").parseHtml() + val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1", HEADER).parseHtml() val scripts = doc.select("script") for (script in scripts) { val data = script.html() @@ -154,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(url), url = url, + preview = null, referer = chapter.url, - source = source + source = source, ) } } @@ -163,7 +178,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : } override suspend fun getTags(): Set { - val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml() + val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name", HEADER).parseHtml() val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") ?.selectFirst("table.table") ?: parseFailed("Cannot find root") return root.select("a.element-link").mapToSet { a -> @@ -188,7 +203,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : private suspend fun advancedSearch(domain: String, tags: Set): Response { val url = "https://$domain/search/advanced" // Step 1: map catalog genres names to advanced-search genres ids - val tagsIndex = loaderContext.httpGet(url).parseHtml() + val tagsIndex = loaderContext.httpGet(url, HEADER).parseHtml() .body().selectFirst("form.search-form") ?.select("div.form-group") ?.get(1) ?: parseFailed("Genres filter element not found") @@ -226,5 +241,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : private const val PAGE_SIZE = 70 private const val PAGE_SIZE_SEARCH = 50 + private val HEADER = Headers.Builder() + .add("User-Agent", "readmangafun") + .build() } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt index a358f50df..072c7611b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt @@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load sortOrder: SortOrder? ): List { return super.getList2(offset, query, tags, sortOrder).map { - val cover = it.coverUrl - if (cover.contains("_blur")) { - it.copy(coverUrl = cover.replace("_blur", "")) - } else { - it - } + it.copy( + coverUrl = it.coverUrl.replace("_blur", ""), + isNsfw = true, + ) } } @@ -49,7 +47,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load url = readLink, source = source, number = 1, - name = manga.title + uploadDate = 0L, + name = manga.title, + scanlator = null, + branch = null, ) ) ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt new file mode 100644 index 000000000..2b289212b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaDexRepository.kt @@ -0,0 +1,215 @@ +package org.koitharu.kotatsu.core.parser.site + +import android.os.Build +import androidx.core.os.LocaleListCompat +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.json.JSONObject +import org.koitharu.kotatsu.base.domain.MangaLoaderContext +import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat +import java.util.* + +private const val PAGE_SIZE = 20 +private const val CONTENT_RATING = + "contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic" +private const val LOCALE_FALLBACK = "en" + +class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { + + override val source = MangaSource.MANGADEX + override val defaultDomain = "mangadex.org" + + override val sortOrders: EnumSet = EnumSet.of( + SortOrder.UPDATED, + SortOrder.ALPHABETICAL, + SortOrder.NEWEST, + SortOrder.POPULARITY, + ) + + override suspend fun getList2( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder?, + ): List { + val domain = getDomain() + val url = buildString { + append("https://api.") + append(domain) + append("/manga?limit=") + append(PAGE_SIZE) + append("&offset=") + append(offset) + append("&includes[]=cover_art&includes[]=author&includes[]=artist&") + tags?.forEach { tag -> + append("includedTags[]=") + append(tag.key) + append('&') + } + if (!query.isNullOrEmpty()) { + append("title=") + append(query.urlEncoded()) + append('&') + } + append(CONTENT_RATING) + append("&order") + append(when (sortOrder) { + null, + SortOrder.UPDATED, + -> "[latestUploadedChapter]=desc" + SortOrder.ALPHABETICAL -> "[title]=asc" + SortOrder.NEWEST -> "[createdAt]=desc" + SortOrder.POPULARITY -> "[followedCount]=desc" + else -> "[followedCount]=desc" + }) + } + val json = loaderContext.httpGet(url).parseJson().getJSONArray("data") + return json.map { jo -> + val id = jo.getString("id") + val attrs = jo.getJSONObject("attributes") + val relations = jo.getJSONArray("relationships").associateByKey("type") + val cover = relations["cover_art"] + ?.getJSONObject("attributes") + ?.getString("fileName") + ?.let { + "https://uploads.$domain/covers/$id/$it" + } + Manga( + id = generateUid(id), + title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) { + "Title should not be null" + }, + altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(), + url = id, + publicUrl = "https://$domain/title/$id", + rating = Manga.NO_RATING, + isNsfw = attrs.getStringOrNull("contentRating") == "erotica", + coverUrl = cover?.plus(".256.jpg").orEmpty(), + largeCoverUrl = cover, + description = attrs.optJSONObject("description")?.selectByLocale(), + tags = attrs.getJSONArray("tags").mapToSet { tag -> + MangaTag( + title = tag.getJSONObject("attributes") + .getJSONObject("name") + .firstStringValue(), + key = tag.getString("id"), + source = source, + ) + }, + state = when (jo.getStringOrNull("status")) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + else -> null + }, + author = (relations["author"] ?: relations["artist"]) + ?.getJSONObject("attributes") + ?.getStringOrNull("name"), + source = source, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val domain = getDomain() + val attrsDeferred = async { + loaderContext.httpGet( + "https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art" + ).parseJson().getJSONObject("data").getJSONObject("attributes") + } + val feedDeferred = async { + val url = buildString { + append("https://api.") + append(domain) + append("/manga/") + append(manga.url) + append("/feed") + append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&") + append(CONTENT_RATING) + } + loaderContext.httpGet(url).parseJson().getJSONArray("data") + } + val mangaAttrs = attrsDeferred.await() + val feed = feedDeferred.await() + //2022-01-02T00:27:11+00:00 + val dateFormat = SimpleDateFormat( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + "yyyy-MM-dd'T'HH:mm:ssX" + } else { + "yyyy-MM-dd'T'HH:mm:ss'+00:00'" + }, + Locale.ROOT + ) + manga.copy( + description = mangaAttrs.getJSONObject("description").selectByLocale() + ?: manga.description, + chapters = feed.mapNotNull { jo -> + val id = jo.getString("id") + val attrs = jo.getJSONObject("attributes") + if (!attrs.isNull("externalUrl")) { + return@mapNotNull null + } + val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage")) + val relations = jo.getJSONArray("relationships").associateByKey("type") + val number = attrs.optInt("chapter", 0) + MangaChapter( + id = generateUid(id), + name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty) + ?: "Chapter #$number", + number = number, + url = id, + scanlator = relations["scanlation_group"]?.getStringOrNull("name"), + uploadDate = dateFormat.tryParse(attrs.getString("publishAt")), + branch = locale.getDisplayName(locale).toTitleCase(locale), + source = source, + ) + } + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val domain = getDomain() + val chapter = loaderContext.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false") + .parseJson() + .getJSONObject("chapter") + val pages = chapter.getJSONArray("data") + val prefix = "https://uploads.$domain/data/${chapter.getString("hash")}/" + val referer = "https://$domain/" + return List(pages.length()) { i -> + val url = prefix + pages.getString(i) + MangaPage( + id = generateUid(url), + url = url, + referer = referer, + preview = null, // TODO prefix + dataSaver.getString(i), + source = source, + ) + } + } + + override suspend fun getTags(): Set { + val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson() + .getJSONArray("data") + return tags.mapToSet { jo -> + MangaTag( + title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(), + key = jo.getString("id"), + source = source, + ) + } + } + + private fun JSONObject.firstStringValue() = values().next() as String + + private fun JSONObject.selectByLocale(): String? { + val preferredLocales = LocaleListCompat.getAdjustedDefault() + repeat(preferredLocales.size()) { i -> + val locale = preferredLocales.get(i) + getStringOrNull(locale.language)?.let { return it } + getStringOrNull(locale.toLanguageTag())?.let { return it } + } + return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt index ba0ea771d..ed58f073c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* open class MangaLibRepository(loaderContext: MangaLoaderContext) : @@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : val info = root.selectFirst("div.media-content") val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml() val scripts = chaptersDoc.select("script") + val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US) var chapters: ArrayList? = null scripts@ for (script in scripts) { val raw = script.html().lines() @@ -91,7 +93,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : for (i in 0 until total) { val item = list.getJSONObject(i) val chapterId = item.getLong("chapter_id") - val branchName = item.getStringOrNull("username") + val scanlator = item.getStringOrNull("username") val url = buildString { append(manga.url) append("/v") @@ -102,19 +104,22 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : append('/') append(item.optString("chapter_string")) } - var name = item.getStringOrNull("chapter_name") - if (name.isNullOrBlank() || name == "null") { - name = "Том " + item.getInt("chapter_volume") + - " Глава " + item.getString("chapter_number") - } + val nameChapter = item.getStringOrNull("chapter_name") + val volume = item.getInt("chapter_volume") + val number = item.getString("chapter_number") + val fullNameChapter = "Том $volume. Глава $number" chapters.add( MangaChapter( id = generateUid(chapterId), url = url, source = source, - branch = branchName, number = total - i, - name = name + uploadDate = dateFormat.tryParse( + item.getString("chapter_created_at").substringBefore(" ") + ), + scanlator = scanlator, + branch = null, + name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter", ) ) } @@ -174,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(pageUrl), url = pageUrl, + preview = null, referer = fullUrl, - source = source + source = source, ) } } @@ -235,8 +241,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : .toFloatOrNull()?.div(5f) ?: Manga.NO_RATING, state = null, source = source, - coverUrl = "https://$domain${covers.getString("thumbnail")}", - largeCoverUrl = "https://$domain${covers.getString("default")}" + coverUrl = covers.getString("thumbnail"), + largeCoverUrl = covers.getString("default") ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt new file mode 100644 index 000000000..5e5429d95 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt @@ -0,0 +1,169 @@ +package org.koitharu.kotatsu.core.parser.site + +import android.util.Base64 +import org.koitharu.kotatsu.base.domain.MangaLoaderContext +import org.koitharu.kotatsu.core.exceptions.ParseException +import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat +import java.util.* + +class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { + + override val source = MangaSource.MANGAOWL + + override val defaultDomain = "mangaowls.com" + + override val sortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.UPDATED + ) + + override suspend fun getList2( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder?, + ): List { + val page = (offset / 36f).toIntUp().inc() + val link = buildString { + append("https://") + append(getDomain()) + when { + !query.isNullOrEmpty() -> { + append("/search/${page}?search=") + append(query.urlEncoded()) + } + !tags.isNullOrEmpty() -> { + for (tag in tags) { + append(tag.key) + } + append("/${page}?type=${getAlternativeSortKey(sortOrder)}") + } + else -> { + append("/${getSortKey(sortOrder)}/${page}") + } + } + } + val doc = loaderContext.httpGet(link).parseHtml() + val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing") + val items = slides.select("div.col-md-2") + return items.mapNotNull { item -> + val href = item.selectFirst("h6 a")?.relUrl("href") ?: return@mapNotNull null + Manga( + id = generateUid(href), + title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null, + coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"), + altTitle = null, + author = null, + rating = runCatching { + item.selectFirst("div.block-stars") + ?.text() + ?.toFloatOrNull() + ?.div(10f) + }.getOrNull() ?: Manga.NO_RATING, + url = href, + publicUrl = href.withDomain(), + source = source + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = loaderContext.httpGet(manga.publicUrl).parseHtml() + val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing") + val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing") + val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US) + val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE) + val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null } ?: parseFailed("Oops, tr not found") + val tr = trRegex.find(trElement.data())!!.groups[1]!!.value + val s = Base64.encodeToString(defaultDomain.toByteArray(), Base64.NO_PADDING) + return manga.copy( + description = info.selectFirst(".description")?.html(), + largeCoverUrl = info.select("img").first()?.let { img -> + if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src") + }, + author = info.selectFirst("p.fexi_header_para a.author_link")?.text(), + state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()), + tags = manga.tags + info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]") + .mapNotNull { + val a = it.selectFirst("a") ?: return@mapNotNull null + MangaTag( + title = a.text(), + key = a.attr("href"), + source = source + ) + }, + chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li -> + val a = li.select("a") + val href = a.attr("data-href").ifEmpty { + parseFailed("Link is missing") + } + MangaChapter( + id = generateUid(href), + name = a.select("label").text(), + number = i + 1, + url = "$href?tr=$tr&s=$s", + scanlator = null, + branch = null, + uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()), + source = MangaSource.MANGAOWL, + ) + } + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.withDomain() + val doc = loaderContext.httpGet(fullUrl).parseHtml() + val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found") + return root.map { div -> + val url = div?.relUrl("data-src") ?: parseFailed("Page image not found") + MangaPage( + id = generateUid(url), + url = url, + preview = null, + referer = url, + source = MangaSource.MANGAOWL, + ) + } + } + + private fun parseStatus(status: String?) = when { + status == null -> null + status.contains("Ongoing") -> MangaState.ONGOING + status.contains("Completed") -> MangaState.FINISHED + else -> null + } + + override suspend fun getTags(): Set { + val doc = loaderContext.httpGet("https://${getDomain()}/").parseHtml() + val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li") + return root.mapToSet { p -> + val a = p.selectFirst("a") ?: parseFailed("a is null") + MangaTag( + title = a.text().toCamelCase(), + key = a.attr("href"), + source = source + ) + } + } + + private fun getSortKey(sortOrder: SortOrder?) = + when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) { + SortOrder.POPULARITY -> "popular" + SortOrder.NEWEST -> "new_release" + SortOrder.UPDATED -> "lastest" + else -> "lastest" + } + + private fun getAlternativeSortKey(sortOrder: SortOrder?) = + when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) { + SortOrder.POPULARITY -> "0" + SortOrder.NEWEST -> "2" + SortOrder.UPDATED -> "3" + else -> "3" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt index bf5ce1b8f..afe3750c3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.utils.ext.* +import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.* class MangaTownRepository(loaderContext: MangaLoaderContext) : @@ -96,6 +98,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : val info = root.selectFirst("div.detail_info")?.selectFirst("ul") val chaptersList = root.selectFirst("div.chapter_content") ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) return manga.copy( tags = manga.tags + info?.select("li")?.find { x -> x.selectFirst("b")?.ownText() == "Genre(s):" @@ -117,9 +120,15 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : url = href, source = MangaSource.MANGATOWN, number = i + 1, - name = name.ifEmpty { "${manga.title} - ${i + 1}" } + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text() + ), + name = name.ifEmpty { "${manga.title} - ${i + 1}" }, + scanlator = null, + branch = null, ) - } + } ?: bypassLicensedChapters(manga) ) } @@ -136,8 +145,9 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(href), url = href, + preview = null, referer = fullUrl, - source = MangaSource.MANGATOWN + source = MangaSource.MANGATOWN, ) } ?: parseFailed("Pages list not found") } @@ -167,11 +177,46 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : } } + private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { + return when { + date.isNullOrEmpty() -> 0L + date.contains("Today") -> Calendar.getInstance().timeInMillis + date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + else -> dateFormat.tryParse(date) + } + } + override fun onCreatePreferences(map: MutableMap) { super.onCreatePreferences(map) map[SourceSettings.KEY_USE_SSL] = true } + private suspend fun bypassLicensedChapters(manga: Manga): List { + val doc = loaderContext.httpGet(manga.url.withDomain("m")).parseHtml() + val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + return list.select("li").asReversed().mapIndexedNotNull { i, li -> + val a = li.selectFirst("a") ?: return@mapIndexedNotNull null + val href = a.relUrl("href") + val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty { + a.ownText() + } + MangaChapter( + id = generateUid(href), + url = href, + source = MangaSource.MANGATOWN, + number = i + 1, + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text() + ), + name = name.ifEmpty { "${manga.title} - ${i + 1}" }, + scanlator = null, + branch = null, + ) + } + } + private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it } private companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt index 0a94c3d04..2b2f9b8cd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt @@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.WordSet import org.koitharu.kotatsu.utils.ext.* +import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.* class MangareadRepository( @@ -52,7 +55,7 @@ class MangareadRepository( id = generateUid(href), url = href, publicUrl = href.inContextOf(div), - coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(), + coverUrl = div.selectFirst("img")?.absUrl("data-src").orEmpty(), title = summary?.selectFirst("h3")?.text().orEmpty(), rating = div.selectFirst("span.total_votes")?.ownText() ?.toFloatOrNull()?.div(5f) ?: -1f, @@ -104,16 +107,7 @@ class MangareadRepository( val root2 = doc.body().selectFirst("div.content-area") ?.selectFirst("div.c-page") ?: throw ParseException("Root2 not found") - val mangaId = doc.getElementsByAttribute("data-post").firstOrNull() - ?.attr("data-post")?.toLongOrNull() - ?: throw ParseException("Cannot obtain manga id") - val doc2 = loaderContext.httpPost( - "https://${getDomain()}/wp-admin/admin-ajax.php", - mapOf( - "action" to "manga_get_chapters", - "manga" to mangaId.toString() - ) - ).parseHtml() + val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US) return manga.copy( tags = root.selectFirst("div.genres-content")?.select("a") ?.mapNotNullToSet { a -> @@ -128,7 +122,7 @@ class MangareadRepository( ?.select("p") ?.filterNot { it.ownText().startsWith("A brief description") } ?.joinToString { it.html() }, - chapters = doc2.select("li").asReversed().mapIndexed { i, li -> + chapters = root2.select("li").asReversed().mapIndexed { i, li -> val a = li.selectFirst("a") val href = a?.relUrl("href").orEmpty().ifEmpty { parseFailed("Link is missing") @@ -138,7 +132,13 @@ class MangareadRepository( name = a!!.ownText(), number = i + 1, url = href, - source = MangaSource.MANGAREAD + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.chapter-release-date i")?.text() + ), + source = MangaSource.MANGAREAD, + scanlator = null, + branch = null, ) } ) @@ -151,17 +151,85 @@ class MangareadRepository( ?.selectFirst("div.reading-content") ?: throw ParseException("Root not found") return root.select("div.page-break").map { div -> - val img = div.selectFirst("img") - val url = img?.relUrl("src") ?: parseFailed("Page image not found") + val img = div.selectFirst("img") ?: parseFailed("Page image not found") + val url = img.relUrl("data-src").ifEmpty { + img.relUrl("src") + } MangaPage( id = generateUid(url), url = url, + preview = null, referer = fullUrl, - source = MangaSource.MANGAREAD + source = MangaSource.MANGAREAD, ) } } + private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { + + date ?: return 0 + return when { + date.endsWith(" ago", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle translated 'ago' in Portuguese. + date.endsWith(" atrás", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle translated 'ago' in Turkish. + date.endsWith(" önce", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle 'yesterday' and 'today', using midnight + date.startsWith("year", ignoreCase = true) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) // yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + date.startsWith("today", ignoreCase = true) -> { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + date.contains(Regex("""\d(st|nd|rd|th)""")) -> { + // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it + date.split(" ").map { + if (it.contains(Regex("""\d\D\D"""))) { + it.replace(Regex("""\D"""), "") + } else { + it + } + } + .let { dateFormat.tryParse(it.joinToString(" ")) } + } + else -> dateFormat.tryParse(date) + } + } + + // Parses dates in this form: + // 21 hours ago + private fun parseRelativeDate(date: String): Long { + val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + + return when { + WordSet("hari", "gün", "jour", "día", "dia", "day").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0 + } + } + private companion object { private const val PAGE_SIZE = 12 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt index 9c67b2146..7b782ab1c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt @@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class NineMangaRepository( @@ -40,7 +41,7 @@ abstract class NineMangaRepository( append("&page=") } !tags.isNullOrEmpty() -> { - append("/search/&category_id=") + append("/search/?category_id=") for (tag in tags) { append(tag.key) append(',') @@ -99,19 +100,22 @@ abstract class NineMangaRepository( ) }.orEmpty(), author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(), + state = parseStatus(infoRoot.select("li a.red").text()), description = infoRoot.getElementsByAttributeValue("itemprop", "description").first() ?.html()?.substringAfter(""), - chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul") - ?.select("li")?.asReversed()?.mapIndexed { i, li -> - val a = li.selectFirst("a") - val href = a?.relUrl("href") ?: parseFailed("Link not found") + chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li") + ?.asReversed()?.mapIndexed { i, li -> + val a = li.selectFirst("a.chapter_list_a") + val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found") MangaChapter( id = generateUid(href), name = a.text(), number = i + 1, url = href, - branch = null, + uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()), source = source, + scanlator = null, + branch = null, ) } ) @@ -153,6 +157,50 @@ abstract class NineMangaRepository( } ?: parseFailed("Root not found") } + private fun parseStatus(status: String) = when { + status.contains("Ongoing") -> MangaState.ONGOING + status.contains("Completed") -> MangaState.FINISHED + else -> null + } + + private fun parseChapterDateByLang(date: String): Long { + val dateWords = date.split(" ") + + if (dateWords.size == 3) { + if (dateWords[1].contains(",")) { + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date) + } else { + val timeAgo = Integer.parseInt(dateWords[0]) + return Calendar.getInstance().apply { + when (dateWords[1]) { + "minutes" -> Calendar.MINUTE // EN-FR + "hours" -> Calendar.HOUR // EN + + "minutos" -> Calendar.MINUTE // ES + "horas" -> Calendar.HOUR + + // "minutos" -> Calendar.MINUTE // BR + "hora" -> Calendar.HOUR + + "минут" -> Calendar.MINUTE // RU + "часа" -> Calendar.HOUR + + "Stunden" -> Calendar.HOUR // DE + + "minuti" -> Calendar.MINUTE // IT + "ore" -> Calendar.HOUR + + "heures" -> Calendar.HOUR // FR ("minutes" also French word) + else -> null + }?.let { + add(it, -timeAgo) + } + }.timeInMillis + } + } + return 0L + } + class English(loaderContext: MangaLoaderContext) : NineMangaRepository( loaderContext, MangaSource.NINEMANGA_EN, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt index 3871a966f..ab95903d5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt @@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) { - override val defaultDomain = "readmanga.live" + override val defaultDomain = "readmanga.io" override val source = MangaSource.READMANGA_RU } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt index 17a3dd5d7..2319dfcd2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt @@ -6,15 +6,20 @@ import org.json.JSONObject import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* -class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { +class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext), + MangaRepositoryAuthProvider { override val source = MangaSource.REMANGA override val defaultDomain = "remanga.org" + override val authUrl: String + get() = "https://${getDomain()}/user/login" override val sortOrders: Set = EnumSet.of( SortOrder.UPDATED, @@ -29,6 +34,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito tags: Set?, sortOrder: SortOrder? ): List { + copyCookies() val domain = getDomain() val urlBuilder = StringBuilder() .append("https://api.") @@ -77,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito } override suspend fun getDetails(manga: Manga): Manga { + copyCookies() val domain = getDomain() val slug = manga.url.find(LAST_URL_PATH_REGEX) ?: throw ParseException("Cannot obtain slug from ${manga.url}") @@ -93,6 +100,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito val chapters = loaderContext.httpGet( url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId" ).parseJson().getJSONArray("content") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) return manga.copy( description = content.getString("description"), state = when (content.optJSONObject("status")?.getInt("id")) { @@ -109,20 +117,27 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito }, chapters = chapters.mapIndexed { i, jo -> val id = jo.getLong("id") - val name = jo.getString("name") + val name = jo.getString("name").toTitleCase(Locale.ROOT) + val publishers = jo.getJSONArray("publishers") MangaChapter( id = generateUid(id), url = "/api/titles/chapters/$id/", number = chapters.length() - i, name = buildString { + append("Том ") + append(jo.optString("tome", "0")) + append(". ") append("Глава ") - append(jo.getString("chapter")) + append(jo.optString("chapter", "0")) if (name.isNotEmpty()) { append(" - ") append(name) } }, - source = MangaSource.REMANGA + uploadDate = dateFormat.tryParse(jo.getString("upload_date")), + scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"), + source = MangaSource.REMANGA, + branch = null, ) }.asReversed() ) @@ -156,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito } } + override fun isAuthorized(): Boolean { + return loaderContext.cookieJar.getCookies(getDomain()).any { + it.name == "user" + } + } + + private fun copyCookies() { + val domain = getDomain() + loaderContext.cookieJar.copyCookies(domain, "api.$domain") + } + private fun getSortKey(order: SortOrder?) = when (order) { SortOrder.UPDATED -> "-chapter_date" SortOrder.POPULARITY -> "-rating" @@ -167,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito private fun parsePage(jo: JSONObject, referer: String) = MangaPage( id = generateUid(jo.getLong("id")), url = jo.getString("link"), + preview = null, referer = referer, - source = source + source = source, ) private companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt index 076da352d..77edbb5b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt @@ -29,7 +29,10 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa name = a.text().trim(), number = i + 1, url = href, - source = source + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, ) } ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index a2f2fbc59..8fac79601 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -2,18 +2,23 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences +import android.os.Build import android.provider.Settings import androidx.appcompat.app.AppCompatDelegate import androidx.collection.arraySetOf import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.google.android.material.color.DynamicColors import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.utils.delegates.prefs.* import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* class AppSettings private constructor(private val prefs: SharedPreferences) : SharedPreferences by prefs { @@ -39,6 +44,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ) + val isDynamicTheme by BoolPreferenceDelegate(KEY_DYNAMIC_THEME, defaultValue = false) + val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false) val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true) @@ -76,6 +83,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true) + var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false) + var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false) val zoomMode by EnumPreferenceDelegate( @@ -104,6 +113,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : val isSourcesSelected: Boolean get() = KEY_SOURCES_HIDDEN in prefs + val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false) + fun getStorageDir(context: Context): File? { val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { File(it) @@ -121,6 +132,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : } } + fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat = + when (format) { + "" -> DateFormat.getDateInstance(DateFormat.SHORT) + else -> SimpleDateFormat(format, Locale.getDefault()) + } + @Deprecated("Use observe()") fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { prefs.registerOnSharedPreferenceChangeListener(listener) @@ -132,7 +149,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : fun observe() = callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - sendBlocking(key) + trySendBlocking(key) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { @@ -151,7 +168,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_LIST_MODE = "list_mode_2" const val KEY_APP_SECTION = "app_section" const val KEY_THEME = "theme" + const val KEY_DYNAMIC_THEME = "dynamic_theme" const val KEY_THEME_AMOLED = "amoled_theme" + const val KEY_DATE_FORMAT = "date_format" const val KEY_HIDE_TOOLBAR = "hide_toolbar" const val KEY_SOURCES_ORDER = "sources_order" const val KEY_SOURCES_HIDDEN = "sources_hidden" @@ -182,6 +201,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_RESTORE = "restore" const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_REVERSE_CHAPTERS = "reverse_chapters" + const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" + const val KEY_PAGES_NUMBERS = "pages_numbers" // About const val KEY_APP_UPDATE = "app_update" @@ -191,5 +212,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_FEEDBACK_4PDA = "about_feedback_4pda" const val KEY_FEEDBACK_GITHUB = "about_feedback_github" const val KEY_SUPPORT_DEVELOPER = "about_support_developer" + + val isDynamicColorAvailable: Boolean + get() = DynamicColors.isDynamicColorAvailable() || + (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + + private val isSamsung + get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt index 8deefacfe..0d38d95ce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt @@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel { } } - data class MinutesAgo(val minutes: Int) : DateTimeAgo() { + class MinutesAgo(val minutes: Int) : DateTimeAgo() { + override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as MinutesAgo + return minutes == other.minutes + } + + override fun hashCode(): Int = minutes } - data class HoursAgo(val hours: Int) : DateTimeAgo() { + class HoursAgo(val hours: Int) : DateTimeAgo() { override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.hours_ago, hours, hours) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as HoursAgo + return hours == other.hours + } + + override fun hashCode(): Int = hours } object Today : DateTimeAgo() { @@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel { } } - data class DaysAgo(val days: Int) : DateTimeAgo() { + class DaysAgo(val days: Int) : DateTimeAgo() { override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.days_ago, days, days) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DaysAgo + return days == other.days + } + + override fun hashCode(): Int = days } object LongAgo : DateTimeAgo() { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt index 8032d9783..148fa409d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt @@ -5,6 +5,7 @@ import coil.ImageLoader import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidContext import org.koin.dsl.module +import org.koitharu.kotatsu.core.parser.FaviconMapper import org.koitharu.kotatsu.local.data.CbzFetcher val uiModule @@ -15,6 +16,7 @@ val uiModule .componentRegistry( ComponentRegistry.Builder() .add(CbzFetcher()) + .add(FaviconMapper()) .build() ).build() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index c2205bf30..6ea44a8ac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -9,8 +9,8 @@ import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.isVisible import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDividerItemDecoration import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment @@ -51,12 +51,7 @@ class ChaptersFragment : BaseFragment(), chaptersAdapter = ChaptersAdapter(this) selectionDecoration = ChaptersSelectionDecoration(view.context) with(binding.recyclerViewChapters) { - addItemDecoration( - DividerItemDecoration( - view.context, - RecyclerView.VERTICAL - ) - ) + addItemDecoration(MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)) addItemDecoration(selectionDecoration!!) setHasFixedSize(true) adapter = chaptersAdapter @@ -117,7 +112,7 @@ class ChaptersFragment : BaseFragment(), } return } - if (item.isMissing) { + if (item.hasFlag(ChapterListItem.FLAG_MISSING)) { (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id) return } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 84745b56d..6b0b78279 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -6,15 +6,17 @@ import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.Insets import androidx.core.net.toFile +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator @@ -44,7 +46,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrategy { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(MangaIntent.from(intent)) } @@ -85,18 +87,24 @@ class DetailsActivity : BaseActivity(), finishAfterTransition() } else -> { - Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) - .show() + binding.snackbar.show(e.getDisplayMessage(resources)) } } } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbar.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right + binding.snackbar.updatePadding( + bottom = insets.bottom ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } if (binding.tabs.parent !is Toolbar) { binding.tabs.updatePadding( left = insets.left, @@ -147,7 +155,7 @@ class DetailsActivity : BaseActivity(), } R.id.action_delete -> { viewModel.manga.value?.let { m -> - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setTitle(R.string.delete_manga) .setMessage(getString(R.string.text_delete_local_manga, m.title)) .setPositiveButton(R.string.delete) { _, _ -> @@ -162,7 +170,7 @@ class DetailsActivity : BaseActivity(), viewModel.manga.value?.let { val chaptersCount = it.chapters?.size ?: 0 if (chaptersCount > 5) { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setTitle(R.string.save_manga) .setMessage( getString( diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index a427f2b3e..3d6ce19f0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.details.ui +import android.app.ActivityOptions import android.os.Bundle import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.Insets import androidx.core.net.toUri import androidx.core.text.parseAsHtml import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader +import coil.request.ImageRequest import coil.util.CoilUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,13 +26,15 @@ import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.MangaState import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog +import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.ext.* -import kotlin.random.Random class DetailsFragment : BaseFragment(), View.OnClickListener, View.OnLongClickListener { @@ -39,11 +44,16 @@ class DetailsFragment : BaseFragment(), View.OnClickList override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, ) = FragmentDetailsBinding.inflate(inflater, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.textViewAuthor.setOnClickListener(this) + binding.buttonFavorite.setOnClickListener(this) + binding.buttonRead.setOnClickListener(this) + binding.buttonRead.setOnLongClickListener(this) + binding.coverCard.setOnClickListener(this) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) @@ -52,12 +62,8 @@ class DetailsFragment : BaseFragment(), View.OnClickList private fun onMangaUpdated(manga: Manga) { with(binding) { - imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl) - .referer(manga.publicUrl) - .fallback(R.drawable.ic_placeholder) - .placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey) - .lifecycle(viewLifecycleOwner) - .enqueueWith(coil) + // Main + loadCover(manga) textViewTitle.text = manga.title textViewSubtitle.textAndVisible = manga.altTitle textViewAuthor.textAndVisible = manga.author @@ -66,6 +72,27 @@ class DetailsFragment : BaseFragment(), View.OnClickList textViewDescription.text = manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank) ?: getString(R.string.no_description) + when (manga.state) { + MangaState.FINISHED -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_finished) + drawableStart = ResourcesCompat.getDrawable(resources, + R.drawable.ic_state_finished, + context.theme) + } + } + MangaState.ONGOING -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_ongoing) + drawableStart = ResourcesCompat.getDrawable(resources, + R.drawable.ic_state_ongoing, + context.theme) + } + } + else -> textViewState.isVisible = false + } + + // Info containers if (manga.chapters?.isNotEmpty() == true) { chaptersContainer.isVisible = true textViewChapters.text = manga.chapters.let { @@ -96,10 +123,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList } else { sizeContainer.isVisible = false } - buttonFavorite.setOnClickListener(this@DetailsFragment) - buttonRead.setOnClickListener(this@DetailsFragment) - buttonRead.setOnLongClickListener(this@DetailsFragment) + + // Buttons buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() + + // Chips bindTags(manga) } } @@ -154,6 +182,26 @@ class DetailsFragment : BaseFragment(), View.OnClickList ) } } + R.id.textView_author -> { + startActivity( + SearchActivity.newIntent( + context = v.context, + source = manga.source, + query = manga.author ?: return, + ) + ) + } + R.id.cover_card -> { + val options = ActivityOptions.makeSceneTransitionAnimation( + requireActivity(), + binding.imageViewCover, + binding.imageViewCover.transitionName, + ) + startActivity( + ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl), + options.toBundle() + ) + } } } @@ -204,4 +252,22 @@ class DetailsFragment : BaseFragment(), View.OnClickList } ) } + + private fun loadCover(manga: Manga) { + val currentCover = binding.imageViewCover.drawable + val request = ImageRequest.Builder(context ?: return) + .target(binding.imageViewCover) + if (currentCover != null) { + request.data(manga.largeCoverUrl ?: return) + .placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey) + .fallback(currentCover) + } else { + request.crossfade(true) + .data(manga.coverUrl) + .fallback(R.drawable.ic_placeholder) + } + request.referer(manga.publicUrl) + .lifecycle(viewLifecycleOwner) + .enqueueWith(coil) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index ebd377a50..1e8e9d063 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.details.ui +import androidx.core.os.LocaleListCompat import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -18,12 +19,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.toTitleCase import java.io.IOException class DetailsViewModel( @@ -58,16 +60,6 @@ class DetailsViewModel( }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) private val remoteManga = MutableStateFlow(null) - /*private val remoteManga = mangaData.mapLatest { - if (it?.source == MangaSource.LOCAL) { - runCatching { - val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null - MangaRepository(m.source).getDetails(m) - }.getOrNull() - } else { - null - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/ private val chaptersReversed = settings.observe() .filter { it == AppSettings.KEY_REVERSE_CHAPTERS } @@ -107,10 +99,10 @@ class DetailsViewModel( selectedBranch ) { chapters, sourceManga, currentId, newCount, branch -> val sourceChapters = sourceManga?.chapters - if (sourceChapters.isNullOrEmpty()) { - mapChapters(chapters, currentId, newCount, branch) - } else { + if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) { mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch) + } else { + mapChapters(chapters, sourceChapters, currentId, newCount, branch) } }.combine(chaptersReversed) { list, reversed -> if (reversed) list.asReversed() else list @@ -121,23 +113,23 @@ class DetailsViewModel( var manga = mangaDataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") mangaData.value = manga - manga = manga.source.repository.getDetails(manga) + manga = MangaRepository(manga.source).getDetails(manga) // find default branch val hist = historyRepository.getOne(manga) selectedBranch.value = if (hist != null) { manga.chapters?.find { it.id == hist.chapterId }?.branch } else { - manga.chapters - ?.groupBy { it.branch } - ?.maxByOrNull { it.value.size }?.key + predictBranch(manga.chapters) } mangaData.value = manga - if (manga.source == MangaSource.LOCAL) { - remoteManga.value = runCatching { + remoteManga.value = runCatching { + if (manga.source == MangaSource.LOCAL) { val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null MangaRepository(m.source).getDetails(m) - }.getOrNull() - } + } else { + localMangaRepository.findSavedManga(manga) + } + }.getOrNull() } } @@ -166,26 +158,28 @@ class DetailsViewModel( private fun mapChapters( chapters: List, + downloadedChapters: List?, currentId: Long?, newCount: Int, branch: String?, ): List { val result = ArrayList(chapters.size) + val dateFormat = settings.dateFormat() val currentIndex = chapters.indexOfFirst { it.id == currentId } val firstNewIndex = chapters.size - newCount + val downloadedIds = downloadedChapters?.mapToSet { it.id } for (i in chapters.indices) { val chapter = chapters[i] if (chapter.branch != branch) { continue } result += chapter.toListItem( - extra = when { - i >= firstNewIndex -> ChapterExtra.NEW - i == currentIndex -> ChapterExtra.CURRENT - i < currentIndex -> ChapterExtra.READ - else -> ChapterExtra.UNREAD - }, - isMissing = false + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = downloadedIds?.contains(chapter.id) == true, + dateFormat = dateFormat, ) } return result @@ -202,6 +196,7 @@ class DetailsViewModel( val result = ArrayList(sourceChapters.size) val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } val firstNewIndex = sourceChapters.size - newCount + val dateFormat = settings.dateFormat() for (i in sourceChapters.indices) { val chapter = sourceChapters[i] if (chapter.branch != branch) { @@ -209,30 +204,53 @@ class DetailsViewModel( } val localChapter = chaptersMap.remove(chapter.id) result += localChapter?.toListItem( - extra = when { - i >= firstNewIndex -> ChapterExtra.NEW - i == currentIndex -> ChapterExtra.CURRENT - i < currentIndex -> ChapterExtra.READ - else -> ChapterExtra.UNREAD - }, - isMissing = false + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, ) ?: chapter.toListItem( - extra = when { - i >= firstNewIndex -> ChapterExtra.NEW - i == currentIndex -> ChapterExtra.CURRENT - i < currentIndex -> ChapterExtra.READ - else -> ChapterExtra.UNREAD - }, - isMissing = true + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = true, + isDownloaded = false, + dateFormat = dateFormat, ) } if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source result.ensureCapacity(result.size + chaptersMap.size) chaptersMap.values.mapTo(result) { - it.toListItem(ChapterExtra.UNREAD, false) + it.toListItem( + isCurrent = false, + isUnread = true, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) } result.sortBy { it.chapter.number } } return result } + + private fun predictBranch(chapters: List?): String? { + if (chapters.isNullOrEmpty()) { + return null + } + val groups = chapters.groupBy { it.branch } + for (locale in LocaleListCompat.getAdjustedDefault()) { + var language = locale.getDisplayLanguage(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + language = locale.getDisplayName(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + } + return groups.maxByOrNull { it.value.size }?.key + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 983f322a8..9a423b2e2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -1,12 +1,19 @@ package org.koitharu.kotatsu.details.ui.adapter +import android.view.View +import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.history.domain.ChapterExtra +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD import org.koitharu.kotatsu.utils.ext.getThemeColor +import org.koitharu.kotatsu.utils.ext.textAndVisible fun chapterListItemAD( clickListener: OnListItemClickListener, @@ -14,35 +21,40 @@ fun chapterListItemAD( { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } ) { - itemView.setOnClickListener { - clickListener.onItemClick(item, it) - } - itemView.setOnLongClickListener { - clickListener.onItemLongClick(item, it) + val eventListener = object : View.OnClickListener, View.OnLongClickListener { + override fun onClick(v: View) = clickListener.onItemClick(item, v) + override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v) } - bind { payload -> - binding.textViewTitle.text = item.chapter.name - binding.textViewNumber.text = item.chapter.number.toString() - when (item.extra) { - ChapterExtra.UNREAD -> { + itemView.setOnClickListener(eventListener) + itemView.setOnLongClickListener(eventListener) + + bind { payloads -> + if (payloads.isEmpty()) { + binding.textViewTitle.text = item.chapter.name + binding.textViewNumber.text = item.chapter.number.toString() + binding.textViewDescription.textAndVisible = item.description() + } + when (item.status) { + FLAG_UNREAD -> { binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse)) } - ChapterExtra.READ -> { - binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline) - binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) - } - ChapterExtra.CURRENT -> { - binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline_accent) - binding.textViewNumber.setTextColor(context.getThemeColor(androidx.appcompat.R.attr.colorAccent)) - } - ChapterExtra.NEW -> { + FLAG_CURRENT -> { binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) } + else -> { + binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline) + binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) + } } - binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f - binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f + val isMissing = item.hasFlag(FLAG_MISSING) + binding.textViewTitle.alpha = if (isMissing) 0.3f else 1f + binding.textViewDescription.alpha = if (isMissing) 0.3f else 1f + binding.textViewNumber.alpha = if (isMissing) 0.3f else 1f + + binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED) + binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt index 46dc930d1..033b9ed92 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -19,10 +19,6 @@ class ChaptersAdapter( return items[position].chapter.id } - fun setItems(newItems: List, callback: Runnable) { - differ.submitList(newItems, callback) - } - private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean { @@ -37,8 +33,8 @@ class ChaptersAdapter( } override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? { - if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) { - return newItem.extra + if (oldItem.flags != newItem.flags && oldItem.chapter == newItem.chapter) { + return newItem.flags } return null } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt index 69ad32f06..dbd64b9b7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt @@ -4,20 +4,14 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect -import androidx.collection.ArraySet -import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.utils.ext.getThemeColor -import org.koitharu.kotatsu.utils.ext.resolveDp class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() { - private val icon = ContextCompat.getDrawable(context, R.drawable.ic_check) - private val padding = context.resources.resolveDp(16) private val bounds = Rect() - private val selection = ArraySet() + private val selection = HashSet() private val paint = Paint(Paint.ANTI_ALIAS_FLAG) init { @@ -54,7 +48,6 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio } override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - icon ?: return canvas.save() if (parent.clipToPadding) { canvas.clipRect( @@ -73,36 +66,4 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio } canvas.restore() } - - override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - icon ?: return - canvas.save() - val left: Int - val right: Int - if (parent.clipToPadding) { - left = parent.paddingLeft - right = parent.width - parent.paddingRight - canvas.clipRect( - left, parent.paddingTop, right, - parent.height - parent.paddingBottom - ) - } else { - left = 0 - right = parent.width - } - - for (child in parent.children) { - val itemId = parent.getChildItemId(child) - if (itemId in selection) { - parent.getDecoratedBoundsWithMargins(child, bounds) - bounds.offset(child.translationX.toInt(), child.translationY.toInt()) - val hh = (bounds.height() - icon.intrinsicHeight) / 2 - val top: Int = bounds.top + hh - val bottom: Int = bounds.bottom - hh - icon.setBounds(right - icon.intrinsicWidth - padding, top, right - padding, bottom) - icon.draw(canvas) - } - } - canvas.restore() - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index 82f00decf..2e1ac64bd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -1,10 +1,57 @@ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.core.model.MangaChapter -import org.koitharu.kotatsu.history.domain.ChapterExtra -data class ChapterListItem( +class ChapterListItem( val chapter: MangaChapter, - val extra: ChapterExtra, - val isMissing: Boolean, -) + val flags: Int, + val uploadDate: String?, +) { + + val status: Int + get() = flags and MASK_STATUS + + fun hasFlag(flag: Int): Boolean { + return (flags and flag) == flag + } + + fun description(): CharSequence? { + val scanlator = chapter.scanlator?.takeUnless { it.isBlank() } + return when { + uploadDate != null && scanlator != null -> "$uploadDate • $scanlator" + scanlator != null -> scanlator + else -> uploadDate + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChapterListItem + + if (chapter != other.chapter) return false + if (flags != other.flags) return false + if (uploadDate != other.uploadDate) return false + + return true + } + + override fun hashCode(): Int { + var result = chapter.hashCode() + result = 31 * result + flags + result = 31 * result + uploadDate.hashCode() + return result + } + + + companion object { + + const val FLAG_UNREAD = 2 + const val FLAG_CURRENT = 4 + const val FLAG_NEW = 8 + const val FLAG_MISSING = 16 + const val FLAG_DOWNLOADED = 32 + const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt index 0a1609989..98a7f9c4b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt @@ -1,13 +1,30 @@ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.core.model.MangaChapter -import org.koitharu.kotatsu.history.domain.ChapterExtra +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD +import java.text.DateFormat fun MangaChapter.toListItem( - extra: ChapterExtra, + isCurrent: Boolean, + isUnread: Boolean, + isNew: Boolean, isMissing: Boolean, -) = ChapterListItem( - chapter = this, - extra = extra, - isMissing = isMissing, -) \ No newline at end of file + isDownloaded: Boolean, + dateFormat: DateFormat, +): ChapterListItem { + var flags = 0 + if (isCurrent) flags = flags or FLAG_CURRENT + if (isUnread) flags = flags or FLAG_UNREAD + if (isNew) flags = flags or FLAG_NEW + if (isMissing) flags = flags or FLAG_MISSING + if (isDownloaded) flags = flags or FLAG_DOWNLOADED + return ChapterListItem( + chapter = this, + flags = flags, + uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 75905aa51..1a36ec7fc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -145,7 +145,7 @@ class DownloadManager( while (true) { try { val response = call.clone().await() - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { file.outputStream().use { out -> checkNotNull(response.body).byteStream().copyTo(out) } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt index 649b67342..20724d769 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.download.ui import androidx.core.view.isVisible +import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -10,12 +11,11 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.utils.JobStateFlow -import org.koitharu.kotatsu.utils.ext.format -import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat +import org.koitharu.kotatsu.utils.ext.* fun downloadItemAD( scope: CoroutineScope, + coil: ImageLoader, ) = adapterDelegateViewBinding, JobStateFlow, ItemDownloadBinding>( { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } ) { @@ -24,11 +24,16 @@ fun downloadItemAD( bind { job?.cancel() - job = item.onEach { state -> + job = item.onFirst { state -> + binding.imageViewCover.newImageRequest(state.manga.coverUrl) + .referer(state.manga.publicUrl) + .placeholder(state.cover) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .allowRgb565(true) + .enqueueWith(coil) + }.onEach { state -> binding.textViewTitle.text = state.manga.title - binding.imageViewCover.setImageDrawable( - state.cover ?: getDrawable(R.drawable.ic_placeholder) - ) when (state) { is DownloadManager.State.Cancelling -> { binding.textViewStatus.setText(R.string.cancelling_) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt index 5b39881ed..d590a724d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -3,14 +3,17 @@ package org.koitharu.kotatsu.download.ui import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.koin.android.ext.android.get import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.download.ui.service.DownloadService @@ -22,7 +25,7 @@ class DownloadsActivity : BaseActivity() { super.onCreate(savedInstanceState) setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - val adapter = DownloadsAdapter(lifecycleScope) + val adapter = DownloadsAdapter(lifecycleScope, get()) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.adapter = adapter LifecycleAwareServiceConnection.bindService( @@ -44,11 +47,15 @@ class DownloadsActivity : BaseActivity() { right = insets.right, bottom = insets.bottom ) - binding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - top = insets.top - ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt index e6998f894..325180a79 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.download.ui import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import kotlinx.coroutines.CoroutineScope import org.koitharu.kotatsu.download.domain.DownloadManager @@ -8,10 +9,11 @@ import org.koitharu.kotatsu.utils.JobStateFlow class DownloadsAdapter( scope: CoroutineScope, + coil: ImageLoader, ) : AsyncListDifferDelegationAdapter>(DiffCallback()) { init { - delegatesManager.addDelegate(downloadItemAD(scope)) + delegatesManager.addDelegate(downloadItemAD(scope, coil)) setHasStableIds(true) } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index d44204533..03712d889 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -79,6 +78,11 @@ class DownloadService : BaseService() { return binder ?: DownloadBinder(this).also { binder = it } } + override fun onUnbind(intent: Intent?): Boolean { + binder = null + return super.onUnbind(intent) + } + override fun onDestroy() { unregisterReceiver(controlReceiver) binder = null diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt index f66c87fae..aa054c441 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.SortOrder import java.util.* @Entity(tableName = "favourite_categories") -data class FavouriteCategoryEntity( +class FavouriteCategoryEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "created_at") val createdAt: Long, diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt index 95ae66e87..d79660a12 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity ) ] ) -data class FavouriteEntity( +class FavouriteEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long, @ColumnInfo(name = "created_at") val createdAt: Long diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt index 5b74bc08f..e98c84904 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity -data class FavouriteManga( +class FavouriteManga( @Embedded val favourite: FavouriteEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index 399314311..096d26cfd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -30,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment(), override val recycledViewPool = RecyclerView.RecycledViewPool() - private val viewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { CategoriesEditDelegate(requireContext(), this) } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt index 17639f06f..f08a83032 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt @@ -2,8 +2,6 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Color import android.os.Bundle import android.view.Menu import android.view.View @@ -12,9 +10,9 @@ import androidx.core.graphics.Insets import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R @@ -24,15 +22,14 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.showPopupMenu class CategoriesActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { - private val viewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() private lateinit var adapter: CategoriesAdapter private lateinit var reorderHelper: ItemTouchHelper @@ -42,10 +39,9 @@ class CategoriesActivity : BaseActivity(), super.onCreate(savedInstanceState) setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding.fabAdd.imageTintList = ColorStateList.valueOf(Color.WHITE) adapter = CategoriesAdapter(this) editDelegate = CategoriesEditDelegate(this, this) - binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) + binding.recyclerView.addItemDecoration(MaterialDividerItemDecoration(this, RecyclerView.VERTICAL)) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.adapter = adapter binding.fabAdd.setOnClickListener(this) @@ -95,13 +91,17 @@ class CategoriesActivity : BaseActivity(), binding.recyclerView.updatePadding( left = insets.left, right = insets.right, - bottom = insets.bottom - ) - binding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - top = insets.top + bottom = 2 * insets.bottom + binding.fabAdd.measureHeight() ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } private fun onCategoriesChanged(categories: List) { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt index a9a80ce59..bae47cc45 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt @@ -2,7 +2,8 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context import android.text.InputType -import androidx.appcompat.app.AlertDialog +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.core.model.FavouriteCategory @@ -13,7 +14,7 @@ class CategoriesEditDelegate( ) { fun deleteCategory(category: FavouriteCategory) { - AlertDialog.Builder(context) + MaterialAlertDialogBuilder(context) .setMessage(context.getString(R.string.category_delete_confirm, category.title)) .setTitle(R.string.remove_category) .setNegativeButton(android.R.string.cancel, null) @@ -32,7 +33,12 @@ class CategoriesEditDelegate( .setNegativeButton(android.R.string.cancel) .setMaxLength(MAX_TITLE_LENGTH, false) .setPositiveButton(R.string.rename) { _, name -> - callback.onRenameCategory(category, name) + val trimmed = name.trim() + if (trimmed.isEmpty()) { + Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() + } else { + callback.onRenameCategory(category, name) + } }.create() .show() } @@ -45,7 +51,12 @@ class CategoriesEditDelegate( .setNegativeButton(android.R.string.cancel) .setMaxLength(MAX_TITLE_LENGTH, false) .setPositiveButton(R.string.add) { _, name -> - callback.onCreateCategory(name) + val trimmed = name.trim() + if (trimmed.isEmpty()) { + Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() + } else { + callback.onCreateCategory(trimmed) + } }.create() .show() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt index 3736b959a..c1496aa4c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt @@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet, CategoriesEditDelegate.CategoriesEditCallback, View.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(requireNotNull(arguments?.getParcelable(MangaIntent.KEY_MANGA))) } @@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(categoryId) } diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt index 6ca026947..bf6ea6304 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel val historyModule get() = module { - single { HistoryRepository(get(), get()) } + single { HistoryRepository(get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt index 34a75b6a5..770181595 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -18,14 +18,14 @@ import java.util.* ) ] ) -data class HistoryEntity( +class HistoryEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "page") val page: Int, - @ColumnInfo(name = "scroll") val scroll: Float + @ColumnInfo(name = "scroll") val scroll: Float, ) { fun toMangaHistory() = MangaHistory( diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt index d1c8ee2b0..55f41adc6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity -data class HistoryWithManga( +class HistoryWithManga( @Embedded val history: HistoryEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/ChapterExtra.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/ChapterExtra.kt deleted file mode 100644 index 32178c19d..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/ChapterExtra.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.history.domain - -enum class ChapterExtra { - - READ, CURRENT, UNREAD, NEW -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index ade87172b..e34a4fd92 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.ext.mapItems @@ -17,6 +18,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet class HistoryRepository( private val db: MangaDatabase, private val trackingRepository: TrackingRepository, + private val settings: AppSettings, ) { suspend fun getList(offset: Int, limit: Int = 20): List { @@ -46,6 +48,9 @@ class HistoryRepository( } suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) { + if (manga.isNsfw && settings.isHistoryExcludeNsfw) { + return + } val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) db.withTransaction { db.tagsDao.upsert(tags) diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 311adb720..550fab1a5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -5,7 +5,7 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R @@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize class HistoryListFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + override val viewModel by viewModel() override val isSwipeRefreshEnabled = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -42,7 +42,7 @@ class HistoryListFragment : MangaListFragment() { override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_clear_history -> { - AlertDialog.Builder(context ?: return false) + MaterialAlertDialogBuilder(context ?: return false) .setTitle(R.string.clear_history) .setMessage(R.string.text_clear_history_prompt) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt new file mode 100644 index 000000000..8e674701a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.image.ui + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.graphics.Insets +import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.target.PoolableViewTarget +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityImageBinding +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.indicator + +class ImageActivity : BaseActivity() { + + private val coil: ImageLoader by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityImageBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowTitleEnabled(false) + } + loadImage(intent.data) + } + + override fun onWindowInsetsChanged(insets: Insets) { + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + } + + private fun loadImage(url: Uri?) { + ImageRequest.Builder(this) + .data(url) + .memoryCachePolicy(CachePolicy.DISABLED) + .lifecycle(this) + .target(SsivTarget(binding.ssiv)) + .indicator(binding.progressBar) + .enqueueWith(coil) + } + + private class SsivTarget( + override val view: SubsamplingScaleImageView, + ) : PoolableViewTarget { + + override fun onStart(placeholder: Drawable?) = setDrawable(placeholder) + + override fun onError(error: Drawable?) = setDrawable(error) + + override fun onSuccess(result: Drawable) = setDrawable(result) + + override fun onClear() = setDrawable(null) + + override fun equals(other: Any?): Boolean { + return (this === other) || (other is SsivTarget && view == other.view) + } + + override fun hashCode() = view.hashCode() + + override fun toString() = "SsivTarget(view=$view)" + + private fun setDrawable(drawable: Drawable?) { + if (drawable != null) { + view.setImage(ImageSource.bitmap(drawable.toBitmap())) + } else { + view.recycle() + } + } + } + + companion object { + + fun newIntent(context: Context, url: String): Intent { + return Intent(context, ImageActivity::class.java) + .setData(Uri.parse(url)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt new file mode 100644 index 000000000..414a219db --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.list.domain + +import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.core.model.SortOrder + +class AvailableFilters( + val sortOrders: Set, + val tags: Set, +) { + + val size: Int + get() = sortOrders.size + tags.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as AvailableFilters + if (sortOrders != other.sortOrders) return false + if (tags != other.tags) return false + return true + } + + override fun hashCode(): Int { + var result = sortOrders.hashCode() + result = 31 * result + tags.hashCode() + return result + } + + fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt index 56dc55d19..46dfaefd5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt @@ -4,37 +4,31 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.SeekBar -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.DialogListModeBinding +import org.koitharu.kotatsu.utils.ext.setValueRounded +import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter -class ListModeSelectDialog : AlertDialogFragment(), View.OnClickListener, - SeekBar.OnSeekBarChangeListener { +class ListModeSelectDialog : AlertDialogFragment(), + CheckableButtonGroup.OnCheckedChangeListener, Slider.OnSliderTouchListener { private val settings by inject(mode = LazyThreadSafetyMode.NONE) - private var mode: ListMode = ListMode.GRID - private var pendingGridSize: Int = 100 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - mode = settings.listMode - pendingGridSize = settings.gridSize - } - override fun onInflateView( inflater: LayoutInflater, container: ViewGroup? ) = DialogListModeBinding.inflate(inflater, container, false) - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setTitle(R.string.list_mode) .setPositiveButton(R.string.done, null) .setCancelable(true) @@ -42,51 +36,42 @@ class ListModeSelectDialog : AlertDialogFragment(), View. override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val mode = settings.listMode binding.buttonList.isChecked = mode == ListMode.LIST binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST binding.buttonGrid.isChecked = mode == ListMode.GRID binding.textViewGridTitle.isVisible = mode == ListMode.GRID - binding.seekbarGrid.isVisible = mode == ListMode.GRID + binding.sliderGrid.isVisible = mode == ListMode.GRID - with(binding.seekbarGrid) { - progress = pendingGridSize - 50 - setOnSeekBarChangeListener(this@ListModeSelectDialog) - } + binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter()) + binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) + binding.sliderGrid.addOnSliderTouchListener(this) - binding.buttonList.setOnClickListener(this) - binding.buttonGrid.setOnClickListener(this) - binding.buttonListDetailed.setOnClickListener(this) + binding.checkableGroup.onCheckedChangeListener = this } - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - pendingGridSize = progress + 50 - } - - override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit - - override fun onStopTrackingTouch(seekBar: SeekBar?) { - settings.gridSize = pendingGridSize - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_list -> mode = ListMode.LIST - R.id.button_list_detailed -> mode = ListMode.DETAILED_LIST - R.id.button_grid -> mode = ListMode.GRID + override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { + val mode = when (checkedId) { + R.id.button_list -> ListMode.LIST + R.id.button_list_detailed -> ListMode.DETAILED_LIST + R.id.button_grid -> ListMode.GRID + else -> return } binding.textViewGridTitle.isVisible = mode == ListMode.GRID - binding.seekbarGrid.isVisible = mode == ListMode.GRID + binding.sliderGrid.isVisible = mode == ListMode.GRID settings.listMode = mode } + override fun onStartTrackingTouch(slider: Slider) = Unit + + override fun onStopTrackingTouch(slider: Slider) { + settings.gridSize = slider.value.toInt() + } + companion object { private const val TAG = "ListModeSelectDialog" - fun show(fm: FragmentManager) = ListModeSelectDialog() - .show( - fm, - TAG - ) + fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt deleted file mode 100644 index e2147aa97..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.list.ui - -import org.koitharu.kotatsu.core.model.MangaFilter -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.core.model.SortOrder - -data class MangaFilterConfig( - val sortOrders: List, - val tags: List, - val currentFilter: MangaFilter? -) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 1fa44344c..bee787480 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -21,20 +21,17 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener -import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration -import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter -import org.koitharu.kotatsu.list.ui.filter.FilterAdapter -import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener +import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2 +import org.koitharu.kotatsu.list.ui.filter.FilterItem import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.MainActivity @@ -42,10 +39,11 @@ import org.koitharu.kotatsu.utils.RecycledViewPoolHolder import org.koitharu.kotatsu.utils.ext.* abstract class MangaListFragment : BaseFragment(), - PaginationScrollListener.Callback, OnListItemClickListener, OnFilterChangedListener, - SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener { + PaginationScrollListener.Callback, OnListItemClickListener, + SwipeRefreshLayout.OnRefreshListener { private var listAdapter: MangaListAdapter? = null + private var filterAdapter: FilterAdapter2? = null private var paginationListener: PaginationScrollListener? = null private val spanResolver = MangaListSpanResolver() private val spanSizeLookup = SpanSizeLookup() @@ -78,6 +76,7 @@ abstract class MangaListFragment : BaseFragment(), onRetryClick = ::resolveException, onTagRemoveClick = viewModel::onRemoveFilterTag ) + filterAdapter = FilterAdapter2(viewModel) paginationListener = PaginationScrollListener(4, this) with(binding.recyclerView) { setHasFixedSize(true) @@ -85,17 +84,14 @@ abstract class MangaListFragment : BaseFragment(), addOnScrollListener(paginationListener!!) } with(binding.swipeRefreshLayout) { - setColorSchemeColors( - ContextCompat.getColor(context, R.color.color_primary), - ContextCompat.getColor(context, R.color.color_primary_variant) - ) + setProgressBackgroundColorSchemeColor(context.getThemeColor(com.google.android.material.R.attr.colorPrimary)) + setColorSchemeColors(context.getThemeColor(com.google.android.material.R.attr.colorOnPrimary)) setOnRefreshListener(this@MangaListFragment) isEnabled = isSwipeRefreshEnabled } with(binding.recyclerViewFilter) { setHasFixedSize(true) - addItemDecoration(ItemTypeDividerDecoration(view.context)) - addItemDecoration(SectionItemDecoration(false, this@MangaListFragment)) + adapter = filterAdapter } (parentFragment as? RecycledViewPoolHolder)?.let { @@ -113,6 +109,7 @@ abstract class MangaListFragment : BaseFragment(), override fun onDestroyView() { drawer = null listAdapter = null + filterAdapter = null paginationListener = null spanSizeLookup.invalidateCache() super.onDestroyView() @@ -203,28 +200,21 @@ abstract class MangaListFragment : BaseFragment(), } } - protected fun onInitFilter(config: MangaFilterConfig) { - binding.recyclerViewFilter.adapter = FilterAdapter( - sortOrders = config.sortOrders, - tags = config.tags, - state = config.currentFilter, - listener = this - ) + protected fun onInitFilter(filter: List) { + filterAdapter?.items = filter drawer?.setDrawerLockMode( - if (config.sortOrders.isEmpty() && config.tags.isEmpty()) { + if (filter.isEmpty()) { DrawerLayout.LOCK_MODE_LOCKED_CLOSED } else { DrawerLayout.LOCK_MODE_UNLOCKED } ) ?: binding.dividerFilter?.let { - it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty() + it.isGone = filter.isEmpty() binding.recyclerViewFilter.isVisible = it.isVisible } activity?.invalidateOptionsMenu() } - override fun onFilterChanged(filter: MangaFilter) = Unit - override fun onWindowInsetsChanged(insets: Insets) { val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.recyclerViewFilter.updatePadding( @@ -284,20 +274,6 @@ abstract class MangaListFragment : BaseFragment(), } } - final override fun isSection(position: Int): Boolean { - return position == 0 || binding.recyclerViewFilter.adapter?.run { - getItemViewType(position) != getItemViewType(position - 1) - } ?: false - } - - final override fun getSectionTitle(position: Int): CharSequence? { - return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) { - FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order) - FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres) - else -> null - } - } - protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 3d94d1b65..e2a463f4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -1,23 +1,32 @@ package org.koitharu.kotatsu.list.ui +import androidx.annotation.CallSuper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.list.domain.AvailableFilters +import org.koitharu.kotatsu.list.ui.filter.FilterItem +import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct abstract class MangaListViewModel( - private val settings: AppSettings -) : BaseViewModel() { + private val settings: AppSettings, +) : BaseViewModel(), OnFilterChangedListener { abstract val content: LiveData> - val filter = MutableLiveData() + val filter = MutableLiveData>() val listMode = MutableLiveData() val gridScale = settings.observe() .filter { it == AppSettings.KEY_GRID_SIZE } @@ -37,7 +46,62 @@ abstract class MangaListViewModel( } } - open fun onRemoveFilterTag(tag: MangaTag) = Unit + protected var currentFilter: MangaFilter = MangaFilter(null, emptySet()) + private set(value) { + field = value + onFilterChanged() + } + protected var availableFilters: AvailableFilters? = null + private var filterJob: Job? = null + + final override fun onSortItemClick(item: FilterItem.Sort) { + currentFilter = currentFilter.copy(sortOrder = item.order) + } + + final override fun onTagItemClick(item: FilterItem.Tag) { + val tags = if (item.isChecked) { + currentFilter.tags - item.tag + } else { + currentFilter.tags + item.tag + } + currentFilter = currentFilter.copy(tags = tags) + } + + fun onRemoveFilterTag(tag: MangaTag) { + val tags = currentFilter.tags + if (tag !in tags) { + return + } + currentFilter = currentFilter.copy(tags = tags - tag) + } + + @CallSuper + open fun onFilterChanged() { + val previousJob = filterJob + filterJob = launchJob(Dispatchers.Default) { + previousJob?.cancelAndJoin() + filter.postValue( + availableFilters?.run { + val list = ArrayList(size + 2) + if (sortOrders.isNotEmpty()) { + val selectedSort = currentFilter.sortOrder ?: sortOrders.first() + list += FilterItem.Header(R.string.sort_order) + sortOrders.sortedBy { it.ordinal }.mapTo(list) { + FilterItem.Sort(it, isSelected = it == selectedSort) + } + } + if (tags.isNotEmpty()) { + list += FilterItem.Header(R.string.genres) + tags.sortedBy { it.title }.mapTo(list) { + FilterItem.Tag(it, isChecked = it in currentFilter.tags) + } + } + ensureActive() + list + }.orEmpty() + ) + } + } abstract fun onRefresh() diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index cc4c80967..61cd60c03 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -43,10 +43,6 @@ class MangaListAdapter( .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick)) } - fun setItems(list: List, commitCallback: Runnable) { - differ.submitList(list, commitCallback) - } - private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt deleted file mode 100644 index 983fdf0f1..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaFilter -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.core.model.SortOrder - -class FilterAdapter( - private val sortOrders: List = emptyList(), - private val tags: List = emptyList(), - state: MangaFilter?, - private val listener: OnFilterChangedListener -) : RecyclerView.Adapter>() { - - private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet()) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - VIEW_TYPE_SORT -> FilterSortHolder(parent).apply { - itemView.setOnClickListener { - setCheckedSort(requireData()) - } - } - VIEW_TYPE_TAG -> FilterTagHolder(parent).apply { - itemView.setOnClickListener { - setCheckedTag(boundData ?: return@setOnClickListener, !isChecked) - } - } - else -> throw IllegalArgumentException("Unknown viewType $viewType") - } - - override fun getItemCount() = sortOrders.size + tags.size - - override fun onBindViewHolder(holder: BaseViewHolder<*, Boolean, *>, position: Int) { - when (holder) { - is FilterSortHolder -> { - val item = sortOrders[position] - holder.bind(item, item == currentState.sortOrder) - } - is FilterTagHolder -> { - val item = tags[position - sortOrders.size] - holder.bind(item, item in currentState.tags) - } - } - } - - override fun getItemViewType(position: Int) = when (position) { - in sortOrders.indices -> VIEW_TYPE_SORT - else -> VIEW_TYPE_TAG - } - - fun setCheckedTag(tag: MangaTag, isChecked: Boolean) { - currentState = if (tag in currentState.tags) { - if (!isChecked) { - currentState.copy(tags = currentState.tags - tag) - } else { - return - } - } else { - if (isChecked) { - currentState.copy(tags = currentState.tags + tag) - } else { - return - } - } - val index = tags.indexOf(tag) - if (index in tags.indices) { - notifyItemChanged(sortOrders.size + index) - } - listener.onFilterChanged(currentState) - } - - fun setCheckedSort(sort: SortOrder) { - if (sort != currentState.sortOrder) { - val oldItemPos = sortOrders.indexOf(currentState.sortOrder) - val newItemPos = sortOrders.indexOf(sort) - currentState = currentState.copy(sortOrder = sort) - if (oldItemPos in sortOrders.indices) { - notifyItemChanged(oldItemPos) - } - if (newItemPos in sortOrders.indices) { - notifyItemChanged(newItemPos) - } - listener.onFilterChanged(currentState) - } - } - - companion object { - - const val VIEW_TYPE_SORT = 0 - const val VIEW_TYPE_TAG = 1 - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt new file mode 100644 index 000000000..67b4d3585 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.list.ui.filter + +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter + +class FilterAdapter2( + listener: OnFilterChangedListener, +) : AsyncListDifferDelegationAdapter( + FilterDiffCallback(), + filterSortDelegate(listener), + filterTagDelegate(listener), + filterHeaderDelegate(), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt new file mode 100644 index 000000000..8b926d768 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.list.ui.filter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding +import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding + +fun filterSortDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onSortItemClick(item) + } + + bind { + binding.root.setText(item.order.titleRes) + binding.root.isChecked = item.isSelected + } +} + +fun filterTagDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onTagItemClick(item) + } + + bind { + binding.root.text = item.tag.title + binding.root.isChecked = item.isChecked + } +} + +fun filterHeaderDelegate() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } +) { + + bind { + binding.root.setText(item.titleResId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt new file mode 100644 index 000000000..1ccd4e813 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.recyclerview.widget.DiffUtil + +class FilterDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is FilterItem.Header && newItem is FilterItem.Header -> { + oldItem.titleResId == newItem.titleResId + } + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.tag == newItem.tag + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.order == newItem.order + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem is FilterItem.Header && newItem is FilterItem.Header -> true + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked == newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected == newItem.isSelected + } + else -> false + } + } + + override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? { + val isCheckedChanged = when { + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked != newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected != newItem.isSelected + } + else -> false + } + return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt new file mode 100644 index 000000000..a74d93b1d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.core.model.SortOrder + +sealed interface FilterItem { + + class Header( + @StringRes val titleResId: Int, + ) : FilterItem + + class Sort( + val order: SortOrder, + val isSelected: Boolean, + ) : FilterItem + + class Tag( + val tag: MangaTag, + val isChecked: Boolean, + ) : FilterItem +} diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt deleted file mode 100644 index 9275ae831..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.SortOrder -import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding - -class FilterSortHolder(parent: ViewGroup) : - BaseViewHolder( - ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - override fun onBind(data: SortOrder, extra: Boolean) { - binding.root.setText(data.titleRes) - binding.root.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt deleted file mode 100644 index 2054d4cb9..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding - -class FilterTagHolder(parent: ViewGroup) : - BaseViewHolder( - ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - val isChecked: Boolean - get() = binding.root.isChecked - - override fun onBind(data: MangaTag, extra: Boolean) { - binding.root.text = data.title - binding.root.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt index 93a1b7db5..a28596c9f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.list.ui.filter -import org.koitharu.kotatsu.core.model.MangaFilter +interface OnFilterChangedListener { -fun interface OnFilterChangedListener { + fun onSortItemClick(item: FilterItem.Sort) - fun onFilterChanged(filter: MangaFilter) + fun onTagItemClick(item: FilterItem.Tag) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index cd4f5ea83..4e2746cec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -9,23 +9,24 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.size.Size +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source import java.util.zip.ZipFile class CbzFetcher : Fetcher { - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun fetch( pool: BitmapPool, data: Uri, size: Size, options: Options, - ): FetchResult { + ): FetchResult = runInterruptible(Dispatchers.IO) { val zip = ZipFile(data.schemeSpecificPart) val entry = zip.getEntry(data.fragment) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) - return SourceResult( + SourceResult( source = ExtraCloseableBufferedSource( zip.getInputStream(entry).source().buffer(), zip, diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 45df69340..f678b83b7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault +import org.koitharu.kotatsu.utils.ext.getLongOrDefault import org.koitharu.kotatsu.utils.ext.getStringOrNull import org.koitharu.kotatsu.utils.ext.mapToSet @@ -20,9 +22,11 @@ class MangaIndex(source: String?) { json.put("title_alt", manga.altTitle) json.put("url", manga.url) json.put("public_url", manga.publicUrl) + json.put("author", manga.author) json.put("cover", manga.coverUrl) json.put("description", manga.description) json.put("rating", manga.rating) + json.put("nsfw", manga.isNsfw) json.put("source", manga.source.name) json.put("cover_large", manga.largeCoverUrl) json.put("tags", JSONArray().also { a -> @@ -48,8 +52,11 @@ class MangaIndex(source: String?) { altTitle = json.getStringOrNull("title_alt"), url = json.getString("url"), publicUrl = json.getStringOrNull("public_url").orEmpty(), + author = json.getStringOrNull("author"), + largeCoverUrl = json.getStringOrNull("cover_large"), source = source, rating = json.getDouble("rating").toFloat(), + isNsfw = json.getBooleanOrDefault("nsfw", false), coverUrl = json.getString("cover"), description = json.getStringOrNull("description"), tags = json.getJSONArray("tags").mapToSet { x -> @@ -59,7 +66,7 @@ class MangaIndex(source: String?) { source = source ) }, - chapters = getChapters(json.getJSONObject("chapters"), source) + chapters = getChapters(json.getJSONObject("chapters"), source), ) }.getOrNull() @@ -72,6 +79,8 @@ class MangaIndex(source: String?) { jo.put("number", chapter.number) jo.put("url", chapter.url) jo.put("name", chapter.name) + jo.put("uploadDate", chapter.uploadDate) + jo.put("scanlator", chapter.scanlator) jo.put("branch", chapter.branch) jo.put("entries", "%03d\\d{3}".format(chapter.number)) chapters.put(chapter.id.toString(), jo) @@ -98,8 +107,10 @@ class MangaIndex(source: String?) { name = v.getString("name"), url = v.getString("url"), number = v.getInt("number"), + uploadDate = v.getLongOrDefault("uploadDate", 0L), + scanlator = v.getStringOrNull("scanlator"), branch = v.getStringOrNull("branch"), - source = source + source = source, ) ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt index 2904910d6..c9d93f147 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt @@ -31,7 +31,7 @@ class MangaZip(val file: File) { return writableCbz.flush() } - fun addCover(file: File, ext: String) { + suspend fun addCover(file: File, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(0, 0)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -39,11 +39,11 @@ class MangaZip(val file: File) { append(ext) } } - writableCbz[name] = file + writableCbz.put(name, file) index.setCoverEntry(name) } - fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { + suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(chapter.number, pageNumber)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -51,7 +51,7 @@ class MangaZip(val file: File) { append(ext) } } - writableCbz[name] = file + writableCbz.put(name, file) index.addChapter(chapter) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt index 5a591740f..fbc2637aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.local.data import androidx.annotation.CheckResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import org.koitharu.kotatsu.utils.ext.deleteAwait import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -14,7 +14,6 @@ class WritableCbzFile(private val file: File) { private val dir = File(file.parentFile, file.nameWithoutExtension) - @Suppress("BlockingMethodInNonBlockingContext") suspend fun prepare() = withContext(Dispatchers.IO) { check(dir.list().isNullOrEmpty()) { "Dir ${dir.name} is not empty" @@ -27,11 +26,13 @@ class WritableCbzFile(private val file: File) { } ZipInputStream(FileInputStream(file)).use { zip -> var entry = zip.nextEntry - while (entry != null) { + while (entry != null && currentCoroutineContext().isActive) { val target = File(dir.path + File.separator + entry.name) - target.parentFile?.mkdirs() - target.outputStream().use { out -> - zip.copyTo(out) + runInterruptible { + target.parentFile?.mkdirs() + target.outputStream().use { out -> + zip.copyTo(out) + } } zip.closeEntry() entry = zip.nextEntry @@ -44,52 +45,50 @@ class WritableCbzFile(private val file: File) { } @CheckResult - @Suppress("BlockingMethodInNonBlockingContext") suspend fun flush() = withContext(Dispatchers.IO) { val tempFile = File(file.path + ".tmp") if (tempFile.exists()) { - tempFile.delete() + tempFile.deleteAwait() } try { - ZipOutputStream(FileOutputStream(tempFile)).use { zip -> - dir.listFiles()?.forEach { - zipFile(it, it.name, zip) + runInterruptible { + ZipOutputStream(FileOutputStream(tempFile)).use { zip -> + dir.listFiles()?.forEach { + zipFile(it, it.name, zip) + } + zip.flush() } - zip.flush() } tempFile.renameTo(file) } finally { if (tempFile.exists()) { - tempFile.delete() + tempFile.deleteAwait() } } } operator fun get(name: String) = File(dir, name) - operator fun set(name: String, file: File) { + suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) { file.copyTo(this[name], overwrite = true) } - companion object { - - private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { - if (fileToZip.isDirectory) { - if (fileName.endsWith("/")) { - zipOut.putNextEntry(ZipEntry(fileName)) - } else { - zipOut.putNextEntry(ZipEntry("$fileName/")) - } - zipOut.closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - zipFile(childFile, "$fileName/${childFile.name}", zipOut) - } + private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { + if (fileToZip.isDirectory) { + if (fileName.endsWith("/")) { + zipOut.putNextEntry(ZipEntry(fileName)) } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(fileName) - zipOut.putNextEntry(zipEntry) - fis.copyTo(zipOut) - } + zipOut.putNextEntry(ZipEntry("$fileName/")) + } + zipOut.closeEntry() + fileToZip.listFiles()?.forEach { childFile -> + zipFile(childFile, "$fileName/${childFile.name}", zipOut) + } + } else { + FileInputStream(fileToZip).use { fis -> + val zipEntry = ZipEntry(fileName) + zipOut.putNextEntry(zipEntry) + fis.copyTo(zipOut) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 3d4c51571..2dfb798f2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -8,6 +8,7 @@ import androidx.collection.ArraySet import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.MangaRepository @@ -23,6 +24,7 @@ import java.util.zip.ZipFile class LocalMangaRepository(private val context: Context) : MangaRepository { + override val source = MangaSource.LOCAL private val filenameFilter = CbzFilter() override suspend fun getList2( @@ -42,37 +44,39 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { getFromFile(Uri.parse(manga.url).toFile()) } else manga - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun getPages(chapter: MangaChapter): List { - val uri = Uri.parse(chapter.url) - val file = uri.toFile() - val zip = ZipFile(file) - val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) - var entries = zip.entries().asSequence() - entries = if (index != null) { - val pattern = index.getChapterNamesPattern(chapter) - entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } - } else { - val parent = uri.fragment.orEmpty() - entries.filter { x -> - !x.isDirectory && x.name.substringBeforeLast( - File.separatorChar, - "" - ) == parent + return runInterruptible(Dispatchers.IO){ + val uri = Uri.parse(chapter.url) + val file = uri.toFile() + val zip = ZipFile(file) + val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) + var entries = zip.entries().asSequence() + entries = if (index != null) { + val pattern = index.getChapterNamesPattern(chapter) + entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } + } else { + val parent = uri.fragment.orEmpty() + entries.filter { x -> + !x.isDirectory && x.name.substringBeforeLast( + File.separatorChar, + "" + ) == parent + } } + entries + .toList() + .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) + .map { x -> + val entryUri = zipUri(file, x.name) + MangaPage( + id = entryUri.longHashCode(), + url = entryUri, + preview = null, + referer = chapter.url, + source = MangaSource.LOCAL, + ) + } } - return entries - .toList() - .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) - .map { x -> - val entryUri = zipUri(file, x.name) - MangaPage( - id = entryUri.longHashCode(), - url = entryUri, - referer = chapter.url, - source = MangaSource.LOCAL - ) - } } suspend fun delete(manga: Manga): Boolean { @@ -123,7 +127,10 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { name = s.ifEmpty { title }, number = i + 1, source = MangaSource.LOCAL, - url = uriBuilder.fragment(s).build().toString() + uploadDate = 0L, + url = uriBuilder.fragment(s).build().toString(), + scanlator = null, + branch = null, ) } ) @@ -133,20 +140,18 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { val file = runCatching { Uri.parse(localManga.url).toFile() }.getOrNull() ?: return null - return withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") + return runInterruptible(Dispatchers.IO) { ZipFile(file).use { zip -> val entry = zip.getEntry(MangaZip.INDEX_ENTRY) - val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null - index.getMangaInfo() + val index = entry?.let(zip::readText)?.let(::MangaIndex) + index?.getMangaInfo() } } } - suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) { + suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) { val files = getAllFiles() for (file in files) { - @Suppress("BlockingMethodInNonBlockingContext") val index = ZipFile(file).use { zip -> val entry = zip.getEntry(MangaZip.INDEX_ENTRY) entry?.let(zip::readText)?.let(::MangaIndex) @@ -154,7 +159,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { val info = index.getMangaInfo() ?: continue if (info.id == remoteManga.id) { val fileUri = file.toUri().toString() - return@withContext info.copy( + return@runInterruptible info.copy( source = MangaSource.LOCAL, url = fileUri, chapters = info.chapters?.map { c -> c.copy(url = fileUri) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 319cf5619..586e96bfc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -9,7 +9,7 @@ import android.view.MenuItem import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.BuildConfig @@ -19,9 +19,9 @@ import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.utils.ext.ellipsize -class LocalListFragment : MangaListFragment(), ActivityResultCallback { +class LocalListFragment : MangaListFragment(), ActivityResultCallback { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + override val viewModel by viewModel() private val importCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), this @@ -98,7 +98,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback { override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean { return when (item.itemId) { R.id.action_delete -> { - AlertDialog.Builder(context ?: return false) + MaterialAlertDialogBuilder(context ?: return false) .setTitle(R.string.delete_manga) .setMessage(getString(R.string.text_delete_local_manga, data.title)) .setPositiveButton(R.string.delete) { _, _ -> diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 5c12ad115..b1fc2493e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException @@ -16,9 +17,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.resolveName import java.io.File import java.io.IOException @@ -74,17 +75,18 @@ class LocalListViewModel( launchLoadingJob { val contentResolver = context.contentResolver withContext(Dispatchers.IO) { - val name = MediaStoreCompat(contentResolver).getName(uri) + val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") if (!LocalMangaRepository.isFileSupported(name)) { throw UnsupportedFileException("Unsupported file on $uri") } val dest = settings.getStorageDir(context)?.let { File(it, name) } ?: throw IOException("External files dir unavailable") - @Suppress("BlockingMethodInNonBlockingContext") - contentResolver.openInputStream(uri)?.use { source -> - dest.outputStream().use { output -> - source.copyTo(output) + runInterruptible { + contentResolver.openInputStream(uri)?.use { source -> + dest.outputStream().use { output -> + source.copyTo(output) + } } } ?: throw IOException("Cannot open input stream: $uri") } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index d333fddb7..6b59897ad 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -1,17 +1,13 @@ package org.koitharu.kotatsu.main.ui import android.app.ActivityOptions -import android.content.res.ColorStateList import android.content.res.Configuration -import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.* @@ -21,8 +17,8 @@ import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import androidx.transition.TransitionManager import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.get @@ -32,7 +28,6 @@ import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.prefs.AppSection -import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.NavigationHeaderBinding import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -59,14 +54,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener, AppBarOwner, View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) - private val searchSuggestionViewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() + private val searchSuggestionViewModel by viewModel() private lateinit var navHeaderBinding: NavigationHeaderBinding private lateinit var drawerToggle: ActionBarDrawerToggle - private var searchViewElevation = 0f override val appBar: AppBarLayout get() = binding.appbar @@ -74,7 +66,6 @@ class MainActivity : BaseActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMainBinding.inflate(layoutInflater)) - searchViewElevation = binding.toolbarCard.cardElevation navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater) drawerToggle = ActionBarDrawerToggle( this, @@ -91,13 +82,6 @@ class MainActivity : BaseActivity(), binding.drawer.addDrawerListener(drawerToggle) supportActionBar?.setDisplayHomeAsUpEnabled(true) - if (get().isAmoledTheme && get().theme == AppCompatDelegate.MODE_NIGHT_YES) { - binding.appbar.setBackgroundColor(Color.BLACK) - binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background)) - } else { - binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface)) - } - with(binding.searchView) { onFocusChangeListener = this@MainActivity searchSuggestionListener = this@MainActivity @@ -114,17 +98,13 @@ class MainActivity : BaseActivity(), insets } addHeaderView(navHeaderBinding.root) - itemBackground = navigationItemBackground(context) setNavigationItemSelectedListener(this@MainActivity) } - with(binding.fab) { - imageTintList = ColorStateList.valueOf(Color.WHITE) - setOnClickListener(this@MainActivity) - } + binding.fab.setOnClickListener(this@MainActivity) supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let { - binding.fab.isVisible = it is HistoryListFragment + if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide() } ?: run { openDefaultSection() } @@ -271,7 +251,7 @@ class MainActivity : BaseActivity(), } override fun onClearSearchHistory() { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setTitle(R.string.clear_search_history) .setMessage(R.string.text_clear_search_history_prompt) .setNegativeButton(android.R.string.cancel, null) @@ -302,7 +282,7 @@ class MainActivity : BaseActivity(), binding.fab.isEnabled = !isLoading if (isLoading) { binding.fab.setImageDrawable(CircularProgressDrawable(this).also { - it.setColorSchemeColors(Color.WHITE) + it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer) it.strokeWidth = resources.resolveDp(2f) it.start() }) @@ -316,6 +296,7 @@ class MainActivity : BaseActivity(), submenu.removeGroup(R.id.group_remote_sources) remoteSources.forEachIndexed { index, source -> submenu.add(R.id.group_remote_sources, source.ordinal, index, source.title) + .setIcon(R.drawable.ic_manga_source) } submenu.setGroupCheckable(R.id.group_remote_sources, true, true) } @@ -349,48 +330,17 @@ class MainActivity : BaseActivity(), supportFragmentManager.beginTransaction() .replace(R.id.container, fragment, TAG_PRIMARY) .commit() - binding.fab.isVisible = fragment is HistoryListFragment + if (fragment is HistoryListFragment) binding.fab.show() else binding.fab.hide() } private fun onSearchOpened() { binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) drawerToggle.isDrawerIndicatorEnabled = false - TransitionManager.beginDelayedTransition(binding.appbar) - // Avoiding shadows on the sides if the color is transparent, so we make the AppBarLayout white/grey/black - if (isDarkAmoledTheme()) { - binding.toolbar.setBackgroundColor(Color.BLACK) - } else { - binding.appbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface)) - } - binding.toolbarCard.apply { - cardElevation = 0f - // Remove margin - updateLayoutParams { - leftMargin = 0 - rightMargin = 0 - } - - } - binding.appbar.elevation = searchViewElevation } private fun onSearchClosed() { binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) drawerToggle.isDrawerIndicatorEnabled = true - if (isDarkAmoledTheme()) { - binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background)) - } - TransitionManager.beginDelayedTransition(binding.appbar) - // Returning transparent color - binding.appbar.setBackgroundColor(Color.TRANSPARENT) - binding.appbar.elevation = 0f - binding.toolbarCard.apply { - cardElevation = searchViewElevation - updateLayoutParams { - leftMargin = resources.resolveDp(16) - rightMargin = resources.resolveDp(16) - } - } } private fun onFirstStart() { diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index 19ad00c4b..97ca1df3a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -19,7 +19,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, TextWatcher, View.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index 74ea5b618..a7a806fef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -13,6 +13,6 @@ val readerModule single { PagesCache(get()) } viewModel { params -> - ReaderViewModel(params[0], params[1], get(), get(), get(), get()) + ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 38cfe450e..8e4e8316d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -50,7 +50,7 @@ class PageLoader( private fun loadAsync(page: MangaPage): Deferred { var repo = repository - if (repo?.javaClass != page.source.cls) { + if (repo?.source != page.source) { repo = mangaRepositoryOf(page.source) repository = repo } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt index 78d713fff..4575e12fa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt @@ -4,20 +4,21 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.divider.MaterialDividerItemDecoration +import org.koin.android.ext.android.get import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.MangaChapter +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.DialogChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem -import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.utils.ext.withArgs class ChaptersDialog : AlertDialogFragment(), @@ -28,7 +29,7 @@ class ChaptersDialog : AlertDialogFragment(), container: ViewGroup?, ) = DialogChaptersBinding.inflate(inflater, container, false) - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setTitle(R.string.chapters) .setNegativeButton(R.string.close, null) .setCancelable(true) @@ -36,7 +37,7 @@ class ChaptersDialog : AlertDialogFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.recyclerViewChapters.addItemDecoration( - DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) + MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL) ) val chapters = arguments?.getParcelableArrayList(ARG_CHAPTERS) if (chapters == null) { @@ -45,15 +46,16 @@ class ChaptersDialog : AlertDialogFragment(), } val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L val currentPosition = chapters.indexOfFirst { it.id == currentId } + val dateFormat = get().dateFormat() binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply { setItems(chapters.mapIndexed { index, chapter -> chapter.toListItem( - when { - index < currentPosition -> ChapterExtra.READ - index == currentPosition -> ChapterExtra.CURRENT - else -> ChapterExtra.UNREAD - }, - isMissing = false + isCurrent = index == currentPosition, + isUnread = index > currentPosition, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, ) }) { if (currentPosition >= 0) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index b8b7bcb19..dc57a4fb2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -10,12 +10,15 @@ import android.view.* import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.view.* +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.postDelayed +import androidx.core.view.updatePadding import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn @@ -52,7 +55,7 @@ class ReaderActivity : BaseFullscreenActivity(), GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, ActivityResultCallback, ReaderControlDelegate.OnInteractionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra(EXTRA_STATE)) } @@ -192,7 +195,8 @@ class ReaderActivity : BaseFullscreenActivity(), override fun onActivityResult(result: Boolean) { if (result) { - viewModel.saveCurrentPage(contentResolver) + viewModel.saveCurrentState(reader?.getCurrentState()) + viewModel.saveCurrentPage() } } @@ -207,7 +211,7 @@ class ReaderActivity : BaseFullscreenActivity(), } private fun onError(e: Throwable) { - val dialog = AlertDialog.Builder(this) + val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.error_occurred) .setMessage(e.getDisplayMessage(resources)) .setPositiveButton(R.string.close, null) @@ -234,8 +238,8 @@ class ReaderActivity : BaseFullscreenActivity(), ) { false } else { - val targets = binding.root.hitTest(rawX, rawY) - targets.none { it.hasOnClickListeners() } + val touchables = window.peekDecorView()?.touchables + touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true } } @@ -277,7 +281,7 @@ class ReaderActivity : BaseFullscreenActivity(), private fun onPageSaved(uri: Uri?) { if (uri != null) { - Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_LONG) + Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_INDEFINITE) .setAnchorView(binding.appbarBottom) .setAction(R.string.share) { ShareHelper(this).shareImage(uri) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt index 42824267d..85f326c68 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt @@ -5,16 +5,17 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding import org.koitharu.kotatsu.utils.ext.withArgs class ReaderConfigDialog : AlertDialogFragment(), - View.OnClickListener { + CheckableButtonGroup.OnCheckedChangeListener { private lateinit var mode: ReaderMode @@ -30,7 +31,7 @@ class ReaderConfigDialog : AlertDialogFragment(), ?: ReaderMode.STANDARD } - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setTitle(R.string.read_mode) .setPositiveButton(R.string.done, null) .setCancelable(true) @@ -42,9 +43,7 @@ class ReaderConfigDialog : AlertDialogFragment(), binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON - binding.buttonStandard.setOnClickListener(this) - binding.buttonReversed.setOnClickListener(this) - binding.buttonWebtoon.setOnClickListener(this) + binding.checkableGroup.onCheckedChangeListener = this } override fun onDismiss(dialog: DialogInterface) { @@ -53,11 +52,12 @@ class ReaderConfigDialog : AlertDialogFragment(), super.onDismiss(dialog) } - override fun onClick(v: View) { - when (v.id) { - R.id.button_standard -> mode = ReaderMode.STANDARD - R.id.button_webtoon -> mode = ReaderMode.WEBTOON - R.id.button_reversed -> mode = ReaderMode.REVERSED + override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { + mode = when (checkedId) { + R.id.button_standard -> ReaderMode.STANDARD + R.id.button_webtoon -> ReaderMode.WEBTOON + R.id.button_reversed -> ReaderMode.REVERSED + else -> return } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index adb4a5914..fa6203f55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -1,9 +1,7 @@ package org.koitharu.kotatsu.reader.ui -import android.content.ContentResolver import android.net.Uri import android.util.LongSparseArray -import android.webkit.URLUtil import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.* @@ -19,15 +17,17 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.os.ShortcutsRepository +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.utils.MediaStoreCompat +import org.koitharu.kotatsu.utils.DownloadManagerHelper import org.koitharu.kotatsu.utils.SingleLiveEvent -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.IgnoreErrors +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.processLifecycleScope class ReaderViewModel( intent: MangaIntent, @@ -35,7 +35,8 @@ class ReaderViewModel( private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val shortcutsRepository: ShortcutsRepository, - private val settings: AppSettings + private val settings: AppSettings, + private val downloadManagerHelper: DownloadManagerHelper, ) : BaseViewModel() { private var loadingJob: Job? = null @@ -75,7 +76,7 @@ class ReaderViewModel( var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") mangaData.value = manga - val repo = manga.source.repository + val repo = MangaRepository(manga.source) manga = repo.getDetails(manga) manga.chapters?.forEach { chapters.put(it.id, it) @@ -147,22 +148,17 @@ class ReaderViewModel( return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } } - fun saveCurrentPage(resolver: ContentResolver) { + fun saveCurrentPage() { launchJob(Dispatchers.Default) { try { val state = currentState.value ?: error("Undefined state") val page = content.value?.pages?.find { it.chapterId == state.chapterId && it.index == state.page }?.toMangaPage() ?: error("Page not found") - val repo = page.source.repository + val repo = MangaRepository(page.source) val pageUrl = repo.getPageUrl(page) - val file = get()[pageUrl] ?: error("Page not found in cache") - val uri = file.inputStream().use { input -> - val fileName = URLUtil.guessFileName(pageUrl, null, null) - MediaStoreCompat(resolver).insertImage(fileName) { - input.copyTo(it) - } - } + val downloadId = downloadManagerHelper.downloadPage(page, pageUrl) + val uri = downloadManagerHelper.awaitDownload(downloadId) onPageSaved.postCall(uri) } catch (e: CancellationException) { } catch (e: Exception) { @@ -209,7 +205,7 @@ class ReaderViewModel( private suspend fun loadChapter(chapterId: Long): List { val manga = checkNotNull(mangaData.value) { "Manga is null" } val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } - val repo = manga.source.repository + val repo = MangaRepository(manga.source) return repo.getPages(chapter).mapIndexed { index, page -> ReaderPage.from(page, index, chapterId) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt index 0113bb475..8efab6827 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.view.ViewGroup import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.commit import org.koitharu.kotatsu.BuildConfig @@ -38,11 +40,15 @@ class SimpleSettingsActivity : BaseActivity() { } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbar.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right - ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt index c44a5a1c0..b6adc87b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt @@ -47,10 +47,6 @@ abstract class BaseReaderAdapter>( viewType: Int ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) - fun setItems(items: List, callback: Runnable) { - differ.submitList(items, callback) - } - suspend fun setItems(items: List) = suspendCoroutine { cont -> differ.submitList(items) { cont.resume(Unit) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt index 1da4a9d39..87ea32b7f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt @@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2 class ReversedPageAnimTransformer : ViewPager2.PageTransformer { - override fun transformPage(page: View, position: Float) { - with(page) { - val pageWidth = width - when { - position > 1 -> alpha = 0f - position >= 0 -> { - alpha = 1f - translationX = 0f - translationZ = 0f - scaleX = 1 + FACTOR * position - scaleY = 1f - } - position >= -1 -> { - alpha = 1f - translationX = pageWidth * -position - translationZ = -1f - scaleX = 1f - scaleY = 1f - } - else -> alpha = 0f + override fun transformPage(page: View, position: Float) = with(page) { + translationX = -position * width + pivotX = width.toFloat() + pivotY = height / 2f + cameraDistance = 20000f + when { + position < -1f || position > 1f -> { + alpha = 0f + rotationY = 0f + translationZ = -1f + } + position <= 0f -> { + alpha = 1f + rotationY = 0f + translationZ = 0f + } + position > 0f -> { + alpha = 1f + rotationY = 120 * position + translationZ = 2f } } } - - private companion object { - - const val FACTOR = 0.1f - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt index edf5b205b..33920e631 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.graphics.PointF +import android.view.Gravity +import android.widget.FrameLayout import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode @@ -16,6 +18,11 @@ class ReversedPageHolder( exceptionResolver: ExceptionResolver ) : PageHolder(binding, loader, settings, exceptionResolver) { + init { + (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) + .gravity = Gravity.START or Gravity.BOTTOM + } + override fun onImageShowing(zoom: ZoomMode) { with(binding.ssiv) { maxScale = 2f * maxOf( diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt index cd03ce8eb..1aad3c8b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt @@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2 class PageAnimTransformer : ViewPager2.PageTransformer { - override fun transformPage(page: View, position: Float) { - page.apply { - val pageWidth = width - when { - position < -1 -> alpha = 0f - position <= 0 -> { // [-1,0] - alpha = 1f - translationX = 0f - translationZ = 0f - scaleX = 1 + FACTOR * position - scaleY = 1f - } - position <= 1 -> { // (0,1] - alpha = 1f - translationX = pageWidth * -position - translationZ = -1f - scaleX = 1f - scaleY = 1f - } - else -> alpha = 0f + override fun transformPage(page: View, position: Float) = with(page) { + translationX = -position * width + pivotX = 0f + pivotY = height / 2f + cameraDistance = 20000f + when { + position < -1f || position > 1f -> { + alpha = 0f + rotationY = 0f + translationZ = -1f + } + position > 0f -> { + alpha = 1f + rotationY = 0f + translationZ = 0f + } + position <= 0f -> { + alpha = 1f + rotationY = 120 * position + translationZ = 2f } } } - - private companion object { - - const val FACTOR = 0.1f - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 369bccf48..bd630b8d0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -20,17 +20,20 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage open class PageHolder( binding: ItemPageBinding, loader: PageLoader, - settings: AppSettings, exceptionResolver: ExceptionResolver + settings: AppSettings, + exceptionResolver: ExceptionResolver, ) : BasePageHolder(binding, loader, settings, exceptionResolver), View.OnClickListener { init { binding.ssiv.setOnImageEventListener(delegate) binding.buttonRetry.setOnClickListener(this) + binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled } override fun onBind(data: ReaderPage) { delegate.onBind(data.toMangaPage()) + binding.textViewNumber.text = (data.index + 1).toString() } override fun onRecycled() { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index c93a27ef0..d2d2a6b50 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -12,7 +12,10 @@ import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import kotlin.math.absoluteValue class PagerReaderFragment : BaseReader() { @@ -37,8 +40,8 @@ class PagerReaderFragment : BaseReader() { val transformer = if (it) PageAnimTransformer() else null binding.pager.setPageTransformer(transformer) if (transformer == null) { - binding.pager.recyclerView?.children?.forEach { - it.resetTransformations() + binding.pager.recyclerView?.children?.forEach { view -> + view.resetTransformations() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt index 09cb044c0..a762e05ef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon +import android.app.Activity import android.content.Context import android.graphics.PointF import android.util.AttributeSet import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.utils.ext.toIntUp -class WebtoonImageView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null) : - SubsamplingScaleImageView(context, attr) { +class WebtoonImageView @JvmOverloads constructor( + context: Context, + attr: AttributeSet? = null, +) : SubsamplingScaleImageView(context, attr) { private val ct = PointF() - private val displayHeight = resources.displayMetrics.heightPixels + private val displayHeight = (context as Activity).window.decorView.height private var scrollPos = 0 private var scrollRange = SCROLL_UNKNOWN @@ -55,6 +58,30 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu return desiredHeight } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec) + val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec) + val parentWidth = MeasureSpec.getSize(widthMeasureSpec) + val parentHeight = MeasureSpec.getSize(heightMeasureSpec) + val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY + val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY + var width = parentWidth + var height = parentHeight + if (sWidth > 0 && sHeight > 0) { + if (resizeWidth && resizeHeight) { + width = sWidth + height = sHeight + } else if (resizeHeight) { + height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt() + } else if (resizeWidth) { + width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt() + } + } + width = width.coerceAtLeast(suggestedMinimumWidth) + height = height.coerceIn(suggestedMinimumHeight, displayHeight) + setMeasuredDimension(width, height) + } + private fun scrollToInternal(pos: Int) { scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt index f4535d7be..dfb0bad8c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt @@ -34,7 +34,7 @@ class WebtoonRecyclerView @JvmOverloads constructor( consumed[0] = 0 consumed[1] = consumedY } - return consumedY != 0 + return consumedY != 0 || dy == 0 } private fun consumeVerticalScroll(dy: Int): Int { diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 1f9b543a6..5ae3a92da 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -6,7 +6,6 @@ import android.view.MenuItem import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity @@ -15,7 +14,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class RemoteListFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(source) } @@ -29,10 +28,6 @@ class RemoteListFragment : MangaListFragment() { return source.title } - override fun onFilterChanged(filter: MangaFilter) { - viewModel.applyFilter(filter) - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.opt_list_remote, menu) diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index d8df693a3..b9b1f3d3e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -9,12 +9,10 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.model.MangaFilter -import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.list.ui.MangaFilterConfig +import org.koitharu.kotatsu.list.domain.AvailableFilters import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -27,7 +25,6 @@ class RemoteListViewModel( private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) - private var appliedFilter: MangaFilter? = null private var loadingJob: Job? = null private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0) @@ -68,16 +65,6 @@ class RemoteListViewModel( loadList(append = !mangaList.value.isNullOrEmpty()) } - override fun onRemoveFilterTag(tag: MangaTag) { - val filter = appliedFilter ?: return - if (tag !in filter.tags) { - return - } - applyFilter( - filter.copy(tags = filter.tags - tag) - ) - } - fun loadNextPage() { if (hasNextPage.value && listError.value == null) { loadList(append = true) @@ -93,8 +80,8 @@ class RemoteListViewModel( listError.value = null val list = repository.getList2( offset = if (append) mangaList.value?.size ?: 0 else 0, - sortOrder = appliedFilter?.sortOrder, - tags = appliedFilter?.tags, + sortOrder = currentFilter.sortOrder, + tags = currentFilter.tags, ) if (!append) { mangaList.value = list @@ -111,26 +98,29 @@ class RemoteListViewModel( } } - fun applyFilter(newFilter: MangaFilter) { - appliedFilter = newFilter + override fun onFilterChanged() { + super.onFilterChanged() mangaList.value = null hasNextPage.value = false loadList(false) - filter.value?.run { - filter.value = copy(currentFilter = newFilter) - } } - private fun createFilterModel() = appliedFilter?.run { - CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) + private fun createFilterModel(): CurrentFilterModel? { + val tags = currentFilter.tags + return if (tags.isEmpty()) { + null + } else { + CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) + } } private fun loadFilter() { launchJob(Dispatchers.Default) { try { - val sorts = repository.sortOrders.sortedBy { it.ordinal } - val tags = repository.getTags().sortedBy { it.title } - filter.postValue(MangaFilterConfig(sorts, tags, appliedFilter)) + val sorts = repository.sortOrders + val tags = repository.getTags() + availableFilters = AvailableFilters(sorts, tags) + onFilterChanged() } catch (e: Exception) { if (BuildConfig.DEBUG) { e.printStackTrace() diff --git a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index d099f54c5..efb736d7b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.SortOrder +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.utils.ext.levenshteinDistance @@ -29,7 +30,7 @@ class MangaSearchRepository( MangaProviderFactory.getSources(settings, includeHidden = false).asFlow() .flatMapMerge(concurrency) { source -> runCatching { - source.repository.getList2( + MangaRepository(source).getList2( offset = 0, query = query, sortOrder = SortOrder.POPULARITY diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt index 7221f6d11..725a11303 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -4,8 +4,10 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.commit import org.koin.androidx.viewmodel.ext.android.viewModel @@ -18,9 +20,7 @@ import org.koitharu.kotatsu.utils.ext.showKeyboard class SearchActivity : BaseActivity(), SearchView.OnQueryTextListener { - private val searchSuggestionViewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val searchSuggestionViewModel by viewModel() private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { @@ -46,10 +46,17 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbar.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + binding.container.updatePadding( + bottom = insets.bottom ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt index 3ebafa633..6f2621475 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt @@ -10,7 +10,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class SearchFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(source, query) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt index b1228d4bf..ad23f0b98 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt @@ -3,7 +3,9 @@ package org.koitharu.kotatsu.search.ui.global import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.ViewGroup import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity @@ -31,11 +33,15 @@ class GlobalSearchActivity : BaseActivity() { } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbar.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right - ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt index 4680fa8b1..2f6ca1ae3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt @@ -9,7 +9,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class GlobalSearchFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(query) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt index 0b6b67e5e..90e9f9bbe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt @@ -7,8 +7,8 @@ import android.content.pm.PackageManager import android.net.Uri import androidx.activity.ComponentActivity import androidx.annotation.MainThread -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -77,7 +77,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) { @MainThread private fun showUpdateDialog(version: AppVersion) { - AlertDialog.Builder(activity) + MaterialAlertDialogBuilder(activity) .setTitle(R.string.app_update_available) .setMessage(buildString { append(activity.getString(R.string.new_version_s, version.name)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index 99ec51e9c..82abffced 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -2,8 +2,8 @@ package org.koitharu.kotatsu.settings import android.os.Bundle import android.view.View -import androidx.appcompat.app.AlertDialog import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -119,7 +119,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } private fun clearSearchHistory(preference: Preference) { - AlertDialog.Builder(context ?: return) + MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.clear_search_history) .setMessage(R.string.text_clear_search_history_prompt) .setNegativeButton(android.R.string.cancel, null) @@ -138,7 +138,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } private fun clearCookies() { - AlertDialog.Builder(context ?: return) + MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.clear_cookies) .setMessage(R.string.text_clear_cookies_prompt) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index a61eaa361..1bc8be9fc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -1,35 +1,45 @@ package org.koitharu.kotatsu.settings -import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.os.Bundle -import android.text.InputType +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.appcompat.app.AppCompatDelegate -import androidx.preference.* -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.BuildConfig +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreference +import leakcanary.LeakCanary import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog -import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.settings.utils.SliderPreference +import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.names +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import java.io.File +import java.util.* class MainSettingsFragment : BasePreferenceFragment(R.string.settings), SharedPreferences.OnSharedPreferenceChangeListener, StorageSelectDialog.OnStorageSelectListener { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_main) - findPreference(AppSettings.KEY_GRID_SIZE)?.run { + findPreference(AppSettings.KEY_GRID_SIZE)?.run { summary = "%d%%".format(value) setOnPreferenceChangeListener { preference, newValue -> preference.summary = "%d%%".format(newValue) @@ -40,6 +50,22 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), entryValues = ListMode.values().names() setDefaultValueCompat(ListMode.GRID.name) } + findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = + AppSettings.isDynamicColorAvailable + findPreference(AppSettings.KEY_DATE_FORMAT)?.run { + entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy") + val now = Date().time + entries = entryValues.map { value -> + val formattedDate = settings.dateFormat(value.toString()).format(now) + if (value == "") { + "${context.getString(R.string.system_default)} ($formattedDate)" + } else { + "$value ($formattedDate)" + } + }.toTypedArray() + setDefaultValueCompat("") + summary = "%s" + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -53,6 +79,21 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), settings.subscribe(this) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.opt_settings, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_leaks -> { + startActivity(LeakCanary.newLeakDisplayActivityIntent()) + true + } + else -> super.onOptionsItemSelected(item) + } + } + override fun onDestroyView() { settings.unsubscribe(this) super.onDestroyView() @@ -63,6 +104,9 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), AppSettings.KEY_THEME -> { AppCompatDelegate.setDefaultNightMode(settings.theme) } + AppSettings.KEY_DYNAMIC_THEME -> { + findPreference(key)?.setSummary(R.string.restart_required) + } AppSettings.KEY_THEME_AMOLED -> { findPreference(key)?.setSummary(R.string.restart_required) } @@ -92,12 +136,12 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), } } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - return when (preference?.key) { + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { AppSettings.KEY_LOCAL_STORAGE -> { val ctx = context ?: return false StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx), this) - .setTitle(preference.title) + .setTitle(preference.title ?: "") .setNegativeButton(android.R.string.cancel) .create() .show() @@ -121,53 +165,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), settings.setStorageDir(context ?: return, file) } - private fun enableAppProtection(preference: SwitchPreference) { - val ctx = preference.context ?: return - val cancelListener = - object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener { - - override fun onCancel(dialog: DialogInterface?) { - settings.appPassword = null - preference.isChecked = false - preference.isEnabled = true - } - - override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog) - } - preference.isEnabled = false - TextInputDialog.Builder(ctx) - .setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setHint(R.string.enter_password) - .setNegativeButton(android.R.string.cancel, cancelListener) - .setOnCancelListener(cancelListener) - .setPositiveButton(android.R.string.ok) { d, password -> - if (password.isBlank()) { - cancelListener.onCancel(d) - return@setPositiveButton - } - TextInputDialog.Builder(ctx) - .setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setHint(R.string.repeat_password) - .setNegativeButton(android.R.string.cancel, cancelListener) - .setOnCancelListener(cancelListener) - .setPositiveButton(android.R.string.ok) { d2, password2 -> - if (password == password2) { - settings.appPassword = password.md5() - preference.isChecked = true - preference.isEnabled = true - } else { - cancelListener.onCancel(d2) - Snackbar.make( - listView, - R.string.passwords_mismatch, - Snackbar.LENGTH_SHORT - ).show() - } - }.setTitle(preference.title) - .create() - .show() - }.setTitle(preference.title) - .create() - .show() - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt index 923a7e8bf..5c021a39f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt @@ -37,8 +37,8 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif } } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - return when (preference?.key) { + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { AppSettings.KEY_NOTIFICATIONS_SOUND -> { ringtonePickContract.launch(settings.notificationSound.toUriOrNull()) true diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index 5382630a7..562462b82 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -3,9 +3,12 @@ package org.koitharu.kotatsu.settings import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.ViewGroup import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.preference.Preference @@ -16,7 +19,8 @@ import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.ActivitySettingsBinding class SettingsActivity : BaseActivity(), - PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, + FragmentManager.OnBackStackChangedListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -30,13 +34,26 @@ class SettingsActivity : BaseActivity(), } } - @Suppress("DEPRECATION") + override fun onStart() { + super.onStart() + supportFragmentManager.addOnBackStackChangedListener(this) + } + + override fun onStop() { + supportFragmentManager.removeOnBackStackChangedListener(this) + super.onStop() + } + + override fun onBackStackChanged() { + binding.appbar.setExpanded(true, true) + } + override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference ): Boolean { val fm = supportFragmentManager - val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment) + val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) fragment.arguments = pref.extras fragment.setTargetFragment(caller, 0) openFragment(fragment) @@ -61,11 +78,15 @@ class SettingsActivity : BaseActivity(), } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbar.updatePadding( - top = insets.top, - left = insets.left, - right = insets.right - ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt index 24fe705d3..6d9f543c4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt @@ -11,6 +11,7 @@ import org.koitharu.kotatsu.settings.backup.BackupViewModel import org.koitharu.kotatsu.settings.backup.RestoreViewModel import org.koitharu.kotatsu.settings.onboard.OnboardViewModel import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel +import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel val settingsModule get() = module { @@ -25,4 +26,5 @@ val settingsModule } viewModel { ProtectSetupViewModel(get()) } viewModel { OnboardViewModel(get()) } + viewModel { SourcesSettingsViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt index da9115e80..750d66013 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt @@ -51,8 +51,8 @@ class SourceSettingsFragment : PreferenceFragmentCompat() { } } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - return when (preference?.key) { + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { SourceSettings.KEY_AUTH -> { startActivity( SourceAuthActivity.newIntent( diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt index a4c489f8b..4c7870e57 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt @@ -35,8 +35,8 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.new_chapters_che } } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - return when (preference?.key) { + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { AppSettings.KEY_NOTIFICATIONS_SETTINGS -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index 19107be54..e295d3bfa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -30,8 +30,8 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - return when (preference?.key) { + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { AppSettings.KEY_APP_VERSION -> { checkForUpdates() true diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index 50c448149..b1d63684b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -7,8 +7,8 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment @@ -21,7 +21,7 @@ import java.io.FileOutputStream class BackupDialogFragment : AlertDialogFragment() { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var backup: File? = null private val saveFileContract = @@ -49,13 +49,13 @@ class BackupDialogFragment : AlertDialogFragment() { viewModel.onError.observe(viewLifecycleOwner, this::onError) } - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setCancelable(false) .setNegativeButton(android.R.string.cancel, null) } private fun onError(e: Throwable) { - AlertDialog.Builder(context ?: return) + MaterialAlertDialogBuilder(context ?: return) .setNegativeButton(R.string.close, null) .setTitle(R.string.error) .setMessage(e.getDisplayMessage(resources)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt index 93bde0e64..b41ebb205 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore), - ActivityResultCallback { + ActivityResultCallback { private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 5353cdefc..9ccb5e1b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -5,8 +5,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R @@ -22,10 +22,10 @@ class RestoreDialogFragment : AlertDialogFragment() { override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, ) = DialogProgressBinding.inflate(inflater, container, false) - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull()) } @@ -39,12 +39,12 @@ class RestoreDialogFragment : AlertDialogFragment() { viewModel.onError.observe(viewLifecycleOwner, this::onError) } - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder.setCancelable(false) } private fun onError(e: Throwable) { - AlertDialog.Builder(context ?: return) + MaterialAlertDialogBuilder(context ?: return) .setNegativeButton(R.string.close, null) .setTitle(R.string.error) .setMessage(e.getDisplayMessage(resources)) @@ -64,7 +64,7 @@ class RestoreDialogFragment : AlertDialogFragment() { } private fun onRestoreDone(result: CompositeResult) { - val builder = AlertDialog.Builder(context ?: return) + val builder = MaterialAlertDialogBuilder(context ?: return) when { result.isAllSuccess -> builder.setTitle(R.string.data_restored) .setMessage(R.string.data_restored_success) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 1948b6585..ed2613fb8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.backup.BackupArchive @@ -32,8 +33,7 @@ class RestoreViewModel( } val contentResolver = context.contentResolver - @Suppress("BlockingMethodInNonBlockingContext") - val backup = withContext(Dispatchers.IO) { + val backup = runInterruptible(Dispatchers.IO) { val tempFile = File.createTempFile("backup_", ".tmp") (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index b6945a7e4..5c0b94478 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -5,8 +5,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class OnboardDialogFragment : AlertDialogFragment(), OnListItemClickListener, DialogInterface.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var isWelcome: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { @@ -36,7 +36,7 @@ class OnboardDialogFragment : AlertDialogFragment(), container: ViewGroup?, ) = DialogOnboardBinding.inflate(inflater, container, false) - override fun onBuildDialog(builder: AlertDialog.Builder) { + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { builder .setPositiveButton(R.string.done, this) .setCancelable(true) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index b0f2a4ee4..44ddad5bc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.toTitleCase import java.util.* class OnboardViewModel( @@ -27,9 +28,9 @@ class OnboardViewModel( init { if (settings.isSourcesSelected) { - selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale }) + selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale }) } else { - val deviceLocales = LocaleListCompat.getDefault().map { x -> + val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> x.language } selectedLocales.retainAll(deviceLocales) @@ -64,7 +65,7 @@ class OnboardViewModel( } else null SourceLocale( key = key, - title = locale?.getDisplayLanguage(locale)?.capitalize(locale), + title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), isChecked = key in selectedLocales ) }.sortedWith(SourceLocaleComparator()) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index f57e5990a..5ab950d69 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -17,7 +17,7 @@ import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding class ProtectSetupActivity : BaseActivity(), TextWatcher, View.OnClickListener, TextView.OnEditorActionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt deleted file mode 100644 index 1660f9bd7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding - -class SourceViewHolder(parent: ViewGroup) : - BaseViewHolder( - ItemSourceConfigBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - override fun onBind(data: MangaSource, extra: Boolean) { - binding.textViewTitle.text = data.title - binding.switchToggle.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt deleted file mode 100644 index 739f35032..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.base.domain.MangaProviderFactory -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.utils.ext.mapToSet - -class SourcesAdapter( - private val settings: AppSettings, - private val onItemClickListener: OnListItemClickListener, -) : RecyclerView.Adapter() { - - private val dataSet = - MangaProviderFactory.getSources(settings, includeHidden = true).toMutableList() - private val hiddenItems = settings.hiddenSources.mapNotNull { - runCatching { - MangaSource.valueOf(it) - }.getOrNull() - }.toMutableSet() - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ) = SourceViewHolder(parent).also(::onViewHolderCreated) - - override fun getItemCount() = dataSet.size - - override fun onBindViewHolder(holder: SourceViewHolder, position: Int) { - val item = dataSet[position] - holder.bind(item, !hiddenItems.contains(item)) - } - - @SuppressLint("ClickableViewAccessibility") - private fun onViewHolderCreated(holder: SourceViewHolder) { - holder.binding.switchToggle.setOnCheckedChangeListener { _, it -> - if (it) { - hiddenItems.remove(holder.requireData()) - } else { - hiddenItems.add(holder.requireData()) - } - settings.hiddenSources = hiddenItems.mapToSet { x -> x.name } - } - holder.binding.imageViewConfig.setOnClickListener { v -> - onItemClickListener.onItemClick(holder.requireData(), v) - } - holder.binding.imageViewHandle.setOnTouchListener { v, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - onItemClickListener.onItemLongClick( - holder.requireData(), - holder.itemView - ) - } else { - false - } - } - } - - fun moveItem(oldPos: Int, newPos: Int) { - val item = dataSet.removeAt(oldPos) - dataSet.add(newPos, item) - notifyItemMoved(oldPos, newPos) - settings.sourcesOrder = dataSet.map { it.ordinal } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt deleted file mode 100644 index 066a43a6f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView - -class SourcesReorderCallback : - ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val adapter = recyclerView.adapter as? SourcesAdapter ?: return false - val oldPos = viewHolder.bindingAdapterPosition - val newPos = target.bindingAdapterPosition - adapter.moveItem(oldPos, newPos) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - - override fun isLongPressDragEnabled() = false -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index ac2d96c79..dd9c4235a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -2,28 +2,30 @@ package org.koitharu.kotatsu.settings.sources import android.os.Bundle import android.view.* +import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigItemDecoration +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem class SourcesSettingsFragment : BaseFragment(), - OnListItemClickListener { + SourceConfigListener, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener { - private lateinit var reorderHelper: ItemTouchHelper + private var reorderHelper: ItemTouchHelper? = null + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - reorderHelper = ItemTouchHelper(SourcesReorderCallback()) setHasOptionsMenu(true) } @@ -39,32 +41,34 @@ class SourcesSettingsFragment : BaseFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val sourcesAdapter = SourceConfigAdapter(this, get(), viewLifecycleOwner) with(binding.recyclerView) { - addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL)) - adapter = SourcesAdapter(get(), this@SourcesSettingsFragment) - reorderHelper.attachToRecyclerView(this) + setHasFixedSize(true) + addItemDecoration(SourceConfigItemDecoration(view.context)) + adapter = sourcesAdapter + reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also { + it.attachToRecyclerView(this) + } + } + viewModel.items.observe(viewLifecycleOwner) { + sourcesAdapter.items = it } } override fun onDestroyView() { - reorderHelper.attachToRecyclerView(null) + reorderHelper = null super.onDestroyView() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) - // TODO handle changes in dialog - // inflater.inflate(R.menu.opt_sources, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when(item.itemId) { - R.id.action_languages -> { - OnboardDialogFragment.show(parentFragmentManager) - true - } - else -> super.onOptionsItemSelected(item) - } + inflater.inflate(R.menu.opt_sources, menu) + val searchMenuItem = menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title } override fun onWindowInsetsChanged(insets: Insets) { @@ -75,14 +79,61 @@ class SourcesSettingsFragment : BaseFragment(), ) } - override fun onItemClick(item: MangaSource, view: View) { - (activity as? SettingsActivity)?.openMangaSourceSettings(item) + override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { + (activity as? SettingsActivity)?.openMangaSourceSettings(item.source) } - override fun onItemLongClick(item: MangaSource, view: View): Boolean { - reorderHelper.startDrag( - binding.recyclerView.findContainingViewHolder(view) ?: return false - ) + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { + viewModel.setEnabled(item.source, isEnabled) + } + + override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) { + reorderHelper?.startDrag(holder) + } + + override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) { + viewModel.expandOrCollapse(header.localeId) + } + + override fun onQueryTextSubmit(query: String?): Boolean = false + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.performSearch(newText) return true } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + (item.actionView as SearchView).setQuery("", false) + return true + } + + private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.DOWN or ItemTouchHelper.UP, + 0, + ) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources( + viewHolder.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder( + current.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun isLongPressDragEnabled() = false + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt new file mode 100644 index 000000000..a908ccf4e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt @@ -0,0 +1,158 @@ +package org.koitharu.kotatsu.settings.sources + +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaProviderFactory +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.move +import org.koitharu.kotatsu.utils.ext.toTitleCase +import java.util.* + +private const val KEY_ENABLED = "!" + +class SourcesSettingsViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + val items = MutableLiveData>(emptyList()) + private val expandedGroups = HashSet() + private var searchQuery: String? = null + + init { + buildList() + } + + fun reorderSources(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + snapshot.move(oldPos, newPos) + settings.sourcesOrder = snapshot.mapNotNull { + (it as? SourceConfigItem.SourceItem)?.source?.ordinal + } + buildList() + return true + } + + fun canReorder(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + return true + } + + fun setEnabled(source: MangaSource, isEnabled: Boolean) { + settings.hiddenSources = if (isEnabled) { + settings.hiddenSources - source.name + } else { + settings.hiddenSources + source.name + } + buildList() + } + + fun expandOrCollapse(headerId: String?) { + if (headerId in expandedGroups) { + expandedGroups.remove(headerId) + } else { + expandedGroups.add(headerId) + } + buildList() + } + + fun performSearch(query: String?) { + searchQuery = query?.trim() + buildList() + } + + private fun buildList() { + val sources = MangaProviderFactory.getSources(settings, includeHidden = true) + val hiddenSources = settings.hiddenSources + val query = searchQuery + if (!query.isNullOrEmpty()) { + items.value = sources.mapNotNull { + if (!it.title.contains(query, ignoreCase = true)) { + return@mapNotNull null + } + SourceConfigItem.SourceItem( + source = it, + isEnabled = it.name !in hiddenSources, + isDraggable = false, + ) + }.ifEmpty { + listOf(SourceConfigItem.EmptySearchResult) + } + return + } + val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { + if (it.name !in hiddenSources) { + KEY_ENABLED + } else { + it.locale + } + } + val result = ArrayList(sources.size + map.size + 1) + val enabledSources = map.remove(KEY_ENABLED) + if (!enabledSources.isNullOrEmpty()) { + result += SourceConfigItem.Header(R.string.enabled_sources) + enabledSources.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + isEnabled = true, + isDraggable = true, + ) + } + } + if (enabledSources?.size != sources.size) { + result += SourceConfigItem.Header(R.string.available_sources) + for ((key, list) in map) { + val locale = if (key != null) { + Locale(key) + } else null + list.sortBy { it.ordinal } + val isExpanded = key in expandedGroups + result += SourceConfigItem.LocaleGroup( + localeId = key, + title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), + isExpanded = isExpanded, + ) + if (isExpanded) { + list.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + isEnabled = false, + isDraggable = false, + ) + } + } + } + } + items.value = result + } + + private class LocaleKeyComparator : Comparator { + + private val deviceLocales = LocaleListCompat.getAdjustedDefault() + .map { it.language } + + override fun compare(a: String?, b: String?): Int { + when { + a == b -> return 0 + a == null -> return 1 + b == null -> return -1 + } + val ai = deviceLocales.indexOf(a!!) + val bi = deviceLocales.indexOf(b!!) + return when { + ai < 0 && bi < 0 -> a.compareTo(b) + ai < 0 -> 1 + bi < 0 -> -1 + else -> ai.compareTo(bi) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt new file mode 100644 index 000000000..d580684be --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +class SourceConfigAdapter( + listener: SourceConfigListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) : AsyncListDifferDelegationAdapter( + SourceConfigDiffCallback(), + sourceConfigHeaderDelegate(), + sourceConfigGroupDelegate(listener), + sourceConfigItemDelegate(listener, coil, lifecycleOwner), + sourceConfigDraggableItemDelegate(listener), + sourceConfigEmptySearchDelegate(), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt new file mode 100644 index 000000000..aa8c9fb35 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -0,0 +1,116 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import android.widget.CompoundButton +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.request.Disposable +import coil.request.ImageRequest +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemExpandableBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding +import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding +import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.enqueueWith + +fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } +) { + + bind { + binding.root.setText(item.titleResId) + } +} + +fun sourceConfigGroupDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) } +) { + + binding.root.setOnClickListener { + listener.onHeaderClick(item) + } + + bind { + binding.root.text = item.title ?: getString(R.string.other) + binding.root.isChecked = item.isExpanded + } +} + +fun sourceConfigItemDelegate( + listener: SourceConfigListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable } +) { + + var imageRequest: Disposable? = null + + binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> + listener.onItemEnabledChanged(item, isChecked) + } + + bind { + binding.textViewTitle.text = item.source.title + binding.switchToggle.isChecked = item.isEnabled + imageRequest = ImageRequest.Builder(context) + .data(item.faviconUrl) + .error(R.drawable.ic_favicon_fallback) + .target(binding.imageViewIcon) + .lifecycle(lifecycleOwner) + .enqueueWith(coil) + } + + onViewRecycled { + imageRequest?.dispose() + imageRequest = null + } +} + +@SuppressLint("ClickableViewAccessibility") +fun sourceConfigDraggableItemDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigDraggableBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable } +) { + + val eventListener = object : View.OnClickListener, View.OnTouchListener, + CompoundButton.OnCheckedChangeListener { + override fun onClick(v: View?) = listener.onItemSettingsClick(item) + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + return if (event.actionMasked == MotionEvent.ACTION_DOWN) { + listener.onDragHandleTouch(this@adapterDelegateViewBinding) + true + } else { + false + } + } + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + listener.onItemEnabledChanged(item, isChecked) + } + } + + binding.imageViewConfig.setOnClickListener(eventListener) + binding.switchToggle.setOnCheckedChangeListener(eventListener) + binding.imageViewHandle.setOnTouchListener(eventListener) + + bind { + binding.textViewTitle.text = item.source.title + binding.switchToggle.isChecked = item.isEnabled + } +} + +fun sourceConfigEmptySearchDelegate() = adapterDelegate( + R.layout.item_sources_empty +) { } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt new file mode 100644 index 000000000..8bab50c2a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.recyclerview.widget.DiffUtil +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.* + +class SourceConfigDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is LocaleGroup && newItem is LocaleGroup -> { + oldItem.localeId == newItem.localeId + } + oldItem is SourceItem && newItem is SourceItem -> { + oldItem.source == newItem.source + } + oldItem is Header && newItem is Header -> { + oldItem.titleResId == newItem.titleResId + } + oldItem == EmptySearchResult && newItem == EmptySearchResult -> { + true + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt new file mode 100644 index 000000000..0171b9dcf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration + +class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) { + + override fun shouldDrawDivider( + above: RecyclerView.ViewHolder, + below: RecyclerView.ViewHolder, + ): Boolean { + return above.itemViewType != 0 && below.itemViewType != 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt new file mode 100644 index 000000000..8bc03a213 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +interface SourceConfigListener { + + fun onItemSettingsClick(item: SourceConfigItem.SourceItem) + + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) + + fun onDragHandleTouch(holder: RecyclerView.ViewHolder) + + fun onHeaderClick(header: SourceConfigItem.LocaleGroup) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt new file mode 100644 index 000000000..dd998ddac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -0,0 +1,81 @@ +package org.koitharu.kotatsu.settings.sources.model + +import android.net.Uri +import androidx.annotation.StringRes +import org.koitharu.kotatsu.core.model.MangaSource + +sealed interface SourceConfigItem { + + class Header( + @StringRes val titleResId: Int, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Header + return titleResId == other.titleResId + } + + override fun hashCode(): Int = titleResId + } + + class LocaleGroup( + val localeId: String?, + val title: String?, + val isExpanded: Boolean, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocaleGroup + + if (localeId != other.localeId) return false + if (title != other.title) return false + if (isExpanded != other.isExpanded) return false + + return true + } + + override fun hashCode(): Int { + var result = localeId?.hashCode() ?: 0 + result = 31 * result + (title?.hashCode() ?: 0) + result = 31 * result + isExpanded.hashCode() + return result + } + } + + class SourceItem( + val source: MangaSource, + val isEnabled: Boolean, + val isDraggable: Boolean, + ) : SourceConfigItem { + + val faviconUrl: Uri + get() = Uri.fromParts("favicon", source.name, null) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SourceItem + + if (source != other.source) return false + if (isEnabled != other.isEnabled) return false + if (isDraggable != other.isDraggable) return false + + return true + } + + override fun hashCode(): Int { + var result = source.hashCode() + result = 31 * result + isEnabled.hashCode() + result = 31 * result + isDraggable.hashCode() + return result + } + } + + object EmptySearchResult : SourceConfigItem +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt index 7083b6bdb..b2f448b1b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt @@ -9,10 +9,11 @@ class EditTextDefaultSummaryProvider( ) : Preference.SummaryProvider { override fun provideSummary(preference: EditTextPreference): CharSequence { - return if (preference.text.isNullOrEmpty()) { + val text = preference.text + return if (text.isNullOrEmpty()) { preference.context.getString(R.string.default_s, defaultValue) } else { - preference.text + text } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt index 1e2976dc6..447092234 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt @@ -8,10 +8,11 @@ class EditTextSummaryProvider(@StringRes private val emptySummaryId: Int) : Preference.SummaryProvider { override fun provideSummary(preference: EditTextPreference): CharSequence { - return if (preference.text.isNullOrEmpty()) { + val text = preference.text + return if (text.isNullOrEmpty()) { preference.context.getString(emptySummaryId) } else { - preference.text + text } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt index cd61ba247..4317f93ea 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt @@ -8,7 +8,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceViewHolder class LinksPreference @JvmOverloads constructor( - context: Context?, + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, defStyleRes: Int = 0, diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt new file mode 100644 index 000000000..472467a1d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt @@ -0,0 +1,155 @@ +package org.koitharu.kotatsu.settings.utils + +import android.content.Context +import android.content.res.TypedArray +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import androidx.core.content.withStyledAttributes +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.slider.Slider +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.utils.ext.setValueRounded + +class SliderPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.sliderPreferenceStyle, + defStyleRes: Int = R.style.Preference_Slider, +) : Preference(context, attrs, defStyleAttr, defStyleRes) { + + private var valueFrom: Int = 0 + private var valueTo: Int = 100 + private var stepSize: Int = 1 + private var currentValue: Int = 0 + + var value: Int + get() = currentValue + set(value) = setValueInternal(value, notifyChanged = true) + + private val sliderListener = Slider.OnChangeListener { _, value, fromUser -> + if (fromUser) { + syncValueInternal(value.toInt()) + } + } + + init { + context.withStyledAttributes(attrs, + R.styleable.SliderPreference, + defStyleAttr, + defStyleRes) { + valueFrom = getFloat(R.styleable.SliderPreference_android_valueFrom, + valueFrom.toFloat()).toInt() + valueTo = + getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() + stepSize = + getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt() + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val slider = holder.findViewById(R.id.slider) as? Slider ?: return + slider.removeOnChangeListener(sliderListener) + slider.addOnChangeListener(sliderListener) + slider.valueFrom = valueFrom.toFloat() + slider.valueTo = valueTo.toFloat() + slider.stepSize = stepSize.toFloat() + slider.setValueRounded(currentValue.toFloat()) + slider.isEnabled = isEnabled + } + + override fun onSetInitialValue(defaultValue: Any?) { + value = getPersistedInt(defaultValue as? Int ?: 0) + } + + override fun onGetDefaultValue(a: TypedArray, index: Int): Any { + return a.getInt(index, 0) + } + + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() + if (superState == null || isPersistent) { + return superState + } + return SavedState( + superState = superState, + valueFrom = valueFrom, + valueTo = valueTo, + currentValue = currentValue, + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + super.onRestoreInstanceState(state.superState) + valueFrom = state.valueFrom + valueTo = state.valueTo + currentValue = state.currentValue + notifyChanged() + } + + private fun setValueInternal(sliderValue: Int, notifyChanged: Boolean) { + val newValue = sliderValue.coerceIn(valueFrom, valueTo) + if (newValue != currentValue) { + currentValue = newValue + persistInt(newValue) + if (notifyChanged) { + notifyChanged() + } + } + } + + private fun syncValueInternal(sliderValue: Int) { + if (sliderValue != currentValue) { + if (callChangeListener(sliderValue)) { + setValueInternal(sliderValue, notifyChanged = false) + } + } + } + + private class SavedState : View.BaseSavedState { + + val valueFrom: Int + val valueTo: Int + val currentValue: Int + + constructor( + superState: Parcelable, + valueFrom: Int, + valueTo: Int, + currentValue: Int, + ) : super(superState) { + this.valueFrom = valueFrom + this.valueTo = valueTo + this.currentValue = currentValue + } + + constructor(source: Parcel) : super(source) { + valueFrom = source.readInt() + valueTo = source.readInt() + currentValue = source.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(valueFrom) + out.writeInt(valueTo) + out.writeInt(currentValue) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(`in`: Parcel) = SavedState(`in`) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt index 174810bc2..97212bf2e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt @@ -18,7 +18,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity ) ] ) -data class SuggestionEntity( +class SuggestionEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @FloatRange(from = 0.0, to = 1.0) diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 66413a5f5..510fe083b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -2,9 +2,9 @@ package org.koitharu.kotatsu.tracker.ui import android.os.Bundle import android.view.* -import androidx.appcompat.app.AlertDialog import androidx.core.graphics.Insets import androidx.core.view.updatePadding +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.viewModel @@ -26,7 +26,7 @@ import org.koitharu.kotatsu.utils.progress.Progress class FeedFragment : BaseFragment(), PaginationScrollListener.Callback, OnListItemClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var feedAdapter: FeedAdapter? = null private var updateStatusSnackbar: Snackbar? = null @@ -78,7 +78,7 @@ class FeedFragment : BaseFragment(), PaginationScrollListen true } R.id.action_clear_feed -> { - AlertDialog.Builder(context ?: return false) + MaterialAlertDialogBuilder(context ?: return false) .setTitle(R.string.clear_updates_feed) .setMessage(R.string.text_clear_updates_feed_prompt) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt new file mode 100644 index 000000000..87f42f742 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt @@ -0,0 +1,87 @@ +package org.koitharu.kotatsu.utils + +import android.app.DownloadManager +import android.app.DownloadManager.Request.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.MangaPage +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.ext.toFileNameSafe +import java.io.File +import kotlin.coroutines.resume + +class DownloadManagerHelper( + private val context: Context, + private val cookieJar: CookieJar, +) { + + private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + private val subDir = context.getString(R.string.app_name).toFileNameSafe() + + fun downloadPage(page: MangaPage, fullUrl: String): Long { + val uri = fullUrl.toUri() + val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl()) + val dest = subDir + File.separator + uri.lastPathSegment + val request = DownloadManager.Request(uri) + .addRequestHeader(CommonHeaders.REFERER, page.referer) + .addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies)) + .setAllowedOverMetered(true) + .setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE) + .setNotificationVisibility(VISIBILITY_VISIBLE) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + request.allowScanningByMediaScanner() + } + return manager.enqueue(request) + } + + suspend fun awaitDownload(id: Long): Uri { + getUriForDownloadedFile(id)?.let { return it } // fast path + suspendCancellableCoroutine { cont -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if ( + intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE && + intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id + ) { + context.unregisterReceiver(this) + cont.resume(Unit) + } + } + } + context.registerReceiver( + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + cont.invokeOnCancellation { + context.unregisterReceiver(receiver) + } + } + return checkNotNull(getUriForDownloadedFile(id)) + } + + private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) { + manager.getUriForDownloadedFile(id) + } + + private fun cookieHeader(cookies: List): String = buildString { + cookies.forEachIndexed { index, cookie -> + if (index > 0) append("; ") + append(cookie.name).append('=').append(cookie.value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt deleted file mode 100644 index 7bf07f4c2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.utils - -import android.content.ContentResolver -import android.content.ContentValues -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.provider.OpenableColumns -import android.webkit.MimeTypeMap -import androidx.core.database.getStringOrNull -import org.koitharu.kotatsu.BuildConfig -import java.io.OutputStream - -class MediaStoreCompat(private val contentResolver: ContentResolver) { - - fun insertImage( - fileName: String, - block: (OutputStream) -> Unit - ): Uri? { - val name = fileName.substringBeforeLast('.') - val cv = ContentValues(7) - cv.put(MediaStore.Images.Media.DISPLAY_NAME, name) - cv.put(MediaStore.Images.Media.TITLE, name) - cv.put( - MediaStore.Images.Media.MIME_TYPE, - MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.')) - ) - cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis()) - cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - cv.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - cv.put(MediaStore.Images.Media.IS_PENDING, 1) - } - var uri: Uri? = null - try { - uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv) - contentResolver.openOutputStream(uri!!)?.use(block) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - cv.clear() - cv.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, cv, null, null) - } - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - uri?.let { - contentResolver.delete(it, null, null) - } - uri = null - } - return uri - } - - fun getName(uri: Uri): String? = - (if (uri.scheme == "content") { - contentResolver.query(uri, null, null, null, null)?.use { - if (it.moveToFirst()) { - it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - } else { - null - } - } - } else { - null - }) ?: uri.path?.substringAfterLast('/') -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt index a57bae29c..b785a62d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils import androidx.annotation.CheckResult import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import java.io.File import java.io.FileInputStream @@ -11,12 +12,11 @@ import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream -@Suppress("BlockingMethodInNonBlockingContext") open class MutableZipFile(val file: File) { protected val dir = File(file.parentFile, file.nameWithoutExtension) - suspend fun unpack(): Unit = withContext(Dispatchers.IO) { + suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) { check(dir.list().isNullOrEmpty()) { "Dir ${dir.name} is not empty" } @@ -24,7 +24,7 @@ open class MutableZipFile(val file: File) { dir.mkdir() } if (!file.exists()) { - return@withContext + return@runInterruptible } ZipInputStream(FileInputStream(file)).use { zip -> var entry = zip.nextEntry @@ -45,7 +45,7 @@ open class MutableZipFile(val file: File) { } @CheckResult - suspend fun flush(): Boolean = withContext(Dispatchers.IO) { + suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) { val tempFile = File(file.path + ".tmp") if (tempFile.exists()) { tempFile.delete() @@ -57,7 +57,7 @@ open class MutableZipFile(val file: File) { } zip.flush() } - return@withContext tempFile.renameTo(file) + tempFile.renameTo(file) } finally { if (tempFile.exists()) { tempFile.delete() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt index 4169a571d..c99050819 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt @@ -10,4 +10,10 @@ object PendingIntentCompat { } else { 0 } + + val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt index 8e19bfc43..6c856f4d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt @@ -7,7 +7,7 @@ import android.database.ContentObserver import android.os.Handler import android.provider.Settings import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart @@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) { fun observeAutoOrientation() = callbackFlow { val observer = object : ContentObserver(Handler(activity.mainLooper)) { override fun onChange(selfChange: Boolean) { - sendBlocking(isAutoRotationEnabled) + trySendBlocking(isAutoRotationEnabled) } } activity.contentResolver.registerContentObserver( diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index fdfb8f20d..b06b18d16 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -46,7 +46,7 @@ class ShareHelper(private val context: Context) { fun shareImage(uri: Uri) { val intent = Intent(Intent.ACTION_SEND) - intent.setDataAndType(uri, context.contentResolver.getType(uri)) + intent.setDataAndType(uri, context.contentResolver.getType(uri) ?: "image/*") intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image)) context.startActivity(shareIntent) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt new file mode 100644 index 000000000..214c934dd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.utils + +class WordSet(private vararg val words: String) { + + fun anyWordIn(dateString: String): Boolean = words.any { + dateString.contains(it, ignoreCase = true) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 87278b1d7..6eef68d17 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -4,7 +4,9 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest +import android.os.Build import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -23,6 +25,6 @@ suspend fun ConnectivityManager.waitForNetwork(): Network { } } -inline fun buildAlertDialog(context: Context, block: AlertDialog.Builder.() -> Unit): AlertDialog { - return AlertDialog.Builder(context).apply(block).create() +inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog { + return MaterialAlertDialogBuilder(context).apply(block).create() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index e31e3e20b..53cf3bdb2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -7,16 +7,16 @@ import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.ImageResult import coil.request.SuccessResult +import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener -@Suppress("NOTHING_TO_INLINE") -inline fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) +fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) .data(url) .crossfade(true) .target(this) -@Suppress("NOTHING_TO_INLINE") -inline fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) +fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageResult.requireBitmap() = when (this) { is SuccessResult -> drawable.toBitmap() @@ -32,7 +32,10 @@ fun ImageResult.toBitmapOrNull() = when (this) { is ErrorResult -> null } -@Suppress("NOTHING_TO_INLINE") -inline fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { +fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { return setHeader(CommonHeaders.REFERER, referer) +} + +fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { + return listener(ImageRequestIndicatorListener(indicator)) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 0b1f342d1..3aa96dc2a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext import androidx.collection.ArrayMap import androidx.collection.ArraySet import androidx.collection.LongSparseArray +import java.util.* fun MutableCollection.replaceWith(subject: Iterable) { clear() @@ -72,4 +73,12 @@ fun Collection.isDistinctBy(selector: (T) -> K): Boolean { } } return set.size == size +} + +fun MutableList.move(sourceIndex: Int, targetIndex: Int) { + if (sourceIndex <= targetIndex) { + Collections.rotate(subList(sourceIndex, targetIndex + 1), -1) + } else { + Collections.rotate(subList(targetIndex, sourceIndex + 1), 1) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index 248f0f158..adc5f6f0b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -1,10 +1,13 @@ package org.koitharu.kotatsu.utils.ext +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import android.provider.OpenableColumns +import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R @@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { delete() +} + +fun ContentResolver.resolveName(uri: Uri): String? { + val fallback = uri.lastPathSegment + if (uri.scheme != "content") { + return fallback + } + query(uri, null, null, null, null)?.use { + if (it.moveToFirst()) { + it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name -> + return name + } + } + } + return fallback } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt new file mode 100644 index 000000000..660bd7b2f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.utils.ext + +fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null + +fun Iterator.toList(): List { + if (!hasNext()) { + return emptyList() + } + val list = ArrayList() + while (hasNext()) list += next() + return list +} + +fun Iterator.toSet(): Set { + if (!hasNext()) { + return emptySet() + } + val list = LinkedHashSet() + while (hasNext()) list += next() + return list +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt index 66f96a6e2..0efa24264 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt @@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext import androidx.collection.ArraySet import org.json.JSONArray import org.json.JSONObject +import org.koitharu.kotatsu.utils.json.JSONIterator +import org.koitharu.kotatsu.utils.json.JSONStringIterator +import org.koitharu.kotatsu.utils.json.JSONValuesIterator +import kotlin.contracts.contract inline fun > JSONArray.mapTo( destination: C, @@ -16,10 +20,26 @@ inline fun > JSONArray.mapTo( return destination } +inline fun > JSONArray.mapNotNullTo( + destination: C, + block: (JSONObject) -> R? +): C { + val len = length() + for (i in 0 until len) { + val jo = getJSONObject(i) + destination.add(block(jo) ?: continue) + } + return destination +} + inline fun JSONArray.map(block: (JSONObject) -> T): List { return mapTo(ArrayList(length()), block) } +inline fun JSONArray.mapNotNull(block: (JSONObject) -> T?): List { + return mapNotNullTo(ArrayList(length()), block) +} + fun JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List { val len = length() val result = ArrayList(len) @@ -38,18 +58,13 @@ fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean it === JSONObject.NULL } as? Boolean ?: defaultValue +fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(name)?.takeUnless { + it === JSONObject.NULL +} as? Long ?: defaultValue + operator fun JSONArray.iterator(): Iterator = JSONIterator(this) -private class JSONIterator(private val array: JSONArray) : Iterator { - - private val total = array.length() - private var index = 0 - - override fun hasNext() = index < total - 1 - - override fun next(): JSONObject = array.getJSONObject(index++) - -} +fun JSONArray.stringIterator(): Iterator = JSONStringIterator(this) fun JSONArray.mapToSet(block: (JSONObject) -> T): Set { val len = length() @@ -59,4 +74,24 @@ fun JSONArray.mapToSet(block: (JSONObject) -> T): Set { result.add(block(jo)) } return result +} + +fun JSONObject.values(): Iterator = JSONValuesIterator(this) + +fun JSONArray.associateByKey(key: String): Map { + val destination = LinkedHashMap(length()) + repeat(length()) { i -> + val item = getJSONObject(i) + val keyValue = item.getString(key) + destination[keyValue] = item + } + return destination +} + +fun JSONArray?.isNullOrEmpty(): Boolean { + contract { + returns(false) implies (this@isNullOrEmpty != null) + } + + return this == null || this.length() == 0 } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt index 1ad4cc003..26cc037d6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.utils.ext import androidx.core.os.LocaleListCompat import java.util.* -import kotlin.collections.ArrayList fun LocaleListCompat.toList(): List { val list = ArrayList(size()) @@ -12,6 +11,12 @@ fun LocaleListCompat.toList(): List { return list } +operator fun LocaleListCompat.iterator() = object : Iterator { + private var index = 0 + override fun hasNext(): Boolean = index < size() + override fun next(): Locale = get(index++) +} + inline fun > LocaleListCompat.mapTo( destination: C, block: (Locale) -> R, @@ -26,4 +31,8 @@ inline fun > LocaleListCompat.mapTo( inline fun LocaleListCompat.map(block: (Locale) -> T): List { return mapTo(ArrayList(size()), block) +} + +inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { + return mapTo(LinkedHashSet(size()), block) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt index 7968b5ba2..4da5a2cff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt @@ -10,6 +10,7 @@ import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.select.Elements +import java.text.DateFormat fun Response.parseHtml(): Document { try { @@ -97,4 +98,12 @@ fun Element.css(property: String): String? { val regex = Regex("${Regex.escape(property)}\\s*:\\s*[^;]+") val css = attr("style").find(regex) ?: return null return css.substringAfter(':').removeSuffix(';').trim() +} + +fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) { + 0L +} else { + runCatching { + parse(str)?.time ?: 0L + }.getOrDefault(0L) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt index eeeb12866..29c3a67f0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt @@ -35,7 +35,7 @@ fun Float.toIntUp(): Int { infix fun Int.upBy(step: Int): Int { val mod = this % step - return if (mod == this) { + return if (mod == 0) { this } else { this - mod + step diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index 05d310506..380019019 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -52,6 +52,10 @@ fun String.toTitleCase(): String { return replaceFirstChar { x -> x.uppercase() } } +fun String.toTitleCase(locale: Locale): String { + return replaceFirstChar { x -> x.uppercase(locale) } +} + fun String.transliterate(skipMissing: Boolean): String { val cyr = charArrayOf( 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', @@ -118,7 +122,7 @@ fun ByteArray.byte2HexFormatted(): String { if (l > 2) { h = h.substring(l - 2, l) } - str.append(h.toUpperCase(Locale.ROOT)) + str.append(h.uppercase(Locale.ROOT)) if (i < size - 1) { str.append(':') } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index ac6950379..95546d3c2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -5,6 +5,7 @@ import android.graphics.Rect import android.view.LayoutInflater import android.view.Menu import android.view.View +import android.view.View.MeasureSpec import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.annotation.LayoutRes @@ -17,7 +18,9 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.progressindicator.BaseProgressIndicator +import com.google.android.material.slider.Slider import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder +import kotlin.math.roundToInt fun View.hideKeyboard() { val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager @@ -53,7 +56,7 @@ var RecyclerView.firstItem: Int inline fun View.showPopupMenu( @MenuRes menuRes: Int, onPrepare: (Menu) -> Unit = {}, - onItemClick: PopupMenu.OnMenuItemClickListener + onItemClick: PopupMenu.OnMenuItemClickListener, ) { val menu = PopupMenu(context, this) menu.inflate(menuRes) @@ -132,6 +135,8 @@ fun View.resetTransformations() { translationZ = 0f scaleX = 1f scaleY = 1f + rotationX = 0f + rotationY = 0f } inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) { @@ -171,4 +176,35 @@ fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) { isIndeterminate = indeterminate } } +} + +fun resolveAdjustedSize( + desiredSize: Int, + maxSize: Int, + measureSpec: Int, +): Int { + val specMode = MeasureSpec.getMode(measureSpec) + val specSize = MeasureSpec.getSize(measureSpec) + return when (specMode) { + MeasureSpec.UNSPECIFIED -> + // Parent says we can be as big as we want. Just don't be larger + // than max size imposed on ourselves. + desiredSize.coerceAtMost(maxSize) + MeasureSpec.AT_MOST -> + // Parent says we can be as big as we want, up to specSize. + // Don't be larger than specSize, and don't be larger than + // the max size imposed on ourselves. + desiredSize.coerceAtMost(specSize).coerceAtMost(maxSize) + MeasureSpec.EXACTLY -> + // No choice. Do what we are told. + specSize + else -> + // This should not happen + desiredSize + } +} + +fun Slider.setValueRounded(newValue: Float) { + val step = stepSize + value = (newValue / step).roundToInt() * step } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt new file mode 100644 index 000000000..9b48640e2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONArray +import org.json.JSONObject + +class JSONIterator(private val array: JSONArray) : Iterator { + + private val total = array.length() + private var index = 0 + + override fun hasNext() = index < total + + override fun next(): JSONObject = array.getJSONObject(index++) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt new file mode 100644 index 000000000..dd9826407 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONArray + +class JSONStringIterator(private val array: JSONArray) : Iterator { + + private val total = array.length() + private var index = 0 + + override fun hasNext() = index < total + + override fun next(): String = array.getString(index++) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt new file mode 100644 index 000000000..1bf833310 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONObject + +class JSONValuesIterator( + private val jo: JSONObject, +): Iterator { + + private val keyIterator = jo.keys() + + override fun hasNext(): Boolean = keyIterator.hasNext() + + override fun next(): Any { + val key = keyIterator.next() + return jo.get(key) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt new file mode 100644 index 000000000..eb38e1d32 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.utils.progress + +import coil.request.ImageRequest +import coil.request.ImageResult +import com.google.android.material.progressindicator.BaseProgressIndicator + +class ImageRequestIndicatorListener( + private val indicator: BaseProgressIndicator<*>, +) : ImageRequest.Listener { + + override fun onCancel(request: ImageRequest) = indicator.hide() + + override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide() + + override fun onStart(request: ImageRequest) = indicator.show() + + override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt new file mode 100644 index 000000000..ed9773c99 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.utils.progress + +import com.google.android.material.slider.LabelFormatter + +class IntPercentLabelFormatter : LabelFormatter { + override fun getFormattedValue(value: Float) = "%d%%".format(value.toInt()) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt index 63a2816ae..a5c6bb748 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt @@ -31,7 +31,7 @@ class RecentWidgetProvider : AppWidgetProvider() { context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE ) ) views.setEmptyView(R.id.stackView, R.id.textView_holder) diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt index 74e24e159..ccad1811d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt @@ -7,6 +7,6 @@ import org.koin.android.ext.android.get class RecentWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { - return RecentListFactory(this, get(), get()) + return RecentListFactory(applicationContext, get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index 1946fb0dd..00a22cc85 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -3,19 +3,16 @@ package org.koitharu.kotatsu.widget.shelf import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Color import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.graphics.Insets -import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R @@ -27,9 +24,10 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem -class ShelfConfigActivity : BaseActivity(), OnListItemClickListener { +class ShelfConfigActivity : BaseActivity(), + OnListItemClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private lateinit var adapter: CategorySelectAdapter private lateinit var config: AppWidgetConfig @@ -38,11 +36,12 @@ class ShelfConfigActivity : BaseActivity(), OnListIte super.onCreate(savedInstanceState) setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding.fabAdd.imageTintList = ColorStateList.valueOf(Color.WHITE) adapter = CategorySelectAdapter(this) - binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) + binding.recyclerView.addItemDecoration( + MaterialDividerItemDecoration(this, RecyclerView.VERTICAL) + ) binding.recyclerView.adapter = adapter - binding.fabAdd.isVisible = false + binding.fabAdd.hide() val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID @@ -92,11 +91,15 @@ class ShelfConfigActivity : BaseActivity(), OnListIte right = insets.right, bottom = insets.bottom ) - binding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - top = insets.top - ) + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } private fun onContentChanged(categories: List) { diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt index 334941d51..7b3ba2059 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt @@ -31,7 +31,7 @@ class ShelfWidgetProvider : AppWidgetProvider() { context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE ) ) views.setEmptyView(R.id.gridView, R.id.textView_holder) diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt index 3f590235a..89d0a8862 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt @@ -12,6 +12,6 @@ class ShelfWidgetService : RemoteViewsService() { AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID ) - return ShelfListFactory(this, get(), get(), widgetId) + return ShelfListFactory(applicationContext, get(), get(), widgetId) } } \ No newline at end of file diff --git a/app/src/main/res/color/ripple_toolbar.xml b/app/src/main/res/color/ripple_toolbar.xml new file mode 100644 index 000000000..7ef8081de --- /dev/null +++ b/app/src/main/res/color/ripple_toolbar.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_overlay.xml b/app/src/main/res/color/selector_overlay.xml index c99447ac2..26cdc3acd 100644 --- a/app/src/main/res/color/selector_overlay.xml +++ b/app/src/main/res/color/selector_overlay.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable-w600dp/tab_indicator.xml b/app/src/main/res/drawable-w600dp/tab_indicator.xml deleted file mode 100644 index e2b1ad5c9..000000000 --- a/app/src/main/res/drawable-w600dp/tab_indicator.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/badge.xml b/app/src/main/res/drawable/badge.xml index dc4263e17..de6af63f4 100644 --- a/app/src/main/res/drawable/badge.xml +++ b/app/src/main/res/drawable/badge.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_default.xml b/app/src/main/res/drawable/bg_badge_default.xml index 847a0497b..4a30a76f4 100644 --- a/app/src/main/res/drawable/bg_badge_default.xml +++ b/app/src/main/res/drawable/bg_badge_default.xml @@ -2,7 +2,7 @@ - + - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..24baef0fd --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand_collapse.xml b/app/src/main/res/drawable/ic_expand_collapse.xml new file mode 100644 index 000000000..a5848a628 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_collapse.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 000000000..8b5d44e5f --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 000000000..bc6e8295b --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_favicon_fallback.xml b/app/src/main/res/drawable/ic_favicon_fallback.xml new file mode 100644 index 000000000..24996b554 --- /dev/null +++ b/app/src/main/res/drawable/ic_favicon_fallback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_manga_source.xml b/app/src/main/res/drawable/ic_manga_source.xml new file mode 100644 index 000000000..599febf8c --- /dev/null +++ b/app/src/main/res/drawable/ic_manga_source.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_new.xml b/app/src/main/res/drawable/ic_new.xml new file mode 100644 index 000000000..15a6a9706 --- /dev/null +++ b/app/src/main/res/drawable/ic_new.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_placeholder.xml b/app/src/main/res/drawable/ic_placeholder.xml index c7eb74389..6a2464393 100644 --- a/app/src/main/res/drawable/ic_placeholder.xml +++ b/app/src/main/res/drawable/ic_placeholder.xml @@ -1,7 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_state_finished.xml b/app/src/main/res/drawable/ic_state_finished.xml new file mode 100644 index 000000000..84c2e8961 --- /dev/null +++ b/app/src/main/res/drawable/ic_state_finished.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_state_ongoing.xml b/app/src/main/res/drawable/ic_state_ongoing.xml new file mode 100644 index 000000000..4b642a0d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_state_ongoing.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_totoro.xml b/app/src/main/res/drawable/ic_totoro.xml index 988727dc6..f57082c81 100644 --- a/app/src/main/res/drawable/ic_totoro.xml +++ b/app/src/main/res/drawable/ic_totoro.xml @@ -2,7 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" - android:tint="@color/color_primary" + android:tint="?attr/colorPrimary" android:viewportWidth="256" android:viewportHeight="256"> - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/toolbar_background.xml b/app/src/main/res/drawable/toolbar_background.xml new file mode 100644 index 000000000..f20301a39 --- /dev/null +++ b/app/src/main/res/drawable/toolbar_background.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp-land/fragment_details.xml b/app/src/main/res/layout-w600dp-land/fragment_details.xml index f1b1f770c..3d985c834 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_details.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_details.xml @@ -5,6 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" android:scrollbars="vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -29,14 +30,12 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" - android:layout_weight="1" - app:cardCornerRadius="4dp" - app:cardElevation="4dp"> + android:layout_weight="1"> @@ -55,9 +54,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:ellipsize="end" - android:textAppearance="@style/TextAppearance.AppCompat.Body2" - android:textColor="?android:textColorPrimary" - android:textSize="20sp" + android:textAppearance="?attr/textAppearanceHeadlineSmall" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -69,7 +66,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:ellipsize="end" - android:textSize="14sp" + android:textAppearance="?attr/textAppearanceBodyMedium" app:layout_constraintEnd_toEndOf="@id/textView_title" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textView_title" @@ -80,25 +77,42 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:background="@drawable/list_selector" android:requiresFadingEdge="horizontal" - android:textColor="?colorAccent" + android:textColor="?colorTertiary" android:textStyle="bold" app:layout_constraintEnd_toEndOf="@id/textView_title" + app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="@id/textView_title" app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + app:layout_constraintWidth_default="wrap" tools:text="@tools:sample/full_names" /> + + + app:layout_constraintTop_toBottomOf="@+id/textView_state"> - - @@ -269,12 +273,12 @@ android:layout_height="wrap_content" android:indeterminate="true" android:visibility="gone" - app:showAnimationBehavior="inward" app:hideAnimationBehavior="outward" app:layout_constraintBottom_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" + app:showAnimationBehavior="inward" tools:visibility="visible" /> diff --git a/app/src/main/res/layout-w600dp-port/fragment_details.xml b/app/src/main/res/layout-w600dp-port/fragment_details.xml index e9d6d8eed..96e5232d9 100644 --- a/app/src/main/res/layout-w600dp-port/fragment_details.xml +++ b/app/src/main/res/layout-w600dp-port/fragment_details.xml @@ -5,6 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" android:scrollbars="vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -29,14 +30,12 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" - android:layout_weight="1" - app:cardCornerRadius="4dp" - app:cardElevation="4dp"> + android:layout_weight="1"> @@ -56,9 +55,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:ellipsize="end" - android:textAppearance="@style/TextAppearance.AppCompat.Body2" - android:textColor="?android:textColorPrimary" - android:textSize="20sp" + android:textAppearance="?attr/textAppearanceHeadlineSmall" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -70,7 +67,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:ellipsize="end" - android:textSize="14sp" + android:textAppearance="?attr/textAppearanceBodyMedium" app:layout_constraintEnd_toEndOf="@id/textView_title" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textView_title" @@ -81,25 +78,41 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:background="@drawable/list_selector" android:requiresFadingEdge="horizontal" - android:textColor="?colorAccent" + android:textColor="?colorTertiary" android:textStyle="bold" app:layout_constraintEnd_toEndOf="@id/textView_title" + app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="@id/textView_title" app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + app:layout_constraintWidth_default="wrap" tools:text="@tools:sample/full_names" /> + + + app:layout_constraintTop_toBottomOf="@+id/textView_state"> - - @@ -276,12 +277,12 @@ android:layout_height="wrap_content" android:indeterminate="true" android:visibility="gone" - app:showAnimationBehavior="inward" app:hideAnimationBehavior="outward" app:layout_constraintBottom_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" + app:showAnimationBehavior="inward" tools:visibility="visible" /> diff --git a/app/src/main/res/layout-w600dp/activity_details.xml b/app/src/main/res/layout-w600dp/activity_details.xml index af351d3aa..6bf664eea 100644 --- a/app/src/main/res/layout-w600dp/activity_details.xml +++ b/app/src/main/res/layout-w600dp/activity_details.xml @@ -3,20 +3,18 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/coordinator" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".details.ui.DetailsActivity"> + android:layout_height="wrap_content"> @@ -41,4 +39,11 @@ android:layout_height="match_parent" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp/activity_settings.xml b/app/src/main/res/layout-w600dp/activity_settings.xml index 3b6db9d0b..b257facd3 100644 --- a/app/src/main/res/layout-w600dp/activity_settings.xml +++ b/app/src/main/res/layout-w600dp/activity_settings.xml @@ -8,17 +8,14 @@ diff --git a/app/src/main/res/layout-w600dp/fragment_list.xml b/app/src/main/res/layout-w600dp/fragment_list.xml index befce9ebf..cc60564d7 100644 --- a/app/src/main/res/layout-w600dp/fragment_list.xml +++ b/app/src/main/res/layout-w600dp/fragment_list.xml @@ -36,7 +36,7 @@ android:id="@+id/divider_filter" android:layout_width="1dp" android:layout_height="match_parent" - android:background="?dividerVertical" + android:background="?attr/colorOutline" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml index b230edce1..b9a3c6992 100644 --- a/app/src/main/res/layout/activity_browser.xml +++ b/app/src/main/res/layout/activity_browser.xml @@ -8,17 +8,14 @@ diff --git a/app/src/main/res/layout/activity_categories.xml b/app/src/main/res/layout/activity_categories.xml index 48043eb74..9c05aee7f 100644 --- a/app/src/main/res/layout/activity_categories.xml +++ b/app/src/main/res/layout/activity_categories.xml @@ -8,16 +8,14 @@ + android:layout_height="wrap_content"> + android:layout_height="?attr/actionBarSize" + android:theme="?attr/actionBarTheme"/> @@ -39,8 +37,7 @@ android:layout_margin="20dp" android:gravity="center" android:text="@string/text_categories_holder" - android:textAppearance="?android:textAppearanceMedium" - android:textColor="?android:textColorSecondary" + android:textAppearance="?attr/textAppearanceBody2" android:visibility="gone" tools:visibility="visible" /> @@ -51,7 +48,6 @@ android:layout_margin="16dp" android:contentDescription="@string/add_new_category" android:src="@drawable/ic_add" - app:backgroundTint="?colorAccent" app:fabSize="normal" app:layout_anchor="@id/recyclerView" app:layout_anchorGravity="bottom|end" diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml index 0fa69b528..5a723948f 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/activity_details.xml @@ -3,23 +3,24 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/coordinator" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".details.ui.DetailsActivity"> + android:layout_height="?attr/actionBarSize" + android:theme="?attr/actionBarTheme" + app:layout_scrollFlags="scroll|enterAlways"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml index 65096b931..927805f6c 100644 --- a/app/src/main/res/layout/activity_downloads.xml +++ b/app/src/main/res/layout/activity_downloads.xml @@ -8,16 +8,16 @@ + android:fitsSystemWindows="true" + app:elevation="0dp"> + android:layout_height="?attr/actionBarSize" + android:theme="?attr/actionBarTheme"/> @@ -39,8 +39,7 @@ android:layout_margin="20dp" android:gravity="center" android:text="@string/text_downloads_holder" - android:textAppearance="?android:textAppearanceMedium" - android:textColor="?android:textColorSecondary" + android:textAppearance="?attr/textAppearanceBody2" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_image.xml b/app/src/main/res/layout/activity_image.xml new file mode 100644 index 000000000..108024a8c --- /dev/null +++ b/app/src/main/res/layout/activity_image.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 64e125d8d..f02321df3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -20,31 +20,28 @@ + android:stateListAnimator="@null"> - + android:layout_marginBottom="8dp"> - + @@ -77,7 +74,6 @@ android:contentDescription="@string/_continue" android:src="@drawable/ic_read_fill" android:visibility="gone" - app:backgroundTint="?colorAccent" app:fabSize="normal" app:layout_anchor="@id/container" app:layout_anchorGravity="bottom|end" @@ -92,10 +88,8 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="start" + android:fitsSystemWindows="true" app:insetForeground="@android:color/transparent" - app:itemHorizontalPadding="16dp" - app:itemIconPadding="24dp" - app:itemIconTint="@color/navigation_item_color_tint" app:menu="@menu/nav_drawer" /> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_protect.xml b/app/src/main/res/layout/activity_protect.xml index 28cc9060b..3cba1f000 100644 --- a/app/src/main/res/layout/activity_protect.xml +++ b/app/src/main/res/layout/activity_protect.xml @@ -60,17 +60,18 @@ -