Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
|
||||
@@ -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 552
|
||||
versionName '5.2'
|
||||
versionCode 560
|
||||
versionName '5.3.3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -79,12 +81,12 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:f732582d55') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:07df5a81cf') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
|
||||
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'
|
||||
@@ -104,17 +106,19 @@ 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'
|
||||
@@ -134,23 +138,23 @@ dependencies {
|
||||
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'
|
||||
|
||||
@@ -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,6 +115,9 @@
|
||||
<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"
|
||||
@@ -188,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>
|
||||
@@ -200,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()
|
||||
}
|
||||
|
||||
@@ -90,6 +101,7 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
ReportField.CUSTOM_DATA,
|
||||
ReportField.SHARED_PREFERENCES,
|
||||
)
|
||||
|
||||
dialog {
|
||||
text = getString(R.string.crash_text)
|
||||
title = getString(R.string.error_occurred)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
|
||||
@@ -49,10 +50,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
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 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
@@ -103,8 +104,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
// ActivityCompat.recreate(this)
|
||||
error("Test")
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -13,7 +14,7 @@ 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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -20,31 +21,40 @@ import javax.inject.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(), "${time()}: ${f.arguments}")
|
||||
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(), "${time()}: ${activity.intent}")
|
||||
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 Activity.key() = "Activity[${javaClass.simpleName}]"
|
||||
|
||||
private fun Fragment.key() = "Fragment[${javaClass.simpleName}]"
|
||||
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?)
|
||||
}
|
||||
@@ -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
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector<T>) {
|
||||
owner.lifecycleScope.launch {
|
||||
owner.lifecycle.repeatOnLifecycle(minState) {
|
||||
collect(collector)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||
owner.lifecycleScope.launch {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.core.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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -66,6 +67,9 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
}
|
||||
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
|
||||
selectionController?.onItemLongClick(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -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
|
||||
@@ -211,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
|
||||
|
||||
@@ -16,12 +16,11 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
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
|
||||
|
||||
@@ -30,7 +29,7 @@ class DetailsMenuProvider(
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val snackbarHost: View,
|
||||
private val appShortcutManager: AppShortcutManager,
|
||||
) : MenuProvider {
|
||||
) : 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 -> {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ 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
|
||||
@@ -72,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)
|
||||
@@ -81,6 +84,7 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val onShowTip = MutableEventFlow<Unit>()
|
||||
val onSelectChapter = MutableEventFlow<Long>()
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
|
||||
val manga = doubleManga.map { it?.any }
|
||||
@@ -176,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,
|
||||
@@ -286,6 +291,14 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.core.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
|
||||
}
|
||||
|
||||
@@ -21,20 +21,17 @@ class FilterSheetFragment :
|
||||
AdaptiveSheetCallback,
|
||||
AsyncListDiffer.ListListener<ListModel> {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val filter = (requireActivity() as FilterOwner).filter
|
||||
addSheetCallback(this)
|
||||
val adapter = FilterAdapter(owner, this)
|
||||
val adapter = FilterAdapter(filter, this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
filter.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
|
||||
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.recyclerView.scrollIndicators = 0
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
interface MangaFilter : OnFilterChangedListener {
|
||||
|
||||
val filterItems: StateFlow<List<ListModel>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
}
|
||||
@@ -45,6 +45,13 @@ class HistoryRepository @Inject constructor(
|
||||
return entity.manga.toManga(entity.tags.toMangaTags())
|
||||
}
|
||||
|
||||
fun observeLast(): Flow<Manga?> {
|
||||
return db.historyDao.observeAll(1).map {
|
||||
val first = it.firstOrNull()
|
||||
first?.manga?.toManga(first.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.historyDao.observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package org.koitharu.kotatsu.history.domain
|
||||
|
||||
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.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.ReaderState
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
class HistoryUpdateUseCase @Inject constructor(
|
||||
@@ -28,9 +31,11 @@ class HistoryUpdateUseCase @Inject constructor(
|
||||
manga: Manga,
|
||||
readerState: ReaderState,
|
||||
percent: Float
|
||||
) = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
) = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
|
||||
runCatchingCancellable {
|
||||
invoke(manga, readerState, percent)
|
||||
withContext(NonCancellable) {
|
||||
invoke(manga, readerState, percent)
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
import java.util.EnumSet
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -192,7 +193,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
files.map { file ->
|
||||
async(dispatcher) {
|
||||
runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull()
|
||||
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
|
||||
}
|
||||
}.awaitAll()
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
|
||||
@@ -2,8 +2,11 @@ package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.net.toFile
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -13,11 +16,14 @@ import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
private const val NOMEDIA = ".nomedia"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||
@@ -74,14 +80,42 @@ class LocalStorageManager @Inject constructor(
|
||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||
}
|
||||
|
||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||
suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) {
|
||||
getAvailableStorageDirs()
|
||||
}
|
||||
|
||||
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
|
||||
if (uri.scheme == "file") {
|
||||
uri.toFile()
|
||||
} else {
|
||||
uri.resolveFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) {
|
||||
File(dir, NOMEDIA).createNewFile()
|
||||
}
|
||||
|
||||
fun takePermissions(uri: Uri) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
contentResolver.takePersistableUriPermission(uri, flags)
|
||||
}
|
||||
|
||||
suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
|
||||
val packageName = context.packageName
|
||||
if (dir.absolutePath.contains(packageName)) {
|
||||
dir.getStorageName(context)
|
||||
} else if (isFullPath) {
|
||||
dir.path
|
||||
} else {
|
||||
dir.name
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
settings.mangaStorageDir?.let {
|
||||
set.add(it)
|
||||
}
|
||||
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||
return set
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
@@ -30,6 +31,12 @@ sealed class LocalMangaInput(
|
||||
else -> LocalMangaZipInput(file)
|
||||
}
|
||||
|
||||
fun ofOrNull(file: File): LocalMangaInput? = when {
|
||||
file.isDirectory -> LocalMangaDirInput(file)
|
||||
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun zipUri(file: File, entryName: String): String =
|
||||
Uri.fromParts("cbz", file.path, entryName).toString()
|
||||
|
||||
@@ -35,18 +35,6 @@ class LocalMangaUtil(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writeIndex(index: MangaIndex) {
|
||||
newOutput().use { output ->
|
||||
when (output) {
|
||||
is LocalMangaDirOutput -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
is LocalMangaZipOutput -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
|
||||
val file = manga.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
|
||||
@@ -85,7 +85,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.setOngoing(true)
|
||||
.setOngoing(false)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@@ -17,18 +17,23 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
|
||||
class LocalListFragment : MangaListFragment() {
|
||||
class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
|
||||
override val viewModel by viewModels<LocalListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
||||
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
|
||||
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
||||
}
|
||||
|
||||
@@ -40,7 +45,7 @@ class LocalListFragment : MangaListFragment() {
|
||||
FilterSheetFragment.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.loadNextPage()
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
|
||||
class LocalListMenuProvider(
|
||||
private val context: Context,
|
||||
private val onImportClick: Function0<Unit>,
|
||||
) : MenuProvider {
|
||||
|
||||
@@ -20,6 +23,12 @@ class LocalListMenuProvider(
|
||||
onImportClick()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_settings -> {
|
||||
context.startActivity(MangaDirectoriesActivity.newIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
filter: FilterCoordinator,
|
||||
settings: AppSettings,
|
||||
private val settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
listExtraProvider: ListExtraProvider,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
@@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor(
|
||||
settings,
|
||||
listExtraProvider,
|
||||
downloadScheduler,
|
||||
) {
|
||||
), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||
|
||||
@@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor(
|
||||
loadList(filter.snapshot(), append = false).join()
|
||||
}
|
||||
}
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
settings.unsubscribe(this)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(ids: Set<Long>) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.await
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
@@ -33,7 +34,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
||||
|
||||
private const val TAG = "cleanup"
|
||||
|
||||
fun enqueue(context: Context) {
|
||||
suspend fun enqueue(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
@@ -42,7 +43,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
||||
.addTag(TAG)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request)
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.main.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
|
||||
class ReadingResumeEnabledUseCase @Inject constructor(
|
||||
private val networkState: NetworkState,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
operator fun invoke(): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
||||
isIncognitoModeEnabled
|
||||
}.flatMapLatest { incognito ->
|
||||
if (incognito) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
combine(networkState, historyRepository.observeLast()) { isOnline, last ->
|
||||
last != null && (isOnline || last.source == MangaSource.LOCAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,8 +69,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -321,8 +319,6 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
TrackWorker.setup(applicationContext)
|
||||
SuggestionsWorker.setup(applicationContext)
|
||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||
}
|
||||
withResumed {
|
||||
|
||||
@@ -97,7 +97,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
||||
setPrimaryFragment(
|
||||
return setPrimaryFragment(
|
||||
when (itemId) {
|
||||
R.id.nav_shelf -> ShelfFragment.newInstance()
|
||||
R.id.nav_explore -> ExploreFragment.newInstance()
|
||||
@@ -106,7 +106,6 @@ class MainNavigationDelegate(
|
||||
else -> return false
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getItemId(fragment: Fragment) = when (fragment) {
|
||||
@@ -117,13 +116,17 @@ class MainNavigationDelegate(
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun setPrimaryFragment(fragment: Fragment) {
|
||||
private fun setPrimaryFragment(fragment: Fragment): Boolean {
|
||||
if (fragmentManager.isStateSaved) {
|
||||
return false
|
||||
}
|
||||
fragmentManager.beginTransaction()
|
||||
.setReorderingAllowed(true)
|
||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
.commit()
|
||||
onFragmentChanged(fragment, fromUser = true)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
|
||||
|
||||
@@ -13,12 +13,12 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
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.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
@@ -29,16 +29,12 @@ class MainViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
trackingRepository: TrackingRepository,
|
||||
settings: AppSettings,
|
||||
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onOpenReader = MutableEventFlow<Manga>()
|
||||
|
||||
val isResumeEnabled = combine(
|
||||
historyRepository.observeHasItems(),
|
||||
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
|
||||
) { hasItems, incognito ->
|
||||
hasItems && !incognito
|
||||
}.stateIn(
|
||||
val isResumeEnabled = readingResumeEnabledUseCase().stateIn(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = false,
|
||||
|
||||
@@ -34,6 +34,8 @@ import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.ramAvailable
|
||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
||||
@@ -42,7 +44,6 @@ import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -83,7 +84,10 @@ class PageLoader @Inject constructor(
|
||||
}
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam()
|
||||
return repository is RemoteMangaRepository
|
||||
&& settings.isPagesPreloadEnabled
|
||||
&& !context.isPowerSaveMode()
|
||||
&& !isLowRam()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -127,7 +128,7 @@ class ReaderActivity :
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, this::onInitReader)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, this::onPageSaved)
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||
|
||||
@@ -15,7 +15,9 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -23,10 +25,13 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RemoteListFragment : MangaListFragment() {
|
||||
class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(RemoteListMenuProvider())
|
||||
|
||||
@@ -20,10 +20,11 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterState
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
@@ -36,7 +37,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val FILTER_MIN_INTERVAL = 250L
|
||||
@@ -49,7 +49,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
settings: AppSettings,
|
||||
listExtraProvider: ListExtraProvider,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter {
|
||||
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
|
||||
private val repository = mangaRepositoryFactory.create(source)
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.koitharu.kotatsu.databinding.ActivityMangaListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.local.ui.LocalListFragment
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -36,11 +36,16 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
@AndroidEntryPoint
|
||||
class MangaListActivity :
|
||||
BaseActivity<ActivityMangaListBinding>(),
|
||||
AppBarOwner, View.OnClickListener {
|
||||
AppBarOwner, View.OnClickListener, FilterOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = checkNotNull(findFilterOwner()) {
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
@@ -74,7 +79,10 @@ class MangaListActivity :
|
||||
|
||||
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
val existingFragment = fm.findFragmentById(R.id.container)
|
||||
if (existingFragment is FilterOwner) {
|
||||
initFilter(existingFragment)
|
||||
} else {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
val fragment = if (source == MangaSource.LOCAL) {
|
||||
@@ -83,17 +91,15 @@ class MangaListActivity :
|
||||
RemoteListFragment.newInstance(source)
|
||||
}
|
||||
replace(R.id.container, fragment)
|
||||
if (!tags.isNullOrEmpty() && fragment is RemoteListFragment) {
|
||||
runOnCommit { initFilter(fragment) }
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
runOnCommit(ApplyFilterRunnable(fragment, tags))
|
||||
}
|
||||
runOnCommit { initFilter() }
|
||||
}
|
||||
} else {
|
||||
initFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initFilter() {
|
||||
private fun initFilter(filterOwner: FilterOwner) {
|
||||
if (viewBinding.containerFilter != null) {
|
||||
if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) {
|
||||
supportFragmentManager.commit {
|
||||
@@ -109,14 +115,14 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
val filterOwner = FilterOwner.from(this)
|
||||
val filter = filterOwner.filter
|
||||
val chipSort = viewBinding.chipSort
|
||||
if (chipSort != null) {
|
||||
filterOwner.header.observe(this) {
|
||||
filter.header.observe(this) {
|
||||
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
|
||||
}
|
||||
} else {
|
||||
filterOwner.header.map {
|
||||
filter.header.map {
|
||||
it.textSummary
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.observe(this) {
|
||||
@@ -125,15 +131,17 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFilterOwner(): FilterOwner? {
|
||||
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
|
||||
}
|
||||
|
||||
private class ApplyFilterRunnable(
|
||||
private val fragment: MangaListFragment,
|
||||
private val filterOwner: FilterOwner,
|
||||
private val tags: Set<MangaTag>,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
checkNotNull(FilterOwner.find(fragment)) {
|
||||
"Cannot find FilterOwner"
|
||||
}.applyFilter(tags)
|
||||
filterOwner.filter.applyFilter(tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,18 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadsSettingsFragment :
|
||||
BasePreferenceFragment(R.string.downloads),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
StorageSelectDialog.OnStorageSelectListener {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var storageManager: LocalStorageManager
|
||||
@@ -39,6 +37,7 @@ class DownloadsSettingsFragment :
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
@@ -53,6 +52,10 @@ class DownloadsSettingsFragment :
|
||||
findPreference<Preference>(key)?.bindStorageName()
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||
findPreference<Preference>(key)?.bindDirectoriesCount()
|
||||
}
|
||||
|
||||
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
||||
updateDownloadsConstraints()
|
||||
}
|
||||
@@ -62,12 +65,12 @@ class DownloadsSettingsFragment :
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
.show()
|
||||
MangaDirectorySelectDialog.show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||
startActivity(MangaDirectoriesActivity.newIntent(preference.context))
|
||||
true
|
||||
}
|
||||
|
||||
@@ -75,14 +78,21 @@ class DownloadsSettingsFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStorageSelected(file: File) {
|
||||
settings.mangaStorageDir = file
|
||||
}
|
||||
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
summary = if (storage != null) {
|
||||
storageManager.getDirectoryDisplayName(storage, isFullPath = true)
|
||||
} else {
|
||||
getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preference.bindDirectoriesCount() {
|
||||
viewLifecycleScope.launch {
|
||||
val dirs = storageManager.getReadableDirs().size
|
||||
summary = resources.getQuantityString(R.plurals.items, dirs, dirs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -38,7 +39,8 @@ class NewSourcesDialogFragment :
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
||||
|
||||
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
viewModel.sources.filterNotNull()
|
||||
.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -15,8 +17,14 @@ class NewSourcesViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val sources = MutableStateFlow<List<SourceConfigItem>>(buildList())
|
||||
private val initialList = settings.newSources
|
||||
val sources = MutableStateFlow<List<SourceConfigItem>?>(null)
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sources.value = buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
if (isEnabled) {
|
||||
@@ -30,6 +38,7 @@ class NewSourcesViewModel @Inject constructor(
|
||||
settings.markKnownSources(initialList)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun buildList(): List<SourceConfigItem.SourceItem> {
|
||||
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
|
||||
val pendingHidden = HashSet<String>()
|
||||
|
||||
@@ -4,16 +4,15 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
@@ -49,10 +48,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
getString(R.string.logged_in_as, it)
|
||||
}
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
|
||||
viewModel.onError.observeEvent(
|
||||
viewLifecycleOwner,
|
||||
SnackbarErrorObserver(
|
||||
listView,
|
||||
this,
|
||||
exceptionResolver,
|
||||
) { viewModel.onResume() },
|
||||
)
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
|
||||
}
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
@@ -61,32 +68,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
viewModel.clearCookies()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(error: Throwable) {
|
||||
val snackbar = Snackbar.make(
|
||||
listView ?: return,
|
||||
error.getDisplayMessage(resources),
|
||||
Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
if (ExceptionResolver.canResolve(error)) {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private fun resolveError(error: Throwable) {
|
||||
view ?: return
|
||||
viewLifecycleScope.launch {
|
||||
if (exceptionResolver.resolve(error)) {
|
||||
viewModel.onResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_AUTH = "auth"
|
||||
|
||||
@@ -5,9 +5,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -17,11 +23,13 @@ import javax.inject.Inject
|
||||
class SourceSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
|
||||
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val username = MutableStateFlow<String?>(null)
|
||||
private var usernameLoadJob: Job? = null
|
||||
|
||||
@@ -35,6 +43,18 @@ class SourceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCookies() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host(repository.domain)
|
||||
.build()
|
||||
cookieJar.removeCookies(url)
|
||||
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
|
||||
loadUsername()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUsername() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
@@ -27,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -66,8 +68,11 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
|
||||
fun directoryAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.imageViewIndicator.isChecked = item.isChecked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
|
||||
class DirectoryDiffCallback : ItemCallback<DirectoryModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem.file == newItem.file
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? {
|
||||
return if (oldItem.isChecked != newItem.isChecked) {
|
||||
Unit
|
||||
} else {
|
||||
super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.io.File
|
||||
|
||||
class DirectoryModel(
|
||||
val title: String?,
|
||||
@StringRes val titleRes: Int,
|
||||
val file: File?,
|
||||
val isChecked: Boolean,
|
||||
val isAvailable: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DirectoryModel
|
||||
|
||||
if (title != other.title) return false
|
||||
if (titleRes != other.titleRes) return false
|
||||
if (file != other.file) return false
|
||||
if (isChecked != other.isChecked) return false
|
||||
return isAvailable == other.isAvailable
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = title?.hashCode() ?: 0
|
||||
result = 31 * result + titleRes
|
||||
result = 31 * result + (file?.hashCode() ?: 0)
|
||||
result = 31 * result + isChecked.hashCode()
|
||||
result = 31 * result + isAvailable.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
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.databinding.DialogDirectorySelectBinding
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBinding>(),
|
||||
OnListItemClickListener<DirectoryModel> {
|
||||
|
||||
private val viewModel: MangaDirectorySelectViewModel by viewModels()
|
||||
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
if (it != null) viewModel.onCustomDirectoryPicked(it)
|
||||
}
|
||||
private val permissionRequestLauncher = registerForActivityResult(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
RequestStorageManagerPermissionContract()
|
||||
} else {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
},
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.refresh()
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
|
||||
return DialogDirectorySelectBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this))
|
||||
binding.root.adapter = adapter
|
||||
viewModel.items.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() }
|
||||
viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() }
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this))
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.manga_save_location)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onItemClick(item)
|
||||
}
|
||||
|
||||
private fun pickCustomDirectory() {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "MangaDirectorySelectDialog"
|
||||
|
||||
fun show(fm: FragmentManager) = MangaDirectorySelectDialog()
|
||||
.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
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.local.data.LocalStorageManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectorySelectViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
val onDismissDialog = MutableEventFlow<Unit>()
|
||||
val onPickDirectory = MutableEventFlow<Unit>()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun onItemClick(item: DirectoryModel) {
|
||||
if (item.file != null) {
|
||||
settings.mangaStorageDir = item.file
|
||||
onDismissDialog.call(Unit)
|
||||
} else {
|
||||
onPickDirectory.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
if (!dir.canWrite()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.mangaStorageDir = dir
|
||||
storageManager.setDirIsNoMedia(dir)
|
||||
}
|
||||
onDismissDialog.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val defaultValue = storageManager.getDefaultWriteableDir()
|
||||
val available = storageManager.getWriteableDirs()
|
||||
items.value = buildList(available.size + 1) {
|
||||
available.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = dir == defaultValue,
|
||||
isAvailable = true,
|
||||
)
|
||||
}
|
||||
this += DirectoryModel(
|
||||
title = null,
|
||||
titleRes = R.string.pick_custom_directory,
|
||||
file = null,
|
||||
isChecked = false,
|
||||
isAvailable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class RequestStorageManagerPermissionContract : ActivityResultContract<String, Boolean>() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = "package:${context.packageName}".toUri()
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return Environment.isExternalStorageManager()
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(context: Context, input: String): SynchronousResult<Boolean>? {
|
||||
return if (Environment.isExternalStorageManager()) {
|
||||
SynchronousResult(true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
|
||||
fun directoryConfigAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.imageViewRemove.isVisible = item.isChecked
|
||||
binding.textViewTitle.drawableStart = if (item.isAvailable) {
|
||||
null
|
||||
} else {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
||||
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
|
||||
|
||||
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
||||
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
if (it != null) viewModel.onCustomDirectoryPicked(it)
|
||||
}
|
||||
private val permissionRequestLauncher = registerForActivityResult(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
RequestStorageManagerPermissionContract()
|
||||
} else {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
},
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.updateList()
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
|
||||
viewBinding.recyclerView.adapter = adapter
|
||||
viewBinding.fabAdd.setOnClickListener(this)
|
||||
viewModel.items.observe(this) { adapter.items = it }
|
||||
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) {
|
||||
if (it) viewModel.updateList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onRemoveClick(item.file ?: return)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
rightMargin = topMargin + insets.right
|
||||
leftMargin = topMargin + insets.left
|
||||
bottomMargin = topMargin + insets.bottom
|
||||
}
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectoriesViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
init {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun updateList() {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
loadingJob?.cancelAndJoin()
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
if (!dir.canWrite()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.userSpecifiedMangaDirectories += dir
|
||||
loadList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveClick(directory: File) {
|
||||
settings.userSpecifiedMangaDirectories -= directory
|
||||
if (settings.mangaStorageDir == directory) {
|
||||
settings.mangaStorageDir = null
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
|
||||
private fun loadList() {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||
val customDirs = settings.userSpecifiedMangaDirectories
|
||||
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||
applicationDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = false,
|
||||
isAvailable = dir.canRead() && dir.canWrite(),
|
||||
)
|
||||
}
|
||||
customDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = true,
|
||||
isAvailable = dir.canRead() && dir.canWrite(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||
@@ -155,7 +156,7 @@ class TrackerSettingsFragment :
|
||||
return
|
||||
}
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val powerManager = context.powerManager ?: return
|
||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||
try {
|
||||
val intent = Intent(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.settings.work
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface PeriodicWorkScheduler {
|
||||
|
||||
suspend fun schedule(context: Context)
|
||||
|
||||
suspend fun unschedule(context: Context)
|
||||
|
||||
suspend fun isScheduled(context: Context): Boolean
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.koitharu.kotatsu.settings.work
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import javax.inject.Inject
|
||||
|
||||
class WorkScheduleManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
) : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_TRACKER_ENABLED -> updateWorker(TrackWorker, settings.isTrackerEnabled)
|
||||
AppSettings.KEY_SUGGESTIONS -> updateWorker(SuggestionsWorker, settings.isSuggestionsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
settings.subscribe(this)
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
updateWorkerImpl(TrackWorker, settings.isTrackerEnabled)
|
||||
updateWorkerImpl(SuggestionsWorker, settings.isSuggestionsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
updateWorkerImpl(scheduler, isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
|
||||
if (scheduler.isScheduled(context) != isEnabled) {
|
||||
if (isEnabled) {
|
||||
scheduler.schedule(context)
|
||||
} else {
|
||||
scheduler.unschedule(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.toMangaList
|
||||
@@ -32,6 +35,7 @@ class ShelfContentObserveUseCase @Inject constructor(
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val suggestionRepository: SuggestionRepository,
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) {
|
||||
|
||||
@@ -46,7 +50,10 @@ class ShelfContentObserveUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
private fun observeLocalManga(sortOrder: SortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return localStorageChanges
|
||||
return combine<LocalManga?, String, Any?>(
|
||||
localStorageChanges,
|
||||
settings.observe().filter { it == AppSettings.KEY_LOCAL_MANGA_DIRS }
|
||||
) { _, _ -> Any() }
|
||||
.onStart { emit(null) }
|
||||
.mapLatest {
|
||||
localMangaRepository.getList(0, null, sortOrder).take(limit)
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
@@ -38,6 +39,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.distinctById
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkManagerHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||
import org.koitharu.kotatsu.core.util.ext.flatten
|
||||
@@ -55,6 +57,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
|
||||
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
|
||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
||||
@@ -75,11 +78,11 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
trySetForeground()
|
||||
if (!appSettings.isSuggestionsEnabled) {
|
||||
suggestionRepository.clear()
|
||||
return Result.success()
|
||||
}
|
||||
trySetForeground()
|
||||
val count = doWorkImpl()
|
||||
val outputData = workDataOf(DATA_COUNT to count)
|
||||
return Result.success(outputData)
|
||||
@@ -106,7 +109,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setDefaults(0)
|
||||
.setOngoing(true)
|
||||
.setOngoing(false)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
@@ -303,7 +306,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
return -1
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object : PeriodicWorkScheduler {
|
||||
|
||||
private const val TAG = "suggestions"
|
||||
private const val TAG_ONESHOT = "suggestions_oneshot"
|
||||
@@ -324,7 +327,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
SortOrder.RATING,
|
||||
)
|
||||
|
||||
fun setup(context: Context) {
|
||||
override suspend fun schedule(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
@@ -336,6 +339,19 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
.await()
|
||||
}
|
||||
|
||||
override suspend fun unschedule(context: Context) {
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(TAG)
|
||||
.await()
|
||||
}
|
||||
|
||||
override suspend fun isScheduled(context: Context): Boolean {
|
||||
return WorkManagerHelper(WorkManager.getInstance(context))
|
||||
.getUniqueWorkInfoByName(TAG)
|
||||
.any { !it.state.isFinished }
|
||||
}
|
||||
|
||||
fun startNow(context: Context) {
|
||||
|
||||
@@ -43,10 +43,12 @@ class SyncController @Inject constructor(
|
||||
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
requestSync(
|
||||
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
|
||||
history = TABLE_HISTORY in tables,
|
||||
)
|
||||
val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables)
|
||||
&& !isSyncActiveOrPending(authorityFavourites)
|
||||
val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory)
|
||||
if (favourites || history) {
|
||||
requestSync(favourites, history)
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnabled(account: Account): Boolean {
|
||||
@@ -126,6 +128,11 @@ class SyncController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSyncActiveOrPending(authority: String): Boolean {
|
||||
val account = peekAccount() ?: return false
|
||||
return ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -11,6 +11,10 @@ import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.contentValuesOf
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -23,9 +27,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
||||
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
||||
import org.koitharu.kotatsu.core.logs.LoggersModule
|
||||
import org.koitharu.kotatsu.core.network.GZipInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.logs.SyncLogger
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.toContentValues
|
||||
import org.koitharu.kotatsu.core.util.ext.toJson
|
||||
@@ -39,23 +43,20 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val FIELD_TIMESTAMP = "timestamp"
|
||||
|
||||
/**
|
||||
* Warning! This class may be used in another process
|
||||
*/
|
||||
@WorkerThread
|
||||
class SyncHelper(
|
||||
context: Context,
|
||||
private val account: Account,
|
||||
private val provider: ContentProviderClient,
|
||||
class SyncHelper @AssistedInject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
@BaseHttpClient baseHttpClient: OkHttpClient,
|
||||
@Assisted private val account: Account,
|
||||
@Assisted private val provider: ContentProviderClient,
|
||||
private val settings: SyncSettings,
|
||||
@SyncLogger private val logger: FileLogger,
|
||||
) {
|
||||
|
||||
private val authorityHistory = context.getString(R.string.sync_authority_history)
|
||||
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
|
||||
private val settings = SyncSettings(context, account)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
private val httpClient = baseHttpClient.newBuilder()
|
||||
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
|
||||
.addInterceptor(SyncInterceptor(context, account))
|
||||
.addInterceptor(GZipInterceptor())
|
||||
.build()
|
||||
private val baseUrl: String by lazy {
|
||||
val host = settings.host
|
||||
@@ -64,8 +65,8 @@ class SyncHelper(
|
||||
}
|
||||
private val defaultGcPeriod: Long // gc period if sync enabled
|
||||
get() = TimeUnit.DAYS.toMillis(4)
|
||||
private val logger = LoggersModule.provideSyncLogger(context, AppSettings(context))
|
||||
|
||||
@WorkerThread
|
||||
fun syncFavourites(syncResult: SyncResult) {
|
||||
val data = JSONObject()
|
||||
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
|
||||
@@ -89,6 +90,7 @@ class SyncHelper(
|
||||
gcFavourites()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun syncHistory(syncResult: SyncResult) {
|
||||
val data = JSONObject()
|
||||
data.put(TABLE_HISTORY, getHistory())
|
||||
@@ -321,4 +323,13 @@ class SyncHelper(
|
||||
logger.log("$code ${request.url}")
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
account: Account,
|
||||
contentProviderClient: ContentProviderClient,
|
||||
): SyncHelper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.sync.ui
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncAdapterEntryPoint {
|
||||
val syncHelperFactory: SyncHelper.Factory
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.onError
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint
|
||||
|
||||
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||
|
||||
@@ -24,7 +25,8 @@ class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(cont
|
||||
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||
return
|
||||
}
|
||||
val syncHelper = SyncHelper(context, account, provider)
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||
runCatchingCancellable {
|
||||
syncHelper.syncFavourites(syncResult)
|
||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||
|
||||
@@ -6,11 +6,12 @@ import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.onError
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint
|
||||
|
||||
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||
|
||||
@@ -24,7 +25,8 @@ class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context
|
||||
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||
return
|
||||
}
|
||||
val syncHelper = SyncHelper(context, account, provider)
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||
runCatchingCancellable {
|
||||
syncHelper.syncHistory(syncResult)
|
||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||
|
||||
@@ -13,9 +13,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
class TrackEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@@ -27,4 +27,4 @@ class TrackEntity(
|
||||
@ColumnInfo(name = "last_check") val lastCheck: Long,
|
||||
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
|
||||
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,6 +43,9 @@ abstract class TracksDao {
|
||||
@Query("UPDATE tracks SET chapters_new = 0")
|
||||
abstract suspend fun clearCounters()
|
||||
|
||||
@Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearCounter(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun delete(mangaId: Long)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user