Compare commits
76 Commits
master
...
861c21faea
| Author | SHA1 | Date | |
|---|---|---|---|
| 861c21faea | |||
|
|
9b4d014b21 | ||
|
c6da7de699
|
|||
|
ef3aa40acc
|
|||
|
07af3ea703
|
|||
|
391c8ab649
|
|||
| 6b1885c89d | |||
| 8423b48fb9 | |||
|
803c825d91
|
|||
|
6a9682a077
|
|||
|
9197b9cc3a
|
|||
|
|
02ea804874 | ||
|
c424466198
|
|||
|
|
18b312dde6 | ||
|
f78262b1a0
|
|||
|
c557a51c4d
|
|||
|
|
8995762935 | ||
|
|
ed2664db78 | ||
|
|
f5a5e53b5a | ||
|
|
9ef961590d | ||
|
|
9b569615ee | ||
|
|
f48cf2efe4 | ||
|
|
18094a310c | ||
|
|
320c49a831 | ||
|
|
2a971d5dae | ||
|
|
4467e79ae6 | ||
|
|
c68b180bf6 | ||
|
|
5f879f6c83 | ||
|
|
aeb3732d75 | ||
|
|
6292a0fd6b | ||
|
|
8985b4135d | ||
|
f8a5397542
|
|||
|
5f51041220
|
|||
|
5a14412b62
|
|||
|
be012f631a
|
|||
|
0165f43603
|
|||
|
55801a1488
|
|||
|
77103f016f
|
|||
|
|
6b6719a259 | ||
|
|
822642abb0 | ||
|
|
260745fb95 | ||
|
|
024ec0388f | ||
|
|
5345998eec | ||
|
|
3d56190e71 | ||
|
|
954431d0a5 | ||
|
|
afec63b443 | ||
|
|
ac5b29c35a | ||
|
|
59f5578b66 | ||
|
|
391dbb4237 | ||
|
|
7d4505eb78 | ||
|
|
e6ceb20cf7 | ||
|
|
8004f8c093 | ||
|
61bf2abb6c
|
|||
|
d9612f3427
|
|||
|
435c3824f7
|
|||
|
c846693570
|
|||
|
123937cd01
|
|||
|
9f56554313
|
|||
|
f8687bb697
|
|||
|
43d3a2cc6a
|
|||
|
|
a95db6ed21 | ||
|
fd0bb57338
|
|||
|
6b94bc2632
|
|||
|
c8b91599c6
|
|||
|
|
3a8b0f9e93 | ||
|
|
17a0725666 | ||
|
|
3be7848ad9 | ||
|
|
08202c11a3 | ||
|
|
5ef907d046 | ||
|
|
c3776ea3c6 | ||
|
a624bffea3
|
|||
|
8f38b4fe30
|
|||
|
71a2de5358
|
|||
|
5478f8fb59
|
|||
|
|
5155c9a33d | ||
|
|
1d1e49123a |
2
.idea/.gitignore
generated
vendored
2
.idea/.gitignore
generated
vendored
@@ -3,3 +3,5 @@
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
/runConfigurations.xml
|
||||
/appInsightsSettings.xml
|
||||
/kotlinCodeInsightSettings.xml
|
||||
|
||||
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
4
.idea/gradle.xml
generated
4
.idea/gradle.xml
generated
@@ -6,7 +6,7 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
@@ -16,4 +16,4 @@
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1028
|
||||
versionName = '9.1.4'
|
||||
versionCode = 1030
|
||||
versionName = '9.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -87,6 +87,7 @@ android {
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||
'-Xjspecify-annotations=strict',
|
||||
'-Xannotation-default-target=first-only',
|
||||
'-Xtype-enhancement-improvements-strict-mode'
|
||||
]
|
||||
}
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -8,8 +8,7 @@
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||
|
||||
-dontwarn okhttp3.internal.platform.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
@@ -17,8 +16,10 @@
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
|
||||
@@ -41,8 +41,8 @@ class KotatsuApp : BaseApp() {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
/*
|
||||
This class is for parser development and testing purposes
|
||||
You can open it in the app via Settings -> Debug
|
||||
*/
|
||||
class TestMangaRepository(
|
||||
@Suppress("unused") private val loaderContext: MangaLoaderContext,
|
||||
cache: MemoryContentCache
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
override val source = TestMangaSource
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = sortOrders.first()
|
||||
set(value) = Unit
|
||||
|
||||
override val filterCapabilities = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions() = MangaListFilterOptions()
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
order: SortOrder?,
|
||||
filter: MangaListFilter?
|
||||
): List<Manga> = TODO("Get manga list by filter")
|
||||
|
||||
override suspend fun getDetailsImpl(
|
||||
manga: Manga
|
||||
): Manga = TODO("Fetch manga details")
|
||||
|
||||
override suspend fun getPagesImpl(
|
||||
chapter: MangaChapter
|
||||
): List<MangaPage> = TODO("Get pages for specific chapter")
|
||||
|
||||
override suspend fun getPageUrl(
|
||||
page: MangaPage
|
||||
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
|
||||
|
||||
override suspend fun getRelatedMangaImpl(
|
||||
seed: Manga
|
||||
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.preference.Preference
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
@@ -35,6 +37,11 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
|
||||
true
|
||||
}
|
||||
|
||||
KEY_TEST_PARSER -> {
|
||||
router.openList(TestMangaSource, null, null)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
@@ -60,5 +67,6 @@ class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference
|
||||
|
||||
const val KEY_LEAK_CANARY = "leak_canary"
|
||||
const val KEY_WORK_INSPECTOR = "work_inspector"
|
||||
const val KEY_TEST_PARSER = "test_parser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:key="leak_canary"
|
||||
android:persistent="false"
|
||||
android:title="LeakCanary" />
|
||||
|
||||
<Preference
|
||||
android:id="@+id/action_works"
|
||||
android:key="work_inspector"
|
||||
android:persistent="false"
|
||||
android:title="@string/wi_lib_name" />
|
||||
|
||||
<Preference
|
||||
android:key="test_parser"
|
||||
android:persistent="false"
|
||||
android:title="@string/test_parser"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
|
||||
@@ -51,9 +51,11 @@
|
||||
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||
android:extractNativeLibs="true"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:restoreAnyVersion="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
|
||||
@@ -36,15 +36,14 @@ class AppBackupAgent : BackupAgent() {
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
super.onFullBackup(data)
|
||||
val file =
|
||||
createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
MangaDatabase(context = applicationContext),
|
||||
AppSettings(applicationContext),
|
||||
TapGridSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
val file = createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
MangaDatabase(context = applicationContext),
|
||||
AppSettings(applicationContext),
|
||||
TapGridSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
try {
|
||||
fullBackupFile(file, data)
|
||||
} finally {
|
||||
@@ -90,8 +89,12 @@ class AppBackupAgent : BackupAgent() {
|
||||
@VisibleForTesting
|
||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||
val sections = EnumSet.allOf(BackupSection::class.java)
|
||||
// managed externally
|
||||
sections.remove(BackupSection.SETTINGS)
|
||||
sections.remove(BackupSection.SETTINGS_READER_GRID)
|
||||
runBlocking {
|
||||
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||
repository.restoreBackup(input, sections, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ enum class BackupSection(
|
||||
|
||||
fun of(entry: ZipEntry): BackupSection? {
|
||||
val name = entry.name.lowercase(Locale.ROOT)
|
||||
return entries.first { x -> x.entryName == name }
|
||||
return entries.find { x -> x.entryName == name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class TelegramBackupUploader @Inject constructor(
|
||||
suspend fun uploadBackup(file: File) {
|
||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.Companion.FORM)
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("chat_id", requireChatId())
|
||||
.addFormDataPart("document", file.name, requestBody)
|
||||
.build()
|
||||
|
||||
@@ -8,11 +8,11 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.platform.PlatformRegistry
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.os.RomCompat
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
@@ -62,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workScheduleManager: WorkScheduleManager
|
||||
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@@ -79,6 +75,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +94,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
@@ -43,6 +44,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
@@ -65,11 +67,13 @@ class CaptchaHandler @Inject constructor(
|
||||
@LocalizedAppContext private val context: Context,
|
||||
private val databaseProvider: Provider<MangaDatabase>,
|
||||
private val coilProvider: Provider<ImageLoader>,
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : EventListener() {
|
||||
|
||||
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
@CheckResult
|
||||
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
||||
|
||||
suspend fun discard(source: MangaSource) {
|
||||
@@ -79,10 +83,18 @@ class CaptchaHandler @Inject constructor(
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
val e = result.throwable
|
||||
if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
|
||||
if (e is CloudFlareException) {
|
||||
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
|
||||
scope.launch {
|
||||
handleException(e.source, e, true)
|
||||
if (
|
||||
handleException(
|
||||
source = e.source,
|
||||
exception = e,
|
||||
notify = request.extras[suppressCaptchaKey] != true,
|
||||
)
|
||||
) {
|
||||
coilProvider.get().enqueue(request) // TODO check if ok
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,11 +102,14 @@ class CaptchaHandler @Inject constructor(
|
||||
private suspend fun handleException(
|
||||
source: MangaSource,
|
||||
exception: CloudFlareException?,
|
||||
notify: Boolean
|
||||
notify: Boolean,
|
||||
): Boolean = withContext(Dispatchers.Default) {
|
||||
if (source == UnknownMangaSource) {
|
||||
return@withContext false
|
||||
}
|
||||
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
|
||||
return@withContext true
|
||||
}
|
||||
mutex.withLock {
|
||||
var removedException: CloudFlareProtectedException? = null
|
||||
if (exception is CloudFlareProtectedException) {
|
||||
@@ -119,7 +134,7 @@ class CaptchaHandler @Inject constructor(
|
||||
notify(exceptions)
|
||||
}
|
||||
}
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -234,7 +249,7 @@ class CaptchaHandler @Inject constructor(
|
||||
.data(source.faviconUri())
|
||||
.allowHardware(false)
|
||||
.allowConversionToBitmap(true)
|
||||
.ignoreCaptchaErrors()
|
||||
.suppressCaptchaErrors()
|
||||
.mangaSourceExtra(source)
|
||||
.size(context.resources.getNotificationIconSize())
|
||||
.scale(Scale.FILL)
|
||||
@@ -260,11 +275,11 @@ class CaptchaHandler @Inject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||
extras[ignoreCaptchaKey] = true
|
||||
fun ImageRequest.Builder.suppressCaptchaErrors() = apply {
|
||||
extras[suppressCaptchaKey] = true
|
||||
}
|
||||
|
||||
val ignoreCaptchaKey = Extras.Key(false)
|
||||
private val suppressCaptchaKey = Extras.Key(false)
|
||||
|
||||
private const val CHANNEL_ID = "captcha"
|
||||
private const val TAG = CHANNEL_ID
|
||||
@@ -272,5 +287,6 @@ class CaptchaHandler @Inject constructor(
|
||||
private const val GROUP_NOTIFICATION_ID = 34
|
||||
private const val SETTINGS_ACTION_CODE = 3
|
||||
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
|
||||
private const val RESOLVE_TIMEOUT = 20_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,15 @@ data object UnknownMangaSource : MangaSource {
|
||||
override val name = "UNKNOWN"
|
||||
}
|
||||
|
||||
data object TestMangaSource : MangaSource {
|
||||
override val name = "TEST"
|
||||
}
|
||||
|
||||
fun MangaSource(name: String?): MangaSource {
|
||||
when (name ?: return UnknownMangaSource) {
|
||||
UnknownMangaSource.name -> return UnknownMangaSource
|
||||
|
||||
LocalMangaSource.name -> return LocalMangaSource
|
||||
TestMangaSource.name -> return TestMangaSource
|
||||
}
|
||||
if (name.startsWith("content:")) {
|
||||
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||
@@ -92,6 +96,7 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
TestMangaSource -> context.getString(R.string.test_parser)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ class AppRouter private constructor(
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun shortMangaUrl(mangaId: Long) = Uri.Builder()
|
||||
fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder()
|
||||
.scheme("kotatsu")
|
||||
.path("manga")
|
||||
.appendQueryParameter("id", mangaId.toString())
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.WorkerThread
|
||||
import androidx.core.util.Predicate
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.parsers.util.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import kotlin.coroutines.Continuation
|
||||
|
||||
class CaptchaContinuationClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val targetUrl: String,
|
||||
continuation: Continuation<Unit>,
|
||||
) : ContinuationResumeWebViewClient(continuation) {
|
||||
|
||||
private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) = Unit
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
checkClearance(view)
|
||||
}
|
||||
|
||||
private fun checkClearance(view: WebView?) {
|
||||
val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
resumeContinuation(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ContinuationResumeWebViewClient(
|
||||
open class ContinuationResumeWebViewClient(
|
||||
private val continuation: Continuation<Unit>,
|
||||
) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
resumeContinuation(view)
|
||||
}
|
||||
|
||||
protected fun resumeContinuation(view: WebView?) {
|
||||
if (continuation !is CancellableContinuation || continuation.isActive) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,127 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.annotation.MainThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Singleton
|
||||
class WebViewExecutor @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
private val proxyProvider: ProxyProvider,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val mangaRepositoryFactoryProvider: Provider<MangaRepository.Factory>,
|
||||
) {
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
private val mutex = Mutex()
|
||||
|
||||
val defaultUserAgent: String? by lazy {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
}
|
||||
|
||||
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
if (!baseUrl.isNullOrEmpty()) {
|
||||
try {
|
||||
if (!baseUrl.isNullOrEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
webView.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun getDefaultUserAgent() = runCatching {
|
||||
obtainWebView().settings.userAgentString.sanitizeHeaderValue().trim().nullIfEmpty()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock {
|
||||
runCatchingCancellable {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
try {
|
||||
exception.source.getUserAgent()?.let {
|
||||
webView.settings.userAgentString = it
|
||||
}
|
||||
withTimeout(timeout) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
webView.webViewClient = CaptchaContinuationClient(
|
||||
cookieJar = cookieJar,
|
||||
targetUrl = exception.url,
|
||||
continuation = cont,
|
||||
)
|
||||
webView.loadUrl(exception.url)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
webView.reset()
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
exception.addSuppressed(e)
|
||||
e.printStackTraceDebug()
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
private suspend fun obtainWebView(): WebView {
|
||||
webViewCached?.get()?.let {
|
||||
return it
|
||||
}
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
webViewCached?.get()?.let {
|
||||
return@withContext it
|
||||
}
|
||||
WebView(context).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
proxyProvider.applyWebViewConfig()
|
||||
it.onResume()
|
||||
it.resumeTimers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaSource.getUserAgent(): String? {
|
||||
val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository
|
||||
return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
private fun WebView.reset() {
|
||||
stopLoading()
|
||||
webViewClient = WebViewClient()
|
||||
settings.userAgentString = defaultUserAgent
|
||||
loadDataWithBaseURL(null, " ", "text/html", null, null)
|
||||
clearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +80,7 @@ class NetworkState(
|
||||
if (settings.isOfflineCheckDisabled) {
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } == true
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
return activeNetwork?.let { isOnline(it) } == true
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
order: SortOrder,
|
||||
filter: MangaListFilter
|
||||
): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
|
||||
import org.koitharu.kotatsu.core.db.entity.ContentRating
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
@@ -189,6 +191,11 @@ class MangaDataRepository @Inject constructor(
|
||||
emitInitialState = emitInitialState,
|
||||
)
|
||||
|
||||
fun observeFavoritesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow(
|
||||
tables = arrayOf(TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES),
|
||||
emitInitialState = emitInitialState,
|
||||
)
|
||||
|
||||
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
|
||||
val cachedChapters = db.getChaptersDao().findAll(id)
|
||||
if (cachedChapters.isEmpty()) {
|
||||
|
||||
@@ -5,8 +5,6 @@ import android.content.Context
|
||||
import android.util.Base64
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -33,7 +31,6 @@ import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
@Singleton
|
||||
class MangaLoaderContextImpl @Inject constructor(
|
||||
@@ -43,7 +40,6 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : MangaLoaderContext() {
|
||||
|
||||
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
||||
|
||||
@Deprecated("Provide a base url")
|
||||
@@ -54,7 +50,7 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
webViewExecutor.evaluateJs(baseUrl, script)
|
||||
}
|
||||
|
||||
override fun getDefaultUserAgent(): String = webViewUserAgent
|
||||
override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE
|
||||
|
||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||
return SourceSettings(androidContext, source)
|
||||
@@ -91,15 +87,4 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||
|
||||
private fun obtainWebViewUserAgent(): String {
|
||||
val mainDispatcher = Dispatchers.Main.immediate
|
||||
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||
webViewExecutor.getDefaultUserAgent()
|
||||
} else {
|
||||
runBlocking(mainDispatcher) {
|
||||
webViewExecutor.getDefaultUserAgent()
|
||||
}
|
||||
} ?: UserAgents.FIREFOX_MOBILE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
|
||||
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return when (source) {
|
||||
MangaParserSource.DUMMY -> DummyParser(loaderContext)
|
||||
else -> loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
@@ -85,11 +86,16 @@ interface MangaRepository {
|
||||
|
||||
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||
is MangaParserSource -> ParserMangaRepository(
|
||||
parser = MangaParser(source, loaderContext),
|
||||
parser = loaderContext.newParserInstance(source),
|
||||
cache = contentCache,
|
||||
mirrorSwitcher = mirrorSwitcher,
|
||||
)
|
||||
|
||||
TestMangaSource -> TestMangaRepository(
|
||||
loaderContext = loaderContext,
|
||||
cache = contentCache,
|
||||
)
|
||||
|
||||
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||
ExternalMangaRepository(
|
||||
contentResolver = context.contentResolver,
|
||||
|
||||
@@ -19,6 +19,7 @@ import coil3.request.Options
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.toAndroidUri
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.FileSystem
|
||||
@@ -41,7 +42,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
class FaviconFetcher(
|
||||
@@ -88,7 +88,7 @@ class FaviconFetcher(
|
||||
var favicons = repository.getFavicons()
|
||||
var lastError: Exception? = null
|
||||
while (favicons.isNotEmpty()) {
|
||||
coroutineContext.ensureActive()
|
||||
currentCoroutineContext().ensureActive()
|
||||
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
||||
try {
|
||||
val result = imageLoader.fetch(icon.url, options)
|
||||
|
||||
@@ -138,6 +138,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||
|
||||
@get:FloatRange(0.0, 1.0)
|
||||
var readerDoublePagesSensitivity: Float
|
||||
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
|
||||
|
||||
val readerScreenOrientation: Int
|
||||
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
@@ -488,6 +493,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
|
||||
|
||||
var isWebtoonPullGestureEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) }
|
||||
|
||||
@get:FloatRange(from = 0.0, to = 0.5)
|
||||
val defaultWebtoonZoomOut: Float
|
||||
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
|
||||
@@ -669,6 +678,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||
@@ -748,6 +758,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
|
||||
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
|
||||
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||
const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture"
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun interface OnContextClickListenerCompat {
|
||||
|
||||
fun onContextClick(v: View): Boolean
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import coil3.asImage
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.image.CoilImageView
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
@@ -57,7 +57,7 @@ class FaviconView @JvmOverloads constructor(
|
||||
.fallback(fallbackFactory)
|
||||
.placeholder(placeholderFactory)
|
||||
.mangaSourceExtra(mangaSource)
|
||||
.ignoreCaptchaErrors()
|
||||
.suppressCaptchaErrors()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,16 @@ package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnContextClickListener
|
||||
import android.view.View.OnLongClickListener
|
||||
import androidx.core.util.Function
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||
private val clickListener: OnListItemClickListener<O>,
|
||||
private val itemMapper: Function<I, O>,
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListener {
|
||||
|
||||
override fun onClick(v: View) {
|
||||
clickListener.onItemClick(mappedItem(), v)
|
||||
@@ -33,7 +32,7 @@ class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
fun attach(itemView: View) {
|
||||
itemView.setOnClickListener(this)
|
||||
itemView.setOnLongClickListener(this)
|
||||
itemView.setOnContextClickListenerCompat(this)
|
||||
itemView.setOnContextClickListener(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -186,6 +186,7 @@ class ListSelectionController(
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_CREATE) {
|
||||
source.lifecycle.removeObserver(this)
|
||||
val registry = registryOwner.savedStateRegistry
|
||||
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
|
||||
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -14,7 +13,6 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -37,14 +35,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
if (window != null) {
|
||||
val ctx = window.context
|
||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
} else {
|
||||
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
|
||||
}
|
||||
val actionModeColor = ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
defaultStatusBarColor = window.statusBarColor
|
||||
window.statusBarColor = actionModeColor
|
||||
val insets = ViewCompat.getRootWindowInsets(window.decorView)
|
||||
|
||||
@@ -4,12 +4,10 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class PopupMenuMediator(
|
||||
private val provider: MenuProvider,
|
||||
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
|
||||
) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener,
|
||||
PopupMenu.OnDismissListener {
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
@@ -37,6 +35,6 @@ class PopupMenuMediator(
|
||||
|
||||
fun attach(view: View) {
|
||||
view.setOnLongClickListener(this)
|
||||
view.setOnContextClickListenerCompat(this)
|
||||
view.setOnContextClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ open class StackLayout @JvmOverloads constructor(
|
||||
val h = b - t - paddingTop - paddingBottom
|
||||
visibleChildren.clear()
|
||||
children.filterNotTo(visibleChildren) { it.isGone }
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val xStep = w / (visibleChildren.size + 1)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.impl.foreground.SystemForegroundService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Workaround for issue
|
||||
* https://issuetracker.google.com/issues/270245927
|
||||
* https://issuetracker.google.com/issues/280504155
|
||||
*/
|
||||
class WorkServiceStopHelper(
|
||||
private val workManagerProvider: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
fun setup() {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
workManagerProvider.get()
|
||||
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.map { it.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
if (it) {
|
||||
delay(1_000)
|
||||
stopWorkerService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun stopWorkerService() {
|
||||
SystemForegroundService.getInstance()?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -169,12 +168,6 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setOnContextClickListener(listener::onContextClick)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setTooltipCompat(tooltip: CharSequence?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
tooltipText = tooltip
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -34,12 +34,17 @@ class ProgressUpdateUseCase @Inject constructor(
|
||||
}
|
||||
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
|
||||
val chapters = details.getChapters(chapter.branch)
|
||||
val chapterRepo = if (repo.source == chapter.source) {
|
||||
repo
|
||||
} else {
|
||||
mangaRepositoryFactory.create(chapter.source)
|
||||
}
|
||||
val chaptersCount = chapters.size
|
||||
if (chaptersCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
|
||||
val pagesCount = repo.getPages(chapter).size
|
||||
val pagesCount = chapterRepo.getPages(chapter).size
|
||||
if (pagesCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
|
||||
// Impossible task, I guess. Good luck on this.
|
||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||
if (isOnHistoryBranch) {
|
||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt()
|
||||
}
|
||||
if (averageTimeSec < 60) {
|
||||
return null
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannedString
|
||||
import android.view.Gravity
|
||||
@@ -209,9 +208,7 @@ class DetailsActivity :
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }
|
||||
|
||||
@@ -140,6 +140,7 @@ class DetailsViewModel @Inject constructor(
|
||||
get() = scrobblers.any { it.isEnabled }
|
||||
|
||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
|
||||
|
||||
@@ -105,7 +105,14 @@ class PagesViewModel @Inject constructor(
|
||||
chaptersLoader.peekChapter(it) != null
|
||||
} ?: state.details.allChapters.firstOrNull()?.id ?: return
|
||||
if (!chaptersLoader.hasPages(initialChapterId)) {
|
||||
chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
var hasPages = chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
while (!hasPages) {
|
||||
if (chaptersLoader.loadPrevNextChapter(state.details, initialChapterId, isNext = true)) {
|
||||
hasPages = chaptersLoader.snapshot().isNotEmpty()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
updateList(state.readerState)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -35,7 +38,8 @@ class RelatedListViewModel @Inject constructor(
|
||||
settings: AppSettings,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository) {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
|
||||
|
||||
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
private val repository = mangaRepositoryFactory.create(seed.source)
|
||||
|
||||
@@ -202,7 +202,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
?: error("Cannot obtain remote manga instance")
|
||||
}
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
val mangaDetails = if (manga.chapters.isNullOrEmpty() || manga.description.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
output = LocalMangaOutput.getOrCreate(
|
||||
root = destination,
|
||||
manga = mangaDetails,
|
||||
|
||||
@@ -53,11 +53,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
val allMangaSources: Set<MangaParserSource> = Collections.unmodifiableSet(
|
||||
EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
},
|
||||
EnumSet.allOf(MangaParserSource::class.java)
|
||||
)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.explore.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.bold
|
||||
import androidx.core.text.buildSpannedString
|
||||
|
||||
@@ -40,6 +40,9 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
private const val PAGE_SIZE = 16
|
||||
|
||||
@@ -52,7 +55,8 @@ class FavouritesListViewModel @Inject constructor(
|
||||
quickFilterFactory: FavoritesListQuickFilter.Factory,
|
||||
settings: AppSettings,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener {
|
||||
|
||||
val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
|
||||
private val quickFilter = quickFilterFactory.create(categoryId)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.filter.ui.sheet
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -51,9 +50,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
if (dialog == null) {
|
||||
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.scrollView.scrollIndicators = 0
|
||||
}
|
||||
binding.scrollView.scrollIndicators = 0
|
||||
}
|
||||
val filter = FilterCoordinator.require(this)
|
||||
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||
|
||||
@@ -43,6 +43,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
private const val PAGE_SIZE = 16
|
||||
|
||||
@@ -54,7 +57,8 @@ class HistoryListViewModel @Inject constructor(
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
private val quickFilter: HistoryListQuickFilter,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
|
||||
|
||||
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.IO,
|
||||
|
||||
@@ -2,14 +2,12 @@ package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
@@ -83,9 +81,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
if (fallbackDrawable == null) {
|
||||
fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
addImageRequestListener(ErrorForegroundListener())
|
||||
}
|
||||
addImageRequestListener(ErrorForegroundListener())
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@@ -169,7 +165,6 @@ class CoverImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private inner class ErrorForegroundListener : ImageRequest.Listener {
|
||||
|
||||
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
|
||||
@@ -208,6 +203,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
is HttpStatusException -> statusCode.toString()
|
||||
is ContentUnavailableException,
|
||||
is FileNotFoundException -> "404"
|
||||
|
||||
is TooManyRequestExceptions -> "429"
|
||||
is ParseException -> "</>"
|
||||
is UnsupportedSourceException -> "X"
|
||||
@@ -269,7 +265,7 @@ class CoverImageView @JvmOverloads constructor(
|
||||
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
|
||||
}
|
||||
}
|
||||
return Size(checkNotNull(width), checkNotNull(height))
|
||||
return Size(width, height)
|
||||
}
|
||||
|
||||
private fun getWidth() = getDimension(
|
||||
|
||||
@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.list.ui
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -22,10 +24,13 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
|
||||
abstract class MangaListViewModel(
|
||||
private val settings: AppSettings,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : BaseViewModel() {
|
||||
|
||||
abstract val content: StateFlow<List<ListModel>>
|
||||
@@ -63,7 +68,11 @@ abstract class MangaListViewModel(
|
||||
|
||||
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
|
||||
listMode,
|
||||
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
|
||||
merge(
|
||||
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
|
||||
mangaDataRepository.observeFavoritesTrigger(emitInitialState = true),
|
||||
localStorageChanges.onStart { emit(null) },
|
||||
),
|
||||
settings.observeChanges().filter { key ->
|
||||
key == AppSettings.KEY_PROGRESS_INDICATORS
|
||||
|| key == AppSettings.KEY_TRACKER_ENABLED
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||
import org.koitharu.kotatsu.parsers.util.json.toStringSet
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.io.File
|
||||
|
||||
@@ -61,7 +61,9 @@ class LocalMangaParser(private val uri: Uri) {
|
||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||
val mangaInfo = index?.getMangaInfo()
|
||||
if (mangaInfo != null) {
|
||||
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }
|
||||
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }?.takeIf {
|
||||
fileSystem.exists(it)
|
||||
}
|
||||
mangaInfo.copy(
|
||||
source = LocalMangaSource,
|
||||
url = rootFile.toUri().toString(),
|
||||
|
||||
@@ -45,7 +45,7 @@ class LocalListViewModel @Inject constructor(
|
||||
mangaListMapper: MangaListMapper,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
exploreRepository: ExploreRepository,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
@param:LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
@@ -58,6 +58,7 @@ class LocalListViewModel @Inject constructor(
|
||||
exploreRepository = exploreRepository,
|
||||
sourcesRepository = sourcesRepository,
|
||||
mangaDataRepository = mangaDataRepository,
|
||||
localStorageChanges = localStorageChanges,
|
||||
), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||
|
||||
@@ -131,7 +131,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
onBackPressedDispatcher.addCallback(exitCallback)
|
||||
onBackPressedDispatcher.addCallback(navigationDelegate)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || !resources.getBoolean(R.bool.is_predictive_back_enabled)) {
|
||||
val legacySearchCallback = SearchViewLegacyBackCallback(viewBinding.searchView)
|
||||
viewBinding.searchView.addTransitionListener(legacySearchCallback)
|
||||
onBackPressedDispatcher.addCallback(legacySearchCallback)
|
||||
|
||||
@@ -20,6 +20,9 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
|
||||
@HiltViewModel
|
||||
class MangaPickerViewModel @Inject constructor(
|
||||
@@ -28,7 +31,8 @@ class MangaPickerViewModel @Inject constructor(
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
) : MangaListViewModel(settings, mangaDataRepository) {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) {
|
||||
|
||||
override val content: StateFlow<List<ListModel>>
|
||||
get() = flow {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.util.LongSparseArray
|
||||
import androidx.annotation.CheckResult
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -32,12 +33,12 @@ class ChaptersLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
|
||||
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean): Boolean {
|
||||
val chapters = manga.allChapters
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||
if (index == -1) return
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||
if (index == -1) return false
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false
|
||||
val newPages = loadChapter(newChapter.id)
|
||||
mutex.withLock {
|
||||
if (chapterPages.chaptersSize > 1) {
|
||||
@@ -56,13 +57,16 @@ class ChaptersLoader @Inject constructor(
|
||||
chapterPages.addFirst(newChapter.id, newPages)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun loadSingleChapter(chapterId: Long) {
|
||||
@CheckResult
|
||||
suspend fun loadSingleChapter(chapterId: Long): Boolean {
|
||||
val pages = loadChapter(chapterId)
|
||||
mutex.withLock {
|
||||
return mutex.withLock {
|
||||
chapterPages.clear()
|
||||
chapterPages.addLast(chapterId, pages)
|
||||
pages.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ import android.graphics.Rect
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -23,7 +21,6 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -46,19 +43,19 @@ class EdgeDetector(private val context: Context) {
|
||||
}
|
||||
val scaleFactor = calculateScaleFactor(size)
|
||||
val sampleSize = (1f / scaleFactor).toInt().coerceAtLeast(1)
|
||||
|
||||
|
||||
val fullBitmap = decoder.decodeRegion(
|
||||
Rect(0, 0, size.x, size.y),
|
||||
sampleSize
|
||||
Rect(0, 0, size.x, size.y),
|
||||
sampleSize,
|
||||
)
|
||||
|
||||
|
||||
try {
|
||||
val edges = coroutineScope {
|
||||
listOf(
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = true) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = true) },
|
||||
async { detectLeftRightEdge(fullBitmap, size, sampleSize, isLeft = false) },
|
||||
async { detectTopBottomEdge(fullBitmap, size, sampleSize, isTop = false) },
|
||||
).awaitAll()
|
||||
}
|
||||
var hasEdges = false
|
||||
@@ -91,10 +88,10 @@ class EdgeDetector(private val context: Context) {
|
||||
val rectCount = size.x / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||
|
||||
|
||||
val bitmapWidth = bitmap.width
|
||||
val bitmapHeight = bitmap.height
|
||||
|
||||
|
||||
for (i in 0 until rectCount) {
|
||||
if (i > maxRect) {
|
||||
return -1
|
||||
@@ -103,16 +100,16 @@ class EdgeDetector(private val context: Context) {
|
||||
for (j in 0 until size.y / BLOCK_SIZE) {
|
||||
val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
|
||||
val regionY = j * BLOCK_SIZE
|
||||
|
||||
|
||||
// Convert to bitmap coordinates
|
||||
val bitmapX = regionX / sampleSize
|
||||
val bitmapY = regionY / sampleSize
|
||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||
|
||||
|
||||
if (blockWidth > 0 && blockHeight > 0) {
|
||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||
|
||||
|
||||
for (ii in 0 until minOf(blockWidth, dd / sampleSize)) {
|
||||
for (jj in 0 until blockHeight) {
|
||||
val bi = if (isLeft) ii else blockWidth - ii - 1
|
||||
@@ -141,10 +138,10 @@ class EdgeDetector(private val context: Context) {
|
||||
val rectCount = size.y / BLOCK_SIZE
|
||||
val maxRect = rectCount / 3
|
||||
val blockPixels = IntArray(BLOCK_SIZE * BLOCK_SIZE)
|
||||
|
||||
|
||||
val bitmapWidth = bitmap.width
|
||||
val bitmapHeight = bitmap.height
|
||||
|
||||
|
||||
for (j in 0 until rectCount) {
|
||||
if (j > maxRect) {
|
||||
return -1
|
||||
@@ -153,16 +150,16 @@ class EdgeDetector(private val context: Context) {
|
||||
for (i in 0 until size.x / BLOCK_SIZE) {
|
||||
val regionX = i * BLOCK_SIZE
|
||||
val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
|
||||
|
||||
|
||||
// Convert to bitmap coordinates
|
||||
val bitmapX = regionX / sampleSize
|
||||
val bitmapY = regionY / sampleSize
|
||||
val blockWidth = min(BLOCK_SIZE / sampleSize, bitmapWidth - bitmapX)
|
||||
val blockHeight = min(BLOCK_SIZE / sampleSize, bitmapHeight - bitmapY)
|
||||
|
||||
|
||||
if (blockWidth > 0 && blockHeight > 0) {
|
||||
bitmap.getPixels(blockPixels, 0, blockWidth, bitmapX, bitmapY, blockWidth, blockHeight)
|
||||
|
||||
|
||||
for (jj in 0 until minOf(blockHeight, dd / sampleSize)) {
|
||||
for (ii in 0 until blockWidth) {
|
||||
val bj = if (isTop) jj else blockHeight - jj - 1
|
||||
@@ -218,4 +215,4 @@ class EdgeDetector(private val context: Context) {
|
||||
|
||||
private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
@@ -215,9 +214,7 @@ class ReaderActivity :
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
@@ -374,6 +371,7 @@ class ReaderActivity :
|
||||
viewBinding.infoBar.isTimeVisible = isFullscreen
|
||||
updateScrollTimerButton()
|
||||
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
|
||||
viewBinding.root.requestApplyInsets()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,8 +393,14 @@ class ReaderActivity :
|
||||
viewBinding.infoBar.updatePadding(
|
||||
top = systemBars.top,
|
||||
)
|
||||
val innerInsets = Insets.of(
|
||||
systemBars.left,
|
||||
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
|
||||
systemBars.right,
|
||||
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,12 @@ class ReaderViewModel @Inject constructor(
|
||||
valueProducer = { isWebtoonGapsEnabled },
|
||||
)
|
||||
|
||||
val isWebtoonPullGestureEnabled = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_WEBTOON_PULL_GESTURE,
|
||||
valueProducer = { isWebtoonPullGestureEnabled },
|
||||
)
|
||||
|
||||
val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest {
|
||||
if (it) {
|
||||
observeWebtoonZoomOut()
|
||||
@@ -345,11 +351,14 @@ class ReaderViewModel @Inject constructor(
|
||||
return@launchJob
|
||||
}
|
||||
ensureActive()
|
||||
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (lowerPos <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value
|
||||
if (autoLoadAllowed) {
|
||||
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (lowerPos <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
}
|
||||
}
|
||||
if (pageLoader.isPrefetchApplicable()) {
|
||||
pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT))
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.slider.Slider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -25,7 +26,9 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
|
||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
@@ -37,7 +40,8 @@ class ReaderConfigSheet :
|
||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
CompoundButton.OnCheckedChangeListener {
|
||||
CompoundButton.OnCheckedChangeListener,
|
||||
Slider.OnChangeListener {
|
||||
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
|
||||
@@ -86,6 +90,13 @@ class ReaderConfigSheet :
|
||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||
binding.switchPullGesture.isChecked = settings.isWebtoonPullGestureEnabled
|
||||
binding.switchPullGesture.isEnabled = mode == ReaderMode.WEBTOON
|
||||
|
||||
binding.textSensitivity.isVisible = settings.isReaderDoubleOnLandscape
|
||||
binding.seekbarSensitivity.isVisible = settings.isReaderDoubleOnLandscape
|
||||
binding.seekbarSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||
binding.seekbarSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
@@ -96,6 +107,8 @@ class ReaderConfigSheet :
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.switchPullGesture.setOnCheckedChangeListener(this)
|
||||
binding.seekbarSensitivity.addOnChangeListener(this)
|
||||
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
@@ -170,11 +183,21 @@ class ReaderConfigSheet :
|
||||
|
||||
R.id.switch_double_reader -> {
|
||||
settings.isReaderDoubleOnLandscape = isChecked
|
||||
viewBinding?.textSensitivity?.isVisible = isChecked
|
||||
viewBinding?.seekbarSensitivity?.isVisible = isChecked
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||
}
|
||||
|
||||
R.id.switch_pull_gesture -> {
|
||||
settings.isWebtoonPullGestureEnabled = isChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
settings.readerDoublePagesSensitivity = value / 100f
|
||||
}
|
||||
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
@@ -191,6 +214,7 @@ class ReaderConfigSheet :
|
||||
else -> return
|
||||
}
|
||||
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
viewBinding?.switchPullGesture?.isEnabled = newMode == ReaderMode.WEBTOON
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ import androidx.recyclerview.widget.OrientationHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
|
||||
import androidx.recyclerview.widget.SnapHelper
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
class DoublePageSnapHelper : SnapHelper() {
|
||||
class DoublePageSnapHelper(private val settings: AppSettings) : SnapHelper() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
@@ -248,28 +251,27 @@ class DoublePageSnapHelper : SnapHelper() {
|
||||
equal to zero.
|
||||
*/
|
||||
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
|
||||
var positionsToMove: Int
|
||||
positionsToMove = roundUpToBlockSize(abs((scroll.toDouble()) / itemSize).roundToInt())
|
||||
if (positionsToMove < blockSize) {
|
||||
// Must move at least one block
|
||||
positionsToMove = blockSize
|
||||
} else if (positionsToMove > maxPositionsToMove) {
|
||||
// Clamp number of positions to move, so we don't get wild flinging.
|
||||
positionsToMove = maxPositionsToMove
|
||||
val sensitivity = settings.readerDoublePagesSensitivity.coerceIn(0f, 1f) * 2.5
|
||||
var positionsToMove = (scroll.toDouble() / (itemSize * (2.5 - sensitivity))).roundToInt()
|
||||
|
||||
// Apply a maximum threshold
|
||||
val maxPages = (4 * sensitivity).roundToInt().coerceAtLeast(1)
|
||||
if (positionsToMove.absoluteValue > maxPages) {
|
||||
positionsToMove = maxPages * positionsToMove.sign
|
||||
}
|
||||
if (scroll < 0) {
|
||||
positionsToMove *= -1
|
||||
|
||||
// Apply a minimum threshold
|
||||
if (positionsToMove == 0 && scroll.absoluteValue > itemSize * 0.2) {
|
||||
positionsToMove = 1 * scroll.sign
|
||||
}
|
||||
if (isRTL) {
|
||||
positionsToMove *= -1
|
||||
}
|
||||
return if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
||||
// Scrolling toward the bottom of data.
|
||||
roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
|
||||
|
||||
val currentPosition = if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
|
||||
llm.findFirstVisibleItemPosition()
|
||||
} else {
|
||||
roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove
|
||||
llm.findLastVisibleItemPosition()
|
||||
}
|
||||
// Scrolling toward the top of the data.
|
||||
val targetPos = currentPosition + positionsToMove * 2
|
||||
return roundDownToBlockSize(targetPos)
|
||||
}
|
||||
|
||||
fun isDirectionToBottom(velocityNegative: Boolean): Boolean {
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
|
||||
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding
|
||||
@@ -33,6 +34,9 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
@@ -51,7 +55,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
addOnScrollListener(it)
|
||||
}
|
||||
addOnScrollListener(PageScrollListener())
|
||||
DoublePageSnapHelper().attachToRecyclerView(this)
|
||||
DoublePageSnapHelper(settings).attachToRecyclerView(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
fun scrollTo(y: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
resetScaleAndCenter()
|
||||
scrollToInternal(0)
|
||||
return
|
||||
}
|
||||
scrollToInternal(y.coerceIn(0, maxScroll))
|
||||
@@ -86,9 +86,9 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
desiredWidth = sWidth
|
||||
desiredHeight = sHeight
|
||||
} else if (resizeHeight) {
|
||||
desiredHeight = (sHeight.toDouble() / sWidth.toDouble() * desiredWidth).roundToInt()
|
||||
desiredHeight = (sHeight.toDouble() / sWidth.toDouble() * desiredWidth).toInt()
|
||||
} else if (resizeWidth) {
|
||||
desiredWidth = (sWidth.toDouble() / sHeight.toDouble() * desiredHeight).roundToInt()
|
||||
desiredWidth = (sWidth.toDouble() / sHeight.toDouble() * desiredHeight).toInt()
|
||||
}
|
||||
}
|
||||
desiredWidth = desiredWidth.coerceAtLeast(suggestedMinimumWidth)
|
||||
|
||||
@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -27,7 +32,8 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>(),
|
||||
WebtoonRecyclerView.OnWebtoonScrollListener {
|
||||
WebtoonRecyclerView.OnWebtoonScrollListener,
|
||||
WebtoonRecyclerView.OnPullGestureListener {
|
||||
|
||||
@Inject
|
||||
lateinit var networkState: NetworkState
|
||||
@@ -38,6 +44,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
private val scrollInterpolator = DecelerateInterpolator()
|
||||
|
||||
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
|
||||
private var canGoPrev = true
|
||||
private var canGoNext = true
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
@@ -53,6 +61,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
|
||||
addOnScrollListener(it)
|
||||
}
|
||||
setOnPullGestureListener(this@WebtoonReaderFragment)
|
||||
}
|
||||
viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) {
|
||||
binding.frame.isZoomEnable = it
|
||||
@@ -70,6 +79,18 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
|
||||
it.applyBackground(binding.root)
|
||||
}
|
||||
viewModel.isWebtoonPullGestureEnabled.observe(viewLifecycleOwner) { enabled ->
|
||||
binding.recyclerView.isPullGestureEnabled = enabled
|
||||
}
|
||||
viewModel.uiState.observe(viewLifecycleOwner) { state ->
|
||||
if (state != null) {
|
||||
canGoPrev = state.chapterIndex > 0
|
||||
canGoNext = state.chapterIndex < state.chaptersTotal - 1
|
||||
} else {
|
||||
canGoPrev = true
|
||||
canGoNext = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -78,6 +99,19 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val offsetInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding?.apply {
|
||||
feedbackTop.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = bottomMargin + offsetInsets.top
|
||||
}
|
||||
feedbackBottom.updateLayoutParams<MarginLayoutParams> {
|
||||
bottomMargin = topMargin + offsetInsets.bottom
|
||||
}
|
||||
}
|
||||
return super.onApplyWindowInsets(v, insets)
|
||||
}
|
||||
|
||||
override fun onCreateAdapter() = WebtoonAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
@@ -168,6 +202,47 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPullProgressTop(progress: Float) {
|
||||
val binding = viewBinding ?: return
|
||||
if (canGoPrev) {
|
||||
binding.feedbackTop.setFeedbackText(getString(R.string.pull_to_prev_chapter))
|
||||
} else {
|
||||
binding.feedbackTop.setFeedbackText(getString(R.string.pull_top_no_prev))
|
||||
}
|
||||
binding.feedbackTop.updateFeedback(progress)
|
||||
}
|
||||
|
||||
override fun onPullProgressBottom(progress: Float) {
|
||||
val binding = viewBinding ?: return
|
||||
if (canGoNext) {
|
||||
binding.feedbackBottom.setFeedbackText(getString(R.string.pull_to_next_chapter))
|
||||
} else {
|
||||
binding.feedbackBottom.setFeedbackText(getString(R.string.pull_bottom_no_next))
|
||||
}
|
||||
binding.feedbackBottom.updateFeedback(progress)
|
||||
}
|
||||
|
||||
override fun onPullTriggeredTop() {
|
||||
(viewBinding ?: return).feedbackTop.fadeOut()
|
||||
if (canGoPrev) {
|
||||
viewModel.switchChapterBy(-1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPullTriggeredBottom() {
|
||||
(viewBinding ?: return).feedbackBottom.fadeOut()
|
||||
if (canGoNext) {
|
||||
viewModel.switchChapterBy(1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPullCancelled() {
|
||||
viewBinding?.apply {
|
||||
feedbackTop.fadeOut()
|
||||
feedbackBottom.fadeOut()
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecyclerView.findCurrentPagePosition(): Int {
|
||||
val centerX = width / 2f
|
||||
val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap)
|
||||
@@ -177,4 +252,25 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
|
||||
return getChildAdapterPosition(view)
|
||||
}
|
||||
|
||||
private fun TextView.updateFeedback(progress: Float) {
|
||||
val clamped = progress.coerceIn(0f, 1.2f)
|
||||
this.alpha = clamped.coerceAtMost(1f)
|
||||
this.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f)
|
||||
this.scaleY = this.scaleX
|
||||
}
|
||||
|
||||
private fun TextView.fadeOut() {
|
||||
animate().alpha(0f).setDuration(150L).start()
|
||||
}
|
||||
|
||||
private fun TextView.setFeedbackText(text: CharSequence) {
|
||||
if (this.alpha <= 0f && text.isNotEmpty()) {
|
||||
this.alpha = 0f
|
||||
this.text = text
|
||||
animate().alpha(1f).setDuration(120L).start()
|
||||
} else {
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.core.view.ViewCompat.TYPE_TOUCH
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.view.isEmpty
|
||||
@@ -10,6 +12,8 @@ import androidx.core.view.isNotEmpty
|
||||
import androidx.core.view.iterator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_BOTTOM
|
||||
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_TOP
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import java.util.WeakHashMap
|
||||
@@ -23,6 +27,26 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
private val detachedViews = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
|
||||
private var isFixingScroll = false
|
||||
|
||||
var isPullGestureEnabled: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
setEdgeEffectFactory(
|
||||
if (value) {
|
||||
PullEffect.Factory()
|
||||
} else {
|
||||
EdgeEffectFactory()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
var pullThreshold: Float = 0.3f
|
||||
private var pullListener: OnPullGestureListener? = null
|
||||
|
||||
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
|
||||
pullListener = listener
|
||||
}
|
||||
|
||||
override fun onChildDetachedFromWindow(child: View) {
|
||||
super.onChildDetachedFromWindow(child)
|
||||
detachedViews.add(child)
|
||||
@@ -179,6 +203,68 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private class PullEffect(
|
||||
view: RecyclerView,
|
||||
private val direction: Int,
|
||||
private val pullThreshold: Float,
|
||||
private val pullListener: OnPullGestureListener,
|
||||
) : EdgeEffect(view.context) {
|
||||
|
||||
private var pullProgressTop: Float = 0f
|
||||
private var pullProgressBottom: Float = 0f
|
||||
|
||||
override fun onPull(deltaDistance: Float) {
|
||||
val sign = if (direction == DIRECTION_TOP) 1f else if (direction == DIRECTION_BOTTOM) 1f else 0f
|
||||
if (sign != 0f) onPull(deltaDistance, 0.5f)
|
||||
}
|
||||
|
||||
override fun onPull(deltaDistance: Float, displacement: Float) {
|
||||
if (direction == DIRECTION_TOP) {
|
||||
pullProgressTop = (pullProgressTop + deltaDistance).coerceAtLeast(0f)
|
||||
pullListener.onPullProgressTop(pullProgressTop / pullThreshold)
|
||||
} else if (direction == DIRECTION_BOTTOM) {
|
||||
pullProgressBottom = (pullProgressBottom + deltaDistance).coerceAtLeast(0f)
|
||||
pullListener.onPullProgressBottom(pullProgressBottom / pullThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRelease() {
|
||||
var triggered = false
|
||||
if (direction == DIRECTION_TOP) {
|
||||
if (pullProgressTop >= pullThreshold) {
|
||||
pullListener.onPullTriggeredTop()
|
||||
triggered = true
|
||||
}
|
||||
pullProgressTop = 0f
|
||||
pullListener.onPullProgressTop(0f)
|
||||
} else if (direction == DIRECTION_BOTTOM) {
|
||||
if (pullProgressBottom >= pullThreshold) {
|
||||
pullListener.onPullTriggeredBottom()
|
||||
triggered = true
|
||||
}
|
||||
pullProgressBottom = 0f
|
||||
pullListener.onPullProgressBottom(0f)
|
||||
}
|
||||
if (!triggered) {
|
||||
pullListener.onPullCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas?): Boolean = false
|
||||
|
||||
class Factory : EdgeEffectFactory() {
|
||||
|
||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||
val pullListener = (view as? WebtoonRecyclerView)?.pullListener
|
||||
return if (pullListener != null) {
|
||||
PullEffect(view, direction, view.pullThreshold, pullListener)
|
||||
} else {
|
||||
super.createEdgeEffect(view, direction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OnWebtoonScrollListener {
|
||||
|
||||
fun onScrollChanged(
|
||||
@@ -188,4 +274,12 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
lastVisiblePosition: Int,
|
||||
)
|
||||
}
|
||||
|
||||
interface OnPullGestureListener {
|
||||
fun onPullProgressTop(progress: Float)
|
||||
fun onPullProgressBottom(progress: Float)
|
||||
fun onPullTriggeredTop()
|
||||
fun onPullTriggeredBottom()
|
||||
fun onPullCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -40,6 +41,8 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import javax.inject.Inject
|
||||
@@ -55,8 +58,9 @@ open class RemoteListViewModel @Inject constructor(
|
||||
protected val mangaListMapper: MangaListMapper,
|
||||
private val exploreRepository: ExploreRepository,
|
||||
sourcesRepository: MangaSourcesRepository,
|
||||
mangaDataRepository: MangaDataRepository
|
||||
) : MangaListViewModel(settings, mangaDataRepository), FilterCoordinator.Owner {
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), FilterCoordinator.Owner {
|
||||
|
||||
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
||||
val isRandomLoading = MutableStateFlow(false)
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -47,7 +46,7 @@ class ScrobblerConfigViewModel @Inject constructor(
|
||||
val content = scrobbler.observeAllScrobblingInfo()
|
||||
.onStart { loadingCounter.increment() }
|
||||
.onFirst { loadingCounter.decrement() }
|
||||
.catch { errorEvent.call(it) }
|
||||
.withErrorHandling()
|
||||
.map { buildContentList(it) }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.scrobbling.discord.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.ArrayMap
|
||||
import com.my.kizzyrpc.KizzyRPC
|
||||
@@ -14,6 +15,7 @@ import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import okio.utf8Size
|
||||
@@ -35,6 +37,7 @@ import javax.inject.Inject
|
||||
private const val STATUS_ONLINE = "online"
|
||||
private const val STATUS_IDLE = "idle"
|
||||
private const val BUTTON_TEXT_LIMIT = 32
|
||||
private const val DEBOUNCE_TIMEOUT = 16_000L // 16 sec
|
||||
|
||||
@ViewModelScoped
|
||||
class DiscordRpc @Inject constructor(
|
||||
@@ -49,6 +52,7 @@ class DiscordRpc @Inject constructor(
|
||||
private val appName = context.getString(R.string.app_name)
|
||||
private val appIcon = context.getString(R.string.app_icon_url)
|
||||
private val mpCache = Collections.synchronizedMap(ArrayMap<String, String>())
|
||||
private var lastUpdate = 0L
|
||||
|
||||
private var rpc: KizzyRPC? = null
|
||||
|
||||
@@ -68,6 +72,7 @@ class DiscordRpc @Inject constructor(
|
||||
fun clearRpc() = synchronized(this) {
|
||||
rpc?.closeRPC()
|
||||
rpc = null
|
||||
lastUpdate = 0L
|
||||
}
|
||||
|
||||
fun setIdle() {
|
||||
@@ -114,6 +119,10 @@ class DiscordRpc @Inject constructor(
|
||||
val prevJob = rpcUpdateJob
|
||||
rpcUpdateJob = coroutineScope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val debounceTime = lastUpdate + DEBOUNCE_TIMEOUT - SystemClock.elapsedRealtime()
|
||||
if (debounceTime > 0) {
|
||||
delay(debounceTime)
|
||||
}
|
||||
val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false
|
||||
val mappedActivity = activity.copy(
|
||||
assets = activity.assets?.let {
|
||||
@@ -131,6 +140,7 @@ class DiscordRpc @Inject constructor(
|
||||
status = if (idle) STATUS_IDLE else STATUS_ONLINE,
|
||||
since = activity.timestamps?.start ?: System.currentTimeMillis(),
|
||||
)
|
||||
lastUpdate = SystemClock.elapsedRealtime()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ class MALRepository @Inject constructor(
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga? {
|
||||
private fun jsonToManga(json: JSONObject, sourceTitle: String): ScrobblerManga {
|
||||
val node = json.getJSONObject("node")
|
||||
val title = node.getString("title")
|
||||
return ScrobblerManga(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.core.net.toUri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
@@ -79,7 +79,7 @@ class AppUpdateViewModel @Inject constructor(
|
||||
private suspend fun observeDownload(id: Long) {
|
||||
val query = DownloadManager.Query()
|
||||
query.setFilterById(id)
|
||||
while (coroutineContext.isActive) {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytesDownloaded = cursor.getLong(
|
||||
|
||||
@@ -24,7 +24,7 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_discord)
|
||||
findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN)?.let { pref ->
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_DISCORD_TOKEN)?.let { pref ->
|
||||
pref.dialogMessage = pref.context.getString(
|
||||
R.string.discord_token_description,
|
||||
pref.context.getString(R.string.sign_in),
|
||||
@@ -44,21 +44,21 @@ class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) {
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||
if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) {
|
||||
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) {
|
||||
if (preference is EditTextPreference && preference.key == AppSettings.KEY_DISCORD_TOKEN) {
|
||||
if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.DIALOG_FRAGMENT_TAG) != null) {
|
||||
return
|
||||
}
|
||||
val f = TokenDialogFragment.newInstance(preference.key)
|
||||
@Suppress("DEPRECATION")
|
||||
f.setTargetFragment(this, 0)
|
||||
f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG)
|
||||
f.show(parentFragmentManager, TokenDialogFragment.DIALOG_FRAGMENT_TAG)
|
||||
return
|
||||
}
|
||||
super.onDisplayPreferenceDialog(preference)
|
||||
}
|
||||
|
||||
private fun bindTokenPreference(state: TokenState, token: String?) {
|
||||
val pref = findPreference<EditTextPreference>(AppSettings.Companion.KEY_DISCORD_TOKEN) ?: return
|
||||
val pref = findPreference<EditTextPreference>(AppSettings.KEY_DISCORD_TOKEN) ?: return
|
||||
when (state) {
|
||||
TokenState.EMPTY -> {
|
||||
pref.icon = null
|
||||
|
||||
@@ -34,7 +34,7 @@ class DiscordSettingsViewModel @Inject constructor(
|
||||
TokenState.CHECKING to settings.discordToken,
|
||||
)
|
||||
|
||||
private suspend fun checkToken(): Flow<Pair<TokenState, String?>> = flow {
|
||||
private fun checkToken(): Flow<Pair<TokenState, String?>> = flow {
|
||||
val token = settings.discordToken
|
||||
if (!settings.isDiscordRpcEnabled) {
|
||||
emit(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings.protect
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.KeyEvent
|
||||
@@ -115,7 +114,6 @@ class ProtectSetupActivity :
|
||||
}
|
||||
|
||||
private fun isBiometricAvailable(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
return packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -33,10 +32,6 @@ class DozeHelper(
|
||||
}
|
||||
|
||||
fun startIgnoreDoseActivity(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
Snackbar.make(fragment.listView ?: return false, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
val context = fragment.context ?: return false
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.powerManager ?: return false
|
||||
@@ -58,9 +53,6 @@ class DozeHelper(
|
||||
}
|
||||
|
||||
private fun isDozeIgnoreAvailable(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return false
|
||||
}
|
||||
val context = fragment.context ?: return false
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
@@ -24,6 +24,9 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionsListQuickFilter
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@HiltViewModel
|
||||
class SuggestionsViewModel @Inject constructor(
|
||||
@@ -33,7 +36,8 @@ class SuggestionsViewModel @Inject constructor(
|
||||
private val quickFilter: SuggestionsListQuickFilter,
|
||||
private val suggestionsScheduler: SuggestionsWorker.Scheduler,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_SUGGESTIONS) { suggestionsListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.suggestionsListMode)
|
||||
|
||||
@@ -46,7 +46,6 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
||||
import org.koitharu.kotatsu.core.model.distinctById
|
||||
import org.koitharu.kotatsu.core.model.getLocale
|
||||
|
||||
@@ -34,7 +34,7 @@ data class FavouriteCategorySyncDto(
|
||||
put("created_at", createdAt)
|
||||
put("sort_key", sortKey)
|
||||
put("title", title)
|
||||
put("order", order)
|
||||
put("`order`", order)
|
||||
put("track", track)
|
||||
put("show_in_lib", isVisibleInLibrary)
|
||||
put("deleted_at", deletedAt)
|
||||
|
||||
@@ -5,8 +5,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SyncDto(
|
||||
@SerialName("history") val history: List<HistorySyncDto>?,
|
||||
@SerialName("categories") val categories: List<FavouriteCategorySyncDto>?,
|
||||
@SerialName("favourites") val favourites: List<FavouriteSyncDto>?,
|
||||
@SerialName("history") val history: List<HistorySyncDto>? = null,
|
||||
@SerialName("categories") val categories: List<FavouriteCategorySyncDto>? = null,
|
||||
@SerialName("favourites") val favourites: List<FavouriteSyncDto>? = null,
|
||||
@SerialName("timestamp") val timestamp: Long,
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -89,12 +88,12 @@ class SyncHelper @AssistedInject constructor(
|
||||
val response = httpClient.newCall(request).execute().parseDtoOrNull()
|
||||
response?.categories?.let { categories ->
|
||||
val categoriesResult = upsertFavouriteCategories(categories)
|
||||
stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
|
||||
stats.numDeletes += categoriesResult.firstOrNull()?.count?.toLong() ?: 0L
|
||||
stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
|
||||
}
|
||||
response?.favourites?.let { favourites ->
|
||||
val favouritesResult = upsertFavourites(favourites)
|
||||
stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L
|
||||
stats.numDeletes += favouritesResult.firstOrNull()?.count?.toLong() ?: 0L
|
||||
stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
|
||||
stats.numEntries += stats.numInserts + stats.numDeletes
|
||||
}
|
||||
@@ -119,7 +118,7 @@ class SyncHelper @AssistedInject constructor(
|
||||
val response = httpClient.newCall(request).execute().parseDtoOrNull()
|
||||
response?.history?.let { history ->
|
||||
val result = upsertHistory(history)
|
||||
stats.numDeletes += result.first().count?.toLong() ?: 0L
|
||||
stats.numDeletes += result.firstOrNull()?.count?.toLong() ?: 0L
|
||||
stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L }
|
||||
stats.numEntries += stats.numInserts + stats.numDeletes
|
||||
}
|
||||
@@ -286,15 +285,11 @@ class SyncHelper @AssistedInject constructor(
|
||||
|
||||
private fun uri(authority: String, table: String) = "content://$authority/$table".toUri()
|
||||
|
||||
private fun Response.parseDtoOrNull(): SyncDto? {
|
||||
return try {
|
||||
when {
|
||||
!isSuccessful -> throw IOException(body?.string())
|
||||
code == HttpURLConnection.HTTP_NO_CONTENT -> null
|
||||
else -> Json.decodeFromString<SyncDto>(body?.string() ?: return null)
|
||||
}
|
||||
} finally {
|
||||
closeQuietly()
|
||||
private fun Response.parseDtoOrNull(): SyncDto? = use {
|
||||
when {
|
||||
!isSuccessful -> throw IOException(body.string())
|
||||
code == HttpURLConnection.HTTP_NO_CONTENT -> null
|
||||
else -> Json.decodeFromString<SyncDto>(body.string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.UpdatesListQuickFilter
|
||||
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@HiltViewModel
|
||||
class UpdatesViewModel @Inject constructor(
|
||||
@@ -39,7 +42,8 @@ class UpdatesViewModel @Inject constructor(
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val quickFilter: UpdatesListQuickFilter,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter {
|
||||
|
||||
override val content = combine(
|
||||
quickFilter.appliedOptions.flatMapLatest { filterOptions ->
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="?android:colorBackground" />
|
||||
</selector>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.6" android:color="?colorSurfaceBright" />
|
||||
</selector>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="?attr/colorSurfaceContainerHigh" />
|
||||
</selector>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.3" android:color="?colorPrimary" android:state_checked="true"/>
|
||||
<item android:color="@android:color/transparent"/>
|
||||
</selector>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.27" android:color="?attr/colorOnSurface" />
|
||||
</selector>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="@color/kotatsu_background" />
|
||||
<item android:alpha="0.4" android:color="?android:colorBackground" />
|
||||
</selector>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
||||
<item android:alpha="0.6" android:color="@color/kotatsu_surfaceBright" />
|
||||
<item android:alpha="0.6" android:color="?colorSurfaceBright" />
|
||||
</selector>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/kotatsu_surfaceContainerHigh" />
|
||||
<item android:color="?attr/colorSurfaceContainerHigh" />
|
||||
</selector>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
||||
<item android:alpha="0.3" android:color="@color/kotatsu_primary" android:state_checked="true"/>
|
||||
<item android:alpha="0.3" android:color="?colorPrimary" android:state_checked="true"/>
|
||||
<item android:color="@android:color/transparent"/>
|
||||
</selector>
|
||||
</selector>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
||||
<item android:alpha="0.27" android:color="@color/kotatsu_onSurface" />
|
||||
<item android:alpha="0.27" android:color="?attr/colorOnSurface" />
|
||||
</selector>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape>
|
||||
<solid android:color="?attr/colorSurface" />
|
||||
<corners android:radius="@dimen/m3_alert_dialog_corner_size" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape>
|
||||
<solid android:color="@color/m3_popupmenu_overlay_color" />
|
||||
<corners android:radius="@dimen/m3_alert_dialog_corner_size" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user