Compare commits
145 Commits
feature/ki
...
v5.3.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
08acf2d882
|
|||
|
1d78c64350
|
|||
|
321a9ecf62
|
|||
|
439a01c43f
|
|||
|
3a9d0def7d
|
|||
|
e4c80b4443
|
|||
|
940d448e00
|
|||
|
5ab48a7545
|
|||
|
cb2bdbdd9a
|
|||
|
|
8fdaf92cc4 | ||
|
|
0416077964 | ||
| 7b60ed6bad | |||
|
|
619be69580 | ||
|
|
9f3c3f8985 | ||
|
|
f345977858 | ||
| 9610caf002 | |||
| b75220a1b7 | |||
| ab2a6f5a17 | |||
| 2aeefc607b | |||
|
|
9af769bc69 | ||
|
|
46b78cfcd7 | ||
|
|
c24324de9a | ||
|
|
48b9c1236d | ||
|
|
c69d293caa | ||
|
|
0f4cca0e07 | ||
|
|
d6500b8fec | ||
|
|
86140cab1e | ||
| 90dfc84119 | |||
| 6a792f8ac3 | |||
| c81e8749b6 | |||
| 5fa260a0c7 | |||
| e0ba4e2686 | |||
| f188d1c0f3 | |||
|
|
6de55afa27 | ||
|
|
21dcb5b754 | ||
|
|
9b3ea57db1 | ||
|
|
032a8607ba | ||
|
|
f7303c5957 | ||
| d696606ef9 | |||
| 0a6e106a1d | |||
| de1a7f0ca8 | |||
| 9d31e76cc7 | |||
|
|
20910ffb5d | ||
|
|
7497ee6364 | ||
|
|
0f2ed50e18 | ||
|
|
ba066b577b | ||
|
|
4496fe876f | ||
|
|
a9f5abebf0 | ||
|
|
bebee2ef27 | ||
|
|
4ec2b0c8fe | ||
| 4a7be70898 | |||
| 2bcba1eb21 | |||
| feca7ba3fc | |||
| 745b349e5e | |||
| 13946783a5 | |||
| 84e5400522 | |||
| 02c9a933d2 | |||
| 92af851d3b | |||
| 009eb9fe44 | |||
| fc8a5ccd9f | |||
| 91f46de547 | |||
| d548993e14 | |||
| 4f32664b33 | |||
| 71b14a3aa8 | |||
|
|
183a61272e | ||
| f1f208ad15 | |||
| c6983d794c | |||
| 8228153c83 | |||
| 844bd13a07 | |||
| 60a5620134 | |||
|
|
dd09a39077 | ||
|
|
1511bd3279 | ||
|
|
259c335607 | ||
|
|
86367b6d3b | ||
| 19b893738d | |||
| d817ae0394 | |||
| d81c22b586 | |||
| cd23b044df | |||
| 4922881343 | |||
| ff0d04bea6 | |||
| 97de629c3b | |||
| 7b482e5bcf | |||
| fd575b8131 | |||
| c77e023bef | |||
| a3cf52859b | |||
| 5e55bce529 | |||
| b1ba70bf77 | |||
| b930272221 | |||
| 75305c0b94 | |||
| 24b16e2ce2 | |||
|
|
0ccbba6787 | ||
|
|
ca314867f2 | ||
|
|
236e284360 | ||
|
|
e9a09b6be4 | ||
|
|
9e1be337ed | ||
|
|
104f2ebfae | ||
| 6a2e12dc29 | |||
| 9587cb439c | |||
| c42d0824b0 | |||
| 09f6dd9b4e | |||
| b494c96e31 | |||
| 0f6d56ee2d | |||
| 8d15691e17 | |||
|
|
bd8b251934 | ||
|
|
2f1b74e45a | ||
|
|
73217b8e11 | ||
|
|
759df969c9 | ||
|
|
466e35fffa | ||
|
|
f44db3dbff | ||
|
|
315870abcb | ||
|
|
3e46b3957c | ||
|
|
6dc81468d2 | ||
|
|
56bc0dbf07 | ||
|
|
7bc33adca8 | ||
|
|
c8794d59f7 | ||
|
|
9c2a57812e | ||
|
|
6bd5033858 | ||
|
|
e7c2a76219 | ||
|
|
0934363298 | ||
|
|
de29527805 | ||
|
|
f11e964f0b | ||
|
|
61a98f54b9 | ||
|
|
50e67daea4 | ||
|
|
0030706226 | ||
|
|
056ef5433d | ||
|
|
c14b2ceeff | ||
|
|
ff2cf9d18a | ||
|
|
96b6900c70 | ||
|
|
c6228d3fe1 | ||
|
|
8ac95e1608 | ||
|
|
69a9ec354b | ||
|
|
0639d3e6c1 | ||
|
|
ae5cebd42d | ||
|
|
cd8381cbfb | ||
|
|
3132049a63 | ||
| bc3a7fc211 | |||
| e794f84c6f | |||
| 76709dda21 | |||
| 6dc460bc20 | |||
| c2ee548f0a | |||
| 1847759ec3 | |||
| 02d5dfb375 | |||
| ef9b16da0b | |||
| 5d1ef983e9 | |||
| eb78a776cf |
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -61,4 +61,6 @@ body:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
required: true
|
||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
required: true
|
||||
|
||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -1,3 +1,4 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
|
||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -4,8 +4,6 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="modules">
|
||||
@@ -14,6 +12,7 @@
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
3
.weblate
Normal file
3
.weblate
Normal file
@@ -0,0 +1,3 @@
|
||||
[weblate]
|
||||
url = https://hosted.weblate.org/api/
|
||||
translation = kotatsu/strings
|
||||
@@ -14,9 +14,11 @@ android {
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
//TODO: update as soon as sources becomes available
|
||||
//noinspection OldTargetApi
|
||||
targetSdkVersion 33
|
||||
versionCode 547
|
||||
versionName '5.1.3'
|
||||
versionCode 565
|
||||
versionName '5.3.8'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -79,17 +81,17 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:69e0a531df') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.7.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.7'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
|
||||
@@ -104,55 +106,57 @@ dependencies {
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||
|
||||
// TODO https://issuetracker.google.com/issues/254846063
|
||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||
//noinspection GradleDependency
|
||||
implementation('com.google.guava:guava:32.0.0-android') {
|
||||
implementation('com.google.guava:guava:32.0.1-android') {
|
||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||
}
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.5.1'
|
||||
implementation 'androidx.room:room-ktx:2.5.1'
|
||||
kapt 'androidx.room:room-compiler:2.5.1'
|
||||
implementation 'androidx.room:room-runtime:2.5.2'
|
||||
implementation 'androidx.room:room-ktx:2.5.2'
|
||||
//noinspection KaptUsageInsteadOfKsp
|
||||
kapt 'androidx.room:room-compiler:2.5.2'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||
implementation 'com.squareup.okio:okio:3.3.0'
|
||||
implementation 'com.squareup.okio:okio:3.4.0'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.46.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.46.1'
|
||||
implementation 'com.google.dagger:hilt-android:2.47'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.47'
|
||||
implementation 'androidx.hilt:hilt-work:1.0.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.0.0'
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.4.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
implementation 'ch.acra:acra-http:5.9.7'
|
||||
implementation 'ch.acra:acra-dialog:5.9.7'
|
||||
implementation 'ch.acra:acra-http:5.10.1'
|
||||
implementation 'ch.acra:acra-dialog:5.10.1'
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20230227'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
||||
testImplementation 'org.json:json:20230618'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
|
||||
|
||||
androidTestImplementation 'androidx.room:room-testing:2.5.1'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.5.2'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ShortcutsUpdaterTest {
|
||||
class AppShortcutManagerTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
@@ -32,7 +32,7 @@ class ShortcutsUpdaterTest {
|
||||
lateinit var historyRepository: HistoryRepository
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutsUpdater: ShortcutsUpdater
|
||||
lateinit var appShortcutManager: AppShortcutManager
|
||||
|
||||
@Inject
|
||||
lateinit var database: MangaDatabase
|
||||
@@ -72,6 +72,6 @@ class ShortcutsUpdaterTest {
|
||||
private suspend fun awaitUpdate() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
instrumentation.awaitForIdle()
|
||||
shortcutsUpdater.await()
|
||||
appShortcutManager.await()
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import java.util.EnumSet
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain()
|
||||
get() = ConfigKey.Domain("")
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.koitharu.kotatsu.util
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.util.Log
|
||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||
@@ -1,3 +1,3 @@
|
||||
package org.koitharu.kotatsu.util.ext
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||
@@ -18,6 +18,22 @@
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
@@ -32,6 +48,7 @@
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
@@ -98,10 +115,17 @@
|
||||
<data android:host="sync-settings" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
|
||||
android:label="@string/local_manga_directories" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
@@ -184,8 +208,7 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
|
||||
android:exported="false"
|
||||
android:label="@string/favourites"
|
||||
android:process=":sync">
|
||||
android:label="@string/favourites">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
@@ -196,8 +219,7 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
|
||||
android:exported="false"
|
||||
android:label="@string/history"
|
||||
android:process=":sync">
|
||||
android:label="@string/history">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
@@ -19,6 +20,7 @@ import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
@@ -26,6 +28,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
@@ -46,8 +49,15 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var appValidator: AppValidator
|
||||
|
||||
@Inject
|
||||
lateinit var workScheduleManager: WorkScheduleManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
||||
if (BuildConfig.DEBUG) {
|
||||
enableStrictMode()
|
||||
}
|
||||
@@ -57,6 +67,7 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(applicationContext).setup()
|
||||
}
|
||||
|
||||
@@ -67,6 +78,10 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
reportFormat = StringFormat.JSON
|
||||
excludeMatchingSharedPreferencesKeys = listOf(
|
||||
"sources_\\w+",
|
||||
AppSettings.KEY_APP_PASSWORD,
|
||||
AppSettings.KEY_PROXY_LOGIN,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PASSWORD,
|
||||
)
|
||||
httpSender {
|
||||
uri = getString(R.string.url_error_report)
|
||||
@@ -83,8 +98,10 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
ReportField.PHONE_MODEL,
|
||||
ReportField.STACK_TRACE,
|
||||
ReportField.CRASH_CONFIGURATION,
|
||||
ReportField.CUSTOM_DATA,
|
||||
ReportField.SHARED_PREFERENCES,
|
||||
)
|
||||
|
||||
dialog {
|
||||
text = getString(R.string.crash_text)
|
||||
title = getString(R.string.error_occurred)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import org.koitharu.kotatsu.local.data.ImageFileFilter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import java.util.Date
|
||||
|
||||
class Bookmark(
|
||||
@@ -14,6 +16,21 @@ class Bookmark(
|
||||
val percent: Float,
|
||||
) {
|
||||
|
||||
val directImageUrl: String?
|
||||
get() = if (isImageUrlDirect()) imageUrl else null
|
||||
|
||||
fun toMangaPage() = MangaPage(
|
||||
id = pageId,
|
||||
url = imageUrl,
|
||||
preview = null,
|
||||
source = manga.source,
|
||||
)
|
||||
|
||||
private fun isImageUrlDirect(): Boolean {
|
||||
val extension = imageUrl.substringAfterLast('.')
|
||||
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
|
||||
@@ -95,8 +95,11 @@ class BookmarksFragment :
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
|
||||
val intent = ReaderActivity.newIntent(view.context, item)
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
|
||||
val intent = ReaderActivity.IntentBuilder(view.context)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,19 @@ import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.drawable.TextDrawable
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeResId
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
@@ -28,7 +32,8 @@ fun bookmarkListAD(
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
|
||||
val data: Any = item.directImageUrl ?: item.toMangaPage()
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@@ -34,8 +36,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
@@ -103,6 +106,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
||||
|
||||
private var pendingResult = RESULT_CANCELED
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
if (url.isEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
viewBinding.webView.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
viewBinding.webView.stopLoading()
|
||||
finishAfterTransition()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(pendingResult)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun onPageLoaded() {
|
||||
viewBinding.progressBar.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult = RESULT_OK
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback?.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
setTitle(title)
|
||||
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
||||
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
||||
return newIntent(context, input.first, input.second)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||
return TaggedActivityResult(TAG, resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "CloudFlareActivity"
|
||||
private const val ARG_UA = "ua"
|
||||
|
||||
fun newIntent(
|
||||
context: Context,
|
||||
url: String,
|
||||
headers: Headers?,
|
||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||
data = url.toUri()
|
||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||
putExtra(ARG_UA, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
|
||||
|
||||
private lateinit var url: String
|
||||
private val pendingResult = Bundle(1)
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
url = requireArguments().getString(ARG_URL).orEmpty()
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentCloudflareBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
with(binding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
|
||||
}
|
||||
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
|
||||
if (url.isEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
} else {
|
||||
binding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
requireViewBinding().webView.stopLoading()
|
||||
requireViewBinding().webView.destroy()
|
||||
onBackPressedCallback = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onDialogCreated(dialog: AlertDialog) {
|
||||
super.onDialogCreated(dialog)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
|
||||
dialog.onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireViewBinding().webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
requireViewBinding().webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
setFragmentResult(TAG, pendingResult)
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onPageLoaded() {
|
||||
viewBinding?.progressBar?.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult.putBoolean(EXTRA_RESULT, true)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback?.onHistoryChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "CloudFlareDialog"
|
||||
const val EXTRA_RESULT = "result"
|
||||
private const val ARG_URL = "url"
|
||||
private const val ARG_UA = "ua"
|
||||
|
||||
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
|
||||
putString(ARG_URL, url)
|
||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||
putString(ARG_UA, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,19 +29,18 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.cache.StubContentCache
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.network.*
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
||||
import org.koitharu.kotatsu.core.util.ext.activityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
@@ -65,9 +64,6 @@ interface AppModule {
|
||||
@Binds
|
||||
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
|
||||
|
||||
@Binds
|
||||
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@@ -130,12 +126,12 @@ interface AppModule {
|
||||
@ElementsIntoSet
|
||||
fun provideDatabaseObservers(
|
||||
widgetUpdater: WidgetUpdater,
|
||||
shortcutsUpdater: ShortcutsUpdater,
|
||||
appShortcutManager: AppShortcutManager,
|
||||
backupObserver: BackupObserver,
|
||||
syncController: SyncController,
|
||||
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
|
||||
widgetUpdater,
|
||||
shortcutsUpdater,
|
||||
appShortcutManager,
|
||||
backupObserver,
|
||||
syncController,
|
||||
)
|
||||
@@ -146,10 +142,12 @@ interface AppModule {
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
incognitoModeIndicator: IncognitoModeIndicator,
|
||||
acraScreenLogger: AcraScreenLogger,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
incognitoModeIndicator,
|
||||
acraScreenLogger,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -33,11 +33,21 @@ abstract class ErrorObserver(
|
||||
return resolver != null && ExceptionResolver.canResolve(error)
|
||||
}
|
||||
|
||||
private fun isAlive(): Boolean {
|
||||
return when {
|
||||
fragment != null -> fragment.view != null
|
||||
activity != null -> !activity.isDestroyed
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
protected fun resolve(error: Throwable) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
if (isAlive()) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,13 @@ import androidx.annotation.StringRes
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.isSuccess
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver private constructor(
|
||||
private val activity: FragmentActivity?,
|
||||
private val fragment: Fragment?,
|
||||
) : ActivityResultCallback<TaggedActivityResult> {
|
||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
|
||||
|
||||
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) {
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||
}
|
||||
|
||||
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) {
|
||||
constructor(fragment: Fragment) {
|
||||
this.fragment = fragment
|
||||
activity = null
|
||||
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: TaggedActivityResult) {
|
||||
@@ -58,22 +62,9 @@ class ExceptionResolver private constructor(
|
||||
else -> false
|
||||
}
|
||||
|
||||
private suspend fun resolveCF(url: String, headers: Headers): Boolean {
|
||||
val dialog = CloudFlareDialog.newInstance(url, headers)
|
||||
val fm = getFragmentManager()
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
fm.clearFragmentResult(CloudFlareDialog.TAG)
|
||||
continuations[CloudFlareDialog.TAG] = cont
|
||||
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
|
||||
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
|
||||
}
|
||||
dialog.show(fm, CloudFlareDialog.TAG)
|
||||
cont.invokeOnCancellation {
|
||||
continuations.remove(CloudFlareDialog.TAG, cont)
|
||||
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
|
||||
dialog.dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(url to headers)
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
|
||||
class ToastErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : ErrorObserver(host, fragment, null, null) {
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -14,28 +10,22 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
|
||||
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||
|
||||
@Singleton
|
||||
class AppUpdateRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val appValidator: AppValidator,
|
||||
private val settings: AppSettings,
|
||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||
) {
|
||||
@@ -85,7 +75,7 @@ class AppUpdateRepository @Inject constructor(
|
||||
}
|
||||
|
||||
fun isUpdateSupported(): Boolean {
|
||||
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
|
||||
return BuildConfig.DEBUG || appValidator.isOriginalApp
|
||||
}
|
||||
|
||||
suspend fun getCurrentVersionChangelog(): String? {
|
||||
@@ -94,22 +84,6 @@ class AppUpdateRepository @Inject constructor(
|
||||
return available.find { x -> x.versionId == currentVersion }?.description
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
|
||||
val signatures = requireNotNull(packageInfo?.signatures)
|
||||
val cert: ByteArray = signatures.first().toByteArray()
|
||||
val input: InputStream = ByteArrayInputStream(cert)
|
||||
val cf = CertificateFactory.getInstance("X509")
|
||||
val c = cf.generateCertificate(input) as X509Certificate
|
||||
val md: MessageDigest = MessageDigest.getInstance("SHA1")
|
||||
val publicKey: ByteArray = md.digest(c.encoded)
|
||||
return publicKey.byte2HexFormatted()
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
|
||||
val size = length()
|
||||
for (i in 0 until size) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@@ -8,10 +8,14 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
@JvmName("mangaIds")
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
|
||||
@JvmName("chaptersIds")
|
||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
|
||||
@@ -41,6 +41,10 @@ class ParcelableManga(
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ParcelableManga(manga=$manga, withChapters=$withChapters)"
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableManga {
|
||||
return ParcelableManga(parcel)
|
||||
|
||||
@@ -23,6 +23,10 @@ class ParcelableMangaChapters(
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ParcelableMangaChapters(chapters=$chapters)"
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
|
||||
return ParcelableMangaChapters(parcel)
|
||||
@@ -32,4 +36,4 @@ class ParcelableMangaChapters(
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ class ParcelableMangaPages(
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ParcelableMangaPages(pages=$pages)"
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
|
||||
return ParcelableMangaPages(parcel)
|
||||
@@ -32,4 +36,4 @@ class ParcelableMangaPages(
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ class ParcelableMangaTags(
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ParcelableMangaTags(tags=$tags)"
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
|
||||
return ParcelableMangaTags(parcel)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
@@ -13,13 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
|
||||
class CloudFlareInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
throw CloudFlareProtectedException(
|
||||
url = response.request.url.toString(),
|
||||
url = request.url.toString(),
|
||||
headers = request.headers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
@@ -10,11 +9,11 @@ import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.net.IDN
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||
val idn = IDN.toASCII(repository.domain)
|
||||
@@ -62,26 +61,4 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val userAgentFallback
|
||||
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
|
||||
BuildConfig.VERSION_NAME,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
Build.BRAND,
|
||||
Build.DEVICE,
|
||||
Locale.getDefault().language,
|
||||
)
|
||||
|
||||
val userAgentChrome
|
||||
get() = (
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
).format(
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.webkit.CookieManager
|
||||
import androidx.annotation.WorkerThread
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -30,6 +31,21 @@ class AndroidCookieJar : MutableCookieJar {
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeCookies(url: HttpUrl) {
|
||||
val cookies = loadForRequest(url)
|
||||
if (cookies.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val urlString = url.toString()
|
||||
for (c in cookies) {
|
||||
val nc = c.newBuilder()
|
||||
.expiresAt(System.currentTimeMillis() - 100000)
|
||||
.build()
|
||||
cookieManager.setCookie(urlString, nc.toString())
|
||||
}
|
||||
check(loadForRequest(url).isEmpty())
|
||||
}
|
||||
|
||||
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
|
||||
cookieManager.removeAllCookies(continuation::resume)
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
|
||||
@WorkerThread
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
|
||||
|
||||
@WorkerThread
|
||||
fun removeCookies(url: HttpUrl)
|
||||
|
||||
suspend fun clear(): Boolean
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
private const val PREFS_NAME = "cookies"
|
||||
|
||||
@@ -21,6 +21,7 @@ class PreferencesCookieJar(
|
||||
private var isLoaded = false
|
||||
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
loadPersistent()
|
||||
val expired = HashSet<String>()
|
||||
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val wrapped = cookies.map { CookieWrapper(it) }
|
||||
prefs.edit(commit = true) {
|
||||
@@ -53,6 +55,22 @@ class PreferencesCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
override fun removeCookies(url: HttpUrl) {
|
||||
loadPersistent()
|
||||
val toRemove = HashSet<String>()
|
||||
for ((key, cookie) in cache) {
|
||||
if (cookie.isExpired() || cookie.cookie.matches(url)) {
|
||||
toRemove += key
|
||||
}
|
||||
}
|
||||
if (toRemove.isNotEmpty()) {
|
||||
cache.removeAll(toRemove)
|
||||
removePersistent(toRemove)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(): Boolean {
|
||||
cache.clear()
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.room.InvalidationTracker
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Precision
|
||||
import coil.size.Scale
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -25,18 +24,19 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShortcutsUpdater @Inject constructor(
|
||||
class AppShortcutManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val coil: ImageLoader,
|
||||
private val historyRepository: HistoryRepository,
|
||||
@@ -128,8 +128,8 @@ class ShortcutsUpdater @Inject constructor(
|
||||
.data(manga.coverUrl)
|
||||
.size(iconSize.width, iconSize.height)
|
||||
.tag(manga.source)
|
||||
.precision(Precision.EXACT)
|
||||
.scale(Scale.FILL)
|
||||
.transformations(ThumbnailTransformation())
|
||||
.build(),
|
||||
).getDrawableOrThrow().toBitmap()
|
||||
}.fold(
|
||||
@@ -142,8 +142,9 @@ class ShortcutsUpdater @Inject constructor(
|
||||
.setLongLabel(manga.title)
|
||||
.setIcon(icon)
|
||||
.setIntent(
|
||||
ReaderActivity.newIntent(context, manga.id)
|
||||
.setAction(ReaderActivity.ACTION_MANGA_READ),
|
||||
ReaderActivity.IntentBuilder(context)
|
||||
.mangaId(manga.id)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.core.os
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppValidator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
val isOriginalApp by lazy {
|
||||
getCertificateSHA1Fingerprint() == CERT_SHA1
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
|
||||
val signatures = requireNotNull(packageInfo?.signatures)
|
||||
val cert: ByteArray = signatures.first().toByteArray()
|
||||
val input: InputStream = ByteArrayInputStream(cert)
|
||||
val cf = CertificateFactory.getInstance("X509")
|
||||
val c = cf.generateCertificate(input) as X509Certificate
|
||||
val md: MessageDigest = MessageDigest.getInstance("SHA1")
|
||||
val publicKey: ByteArray = md.digest(c.encoded)
|
||||
return publicKey.byte2HexFormatted()
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,6 @@ fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaPa
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
source.newParser(loaderContext)
|
||||
loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class MangaTagHighlighter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
|
||||
private val dict by lazy {
|
||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
||||
val set = HashSet<String>()
|
||||
it.bufferedReader().forEachLine { x ->
|
||||
val line = x.trim()
|
||||
if (line.isNotEmpty()) {
|
||||
set.add(line)
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
}
|
||||
|
||||
@ColorRes
|
||||
fun getTint(tag: MangaTag): Int {
|
||||
return if (tag.title.lowercase() in dict) {
|
||||
R.color.warning
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
||||
import java.io.File
|
||||
@@ -234,14 +236,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
get() {
|
||||
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
|
||||
return set.mapNotNullToSet { File(it).takeIfReadable() }
|
||||
}
|
||||
set(value) {
|
||||
val set = value.mapToSet { it.absolutePath }
|
||||
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
|
||||
}
|
||||
|
||||
var mangaStorageDir: File?
|
||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
}?.takeIf { it.exists() }
|
||||
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
|
||||
set(value) = prefs.edit {
|
||||
if (value == null) {
|
||||
remove(KEY_LOCAL_STORAGE)
|
||||
} else {
|
||||
val userDirs = userSpecifiedMangaDirectories
|
||||
if (value !in userDirs) {
|
||||
userSpecifiedMangaDirectories = userDirs + value
|
||||
}
|
||||
putString(KEY_LOCAL_STORAGE, value.path)
|
||||
}
|
||||
}
|
||||
@@ -461,6 +477,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -27,6 +27,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
.setView(binding.root)
|
||||
.run(::onBuildDialog)
|
||||
.create()
|
||||
.also(::onDialogCreated)
|
||||
}
|
||||
|
||||
final override fun onCreateView(
|
||||
|
||||
@@ -45,7 +45,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
@JvmField
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
@@ -62,6 +62,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
insetsDelegate.handleImeInsets = true
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
protected val actionModeDelegate: ActionModeDelegate
|
||||
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||
@@ -44,11 +44,13 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
insetsDelegate.onViewCreated(view)
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
onViewBindingCreated(requireViewBinding(), savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewBinding = null
|
||||
insetsDelegate.removeInsetsListener(this)
|
||||
insetsDelegate.onDestroyView()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
abstract class BaseFullscreenActivity<B : ViewBinding> :
|
||||
BaseActivity<B>() {
|
||||
@@ -18,7 +20,11 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
|
||||
with(window) {
|
||||
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
|
||||
statusBarColor = Color.TRANSPARENT
|
||||
navigationBarColor = Color.TRANSPARENT
|
||||
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
|
||||
} else {
|
||||
Color.TRANSPARENT
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
attributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
@@ -12,10 +12,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@AndroidEntryPoint
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
@@ -26,27 +26,28 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
override val recyclerView: RecyclerView
|
||||
get() = listView
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
|
||||
listView.clipToPadding = false
|
||||
insetsDelegate.onViewCreated(view)
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
insetsDelegate.removeInsetsListener(this)
|
||||
insetsDelegate.onDestroyView()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (titleId != 0) {
|
||||
setTitle(getString(titleId))
|
||||
}
|
||||
setTitle(if (titleId != 0) getString(titleId) else null)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -56,9 +57,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
protected fun setTitle(title: CharSequence) {
|
||||
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||
?: activity?.setTitle(title)
|
||||
protected fun setTitle(title: CharSequence?) {
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.EventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
@@ -34,7 +34,7 @@ abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
val isLoading: StateFlow<Boolean>
|
||||
get() = loadingCounter.map { it > 0 }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), loadingCounter.value > 0)
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
DialogInterface by delegate {
|
||||
|
||||
fun show() = delegate.show()
|
||||
|
||||
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
|
||||
|
||||
private val adapter = VolumesAdapter(storageManager)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
|
||||
init {
|
||||
if (adapter.isEmpty) {
|
||||
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||
} else {
|
||||
val defaultValue = runBlocking {
|
||||
storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||
}
|
||||
delegate.setAdapter(adapter) { d, i ->
|
||||
listener.onStorageSelected(adapter.getItem(i).first)
|
||||
d.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||
delegate.setTitle(titleResId)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setTitle(title: CharSequence): Builder {
|
||||
delegate.setTitle(title)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setNegativeButton(@StringRes textId: Int): Builder {
|
||||
delegate.setNegativeButton(textId, null)
|
||||
return this
|
||||
}
|
||||
|
||||
fun create() = StorageSelectDialog(delegate.create())
|
||||
}
|
||||
|
||||
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
|
||||
|
||||
var selectedItemPosition: Int = -1
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||
view.tag = it
|
||||
}
|
||||
val item = volumes[position]
|
||||
binding.imageViewIndicator.isChecked = selectedItemPosition == position
|
||||
binding.textViewTitle.text = item.second
|
||||
binding.textViewSubtitle.text = item.first.path
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Pair<File, String> = volumes[position]
|
||||
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getCount() = volumes.size
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||
return runBlocking {
|
||||
storageManager.getWriteableDirs().map {
|
||||
it to storageManager.getStorageDisplayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnStorageSelectListener {
|
||||
|
||||
fun onStorageSelected(file: File)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.koitharu.kotatsu.core.ui.drawable
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.graphics.withTranslation
|
||||
import com.google.android.material.resources.TextAppearance
|
||||
import com.google.android.material.resources.TextAppearanceFontCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
|
||||
class TextDrawable(
|
||||
val text: CharSequence,
|
||||
) : Drawable() {
|
||||
|
||||
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
||||
private var cachedLayout: StaticLayout? = null
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
|
||||
val ta = TextAppearance(context, textAppearanceId)
|
||||
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
|
||||
paint.typeface = ta.fallbackFont
|
||||
ta.getFontAsync(
|
||||
context, paint,
|
||||
object : TextAppearanceFontCallback() {
|
||||
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
|
||||
override fun onFontRetrievalFailed(reason: Int) = Unit
|
||||
},
|
||||
)
|
||||
paint.letterSpacing = ta.letterSpacing
|
||||
}
|
||||
|
||||
var alignment = Layout.Alignment.ALIGN_NORMAL
|
||||
|
||||
var lineSpacingMultiplier = 1f
|
||||
|
||||
@Px
|
||||
var lineSpacingExtra = 0f
|
||||
|
||||
@get:ColorInt
|
||||
var textColor: Int
|
||||
get() = paint.color
|
||||
set(@ColorInt value) {
|
||||
paint.color = value
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val b = bounds
|
||||
if (b.isEmpty) {
|
||||
return
|
||||
}
|
||||
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
|
||||
obtainLayout().draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
paint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
paint.setColorFilter(colorFilter)
|
||||
}
|
||||
|
||||
@Suppress("DeprecatedCallableAddReplaceWith")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
private fun obtainLayout(): StaticLayout {
|
||||
val width = bounds.width()
|
||||
cachedLayout?.let {
|
||||
if (it.width == width) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
|
||||
.setAlignment(alignment)
|
||||
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
|
||||
.setIncludePad(true)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
|
||||
}.also { cachedLayout = it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.media.ThumbnailUtils
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
|
||||
class ThumbnailTransformation : Transformation {
|
||||
|
||||
override val cacheKey: String = javaClass.name
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||
return ThumbnailUtils.extractThumbnail(
|
||||
input,
|
||||
size.width.pxOrElse { input.width },
|
||||
size.height.pxOrElse { input.height },
|
||||
)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) = other is ThumbnailTransformation
|
||||
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.annotation.*
|
||||
@@ -24,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
|
||||
import org.koitharu.kotatsu.core.util.ext.parents
|
||||
import org.koitharu.kotatsu.databinding.FastScrollerBinding
|
||||
import kotlin.math.roundToInt
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -115,6 +117,9 @@ class FastScroller @JvmOverloads constructor(
|
||||
return viewHeight * proportion
|
||||
}
|
||||
|
||||
val isScrollbarVisible: Boolean
|
||||
get() = binding.scrollbar.isVisible
|
||||
|
||||
init {
|
||||
clipChildren = false
|
||||
orientation = HORIZONTAL
|
||||
@@ -165,7 +170,9 @@ class FastScroller @JvmOverloads constructor(
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false
|
||||
if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) {
|
||||
return false
|
||||
}
|
||||
|
||||
requestDisallowInterceptTouchEvent(true)
|
||||
setHandleSelected(true)
|
||||
@@ -296,10 +303,12 @@ class FastScroller @JvmOverloads constructor(
|
||||
|
||||
if (parent is ViewGroup) {
|
||||
setLayoutParams(parent as ViewGroup)
|
||||
} else if (recyclerView.parent is ViewGroup) {
|
||||
val viewGroup = recyclerView.parent as ViewGroup
|
||||
viewGroup.addView(this)
|
||||
setLayoutParams(viewGroup)
|
||||
} else {
|
||||
val viewGroup = findValidParent(recyclerView)
|
||||
if (viewGroup != null) {
|
||||
viewGroup.addView(this)
|
||||
setLayoutParams(viewGroup)
|
||||
}
|
||||
}
|
||||
|
||||
recyclerView.addOnScrollListener(scrollListener)
|
||||
@@ -513,6 +522,14 @@ class FastScroller @JvmOverloads constructor(
|
||||
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
|
||||
}
|
||||
|
||||
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
|
||||
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
|
||||
p as ViewGroup
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val BubbleSize.textSize
|
||||
@Px get() = resources.getDimension(textSizeId)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.core.view.updateLayoutParams
|
||||
@@ -36,7 +35,7 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||
|
||||
val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||
get() = (requireDialog() as ComponentDialog).onBackPressedDispatcher
|
||||
get() = requireComponentDialog().onBackPressedDispatcher
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
||||
@@ -14,7 +14,7 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
get() = activeActionMode != null
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
activeActionMode?.finish()
|
||||
finishActionMode()
|
||||
}
|
||||
|
||||
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
@@ -45,6 +45,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
|
||||
}
|
||||
|
||||
fun finishActionMode() {
|
||||
activeActionMode?.finish()
|
||||
}
|
||||
|
||||
private inner class ListenerLifecycleObserver(
|
||||
private val listener: ActionModeListener,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
fun interface ReversibleHandle {
|
||||
|
||||
suspend fun reverse()
|
||||
}
|
||||
|
||||
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
|
||||
runCatchingCancellable {
|
||||
withContext(NonCancellable) {
|
||||
reverse()
|
||||
|
||||
@@ -5,10 +5,9 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import java.util.LinkedList
|
||||
|
||||
class WindowInsetsDelegate(
|
||||
private val listener: WindowInsetsListener,
|
||||
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
|
||||
class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
|
||||
|
||||
@JvmField
|
||||
var handleImeInsets: Boolean = false
|
||||
@@ -16,6 +15,7 @@ class WindowInsetsDelegate(
|
||||
@JvmField
|
||||
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
|
||||
|
||||
private val listeners = LinkedList<WindowInsetsListener>()
|
||||
private var lastInsets: Insets? = null
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
@@ -29,7 +29,7 @@ class WindowInsetsDelegate(
|
||||
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
if (newInsets != lastInsets) {
|
||||
listener.onWindowInsetsChanged(newInsets)
|
||||
listeners.forEach { it.onWindowInsetsChanged(newInsets) }
|
||||
lastInsets = newInsets
|
||||
}
|
||||
return handledInsets
|
||||
@@ -52,6 +52,15 @@ class WindowInsetsDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
fun addInsetsListener(listener: WindowInsetsListener) {
|
||||
listeners.add(listener)
|
||||
lastInsets?.let { listener.onWindowInsetsChanged(it) }
|
||||
}
|
||||
|
||||
fun removeInsetsListener(listener: WindowInsetsListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun onViewCreated(view: View) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
view.addOnLayoutChangeListener(this)
|
||||
|
||||
@@ -92,6 +92,14 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
)
|
||||
}
|
||||
|
||||
fun showOrHide(show: Boolean) {
|
||||
if (show) {
|
||||
show()
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
|
||||
currentAnimator = animate()
|
||||
.translationY(targetY)
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@@ -35,6 +36,18 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
|
||||
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
var title: CharSequence?
|
||||
get() = binding.title.text
|
||||
set(value) {
|
||||
binding.title.text = value
|
||||
}
|
||||
|
||||
var subtitle: CharSequence?
|
||||
get() = binding.subtitle.textAndVisible
|
||||
set(value) {
|
||||
binding.subtitle.textAndVisible = value
|
||||
}
|
||||
|
||||
init {
|
||||
var textColors: ColorStateList? = null
|
||||
context.withStyledAttributes(
|
||||
@@ -76,8 +89,7 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun setIconResource(@DrawableRes resId: Int) {
|
||||
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
||||
binding.icon.setImageDrawable(icon)
|
||||
binding.icon.setImageResource(resId)
|
||||
}
|
||||
|
||||
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
|
||||
import org.acra.ACRA
|
||||
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.WeakHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
|
||||
|
||||
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
|
||||
private val keys = WeakHashMap<Any, String>()
|
||||
|
||||
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
|
||||
super.onFragmentAttached(fm, f, context)
|
||||
ACRA.errorReporter.putCustomData(f.key(), f.arguments.contentToString())
|
||||
}
|
||||
|
||||
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
|
||||
super.onFragmentDetached(fm, f)
|
||||
ACRA.errorReporter.removeCustomData(f.key())
|
||||
keys.remove(f)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(activity, savedInstanceState)
|
||||
ACRA.errorReporter.putCustomData(activity.key(), activity.intent.extras.contentToString())
|
||||
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
super.onActivityDestroyed(activity)
|
||||
ACRA.errorReporter.removeCustomData(activity.key())
|
||||
keys.remove(activity)
|
||||
}
|
||||
|
||||
private fun Any.key() = keys.getOrPut(this) {
|
||||
"${time()}: ${javaClass.simpleName}"
|
||||
}
|
||||
|
||||
private fun time() = timeFormat.format(Date())
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
|
||||
val v = get(k)
|
||||
"$k=$v"
|
||||
} ?: toString()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
fun interface BufferedObserver<T> {
|
||||
|
||||
fun onChanged(t: T, previous: T?)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import android.app.Activity
|
||||
class TaggedActivityResult(
|
||||
val tag: String,
|
||||
val result: Int,
|
||||
)
|
||||
) {
|
||||
|
||||
val TaggedActivityResult.isSuccess: Boolean
|
||||
get() = this.result == Activity.RESULT_OK
|
||||
val isSuccess: Boolean
|
||||
get() = result == Activity.RESULT_OK
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ class WorkManagerHelper(
|
||||
return workManagerImpl.getWorkInfoById(id).await()
|
||||
}
|
||||
|
||||
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
|
||||
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
|
||||
}
|
||||
|
||||
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
|
||||
return workManagerImpl.updateWork(request).await()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.ContextWrapper
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
@@ -16,6 +17,8 @@ import android.database.SQLException
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
@@ -43,7 +46,6 @@ import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import kotlin.math.roundToLong
|
||||
@@ -51,6 +53,9 @@ import kotlin.math.roundToLong
|
||||
val Context.activityManager: ActivityManager?
|
||||
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
|
||||
|
||||
val Context.powerManager: PowerManager?
|
||||
get() = getSystemService(POWER_SERVICE) as? PowerManager
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
@@ -141,6 +146,10 @@ fun Context.isLowRamDevice(): Boolean {
|
||||
return activityManager?.isLowRamDevice ?: false
|
||||
}
|
||||
|
||||
fun Context.isPowerSaveMode(): Boolean {
|
||||
return powerManager?.isPowerSaveMode == true
|
||||
}
|
||||
|
||||
val Context.ramAvailable: Long
|
||||
get() {
|
||||
val result = MemoryInfo()
|
||||
@@ -148,13 +157,17 @@ val Context.ramAvailable: Long
|
||||
return result.availMem
|
||||
}
|
||||
|
||||
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
|
||||
view,
|
||||
0,
|
||||
0,
|
||||
view.width,
|
||||
view.height,
|
||||
)
|
||||
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
|
||||
ActivityOptions.makeScaleUpAnimation(
|
||||
view,
|
||||
0,
|
||||
0,
|
||||
view.width,
|
||||
view.height,
|
||||
).toBundle()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun Resources.getLocalesConfig(): LocaleListCompat {
|
||||
val tagsList = StringJoiner(",")
|
||||
|
||||
@@ -9,8 +9,8 @@ import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
||||
import java.io.Serializable
|
||||
|
||||
// https://issuetracker.google.com/issues/240585930
|
||||
@@ -36,11 +36,11 @@ inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String):
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
|
||||
return readParcelable(ParcelableMangaTags::class.java.classLoader) as T?
|
||||
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
|
||||
return readSerializable() as T?
|
||||
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
|
||||
@@ -49,12 +49,6 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
|
||||
return checkNotNull(getParcelableCompat(key)) {
|
||||
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SavedStateHandle.require(key: String): T {
|
||||
return checkNotNull(get(key)) {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import org.koitharu.kotatsu.parsers.util.removeSuffix
|
||||
import java.io.File
|
||||
import java.lang.reflect.Array as ArrayReflect
|
||||
|
||||
private const val PRIMARY_VOLUME_NAME = "primary"
|
||||
|
||||
fun Uri.resolveFile(context: Context): File? {
|
||||
val volumeId = getVolumeIdFromTreeUri(this) ?: return null
|
||||
val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
|
||||
val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
|
||||
|
||||
return File(
|
||||
if (documentPath.isNotEmpty()) {
|
||||
if (documentPath.startsWith(File.separator)) {
|
||||
volumePath + documentPath
|
||||
} else {
|
||||
volumePath + File.separator + documentPath
|
||||
}
|
||||
} else {
|
||||
volumePath
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVolumePath(volumeId: String, context: Context): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getVolumePathForAndroid11AndAbove(volumeId, context)
|
||||
} else {
|
||||
getVolumePathBeforeAndroid11(volumeId, context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
|
||||
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
|
||||
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
|
||||
val getUuid = storageVolumeClazz.getMethod("getUuid")
|
||||
val getPath = storageVolumeClazz.getMethod("getPath")
|
||||
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
|
||||
val result = getVolumeList.invoke(mStorageManager)
|
||||
val length = ArrayReflect.getLength(checkNotNull(result))
|
||||
(0 until length).firstNotNullOfOrNull { i ->
|
||||
val storageVolumeElement = ArrayReflect.get(result, i)
|
||||
val uuid = getUuid.invoke(storageVolumeElement) as String
|
||||
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
|
||||
when {
|
||||
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
|
||||
uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.R)
|
||||
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
|
||||
if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
|
||||
volume.directory?.path
|
||||
} else {
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null && uuid == volumeId) volume.directory?.path else null
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split = docId.split(":".toRegex())
|
||||
return split.firstOrNull()?.takeUnless { it.isEmpty() }
|
||||
}
|
||||
|
||||
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
|
||||
}
|
||||
@@ -26,6 +26,8 @@ fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
|
||||
|
||||
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
||||
|
||||
fun File.isNotEmpty() = length() != 0L
|
||||
|
||||
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.SystemClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
@@ -62,3 +63,23 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
flow: Flow<T1>,
|
||||
flow2: Flow<T2>,
|
||||
flow3: Flow<T3>,
|
||||
flow4: Flow<T4>,
|
||||
flow5: Flow<T5>,
|
||||
flow6: Flow<T6>,
|
||||
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
|
||||
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
|
||||
transform(
|
||||
args[0] as T1,
|
||||
args[1] as T2,
|
||||
args[2] as T3,
|
||||
args[3] as T4,
|
||||
args[4] as T5,
|
||||
args[5] as T6,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ import org.koitharu.kotatsu.core.util.Event
|
||||
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
|
||||
owner.lifecycleScope.launch(start = start) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
collect(collector)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector<T>) {
|
||||
owner.lifecycleScope.launch {
|
||||
owner.lifecycle.repeatOnLifecycle(minState) {
|
||||
collect(collector)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,11 @@ suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
|
||||
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
|
||||
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
|
||||
return
|
||||
}
|
||||
show(fm, tag)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -38,3 +39,23 @@ fun Response.ensureSuccess() = apply {
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
if (persistent) {
|
||||
c.expiresAt(expiresAt)
|
||||
}
|
||||
if (hostOnly) {
|
||||
c.hostOnlyDomain(domain)
|
||||
} else {
|
||||
c.domain(domain)
|
||||
}
|
||||
c.path(path)
|
||||
if (secure) {
|
||||
c.secure()
|
||||
}
|
||||
if (httpOnly) {
|
||||
c.httpOnly()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.util.UUID
|
||||
|
||||
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
|
||||
inline fun <C : CharSequence> C?.ifNullOrEmpty(defaultValue: () -> C): C {
|
||||
return if (this.isNullOrEmpty()) defaultValue() else this
|
||||
}
|
||||
|
||||
@@ -35,3 +34,9 @@ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float)
|
||||
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
|
||||
return diff < threshold
|
||||
}
|
||||
|
||||
fun CharSequence.sanitize(): CharSequence {
|
||||
return filterNot { c -> c.isReplacement() }
|
||||
}
|
||||
|
||||
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
|
||||
|
||||
@@ -60,3 +60,10 @@ fun Context.getThemeColorStateList(
|
||||
) = obtainStyledAttributes(intArrayOf(resId)).use {
|
||||
it.getColorStateList(0)
|
||||
}
|
||||
|
||||
fun Context.getThemeResId(
|
||||
@AttrRes resId: Int,
|
||||
fallback: Int
|
||||
): Int = obtainStyledAttributes(intArrayOf(resId)).use {
|
||||
it.getResourceId(0, fallback)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException,
|
||||
@@ -87,3 +88,6 @@ fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
|
||||
cause?.isWebViewUnavailable() == true
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
|
||||
fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) {
|
||||
try {
|
||||
setNavigationIcon(iconRes)
|
||||
} catch (e: IllegalStateException) {
|
||||
if (retry) {
|
||||
post { setNavigationIconSafe(iconRes, retry = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -115,6 +116,9 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return false
|
||||
}
|
||||
if (context.isPowerSaveMode()) {
|
||||
return false
|
||||
}
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
|
||||
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.transition.TransitionManager
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.databinding.ItemTipBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ButtonTip(
|
||||
private val root: ViewGroup,
|
||||
private val insetsDelegate: WindowInsetsDelegate,
|
||||
private val viewModel: DetailsViewModel,
|
||||
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
|
||||
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
|
||||
|
||||
init {
|
||||
selfBinding.textView.setText(R.string.details_button_tip)
|
||||
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
|
||||
selfBinding.root.id = R.id.layout_tip
|
||||
selfBinding.buttonClose.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
remove()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
if (root is CoordinatorLayout) {
|
||||
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToRoot() {
|
||||
val lp: ViewGroup.LayoutParams = when (root) {
|
||||
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
// anchorId = R.id.layout_bottom
|
||||
// anchorGravity = Gravity.TOP
|
||||
gravity = Gravity.BOTTOM
|
||||
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
|
||||
bottomMargin += actionBarSize
|
||||
}
|
||||
|
||||
is ConstraintLayout -> ConstraintLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
|
||||
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
|
||||
}
|
||||
|
||||
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
root.addView(selfBinding.root, lp)
|
||||
if (root is ConstraintLayout) {
|
||||
val cs = ConstraintSet()
|
||||
cs.clone(root)
|
||||
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
|
||||
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
|
||||
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
|
||||
cs.applyTo(root)
|
||||
}
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
if (root.context.isAnimationsEnabled) {
|
||||
TransitionManager.beginDelayedTransition(root)
|
||||
}
|
||||
insetsDelegate.removeInsetsListener(this)
|
||||
root.removeView(selfBinding.root)
|
||||
viewModel.onButtonTipClosed()
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class ChaptersBottomSheetMediator(
|
||||
}
|
||||
|
||||
override fun onActionModeStarted(mode: ActionMode) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
lock()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -66,6 +67,9 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
}
|
||||
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
|
||||
selectionController?.onItemLongClick(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -79,12 +83,11 @@ class ChaptersFragment :
|
||||
return
|
||||
}
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = view.context,
|
||||
manga = viewModel.manga.value ?: return,
|
||||
state = ReaderState(item.chapter.id, 0, 0),
|
||||
),
|
||||
scaleUpActivityOptionsOf(view).toBundle(),
|
||||
IntentBuilder(view.context)
|
||||
.manga(viewModel.manga.value ?: return)
|
||||
.state(ReaderState(item.chapter.id, 0, 0))
|
||||
.build(),
|
||||
scaleUpActivityOptionsOf(view),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
@@ -53,8 +54,9 @@ import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -67,9 +69,10 @@ class DetailsActivity :
|
||||
PopupMenu.OnMenuItemClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutsUpdater: ShortcutsUpdater
|
||||
lateinit var appShortcutManager: AppShortcutManager
|
||||
|
||||
private lateinit var viewBadge: ViewBadge
|
||||
private var buttonTip: WeakReference<ButtonTip>? = null
|
||||
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
|
||||
@@ -122,6 +125,7 @@ class DetailsActivity :
|
||||
viewModel.onShowToast.observeEvent(this) {
|
||||
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
viewModel.onShowTip.observeEvent(this) { showTip() }
|
||||
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
||||
viewModel.selectedBranch.observe(this) {
|
||||
viewBinding.toolbarChapters?.subtitle = it
|
||||
@@ -144,7 +148,7 @@ class DetailsActivity :
|
||||
activity = this,
|
||||
viewModel = viewModel,
|
||||
snackbarHost = viewBinding.containerChapters,
|
||||
shortcutsUpdater = shortcutsUpdater,
|
||||
appShortcutManager = appShortcutManager,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -162,6 +166,8 @@ class DetailsActivity :
|
||||
|
||||
override fun onLongClick(v: View): Boolean = when (v.id) {
|
||||
R.id.button_read -> {
|
||||
buttonTip?.get()?.remove()
|
||||
buttonTip = null
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
@@ -206,7 +212,7 @@ class DetailsActivity :
|
||||
}
|
||||
if (isExpanded) {
|
||||
toolbar.addMenuProvider(chaptersMenuProvider)
|
||||
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
|
||||
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
|
||||
} else {
|
||||
toolbar.removeMenuProvider(chaptersMenuProvider)
|
||||
toolbar.navigationIcon = null
|
||||
@@ -244,7 +250,7 @@ class DetailsActivity :
|
||||
bottomMargin = insets.bottom + marginEnd
|
||||
}
|
||||
viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom
|
||||
bottomMargin = insets.top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +302,11 @@ class DetailsActivity :
|
||||
snackbar.show()
|
||||
} else {
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = this,
|
||||
manga = manga,
|
||||
branch = viewModel.selectedBranchValue,
|
||||
isIncognitoMode = isIncognitoMode,
|
||||
),
|
||||
IntentBuilder(this)
|
||||
.manga(manga)
|
||||
.branch(viewModel.selectedBranchValue)
|
||||
.incognito(isIncognitoMode)
|
||||
.build(),
|
||||
)
|
||||
if (isIncognitoMode) {
|
||||
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
@@ -345,8 +350,16 @@ class DetailsActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTip() {
|
||||
val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel)
|
||||
tip.addToRoot()
|
||||
buttonTip = WeakReference(tip)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TIP_BUTTON = "btn_read"
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, DetailsActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.chip.Chip
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -23,7 +24,6 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.core.model.countChaptersByBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -67,7 +68,7 @@ class DetailsFragment :
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var tagHighlighter: MangaTagHighlighter
|
||||
lateinit var tagHighlighter: ListExtraProvider
|
||||
|
||||
private val viewModel by activityViewModels<DetailsViewModel>()
|
||||
|
||||
@@ -96,8 +97,8 @@ class DetailsFragment :
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(view.context, item),
|
||||
scaleUpActivityOptionsOf(view).toBundle(),
|
||||
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
|
||||
scaleUpActivityOptionsOf(view),
|
||||
)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -257,7 +258,7 @@ class DetailsFragment :
|
||||
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
manga.source,
|
||||
),
|
||||
scaleUpActivityOptionsOf(v).toBundle(),
|
||||
scaleUpActivityOptionsOf(v),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -283,7 +284,7 @@ class DetailsFragment :
|
||||
manga.tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTint(tag),
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
@@ -296,7 +297,7 @@ class DetailsFragment :
|
||||
private fun loadCover(manga: Manga) {
|
||||
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
|
||||
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
|
||||
if (lastResult?.request?.data == imageUrl) {
|
||||
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
|
||||
return
|
||||
}
|
||||
val request = ImageRequest.Builder(context ?: return)
|
||||
|
||||
@@ -15,13 +15,12 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
|
||||
@@ -29,8 +28,8 @@ class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val snackbarHost: View,
|
||||
private val shortcutsUpdater: ShortcutsUpdater,
|
||||
) : MenuProvider {
|
||||
private val appShortcutManager: AppShortcutManager,
|
||||
) : MenuProvider, OnListItemClickListener<DownloadOption> {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
@@ -44,7 +43,7 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_favourite).setIcon(
|
||||
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,15 +79,7 @@ class DetailsMenuProvider(
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
val branches = viewModel.branches.value.orEmpty()
|
||||
if (chaptersCount > 5 || branches.size > 1) {
|
||||
showSaveConfirmation(it, chaptersCount, branches)
|
||||
} else {
|
||||
viewModel.download(null)
|
||||
}
|
||||
}
|
||||
DownloadDialogHelper(snackbarHost, viewModel).show(this)
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
@@ -112,7 +103,7 @@ class DetailsMenuProvider(
|
||||
R.id.action_shortcut -> {
|
||||
viewModel.manga.value?.let {
|
||||
activity.lifecycleScope.launch {
|
||||
if (!shortcutsUpdater.requestPinShortcut(it)) {
|
||||
if (!appShortcutManager.requestPinShortcut(it)) {
|
||||
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
@@ -125,35 +116,16 @@ class DetailsMenuProvider(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (branches.size > 1) {
|
||||
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
|
||||
val currentBranch = branches.indexOfFirst { it.isSelected }
|
||||
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
|
||||
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
|
||||
checkedIndices[i] = checked
|
||||
}.setPositiveButton(R.string.save) { _, _ ->
|
||||
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
|
||||
if (checkedIndices[i]) b.name else null
|
||||
}
|
||||
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
|
||||
if (c.branch in selectedBranches) c.id else null
|
||||
}
|
||||
viewModel.download(chaptersIds)
|
||||
}
|
||||
} else {
|
||||
dialogBuilder.setMessage(
|
||||
activity.getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
|
||||
),
|
||||
).setPositiveButton(R.string.save) { _, _ ->
|
||||
viewModel.download(null)
|
||||
override fun onItemClick(item: DownloadOption, view: View) {
|
||||
val chaptersIds: Set<Long>? = when (item) {
|
||||
is DownloadOption.WholeManga -> null
|
||||
is DownloadOption.SelectionHint -> {
|
||||
viewModel.startChaptersSelection()
|
||||
return
|
||||
}
|
||||
|
||||
else -> item.chaptersIds
|
||||
}
|
||||
dialogBuilder.show()
|
||||
viewModel.download(chaptersIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -29,14 +31,17 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.details.domain.BranchComparator
|
||||
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||
@@ -69,6 +74,7 @@ class DetailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
|
||||
networkState: NetworkState,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
@@ -77,6 +83,8 @@ class DetailsViewModel @Inject constructor(
|
||||
private var loadingJob: Job
|
||||
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val onShowTip = MutableEventFlow<Unit>()
|
||||
val onSelectChapter = MutableEventFlow<Long>()
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
|
||||
val manga = doubleManga.map { it?.any }
|
||||
@@ -135,7 +143,7 @@ class DetailsViewModel @Inject constructor(
|
||||
if (description.isNullOrEmpty()) {
|
||||
emit(null)
|
||||
} else {
|
||||
emit(description.parseAsHtml().filterSpans())
|
||||
emit(description.parseAsHtml().filterSpans().sanitize())
|
||||
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
|
||||
@@ -172,8 +180,9 @@ class DetailsViewModel @Inject constructor(
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
) { manga, history, branch, news, bookmarks ->
|
||||
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
|
||||
networkState,
|
||||
) { manga, history, branch, news, bookmarks, isOnline ->
|
||||
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
|
||||
},
|
||||
isChaptersReversed,
|
||||
chaptersQuery,
|
||||
@@ -190,6 +199,12 @@ class DetailsViewModel @Inject constructor(
|
||||
localStorageChanges
|
||||
.collect { onDownloadComplete(it) }
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) {
|
||||
manga.filterNot { it?.chapters.isNullOrEmpty() }.first()
|
||||
onShowTip.call(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
@@ -276,6 +291,18 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun startChaptersSelection() {
|
||||
val chapters = chapters.value
|
||||
val chapter = chapters.find {
|
||||
it.isUnread && !it.isDownloaded
|
||||
} ?: chapters.firstOrNull() ?: return
|
||||
onSelectChapter.call(chapter.chapter.id)
|
||||
}
|
||||
|
||||
fun onButtonTipClosed() {
|
||||
settings.closeTip(DetailsActivity.TIP_BUTTON)
|
||||
}
|
||||
|
||||
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
||||
val result = doubleMangaLoadUseCase(intent)
|
||||
val manga = result.requireAny()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
|
||||
|
||||
class DownloadDialogHelper(
|
||||
private val host: View,
|
||||
private val viewModel: DetailsViewModel,
|
||||
) {
|
||||
|
||||
fun show(callback: OnListItemClickListener<DownloadOption>) {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
val allChapters = viewModel.manga.value?.chapters ?: return
|
||||
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
|
||||
val history = viewModel.history.value
|
||||
|
||||
val options = buildList {
|
||||
add(DownloadOption.WholeManga(allChapters.ids()))
|
||||
if (branch != null && branchChapters.isNotEmpty()) {
|
||||
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
|
||||
}
|
||||
|
||||
if (history != null) {
|
||||
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
|
||||
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
|
||||
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
|
||||
if (unreadChapters.size > 5) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
|
||||
if (unreadChapters.size > 10) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (branchChapters.size > 5) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
|
||||
if (branchChapters.size > 10) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
add(DownloadOption.SelectionHint())
|
||||
}
|
||||
var dialog: DialogInterface? = null
|
||||
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
|
||||
callback.onItemClick(item, host)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
|
||||
.addAdapterDelegate(downloadOptionAD(listener))
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.download)
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setItems(options)
|
||||
.create()
|
||||
.also { it.show() }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
|
||||
|
||||
override fun getItemCount() = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> DetailsFragment()
|
||||
1 -> ChaptersFragment()
|
||||
else -> throw IndexOutOfBoundsException("No fragment for position $position")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
|
||||
@@ -109,7 +110,7 @@ class ScrobblingInfoSheet :
|
||||
R.id.imageView_cover -> {
|
||||
val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
|
||||
val options = scaleUpActivityOptionsOf(v)
|
||||
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle())
|
||||
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class ScrobblingInfoSheet :
|
||||
}
|
||||
requireViewBinding().textViewTitle.text = scrobbling.title
|
||||
requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars
|
||||
requireViewBinding().textViewDescription.text = scrobbling.description
|
||||
requireViewBinding().textViewDescription.text = scrobbling.description?.sanitize()
|
||||
requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
|
||||
requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
|
||||
requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
sealed interface DownloadOption {
|
||||
|
||||
val chaptersIds: Set<Long>
|
||||
|
||||
@get:DrawableRes
|
||||
val iconResId: Int
|
||||
|
||||
val chaptersCount: Int
|
||||
get() = chaptersIds.size
|
||||
|
||||
fun getLabel(resources: Resources): CharSequence
|
||||
|
||||
class AllChapters(
|
||||
val branch: String,
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_select_group
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_all_chapters, branch)
|
||||
}
|
||||
}
|
||||
|
||||
class WholeManga(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_whole_manga)
|
||||
}
|
||||
}
|
||||
|
||||
class FirstChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_start
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_first_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AllUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
val branch: String?,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_end
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return if (branch == null) {
|
||||
resources.getString(R.string.download_option_all_unread)
|
||||
} else {
|
||||
resources.getString(R.string.download_option_all_unread_b, branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NextUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_next
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_next_unread_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SelectionHint : DownloadOption {
|
||||
|
||||
override val chaptersIds: Set<Long> = emptySet()
|
||||
override val iconResId = R.drawable.ic_tap
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_manual_selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
|
||||
|
||||
fun downloadOptionAD(
|
||||
onClickListener: OnListItemClickListener<DownloadOption>,
|
||||
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
|
||||
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
with(binding.root) {
|
||||
title = item.getLabel(resources)
|
||||
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
item.chaptersCount,
|
||||
item.chaptersCount,
|
||||
)
|
||||
setIconResource(item.iconResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
class DownloadsMenuProvider(
|
||||
private val context: Context,
|
||||
@@ -23,6 +24,10 @@ class DownloadsMenuProvider(
|
||||
R.id.action_resume -> viewModel.resumeAll()
|
||||
R.id.action_cancel_all -> confirmCancelAll()
|
||||
R.id.action_remove_completed -> confirmRemoveCompleted()
|
||||
R.id.action_settings -> {
|
||||
context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context))
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -141,11 +141,13 @@ class DownloadsViewModel @Inject constructor(
|
||||
fun remove(ids: Set<Long>) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val snapshot = works.value ?: return@launchJob
|
||||
val uuids = HashSet<UUID>(ids.size)
|
||||
for (work in snapshot) {
|
||||
if (work.id.mostSignificantBits in ids) {
|
||||
workScheduler.delete(work.id)
|
||||
uuids.add(work.id)
|
||||
}
|
||||
}
|
||||
workScheduler.delete(uuids)
|
||||
onActionDone.call(ReversibleAction(R.string.downloads_removed, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.util.UUID
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.core.util.WorkManagerHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
@@ -60,7 +61,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -322,9 +322,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||
manga: Manga,
|
||||
includedIds: LongArray?,
|
||||
): List<MangaChapter> {
|
||||
val chapters = checkNotNull(manga.chapters?.toMutableList()) {
|
||||
val chapters = checkNotNull(manga.chapters) {
|
||||
"Chapters list must not be null"
|
||||
}
|
||||
}.toMutableList()
|
||||
if (includedIds != null) {
|
||||
val chaptersIdsSet = includedIds.toMutableSet()
|
||||
chapters.retainAll { x -> chaptersIdsSet.remove(x.id) }
|
||||
@@ -399,6 +399,13 @@ class DownloadWorker @AssistedInject constructor(
|
||||
WorkManagerHelper(workManager).deleteWork(id)
|
||||
}
|
||||
|
||||
suspend fun delete(ids: Collection<UUID>) {
|
||||
val wm = workManager
|
||||
val helper = WorkManagerHelper(wm)
|
||||
ids.forEach { id -> wm.cancelWorkById(id).await() }
|
||||
helper.deleteWorks(ids)
|
||||
}
|
||||
|
||||
suspend fun removeCompleted() {
|
||||
val helper = WorkManagerHelper(workManager)
|
||||
val finishedWorks = helper.getFinishedWorkInfosByTag(TAG)
|
||||
@@ -406,10 +413,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
suspend fun updateConstraints() {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiresStorageNotLow(true)
|
||||
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.build()
|
||||
val constraints = createConstraints()
|
||||
val helper = WorkManagerHelper(workManager)
|
||||
val works = helper.getWorkInfosByTag(TAG)
|
||||
for (work in works) {
|
||||
@@ -418,6 +422,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG)
|
||||
.setId(work.id)
|
||||
.build()
|
||||
helper.updateWork(request)
|
||||
@@ -425,15 +430,15 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private suspend fun scheduleImpl(data: Collection<Data>) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiresStorageNotLow(true)
|
||||
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.build()
|
||||
if (data.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val constraints = createConstraints()
|
||||
val requests = data.map { inputData ->
|
||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG)
|
||||
.keepResultsForAtLeast(7, TimeUnit.DAYS)
|
||||
.keepResultsForAtLeast(30, TimeUnit.DAYS)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
@@ -441,6 +446,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
workManager.enqueue(requests).await()
|
||||
}
|
||||
|
||||
private fun createConstraints() = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExploreRepository @Inject constructor(
|
||||
|
||||
@@ -112,7 +112,7 @@ class FavouriteCategoriesActivity :
|
||||
}
|
||||
val intent = FavouritesActivity.newIntent(this, item)
|
||||
val options = scaleUpActivityOptionsOf(view)
|
||||
startActivity(intent, options.toBundle())
|
||||
startActivity(intent, options)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
|
||||
@@ -81,6 +82,6 @@ class FavouriteCategoriesSheet :
|
||||
)
|
||||
},
|
||||
)
|
||||
}.show(fm, TAG)
|
||||
}.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
|
||||
@@ -25,7 +26,7 @@ class MangaCategoriesViewModel @Inject constructor(
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga }
|
||||
private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga }
|
||||
private val header = CategoriesHeaderItem()
|
||||
|
||||
val content: StateFlow<List<ListModel>> = combine(
|
||||
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
@@ -21,8 +20,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
|
||||
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
@@ -30,19 +27,16 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: FavouritesRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
private val tagHighlighter: MangaTagHighlighter,
|
||||
private val listExtraProvider: ListExtraProvider,
|
||||
settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
|
||||
) : MangaListViewModel(settings, downloadScheduler) {
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
|
||||
@@ -76,7 +70,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> list.toUi(mode, this, tagHighlighter)
|
||||
else -> list.toUi(mode, listExtraProvider)
|
||||
}
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
@@ -108,20 +102,4 @@ class FavouritesListViewModel @Inject constructor(
|
||||
repository.setCategoryOrder(categoryId, order)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCounter(mangaId: Long): Int {
|
||||
return if (settings.isTrackerEnabled) {
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProgress(mangaId: Long): Float {
|
||||
return if (settings.isReadingIndicatorsEnabled) {
|
||||
historyRepository.getProgress(mangaId)
|
||||
} else {
|
||||
PROGRESS_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
@@ -35,7 +36,6 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.text.Collator
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
|
||||
dataRepository: MangaDataRepository,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) : FilterOwner {
|
||||
) : MangaFilter {
|
||||
|
||||
private val coroutineScope = lifecycle.lifecycleScope
|
||||
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
|
||||
|
||||
@@ -19,9 +19,8 @@ import com.google.android.material.R as materialR
|
||||
|
||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
private val filter: MangaFilter
|
||||
get() = (requireActivity() as FilterOwner).filter
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
@@ -30,7 +29,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
@@ -40,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
if (tag == null) {
|
||||
FilterSheetFragment.show(parentFragmentManager)
|
||||
} else {
|
||||
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
|
||||
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.values
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
interface FilterOwner {
|
||||
|
||||
interface FilterOwner : OnFilterChangedListener {
|
||||
|
||||
val filterItems: StateFlow<List<ListModel>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(activity: FragmentActivity): FilterOwner {
|
||||
for (f in activity.supportFragmentManager.fragments) {
|
||||
return find(f) ?: continue
|
||||
}
|
||||
error("Cannot find FilterOwner")
|
||||
}
|
||||
|
||||
fun find(fragment: Fragment): FilterOwner? {
|
||||
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
|
||||
}
|
||||
}
|
||||
val filter: MangaFilter
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user