Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -15,8 +15,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 33
|
targetSdkVersion 33
|
||||||
versionCode 552
|
versionCode 556
|
||||||
versionName '5.2'
|
versionName '5.3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:f732582d55') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:c2b79b55f8') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ dependencies {
|
|||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||||
|
|
||||||
|
// TODO https://issuetracker.google.com/issues/254846063
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.0-android') {
|
implementation('com.google.guava:guava:32.0.0-android') {
|
||||||
@@ -112,9 +113,10 @@ dependencies {
|
|||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.5.1'
|
implementation 'androidx.room:room-runtime:2.5.2'
|
||||||
implementation 'androidx.room:room-ktx:2.5.1'
|
implementation 'androidx.room:room-ktx:2.5.2'
|
||||||
kapt 'androidx.room:room-compiler:2.5.1'
|
//noinspection KaptUsageInsteadOfKsp
|
||||||
|
kapt 'androidx.room:room-compiler:2.5.2'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||||
@@ -140,7 +142,7 @@ dependencies {
|
|||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20230227'
|
testImplementation 'org.json:json:20230618'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
|
|||||||
@@ -18,6 +18,22 @@
|
|||||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WRITE_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.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
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
@@ -32,6 +48,7 @@
|
|||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:localeConfig="@xml/locales"
|
android:localeConfig="@xml/locales"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Kotatsu"
|
android:theme="@style/Theme.Kotatsu"
|
||||||
@@ -98,6 +115,9 @@
|
|||||||
<data android:host="sync-settings" />
|
<data android:host="sync-settings" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
|
||||||
|
android:label="@string/local_manga_directories" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
@@ -188,8 +208,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
|
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/favourites"
|
android:label="@string/favourites">
|
||||||
android:process=":sync">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.content.SyncAdapter" />
|
<action android:name="android.content.SyncAdapter" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -200,8 +219,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
|
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/history"
|
android:label="@string/history">
|
||||||
android:process=":sync">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.content.SyncAdapter" />
|
<action android:name="android.content.SyncAdapter" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.work.Configuration
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
import org.acra.config.httpSender
|
import org.acra.config.httpSender
|
||||||
@@ -19,6 +20,7 @@ import org.acra.data.StringFormat
|
|||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import org.acra.sender.HttpSender
|
import org.acra.sender.HttpSender
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
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.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
@@ -46,8 +49,15 @@ class KotatsuApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appValidator: AppValidator
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workScheduleManager: WorkScheduleManager
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,7 @@ class KotatsuApp : Application(), Configuration.Provider {
|
|||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
}
|
}
|
||||||
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(applicationContext).setup()
|
WorkServiceStopHelper(applicationContext).setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +101,7 @@ class KotatsuApp : Application(), Configuration.Provider {
|
|||||||
ReportField.CUSTOM_DATA,
|
ReportField.CUSTOM_DATA,
|
||||||
ReportField.SHARED_PREFERENCES,
|
ReportField.SHARED_PREFERENCES,
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
text = getString(R.string.crash_text)
|
text = getString(R.string.crash_text)
|
||||||
title = getString(R.string.error_occurred)
|
title = getString(R.string.error_occurred)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.webkit.CookieManager
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -36,6 +37,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||||
}
|
}
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||||
|
|||||||
@@ -33,11 +33,21 @@ abstract class ErrorObserver(
|
|||||||
return resolver != null && ExceptionResolver.canResolve(error)
|
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) {
|
protected fun resolve(error: Throwable) {
|
||||||
lifecycleScope.launch {
|
if (isAlive()) {
|
||||||
val isResolved = resolver?.resolve(error) ?: false
|
lifecycleScope.launch {
|
||||||
if (isActive) {
|
val isResolved = resolver?.resolve(error) ?: false
|
||||||
onResolved?.accept(isResolved)
|
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
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -14,28 +10,22 @@ import org.json.JSONArray
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
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.json.mapJSONNotNull
|
||||||
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
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.Inject
|
||||||
import javax.inject.Singleton
|
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"
|
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppUpdateRepository @Inject constructor(
|
class AppUpdateRepository @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
private val appValidator: AppValidator,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
@@ -85,7 +75,7 @@ class AppUpdateRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isUpdateSupported(): Boolean {
|
fun isUpdateSupported(): Boolean {
|
||||||
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
|
return BuildConfig.DEBUG || appValidator.isOriginalApp
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentVersionChangelog(): String? {
|
suspend fun getCurrentVersionChangelog(): String? {
|
||||||
@@ -94,22 +84,6 @@ class AppUpdateRepository @Inject constructor(
|
|||||||
return available.find { x -> x.versionId == currentVersion }?.description
|
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? {
|
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
|
||||||
val size = length()
|
val size = length()
|
||||||
for (i in 0 until size) {
|
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.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@JvmName("mangaIds")
|
||||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||||
|
|
||||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||||
|
|
||||||
|
@JvmName("chaptersIds")
|
||||||
|
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||||
|
|
||||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||||
if (size <= 1) {
|
if (size <= 1) {
|
||||||
return size
|
return size
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ class ParcelableManga(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "ParcelableManga(manga=$manga, withChapters=$withChapters)"
|
||||||
|
}
|
||||||
|
|
||||||
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
|
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
|
||||||
override fun createFromParcel(parcel: Parcel): ParcelableManga {
|
override fun createFromParcel(parcel: Parcel): ParcelableManga {
|
||||||
return ParcelableManga(parcel)
|
return ParcelableManga(parcel)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class ParcelableMangaChapters(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "ParcelableMangaChapters(chapters=$chapters)"
|
||||||
|
}
|
||||||
|
|
||||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
|
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
|
||||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
|
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
|
||||||
return ParcelableMangaChapters(parcel)
|
return ParcelableMangaChapters(parcel)
|
||||||
@@ -32,4 +36,4 @@ class ParcelableMangaChapters(
|
|||||||
return arrayOfNulls(size)
|
return arrayOfNulls(size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class ParcelableMangaPages(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "ParcelableMangaPages(pages=$pages)"
|
||||||
|
}
|
||||||
|
|
||||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
|
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
|
||||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
|
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
|
||||||
return ParcelableMangaPages(parcel)
|
return ParcelableMangaPages(parcel)
|
||||||
@@ -32,4 +36,4 @@ class ParcelableMangaPages(
|
|||||||
return arrayOfNulls(size)
|
return arrayOfNulls(size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class ParcelableMangaTags(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "ParcelableMangaTags(tags=$tags)"
|
||||||
|
}
|
||||||
|
|
||||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
|
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
|
||||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
|
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
|
||||||
return ParcelableMangaTags(parcel)
|
return ParcelableMangaTags(parcel)
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
|
|||||||
class CloudFlareInterceptor : Interceptor {
|
class CloudFlareInterceptor : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val response = chain.proceed(chain.request())
|
||||||
val response = chain.proceed(request)
|
|
||||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||||
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
||||||
|
val request = response.request
|
||||||
response.closeQuietly()
|
response.closeQuietly()
|
||||||
throw CloudFlareProtectedException(
|
throw CloudFlareProtectedException(
|
||||||
url = response.request.url.toString(),
|
url = request.url.toString(),
|
||||||
headers = request.headers,
|
headers = request.headers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.webkit.CookieManager
|
|||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.newBuilder
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
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 ->
|
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
|
||||||
cookieManager.removeAllCookies(continuation::resume)
|
cookieManager.removeAllCookies(continuation::resume)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
|
|||||||
@WorkerThread
|
@WorkerThread
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun removeCookies(url: HttpUrl)
|
||||||
|
|
||||||
suspend fun clear(): Boolean
|
suspend fun clear(): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class PreferencesCookieJar(
|
|||||||
private var isLoaded = false
|
private var isLoaded = false
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
|
@Synchronized
|
||||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||||
loadPersistent()
|
loadPersistent()
|
||||||
val expired = HashSet<String>()
|
val expired = HashSet<String>()
|
||||||
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
|
@Synchronized
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
val wrapped = cookies.map { CookieWrapper(it) }
|
val wrapped = cookies.map { CookieWrapper(it) }
|
||||||
prefs.edit(commit = true) {
|
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 {
|
override suspend fun clear(): Boolean {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
withContext(Dispatchers.IO) {
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.getEnumValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
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.core.util.ext.toUriOrNull
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
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.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -234,14 +236,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
}.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?
|
var mangaStorageDir: File?
|
||||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||||
File(it)
|
File(it)
|
||||||
}?.takeIf { it.exists() }
|
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
|
||||||
set(value) = prefs.edit {
|
set(value) = prefs.edit {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
remove(KEY_LOCAL_STORAGE)
|
remove(KEY_LOCAL_STORAGE)
|
||||||
} else {
|
} else {
|
||||||
|
val userDirs = userSpecifiedMangaDirectories
|
||||||
|
if (value !in userDirs) {
|
||||||
|
userSpecifiedMangaDirectories = userDirs + value
|
||||||
|
}
|
||||||
putString(KEY_LOCAL_STORAGE, value.path)
|
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_LOGIN = "proxy_login"
|
||||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||||
|
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
|||||||
import com.google.android.material.shape.ShapeAppearanceModel
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
@@ -35,6 +36,18 @@ class TwoLinesItemView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
|
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 {
|
init {
|
||||||
var textColors: ColorStateList? = null
|
var textColors: ColorStateList? = null
|
||||||
context.withStyledAttributes(
|
context.withStyledAttributes(
|
||||||
@@ -76,8 +89,7 @@ class TwoLinesItemView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setIconResource(@DrawableRes resId: Int) {
|
fun setIconResource(@DrawableRes resId: Int) {
|
||||||
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
binding.icon.setImageResource(resId)
|
||||||
binding.icon.setImageDrawable(icon)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
|
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.text.DateFormat
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.WeakHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -20,31 +21,40 @@ import javax.inject.Singleton
|
|||||||
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
|
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
|
||||||
|
|
||||||
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
|
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
|
||||||
|
private val keys = WeakHashMap<Any, String>()
|
||||||
|
|
||||||
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
|
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
|
||||||
super.onFragmentAttached(fm, f, 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) {
|
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
|
||||||
super.onFragmentDetached(fm, f)
|
super.onFragmentDetached(fm, f)
|
||||||
ACRA.errorReporter.removeCustomData(f.key())
|
ACRA.errorReporter.removeCustomData(f.key())
|
||||||
|
keys.remove(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||||
super.onActivityCreated(activity, savedInstanceState)
|
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)
|
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityDestroyed(activity: Activity) {
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
super.onActivityDestroyed(activity)
|
super.onActivityDestroyed(activity)
|
||||||
ACRA.errorReporter.removeCustomData(activity.key())
|
ACRA.errorReporter.removeCustomData(activity.key())
|
||||||
|
keys.remove(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Activity.key() = "Activity[${javaClass.simpleName}]"
|
private fun Any.key() = keys.getOrPut(this) {
|
||||||
|
"${time()}: ${javaClass.simpleName}"
|
||||||
private fun Fragment.key() = "Fragment[${javaClass.simpleName}]"
|
}
|
||||||
|
|
||||||
private fun time() = timeFormat.format(Date())
|
private fun time() = timeFormat.format(Date())
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
|
||||||
|
val v = get(k)
|
||||||
|
"$k=$v"
|
||||||
|
} ?: toString()
|
||||||
}
|
}
|
||||||
|
|||||||
117
app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
Normal file
117
app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.storage.StorageManager;
|
||||||
|
import android.os.storage.StorageVolume;
|
||||||
|
import android.provider.DocumentsContract;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.reflect.Array;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class FileUtil {
|
||||||
|
|
||||||
|
private static final String PRIMARY_VOLUME_NAME = "primary";
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static String getFullPathFromTreeUri(@Nullable final Uri treeUri, Context con) {
|
||||||
|
if (treeUri == null) return null;
|
||||||
|
String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri), con);
|
||||||
|
if (volumePath == null) return File.separator;
|
||||||
|
if (volumePath.endsWith(File.separator))
|
||||||
|
volumePath = volumePath.substring(0, volumePath.length() - 1);
|
||||||
|
|
||||||
|
String documentPath = getDocumentPathFromTreeUri(treeUri);
|
||||||
|
if (documentPath.endsWith(File.separator))
|
||||||
|
documentPath = documentPath.substring(0, documentPath.length() - 1);
|
||||||
|
|
||||||
|
if (documentPath.length() > 0) {
|
||||||
|
if (documentPath.startsWith(File.separator))
|
||||||
|
return volumePath + documentPath;
|
||||||
|
else
|
||||||
|
return volumePath + File.separator + documentPath;
|
||||||
|
} else return volumePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static String getVolumePath(final String volumeId, Context context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
return getVolumePathForAndroid11AndAbove(volumeId, context);
|
||||||
|
} else
|
||||||
|
return getVolumePathBeforeAndroid11(volumeId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static String getVolumePathBeforeAndroid11(final String volumeId, Context context) {
|
||||||
|
try {
|
||||||
|
StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
|
||||||
|
Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
|
||||||
|
Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
|
||||||
|
Method getUuid = storageVolumeClazz.getMethod("getUuid");
|
||||||
|
Method getPath = storageVolumeClazz.getMethod("getPath");
|
||||||
|
Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
|
||||||
|
Object result = getVolumeList.invoke(mStorageManager);
|
||||||
|
|
||||||
|
final int length = Array.getLength(result);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
Object storageVolumeElement = Array.get(result, i);
|
||||||
|
String uuid = (String) getUuid.invoke(storageVolumeElement);
|
||||||
|
Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
|
||||||
|
|
||||||
|
if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) // primary volume?
|
||||||
|
return (String) getPath.invoke(storageVolumeElement);
|
||||||
|
|
||||||
|
if (uuid != null && uuid.equals(volumeId)) // other volumes?
|
||||||
|
return (String) getPath.invoke(storageVolumeElement);
|
||||||
|
}
|
||||||
|
// not found.
|
||||||
|
return null;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.R)
|
||||||
|
private static String getVolumePathForAndroid11AndAbove(final String volumeId, Context context) {
|
||||||
|
try {
|
||||||
|
StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
|
||||||
|
List<StorageVolume> storageVolumes = mStorageManager.getStorageVolumes();
|
||||||
|
for (StorageVolume storageVolume : storageVolumes) {
|
||||||
|
// primary volume?
|
||||||
|
if (storageVolume.isPrimary() && PRIMARY_VOLUME_NAME.equals(volumeId))
|
||||||
|
return storageVolume.getDirectory().getPath();
|
||||||
|
|
||||||
|
// other volumes?
|
||||||
|
String uuid = storageVolume.getUuid();
|
||||||
|
if (uuid != null && uuid.equals(volumeId))
|
||||||
|
return storageVolume.getDirectory().getPath();
|
||||||
|
|
||||||
|
}
|
||||||
|
// not found.
|
||||||
|
return null;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getVolumeIdFromTreeUri(final Uri treeUri) {
|
||||||
|
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
|
||||||
|
final String[] split = docId.split(":");
|
||||||
|
if (split.length > 0) return split[0];
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static String getDocumentPathFromTreeUri(final Uri treeUri) {
|
||||||
|
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
|
||||||
|
final String[] split = docId.split(":");
|
||||||
|
if ((split.length >= 2) && (split[1] != null)) return split[1];
|
||||||
|
else return File.separator;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ class WorkManagerHelper(
|
|||||||
return workManagerImpl.getWorkInfoById(id).await()
|
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 {
|
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
|
||||||
return workManagerImpl.updateWork(request).await()
|
return workManagerImpl.updateWork(request).await()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.app.ActivityManager.MemoryInfo
|
|||||||
import android.app.ActivityOptions
|
import android.app.ActivityOptions
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.ACTIVITY_SERVICE
|
import android.content.Context.ACTIVITY_SERVICE
|
||||||
|
import android.content.Context.POWER_SERVICE
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.content.OperationApplicationException
|
import android.content.OperationApplicationException
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -17,6 +18,7 @@ import android.graphics.Color
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewPropertyAnimator
|
import android.view.ViewPropertyAnimator
|
||||||
@@ -51,6 +53,9 @@ import kotlin.math.roundToLong
|
|||||||
val Context.activityManager: ActivityManager?
|
val Context.activityManager: ActivityManager?
|
||||||
get() = getSystemService(ACTIVITY_SERVICE) as? 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)
|
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||||
|
|
||||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||||
@@ -141,6 +146,10 @@ fun Context.isLowRamDevice(): Boolean {
|
|||||||
return activityManager?.isLowRamDevice ?: false
|
return activityManager?.isLowRamDevice ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Context.isPowerSaveMode(): Boolean {
|
||||||
|
return powerManager?.isPowerSaveMode == true
|
||||||
|
}
|
||||||
|
|
||||||
val Context.ramAvailable: Long
|
val Context.ramAvailable: Long
|
||||||
get() {
|
get() {
|
||||||
val result = MemoryInfo()
|
val result = MemoryInfo()
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import android.os.Parcel
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.os.BundleCompat
|
import androidx.core.os.BundleCompat
|
||||||
|
import androidx.core.os.ParcelCompat
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
// https://issuetracker.google.com/issues/240585930
|
// 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? {
|
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? {
|
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 {
|
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 {
|
fun <T> SavedStateHandle.require(key: String): T {
|
||||||
return checkNotNull(get(key)) {
|
return checkNotNull(get(key)) {
|
||||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
"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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
@@ -62,3 +63,23 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
|
|||||||
emit(result)
|
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>) {
|
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||||
owner.lifecycleScope.launch {
|
owner.lifecycleScope.launch {
|
||||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -38,3 +39,23 @@ fun Response.ensureSuccess() = apply {
|
|||||||
throw IllegalStateException(message)
|
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 UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
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 EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||||
is SyncApiException,
|
is SyncApiException,
|
||||||
is ContentUnavailableException,
|
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.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
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.history.data.HistoryRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -115,6 +116,9 @@ class MangaPrefetchService : CoroutineIntentService() {
|
|||||||
if (source == MangaSource.LOCAL) {
|
if (source == MangaSource.LOCAL) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (context.isPowerSaveMode()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
|
||||||
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
|
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ChaptersBottomSheetMediator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionModeStarted(mode: ActionMode) {
|
override fun onActionModeStarted(mode: ActionMode) {
|
||||||
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
lock()
|
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.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
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.core.util.ext.scaleUpActivityOptionsOf
|
||||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||||
@@ -66,6 +67,9 @@ class ChaptersFragment :
|
|||||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||||
binding.textViewHolder.isVisible = it
|
binding.textViewHolder.isVisible = it
|
||||||
}
|
}
|
||||||
|
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
|
||||||
|
selectionController?.onItemLongClick(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
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.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
|
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.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
||||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||||
@@ -211,7 +212,7 @@ class DetailsActivity :
|
|||||||
}
|
}
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
toolbar.addMenuProvider(chaptersMenuProvider)
|
toolbar.addMenuProvider(chaptersMenuProvider)
|
||||||
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
|
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
|
||||||
} else {
|
} else {
|
||||||
toolbar.removeMenuProvider(chaptersMenuProvider)
|
toolbar.removeMenuProvider(chaptersMenuProvider)
|
||||||
toolbar.navigationIcon = null
|
toolbar.navigationIcon = null
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import kotlinx.coroutines.launch
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
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.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.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ class DetailsMenuProvider(
|
|||||||
private val viewModel: DetailsViewModel,
|
private val viewModel: DetailsViewModel,
|
||||||
private val snackbarHost: View,
|
private val snackbarHost: View,
|
||||||
private val appShortcutManager: AppShortcutManager,
|
private val appShortcutManager: AppShortcutManager,
|
||||||
) : MenuProvider {
|
) : MenuProvider, OnListItemClickListener<DownloadOption> {
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(R.menu.opt_details, menu)
|
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_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||||
menu.findItem(R.id.action_favourite).setIcon(
|
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 -> {
|
R.id.action_save -> {
|
||||||
viewModel.manga.value?.let {
|
DownloadDialogHelper(snackbarHost, viewModel).show(this)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
@@ -125,35 +116,16 @@ class DetailsMenuProvider(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
|
override fun onItemClick(item: DownloadOption, view: View) {
|
||||||
val dialogBuilder = MaterialAlertDialogBuilder(activity)
|
val chaptersIds: Set<Long>? = when (item) {
|
||||||
.setTitle(R.string.save_manga)
|
is DownloadOption.WholeManga -> null
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
is DownloadOption.SelectionHint -> {
|
||||||
if (branches.size > 1) {
|
viewModel.startChaptersSelection()
|
||||||
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
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.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
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.computeSize
|
||||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||||
@@ -72,6 +74,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||||
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
|
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
|
||||||
|
networkState: NetworkState,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private val intent = MangaIntent(savedStateHandle)
|
private val intent = MangaIntent(savedStateHandle)
|
||||||
@@ -81,6 +84,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val onShowToast = MutableEventFlow<Int>()
|
val onShowToast = MutableEventFlow<Int>()
|
||||||
val onShowTip = MutableEventFlow<Unit>()
|
val onShowTip = MutableEventFlow<Unit>()
|
||||||
|
val onSelectChapter = MutableEventFlow<Long>()
|
||||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||||
|
|
||||||
val manga = doubleManga.map { it?.any }
|
val manga = doubleManga.map { it?.any }
|
||||||
@@ -176,8 +180,9 @@ class DetailsViewModel @Inject constructor(
|
|||||||
selectedBranch,
|
selectedBranch,
|
||||||
newChaptersCount,
|
newChaptersCount,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
) { manga, history, branch, news, bookmarks ->
|
networkState,
|
||||||
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
|
) { manga, history, branch, news, bookmarks, isOnline ->
|
||||||
|
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
|
||||||
},
|
},
|
||||||
isChaptersReversed,
|
isChaptersReversed,
|
||||||
chaptersQuery,
|
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() {
|
fun onButtonTipClosed() {
|
||||||
settings.closeTip(DetailsActivity.TIP_BUTTON)
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
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.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
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.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
dataRepository: MangaDataRepository,
|
dataRepository: MangaDataRepository,
|
||||||
private val searchRepository: MangaSearchRepository,
|
private val searchRepository: MangaSearchRepository,
|
||||||
lifecycle: ViewModelLifecycle,
|
lifecycle: ViewModelLifecycle,
|
||||||
) : FilterOwner {
|
) : MangaFilter {
|
||||||
|
|
||||||
private val coroutineScope = lifecycle.lifecycleScope
|
private val coroutineScope = lifecycle.lifecycleScope
|
||||||
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
|
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 {
|
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
|
||||||
|
|
||||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
private val filter: MangaFilter
|
||||||
FilterOwner.from(requireActivity())
|
get() = (requireActivity() as FilterOwner).filter
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||||
@@ -30,7 +29,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
|||||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
binding.chipsTags.onChipClickListener = this
|
binding.chipsTags.onChipClickListener = this
|
||||||
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
|
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||||
@@ -40,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
|||||||
if (tag == null) {
|
if (tag == null) {
|
||||||
FilterSheetFragment.show(parentFragmentManager)
|
FilterSheetFragment.show(parentFragmentManager)
|
||||||
} else {
|
} 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
|
package org.koitharu.kotatsu.filter.ui
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
interface FilterOwner {
|
||||||
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 : OnFilterChangedListener {
|
val filter: MangaFilter
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,17 @@ class FilterSheetFragment :
|
|||||||
AdaptiveSheetCallback,
|
AdaptiveSheetCallback,
|
||||||
AsyncListDiffer.ListListener<ListModel> {
|
AsyncListDiffer.ListListener<ListModel> {
|
||||||
|
|
||||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
|
||||||
FilterOwner.from(requireActivity())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||||
return SheetFilterBinding.inflate(inflater, container, false)
|
return SheetFilterBinding.inflate(inflater, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
val filter = (requireActivity() as FilterOwner).filter
|
||||||
addSheetCallback(this)
|
addSheetCallback(this)
|
||||||
val adapter = FilterAdapter(owner, this)
|
val adapter = FilterAdapter(filter, this)
|
||||||
binding.recyclerView.adapter = adapter
|
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) {
|
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
binding.recyclerView.scrollIndicators = 0
|
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())
|
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>> {
|
fun observeAll(): Flow<List<Manga>> {
|
||||||
return db.historyDao.observeAll().mapItems {
|
return db.historyDao.observeAll().mapItems {
|
||||||
it.manga.toManga(it.tags.toMangaTags())
|
it.manga.toManga(it.tags.toMangaTags())
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.history.domain
|
package org.koitharu.kotatsu.history.domain
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.launch
|
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.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class HistoryUpdateUseCase @Inject constructor(
|
class HistoryUpdateUseCase @Inject constructor(
|
||||||
@@ -30,7 +32,9 @@ class HistoryUpdateUseCase @Inject constructor(
|
|||||||
percent: Float
|
percent: Float
|
||||||
) = processLifecycleScope.launch(Dispatchers.Default) {
|
) = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
invoke(manga, readerState, percent)
|
withContext(NonCancellable) {
|
||||||
|
invoke(manga, readerState, percent)
|
||||||
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
@@ -13,11 +15,13 @@ import okhttp3.Cache
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val DIR_NAME = "manga"
|
private const val DIR_NAME = "manga"
|
||||||
|
private const val NOMEDIA = ".nomedia"
|
||||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||||
@@ -74,14 +78,38 @@ class LocalStorageManager @Inject constructor(
|
|||||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
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) {
|
||||||
|
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
|
@WorkerThread
|
||||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||||
val set = getAvailableStorageDirs()
|
val set = getAvailableStorageDirs()
|
||||||
settings.mangaStorageDir?.let {
|
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||||
set.add(it)
|
|
||||||
}
|
|
||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
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.FilterSheetFragment
|
||||||
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||||
|
|
||||||
class LocalListFragment : MangaListFragment() {
|
class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||||
|
|
||||||
override val viewModel by viewModels<LocalListViewModel>()
|
override val viewModel by viewModels<LocalListViewModel>()
|
||||||
|
|
||||||
|
override val filter: MangaFilter
|
||||||
|
get() = viewModel
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
|
||||||
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ class LocalListFragment : MangaListFragment() {
|
|||||||
FilterSheetFragment.show(childFragmentManager)
|
FilterSheetFragment.show(childFragmentManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = viewModel.loadNextPage()
|
||||||
|
|
||||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
|
|
||||||
class LocalListMenuProvider(
|
class LocalListMenuProvider(
|
||||||
|
private val context: Context,
|
||||||
private val onImportClick: Function0<Unit>,
|
private val onImportClick: Function0<Unit>,
|
||||||
) : MenuProvider {
|
) : MenuProvider {
|
||||||
|
|
||||||
@@ -20,6 +23,12 @@ class LocalListMenuProvider(
|
|||||||
onImportClick()
|
onImportClick()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_settings -> {
|
||||||
|
context.startActivity(MangaDirectoriesActivity.newIntent(context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
filter: FilterCoordinator,
|
filter: FilterCoordinator,
|
||||||
settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
downloadScheduler: DownloadWorker.Scheduler,
|
downloadScheduler: DownloadWorker.Scheduler,
|
||||||
listExtraProvider: ListExtraProvider,
|
listExtraProvider: ListExtraProvider,
|
||||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||||
@@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
settings,
|
settings,
|
||||||
listExtraProvider,
|
listExtraProvider,
|
||||||
downloadScheduler,
|
downloadScheduler,
|
||||||
) {
|
), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor(
|
|||||||
loadList(filter.snapshot(), append = false).join()
|
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>) {
|
fun delete(ids: Set<Long>) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.work.ExistingWorkPolicy
|
|||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.await
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
@@ -33,7 +34,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
private const val TAG = "cleanup"
|
private const val TAG = "cleanup"
|
||||||
|
|
||||||
fun enqueue(context: Context) {
|
suspend fun enqueue(context: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -42,7 +43,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
||||||
.build()
|
.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.newsources.NewSourcesDialogFragment
|
||||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||||
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
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 javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -321,8 +319,6 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
TrackWorker.setup(applicationContext)
|
|
||||||
SuggestionsWorker.setup(applicationContext)
|
|
||||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||||
}
|
}
|
||||||
withResumed {
|
withResumed {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class MainNavigationDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
||||||
setPrimaryFragment(
|
return setPrimaryFragment(
|
||||||
when (itemId) {
|
when (itemId) {
|
||||||
R.id.nav_shelf -> ShelfFragment.newInstance()
|
R.id.nav_shelf -> ShelfFragment.newInstance()
|
||||||
R.id.nav_explore -> ExploreFragment.newInstance()
|
R.id.nav_explore -> ExploreFragment.newInstance()
|
||||||
@@ -106,7 +106,6 @@ class MainNavigationDelegate(
|
|||||||
else -> return false
|
else -> return false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getItemId(fragment: Fragment) = when (fragment) {
|
private fun getItemId(fragment: Fragment) = when (fragment) {
|
||||||
@@ -117,13 +116,17 @@ class MainNavigationDelegate(
|
|||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPrimaryFragment(fragment: Fragment) {
|
private fun setPrimaryFragment(fragment: Fragment): Boolean {
|
||||||
|
if (fragmentManager.isStateSaved) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
fragmentManager.beginTransaction()
|
fragmentManager.beginTransaction()
|
||||||
.setReorderingAllowed(true)
|
.setReorderingAllowed(true)
|
||||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||||
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||||
.commit()
|
.commit()
|
||||||
onFragmentChanged(fragment, fromUser = true)
|
onFragmentChanged(fragment, fromUser = true)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
|
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.exceptions.EmptyHistoryException
|
||||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.prefs.observeAsStateFlow
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
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.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -29,16 +29,12 @@ class MainViewModel @Inject constructor(
|
|||||||
private val appUpdateRepository: AppUpdateRepository,
|
private val appUpdateRepository: AppUpdateRepository,
|
||||||
trackingRepository: TrackingRepository,
|
trackingRepository: TrackingRepository,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
|
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val onOpenReader = MutableEventFlow<Manga>()
|
val onOpenReader = MutableEventFlow<Manga>()
|
||||||
|
|
||||||
val isResumeEnabled = combine(
|
val isResumeEnabled = readingResumeEnabledUseCase().stateIn(
|
||||||
historyRepository.observeHasItems(),
|
|
||||||
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
|
|
||||||
) { hasItems, incognito ->
|
|
||||||
hasItems && !incognito
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope + Dispatchers.Default,
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = false,
|
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.RetainedLifecycleCoroutineScope
|
||||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||||
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
|
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.ramAvailable
|
||||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
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.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
@@ -83,7 +84,10 @@ class PageLoader @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isPrefetchApplicable(): Boolean {
|
fun isPrefetchApplicable(): Boolean {
|
||||||
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam()
|
return repository is RemoteMangaRepository
|
||||||
|
&& settings.isPagesPreloadEnabled
|
||||||
|
&& !context.isPowerSaveMode()
|
||||||
|
&& !isLowRam()
|
||||||
}
|
}
|
||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.core.view.isGone
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.onPageSaved.observeEvent(this, this::onPageSaved)
|
||||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
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.addMenuProvider
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
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.FilterSheetFragment
|
||||||
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -23,10 +25,13 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
|
|||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class RemoteListFragment : MangaListFragment() {
|
class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||||
|
|
||||||
override val viewModel by viewModels<RemoteListViewModel>()
|
override val viewModel by viewModels<RemoteListViewModel>()
|
||||||
|
|
||||||
|
override val filter: MangaFilter
|
||||||
|
get() = viewModel
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
addMenuProvider(RemoteListMenuProvider())
|
addMenuProvider(RemoteListMenuProvider())
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
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.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
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.filter.ui.model.FilterState
|
||||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
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.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val FILTER_MIN_INTERVAL = 250L
|
private const val FILTER_MIN_INTERVAL = 250L
|
||||||
@@ -49,7 +49,7 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
listExtraProvider: ListExtraProvider,
|
listExtraProvider: ListExtraProvider,
|
||||||
downloadScheduler: DownloadWorker.Scheduler,
|
downloadScheduler: DownloadWorker.Scheduler,
|
||||||
) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter {
|
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
|
||||||
|
|
||||||
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
|
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
|
||||||
private val repository = mangaRepositoryFactory.create(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.FilterHeaderFragment
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
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.local.ui.LocalListFragment
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -36,11 +36,16 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MangaListActivity :
|
class MangaListActivity :
|
||||||
BaseActivity<ActivityMangaListBinding>(),
|
BaseActivity<ActivityMangaListBinding>(),
|
||||||
AppBarOwner, View.OnClickListener {
|
AppBarOwner, View.OnClickListener, FilterOwner {
|
||||||
|
|
||||||
override val appBar: AppBarLayout
|
override val appBar: AppBarLayout
|
||||||
get() = viewBinding.appbar
|
get() = viewBinding.appbar
|
||||||
|
|
||||||
|
override val filter: MangaFilter
|
||||||
|
get() = checkNotNull(findFilterOwner()) {
|
||||||
|
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||||
|
}.filter
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||||
@@ -74,7 +79,10 @@ class MangaListActivity :
|
|||||||
|
|
||||||
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
|
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
|
||||||
val fm = supportFragmentManager
|
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 {
|
fm.commit {
|
||||||
setReorderingAllowed(true)
|
setReorderingAllowed(true)
|
||||||
val fragment = if (source == MangaSource.LOCAL) {
|
val fragment = if (source == MangaSource.LOCAL) {
|
||||||
@@ -83,17 +91,15 @@ class MangaListActivity :
|
|||||||
RemoteListFragment.newInstance(source)
|
RemoteListFragment.newInstance(source)
|
||||||
}
|
}
|
||||||
replace(R.id.container, fragment)
|
replace(R.id.container, fragment)
|
||||||
if (!tags.isNullOrEmpty() && fragment is RemoteListFragment) {
|
runOnCommit { initFilter(fragment) }
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
runOnCommit(ApplyFilterRunnable(fragment, tags))
|
runOnCommit(ApplyFilterRunnable(fragment, tags))
|
||||||
}
|
}
|
||||||
runOnCommit { initFilter() }
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
initFilter()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initFilter() {
|
private fun initFilter(filterOwner: FilterOwner) {
|
||||||
if (viewBinding.containerFilter != null) {
|
if (viewBinding.containerFilter != null) {
|
||||||
if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) {
|
if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) {
|
||||||
supportFragmentManager.commit {
|
supportFragmentManager.commit {
|
||||||
@@ -109,14 +115,14 @@ class MangaListActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val filterOwner = FilterOwner.from(this)
|
val filter = filterOwner.filter
|
||||||
val chipSort = viewBinding.chipSort
|
val chipSort = viewBinding.chipSort
|
||||||
if (chipSort != null) {
|
if (chipSort != null) {
|
||||||
filterOwner.header.observe(this) {
|
filter.header.observe(this) {
|
||||||
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
|
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filterOwner.header.map {
|
filter.header.map {
|
||||||
it.textSummary
|
it.textSummary
|
||||||
}.flowOn(Dispatchers.Default)
|
}.flowOn(Dispatchers.Default)
|
||||||
.observe(this) {
|
.observe(this) {
|
||||||
@@ -125,15 +131,17 @@ class MangaListActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findFilterOwner(): FilterOwner? {
|
||||||
|
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
|
||||||
|
}
|
||||||
|
|
||||||
private class ApplyFilterRunnable(
|
private class ApplyFilterRunnable(
|
||||||
private val fragment: MangaListFragment,
|
private val filterOwner: FilterOwner,
|
||||||
private val tags: Set<MangaTag>,
|
private val tags: Set<MangaTag>,
|
||||||
) : Runnable {
|
) : Runnable {
|
||||||
|
|
||||||
override fun run() {
|
override fun run() {
|
||||||
checkNotNull(FilterOwner.find(fragment)) {
|
filterOwner.filter.applyFilter(tags)
|
||||||
"Cannot find FilterOwner"
|
|
||||||
}.applyFilter(tags)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,18 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||||
import java.io.File
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DownloadsSettingsFragment :
|
class DownloadsSettingsFragment :
|
||||||
BasePreferenceFragment(R.string.downloads),
|
BasePreferenceFragment(R.string.downloads),
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
StorageSelectDialog.OnStorageSelectListener {
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var storageManager: LocalStorageManager
|
lateinit var storageManager: LocalStorageManager
|
||||||
@@ -39,6 +37,7 @@ class DownloadsSettingsFragment :
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||||
|
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
|
||||||
settings.subscribe(this)
|
settings.subscribe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +52,10 @@ class DownloadsSettingsFragment :
|
|||||||
findPreference<Preference>(key)?.bindStorageName()
|
findPreference<Preference>(key)?.bindStorageName()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||||
|
findPreference<Preference>(key)?.bindDirectoriesCount()
|
||||||
|
}
|
||||||
|
|
||||||
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
||||||
updateDownloadsConstraints()
|
updateDownloadsConstraints()
|
||||||
}
|
}
|
||||||
@@ -62,12 +65,12 @@ class DownloadsSettingsFragment :
|
|||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
return when (preference.key) {
|
return when (preference.key) {
|
||||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||||
val ctx = context ?: return false
|
MangaDirectorySelectDialog.show(childFragmentManager)
|
||||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
true
|
||||||
.setTitle(preference.title ?: "")
|
}
|
||||||
.setNegativeButton(android.R.string.cancel)
|
|
||||||
.create()
|
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||||
.show()
|
startActivity(MangaDirectoriesActivity.newIntent(preference.context))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +78,21 @@ class DownloadsSettingsFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageSelected(file: File) {
|
|
||||||
settings.mangaStorageDir = file
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Preference.bindStorageName() {
|
private fun Preference.bindStorageName() {
|
||||||
viewLifecycleScope.launch {
|
viewLifecycleScope.launch {
|
||||||
val storage = storageManager.getDefaultWriteableDir()
|
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 coil.ImageLoader
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
@@ -38,7 +39,8 @@ class NewSourcesDialogFragment :
|
|||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
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 {
|
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.settings.newsources
|
package org.koitharu.kotatsu.settings.newsources
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -15,8 +17,14 @@ class NewSourcesViewModel @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val sources = MutableStateFlow<List<SourceConfigItem>>(buildList())
|
|
||||||
private val initialList = settings.newSources
|
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) {
|
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
@@ -30,6 +38,7 @@ class NewSourcesViewModel @Inject constructor(
|
|||||||
settings.markKnownSources(initialList)
|
settings.markKnownSources(initialList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
private fun buildList(): List<SourceConfigItem.SourceItem> {
|
private fun buildList(): List<SourceConfigItem.SourceItem> {
|
||||||
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
|
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
|
||||||
val pendingHidden = HashSet<String>()
|
val pendingHidden = HashSet<String>()
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import android.os.Bundle
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
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.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.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
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.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
@@ -49,10 +48,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
|||||||
getString(R.string.logged_in_as, it)
|
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 ->
|
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||||
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
|
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
|
||||||
}
|
}
|
||||||
|
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
@@ -61,32 +68,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
|||||||
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
|
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||||
|
viewModel.clearCookies()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
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 {
|
companion object {
|
||||||
|
|
||||||
private const val KEY_AUTH = "auth"
|
private const val KEY_AUTH = "auth"
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
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.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -17,11 +23,13 @@ import javax.inject.Inject
|
|||||||
class SourceSettingsViewModel @Inject constructor(
|
class SourceSettingsViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val cookieJar: MutableCookieJar,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
|
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
|
||||||
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
|
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
|
||||||
|
|
||||||
|
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||||
val username = MutableStateFlow<String?>(null)
|
val username = MutableStateFlow<String?>(null)
|
||||||
private var usernameLoadJob: Job? = 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() {
|
private fun loadUsername() {
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.webkit.CookieManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
@@ -68,6 +69,7 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
|
|||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||||
}
|
}
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
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,78 @@
|
|||||||
|
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 = storageManager.resolveUri(uri) ?: throw FileNotFoundException()
|
||||||
|
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,85 @@
|
|||||||
|
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 = storageManager.resolveUri(uri) ?: throw FileNotFoundException()
|
||||||
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
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.tracker.categories.TrackerCategoriesConfigSheet
|
||||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||||
@@ -155,7 +156,7 @@ class TrackerSettingsFragment :
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val packageName = context.packageName
|
val packageName = context.packageName
|
||||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = context.powerManager ?: return
|
||||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
try {
|
try {
|
||||||
val intent = Intent(
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.work.OutOfQuotaPolicy
|
|||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.await
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
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.model.distinctById
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.almostEquals
|
||||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||||
import org.koitharu.kotatsu.core.util.ext.flatten
|
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.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
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.MangaSuggestion
|
||||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
||||||
@@ -75,11 +78,11 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
) : CoroutineWorker(appContext, params) {
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
trySetForeground()
|
||||||
if (!appSettings.isSuggestionsEnabled) {
|
if (!appSettings.isSuggestionsEnabled) {
|
||||||
suggestionRepository.clear()
|
suggestionRepository.clear()
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
trySetForeground()
|
|
||||||
val count = doWorkImpl()
|
val count = doWorkImpl()
|
||||||
val outputData = workDataOf(DATA_COUNT to count)
|
val outputData = workDataOf(DATA_COUNT to count)
|
||||||
return Result.success(outputData)
|
return Result.success(outputData)
|
||||||
@@ -303,7 +306,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object : PeriodicWorkScheduler {
|
||||||
|
|
||||||
private const val TAG = "suggestions"
|
private const val TAG = "suggestions"
|
||||||
private const val TAG_ONESHOT = "suggestions_oneshot"
|
private const val TAG_ONESHOT = "suggestions_oneshot"
|
||||||
@@ -324,7 +327,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
SortOrder.RATING,
|
SortOrder.RATING,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun setup(context: Context) {
|
override suspend fun schedule(context: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
@@ -336,6 +339,19 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context)
|
WorkManager.getInstance(context)
|
||||||
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
.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) {
|
fun startNow(context: Context) {
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ class SyncController @Inject constructor(
|
|||||||
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
|
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
|
||||||
|
|
||||||
override fun onInvalidated(tables: Set<String>) {
|
override fun onInvalidated(tables: Set<String>) {
|
||||||
requestSync(
|
val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables)
|
||||||
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
|
&& !isSyncActiveOrPending(authorityFavourites)
|
||||||
history = TABLE_HISTORY in tables,
|
val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory)
|
||||||
)
|
if (favourites || history) {
|
||||||
|
requestSync(favourites, history)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isEnabled(account: Account): Boolean {
|
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 {
|
companion object {
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import android.database.Cursor
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.core.content.contentValuesOf
|
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.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
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
|
||||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
||||||
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
||||||
import org.koitharu.kotatsu.core.logs.LoggersModule
|
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||||
import org.koitharu.kotatsu.core.network.GZipInterceptor
|
import org.koitharu.kotatsu.core.logs.SyncLogger
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
|
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.toContentValues
|
import org.koitharu.kotatsu.core.util.ext.toContentValues
|
||||||
import org.koitharu.kotatsu.core.util.ext.toJson
|
import org.koitharu.kotatsu.core.util.ext.toJson
|
||||||
@@ -39,23 +43,20 @@ import java.util.concurrent.TimeUnit
|
|||||||
|
|
||||||
private const val FIELD_TIMESTAMP = "timestamp"
|
private const val FIELD_TIMESTAMP = "timestamp"
|
||||||
|
|
||||||
/**
|
class SyncHelper @AssistedInject constructor(
|
||||||
* Warning! This class may be used in another process
|
@ApplicationContext context: Context,
|
||||||
*/
|
@BaseHttpClient baseHttpClient: OkHttpClient,
|
||||||
@WorkerThread
|
@Assisted private val account: Account,
|
||||||
class SyncHelper(
|
@Assisted private val provider: ContentProviderClient,
|
||||||
context: Context,
|
private val settings: SyncSettings,
|
||||||
private val account: Account,
|
@SyncLogger private val logger: FileLogger,
|
||||||
private val provider: ContentProviderClient,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val authorityHistory = context.getString(R.string.sync_authority_history)
|
private val authorityHistory = context.getString(R.string.sync_authority_history)
|
||||||
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
|
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
|
||||||
private val settings = SyncSettings(context, account)
|
private val httpClient = baseHttpClient.newBuilder()
|
||||||
private val httpClient = OkHttpClient.Builder()
|
|
||||||
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
|
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
|
||||||
.addInterceptor(SyncInterceptor(context, account))
|
.addInterceptor(SyncInterceptor(context, account))
|
||||||
.addInterceptor(GZipInterceptor())
|
|
||||||
.build()
|
.build()
|
||||||
private val baseUrl: String by lazy {
|
private val baseUrl: String by lazy {
|
||||||
val host = settings.host
|
val host = settings.host
|
||||||
@@ -64,8 +65,8 @@ class SyncHelper(
|
|||||||
}
|
}
|
||||||
private val defaultGcPeriod: Long // gc period if sync enabled
|
private val defaultGcPeriod: Long // gc period if sync enabled
|
||||||
get() = TimeUnit.DAYS.toMillis(4)
|
get() = TimeUnit.DAYS.toMillis(4)
|
||||||
private val logger = LoggersModule.provideSyncLogger(context, AppSettings(context))
|
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
fun syncFavourites(syncResult: SyncResult) {
|
fun syncFavourites(syncResult: SyncResult) {
|
||||||
val data = JSONObject()
|
val data = JSONObject()
|
||||||
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
|
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
|
||||||
@@ -89,6 +90,7 @@ class SyncHelper(
|
|||||||
gcFavourites()
|
gcFavourites()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
fun syncHistory(syncResult: SyncResult) {
|
fun syncHistory(syncResult: SyncResult) {
|
||||||
val data = JSONObject()
|
val data = JSONObject()
|
||||||
data.put(TABLE_HISTORY, getHistory())
|
data.put(TABLE_HISTORY, getHistory())
|
||||||
@@ -321,4 +323,13 @@ class SyncHelper(
|
|||||||
logger.log("$code ${request.url}")
|
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.Context
|
||||||
import android.content.SyncResult
|
import android.content.SyncResult
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.onError
|
import org.koitharu.kotatsu.core.util.ext.onError
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
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) {
|
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)) {
|
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val syncHelper = SyncHelper(context, account, provider)
|
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||||
|
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
syncHelper.syncFavourites(syncResult)
|
syncHelper.syncFavourites(syncResult)
|
||||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import android.content.ContentProviderClient
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SyncResult
|
import android.content.SyncResult
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.onError
|
import org.koitharu.kotatsu.core.util.ext.onError
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
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) {
|
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)) {
|
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val syncHelper = SyncHelper(context, account, provider)
|
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||||
|
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
syncHelper.syncHistory(syncResult)
|
syncHelper.syncHistory(syncResult)
|
||||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
childColumns = ["manga_id"],
|
childColumns = ["manga_id"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
class TrackEntity(
|
class TrackEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@@ -27,4 +27,4 @@ class TrackEntity(
|
|||||||
@ColumnInfo(name = "last_check") val lastCheck: Long,
|
@ColumnInfo(name = "last_check") val lastCheck: Long,
|
||||||
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
|
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
|
||||||
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
|
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ abstract class TracksDao {
|
|||||||
@Query("UPDATE tracks SET chapters_new = 0")
|
@Query("UPDATE tracks SET chapters_new = 0")
|
||||||
abstract suspend fun clearCounters()
|
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")
|
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
|
||||||
abstract suspend fun delete(mangaId: Long)
|
abstract suspend fun delete(mangaId: Long)
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ class TrackingRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun clearUpdates(ids: Collection<Long>) {
|
||||||
|
when {
|
||||||
|
ids.isEmpty() -> return
|
||||||
|
ids.size == 1 -> db.tracksDao.clearCounter(ids.single())
|
||||||
|
else -> db.withTransaction {
|
||||||
|
for (id in ids) {
|
||||||
|
db.tracksDao.clearCounter(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
|
suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
|
||||||
val chapters = manga.chapters ?: return
|
val chapters = manga.chapters ?: return
|
||||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
|
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.tracker.ui.updates
|
package org.koitharu.kotatsu.tracker.ui.updates
|
||||||
|
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -12,6 +17,22 @@ class UpdatesFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|
||||||
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.menuInflater.inflate(R.menu.mode_updates, menu)
|
||||||
|
return super.onCreateActionMode(controller, mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_remove -> {
|
||||||
|
viewModel.remove(controller.snapshot())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onActionItemClicked(controller, mode, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance() = UpdatesFragment()
|
fun newInstance() = UpdatesFragment()
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ class UpdatesViewModel @Inject constructor(
|
|||||||
override fun onRefresh() = Unit
|
override fun onRefresh() = Unit
|
||||||
|
|
||||||
override fun onRetry() = Unit
|
override fun onRetry() = Unit
|
||||||
|
|
||||||
|
fun remove(ids: Set<Long>) {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
repository.clearUpdates(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.work.WorkInfo
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkQuery
|
import androidx.work.WorkQuery
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.await
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
@@ -42,12 +43,14 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||||
import org.koitharu.kotatsu.core.logs.TrackerLogger
|
import org.koitharu.kotatsu.core.logs.TrackerLogger
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.WorkManagerHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
|
||||||
import org.koitharu.kotatsu.tracker.domain.Tracker
|
import org.koitharu.kotatsu.tracker.domain.Tracker
|
||||||
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -67,6 +70,7 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
trySetForeground()
|
||||||
logger.log("doWork()")
|
logger.log("doWork()")
|
||||||
try {
|
try {
|
||||||
return doWorkImpl()
|
return doWorkImpl()
|
||||||
@@ -85,7 +89,6 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
if (!settings.isTrackerEnabled) {
|
if (!settings.isTrackerEnabled) {
|
||||||
return Result.success(workDataOf(0, 0))
|
return Result.success(workDataOf(0, 0))
|
||||||
}
|
}
|
||||||
trySetForeground()
|
|
||||||
val tracks = tracker.getAllTracks()
|
val tracks = tracker.getAllTracks()
|
||||||
logger.log("Total ${tracks.size} tracks")
|
logger.log("Total ${tracks.size} tracks")
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
@@ -234,7 +237,7 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object : PeriodicWorkScheduler {
|
||||||
|
|
||||||
private const val WORKER_CHANNEL_ID = "track_worker"
|
private const val WORKER_CHANNEL_ID = "track_worker"
|
||||||
private const val WORKER_NOTIFICATION_ID = 35
|
private const val WORKER_NOTIFICATION_ID = 35
|
||||||
@@ -244,14 +247,28 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
private const val DATA_KEY_SUCCESS = "success"
|
private const val DATA_KEY_SUCCESS = "success"
|
||||||
private const val DATA_KEY_FAILED = "failed"
|
private const val DATA_KEY_FAILED = "failed"
|
||||||
|
|
||||||
fun setup(context: Context) {
|
override suspend fun schedule(context: Context) {
|
||||||
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||||
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
|
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
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) {
|
fun startNow(context: Context) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class RecentListFactory(
|
|||||||
|
|
||||||
override fun getViewAt(position: Int): RemoteViews {
|
override fun getViewAt(position: Int): RemoteViews {
|
||||||
val views = RemoteViews(context.packageName, R.layout.item_recent)
|
val views = RemoteViews(context.packageName, R.layout.item_recent)
|
||||||
val item = dataSet[position]
|
val item = dataSet.getOrNull(position) ?: return views
|
||||||
runCatching {
|
runCatching {
|
||||||
coil.executeBlocking(
|
coil.executeBlocking(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class ShelfListFactory(
|
|||||||
|
|
||||||
override fun getViewAt(position: Int): RemoteViews {
|
override fun getViewAt(position: Int): RemoteViews {
|
||||||
val views = RemoteViews(context.packageName, R.layout.item_shelf)
|
val views = RemoteViews(context.packageName, R.layout.item_shelf)
|
||||||
val item = dataSet[position]
|
val item = dataSet.getOrNull(position) ?: return views
|
||||||
views.setTextViewText(R.id.textView_title, item.title)
|
views.setTextViewText(R.id.textView_title, item.title)
|
||||||
runCatching {
|
runCatching {
|
||||||
coil.executeBlocking(
|
coil.executeBlocking(
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_list_end.xml
Normal file
12
app/src/main/res/drawable/ic_list_end.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12C7,9.58 8.72,7.56 11,7.1V3H13V7.1C15.28,7.56 17,9.58 17,12M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_list_next.xml
Normal file
12
app/src/main/res/drawable/ic_list_next.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12M12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15M13,3V5H11V3H13Z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_list_start.xml
Normal file
12
app/src/main/res/drawable/ic_list_start.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_select_group.xml
Normal file
11
app/src/main/res/drawable/ic_select_group.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M5 3A2 2 0 0 0 3 5H5M7 3V5H9V3M11 3V5H13V3M15 3V5H17V3M19 3V5H21A2 2 0 0 0 19 3M3 7V9H5V7M7 7V11H11V7M13 7V11H17V7M19 7V9H21V7M3 11V13H5V11M19 11V13H21V11M7 13V17H11V13M13 13V17H17V13M3 15V17H5V15M19 15V17H21V15M3 19A2 2 0 0 0 5 21V19M7 19V21H9V19M11 19V21H13V19M15 19V21H17V19M19 19V21A2 2 0 0 0 21 19Z" />
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user