Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4502ffb6d2 | ||
|
|
b6f9ce824e | ||
|
|
d33081c1c7 | ||
|
|
76c08535d6 | ||
|
|
b55fef67e1 | ||
|
|
56798677d5 | ||
|
|
ff30b9c225 | ||
|
|
5c3293ec44 | ||
|
|
1b17605e0e | ||
|
|
ba4e4dcf56 | ||
|
|
b35d5d4779 | ||
|
|
124f31ebe1 | ||
|
|
173087ee19 | ||
|
|
8d7bad97de | ||
|
|
188fbfbb95 | ||
|
|
3498a54bdf | ||
|
|
18169c2355 | ||
|
|
87beb9442f | ||
|
|
e642d54929 | ||
|
|
59ce5d5e67 | ||
|
|
58d5237692 | ||
|
|
8d5bde6e60 | ||
|
|
bf740ddc93 | ||
|
|
fddbf35e8c | ||
|
|
a47fea02d1 | ||
|
|
250136cfdc | ||
|
|
597ad01e8f | ||
|
|
f7b44f2b0f | ||
|
|
5aab43ac93 | ||
|
|
2d278159ea | ||
|
|
da61462d79 | ||
|
|
2ab0912880 | ||
|
|
3914616222 | ||
|
|
a73b2703be | ||
|
|
49590f6d02 | ||
|
|
f4a0fcf5ba | ||
|
|
6ab803e682 | ||
|
|
0faa97b08c | ||
|
|
2ae488544b | ||
|
|
a7e2cfc878 | ||
|
|
da6db9c1b4 | ||
|
|
88b3e5cf34 | ||
|
|
7347f0ba10 | ||
|
|
4c55682552 | ||
|
|
324031aa2a | ||
|
|
1355c3d75c | ||
|
|
8533168155 | ||
|
|
51f6ec6e55 | ||
|
|
7e3f67c14d | ||
|
|
c51320f033 | ||
|
|
9c50a47abc | ||
|
|
473d273d18 | ||
|
|
f19b628655 | ||
|
|
fa74d4b27a | ||
|
|
cdb6655e37 | ||
|
|
4f19f7ebdf | ||
|
|
bf8838f943 | ||
|
|
1e1e9fabdc | ||
|
|
745972a717 | ||
|
|
6055776329 | ||
|
|
4074791f9a | ||
|
|
b1ab48e912 | ||
|
|
a71e2dd289 | ||
|
|
b8283acd0d | ||
|
|
bbdf1c756e | ||
|
|
283878879b | ||
|
|
b74ec98d68 | ||
|
|
3691db8e8e | ||
|
|
e25ccf6b25 | ||
|
|
ffebdb0c49 | ||
|
|
6accdbced5 | ||
|
|
2fcb94e1d7 | ||
|
|
6211ef974d | ||
|
|
0eacf7bb98 | ||
|
|
c9b7d650a8 | ||
|
|
a29f7d6533 | ||
|
|
72f8c626d7 | ||
|
|
f05ef5125d | ||
|
|
40b3d8e6fd | ||
|
|
a695bdc565 | ||
|
|
9700fabd9a | ||
|
|
4877db42f9 | ||
|
|
9b418fd63b | ||
|
|
b2eef0df11 | ||
|
|
34462829ff | ||
|
|
2afcbef8d0 | ||
|
|
695becbda0 | ||
|
|
5877d8215d | ||
|
|
48b357dfef | ||
|
|
b20cc7c0d9 | ||
|
|
0f43f02fad | ||
|
|
9b658cf0b8 | ||
|
|
ce705e12a8 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠️ Source issue
|
||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
||||
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
required: true
|
||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
required: true
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 641
|
||||
versionName = '7.0'
|
||||
versionCode = 648
|
||||
versionName = '7.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:3e32a6280a') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:56fd22b43f') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -90,22 +90,23 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.7.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-rc01'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.1'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
|
||||
<activity
|
||||
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/check_for_new_chapters" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
context.startActivity(WorkInspector.getIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,9 @@
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@id/action_tracker"
|
||||
android:title="@string/check_for_new_chapters"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@id/action_works"
|
||||
android:title="Works"
|
||||
android:title="@string/wi_lib_name"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
@@ -100,6 +100,13 @@
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.READ_MANGA" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||
android:resource="@xml/remote_action" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||
@@ -245,6 +252,12 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AlternativesActivity"
|
||||
android:label="@string/alternatives" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
|
||||
android:label="@string/app_update_available" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/tracker_debug_info" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
@@ -357,13 +370,6 @@
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_recent" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.settings.about.UpdateDownloadReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.core.ErrorReporterReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -69,6 +69,7 @@ class MigrateUseCase @Inject constructor(
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
lastError = null,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
|
||||
@@ -108,8 +108,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
if (hasViewBinding()) {
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
|
||||
@@ -27,13 +27,14 @@ import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
@@ -47,6 +48,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||
@@ -70,8 +72,9 @@ interface AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
@ApplicationContext context: Context
|
||||
) = NetworkState(context.connectivityManager)
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = NetworkState(context.connectivityManager, settings)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -150,10 +153,12 @@ interface AppModule {
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
acraScreenLogger: AcraScreenLogger,
|
||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
acraScreenLogger,
|
||||
screenshotPolicyHelper,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
|
||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
|
||||
@@ -84,6 +84,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
source = json.getString("source"),
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
|
||||
@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
||||
|
||||
private val isLowRam = application.isLowRamDevice()
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache =
|
||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache =
|
||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -58,7 +59,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 20
|
||||
const val DATABASE_VERSION = 21
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -118,6 +119,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
|
||||
@@ -23,6 +24,9 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||
abstract suspend fun findAllEnabledNames(): List<String>
|
||||
|
||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@@ -68,6 +72,7 @@ abstract class MangaSourcesDao {
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ data class MangaSourceEntity(
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration20To21 : Migration(20, 21) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,48 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||
|
||||
val context: Context?
|
||||
get() = activity ?: fragment?.context
|
||||
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
@@ -56,6 +68,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
@@ -80,13 +98,37 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||
context?.run {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||
context?.run {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = context ?: return
|
||||
val settings = getAppSettings(ctx)
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.ignore_ssl_errors)
|
||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||
ctx.findActivity()?.finishAffinity()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getAppSettings(context: Context): AppSettings {
|
||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
@@ -99,6 +141,9 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@JvmName("mangaIds")
|
||||
@@ -119,17 +118,10 @@ val Manga.appUrl: Uri
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
|
||||
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
|
||||
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
|
||||
it.decimalSeparator = '.'
|
||||
}
|
||||
}
|
||||
|
||||
fun MangaChapter.formatNumber(): String? {
|
||||
if (number <= 0f) {
|
||||
return null
|
||||
}
|
||||
return chaptersNumberFormat.format(number.toDouble())
|
||||
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||
number.formatSimple()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun Manga.chaptersCount(): Int {
|
||||
|
||||
@@ -15,8 +15,6 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
@@ -39,7 +37,7 @@ val ContentType.titleResId
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale?.toLocale().getDisplayName(context)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Interceptor.Chain
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -13,6 +15,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.IDN
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -23,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
override fun intercept(chain: Chain): Response {
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
@@ -46,7 +49,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
||||
}
|
||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||
return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||
@@ -55,10 +58,21 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||
intercept(chain)
|
||||
}.getOrElse { e ->
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
// only IOException can be safely thrown from an Interceptor
|
||||
throw IOException("Error in interceptor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private class ProxyChain(
|
||||
private val delegate: Interceptor.Chain,
|
||||
private val delegate: Chain,
|
||||
private val request: Request,
|
||||
) : Interceptor.Chain by delegate {
|
||||
) : Chain by delegate {
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ class DoHManager(
|
||||
tryGetByIp("2a10:50c0::2:ff"),
|
||||
),
|
||||
).build()
|
||||
|
||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
||||
.resolvePublicAddresses(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||
|
||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
enum class DoHProvider {
|
||||
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
||||
}
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
val result = chain.proceed(newRequest)
|
||||
return if (result is SuccessResult) {
|
||||
result
|
||||
} else {
|
||||
logDebug((result as? ErrorResult)?.throwable)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return okHttp.newCall(request).await()
|
||||
}
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val newRequest = request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover {
|
||||
logDebug(it)
|
||||
okHttp.doCall(request).also {
|
||||
blacklist.add(sourceUrl.host)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable?) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
@@ -29,6 +31,9 @@ interface NetworkModule {
|
||||
@Binds
|
||||
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
||||
|
||||
@Binds
|
||||
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.network.HttpException
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.Collections
|
||||
|
||||
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newRequest = onInterceptImageRequest(request, url)
|
||||
return when (val result = chain.proceed(newRequest)) {
|
||||
is SuccessResult -> result
|
||||
is ErrorResult -> {
|
||||
logDebug(result.throwable, newRequest.data)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
val newRequest = onInterceptPageRequest(request)
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover { error ->
|
||||
logDebug(error, newRequest.url)
|
||||
okHttp.doCall(request).also {
|
||||
if (error.isBlockedByServer()) {
|
||||
blacklist.add(request.url.host)
|
||||
}
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
|
||||
|
||||
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable, url: Any) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", "${e.message}: $url", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isBlockedByServer(): Boolean {
|
||||
return this is CloudFlareBlockedException
|
||||
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
interface ImageProxyInterceptor : Interceptor {
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.plus
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : ImageProxyInterceptor {
|
||||
|
||||
private val delegate = settings.observeAsStateFlow(
|
||||
scope = processLifecycleScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_IMAGES_PROXY,
|
||||
valueProducer = { createDelegate() },
|
||||
)
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
|
||||
}
|
||||
|
||||
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
|
||||
}
|
||||
|
||||
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
|
||||
-1 -> null
|
||||
0 -> WsrvNlProxyInterceptor()
|
||||
1 -> ZeroMsProxyInterceptor()
|
||||
else -> error("Unsupported images proxy $proxy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
return request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
return request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
|
||||
return request
|
||||
}
|
||||
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.data(newUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import android.net.ConnectivityManager.NetworkCallback
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.isOnline
|
||||
|
||||
class NetworkState(
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
|
||||
private val settings: AppSettings,
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) {
|
||||
|
||||
private val callback = NetworkCallbackImpl()
|
||||
|
||||
@@ -19,7 +21,10 @@ class NetworkState(
|
||||
override fun onActive() {
|
||||
invalidate()
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
}
|
||||
@@ -37,7 +42,7 @@ class NetworkState(
|
||||
}
|
||||
|
||||
private fun invalidate() {
|
||||
publishValue(connectivityManager.isOnline())
|
||||
publishValue(connectivityManager.isOnline(settings))
|
||||
}
|
||||
|
||||
private inner class NetworkCallbackImpl : NetworkCallback() {
|
||||
@@ -48,4 +53,27 @@ class NetworkState(
|
||||
|
||||
override fun onUnavailable() = invalidate()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
fun ConnectivityManager.isOnline(settings: AppSettings): Boolean {
|
||||
if (settings.isOfflineCheckDisabled) {
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } ?: false
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
val capabilities = getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.graphics.Canvas
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||
import java.io.OutputStream
|
||||
import android.graphics.Bitmap as AndroidBitmap
|
||||
import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap,
|
||||
) : Bitmap {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||
|
||||
override val height: Int
|
||||
get() = androidBitmap.height
|
||||
|
||||
override val width: Int
|
||||
get() = androidBitmap.width
|
||||
|
||||
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
|
||||
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||
)
|
||||
|
||||
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
@@ -10,15 +11,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
@@ -68,6 +75,27 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||
val image = response.requireBody().byteStream()
|
||||
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||
|
||||
val body = Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
@@ -150,10 +151,6 @@ class FaviconFetcher(
|
||||
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
||||
}
|
||||
|
||||
private fun Response.requireBody(): ResponseBody {
|
||||
return checkNotNull(body) { "response body == null" }
|
||||
}
|
||||
|
||||
private fun Size.toCacheKey() = buildString {
|
||||
append(width.toString())
|
||||
append('x')
|
||||
|
||||
@@ -136,6 +136,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
val isOfflineCheckDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
|
||||
|
||||
var isAllFavouritesVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||
@@ -152,6 +155,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isTrackerNotificationsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||
|
||||
val isTrackerNsfwDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||
|
||||
var notificationSound: Uri
|
||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
@@ -284,17 +290,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
||||
|
||||
val isNewSourcesTipEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
|
||||
var sourcesVersion: Int
|
||||
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
|
||||
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
val screenshotsPolicy: ScreenshotsPolicy
|
||||
get() = runCatching {
|
||||
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
get() {
|
||||
@@ -377,14 +381,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isImagesProxyEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
||||
val imagesProxy: Int
|
||||
get() {
|
||||
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
|
||||
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
|
||||
}
|
||||
|
||||
val dnsOverHttps: DoHProvider
|
||||
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
||||
|
||||
val isSSLBypassEnabled: Boolean
|
||||
var isSSLBypassEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
|
||||
|
||||
val proxyType: Proxy.Type
|
||||
get() {
|
||||
@@ -544,8 +552,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
||||
|
||||
const val TRACK_HISTORY = "history"
|
||||
const val TRACK_FAVOURITES = "favourites"
|
||||
|
||||
@@ -557,6 +563,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_COLOR_THEME = "color_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
const val KEY_OFFLINE_DISABLED = "no_offline"
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
||||
@@ -581,6 +588,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
@@ -593,7 +601,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_ZOOM_MODE = "zoom_mode"
|
||||
const val KEY_BACKUP = "backup"
|
||||
const val KEY_RESTORE = "restore"
|
||||
@@ -643,9 +650,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_SOURCES_NEW = "sources_new"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
@@ -658,7 +663,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_AUTH = "proxy_auth"
|
||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy_2"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||
const val KEY_RELATED_MANGA = "related_manga"
|
||||
@@ -672,7 +677,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||
@@ -680,9 +684,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
const val KEY_SOURCES_VERSION = "sources_version"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
|
||||
enum class ScreenshotsPolicy {
|
||||
|
||||
// Do not rename this
|
||||
ALLOW, BLOCK_NSFW, BLOCK_ALL;
|
||||
}
|
||||
ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.prefs
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
@@ -12,6 +11,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
@@ -31,7 +31,11 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
.ifNullOrEmpty { key.defaultValue }
|
||||
.sanitizeHeaderValue()
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue)
|
||||
?.trim()
|
||||
?.takeIf { DomainValidator.isValidDomain(it) }
|
||||
?: key.defaultValue
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
} as T
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -18,6 +19,8 @@ import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
@@ -25,10 +28,12 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ScreenshotPolicyHelper.ContentContainer,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var isAmoledTheme = false
|
||||
@@ -92,11 +97,20 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// TODO fix behavior on Android 14
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
val fm = supportFragmentManager
|
||||
if (fm.isStateSaved) {
|
||||
return false
|
||||
}
|
||||
dispatchNavigateUp()
|
||||
if (fm.backStackEntryCount > 0) {
|
||||
fm.popBackStack()
|
||||
} else {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -141,6 +155,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
@@ -160,6 +176,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
}
|
||||
|
||||
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface BaseActivityEntryPoint {
|
||||
|
||||
@@ -63,7 +63,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
)
|
||||
}
|
||||
|
||||
protected fun setTitle(title: CharSequence?) {
|
||||
protected open fun setTitle(title: CharSequence?) {
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,16 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
||||
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
|
||||
}
|
||||
|
||||
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.setPadding(left, top, right, bottom)
|
||||
fastScroller.setPadding(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
super.setPaddingRelative(start, top, end, bottom)
|
||||
fastScroller.setPaddingRelative(start, top, end, bottom)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
fastScroller.attachRecyclerView(this)
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
class BottomSheetCollapseCallback(
|
||||
private val behavior: BottomSheetBehavior<*>,
|
||||
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {
|
||||
private val sheet: ViewGroup,
|
||||
private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet),
|
||||
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) {
|
||||
|
||||
init {
|
||||
behavior.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
|
||||
@SuppressLint("SwitchIntDef")
|
||||
override fun onStateChanged(view: View, state: Int) {
|
||||
isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED
|
||||
when (state) {
|
||||
STATE_EXPANDED,
|
||||
STATE_HALF_EXPANDED -> isEnabled = true
|
||||
|
||||
STATE_COLLAPSED,
|
||||
STATE_HIDDEN -> isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(p0: View, p1: Float) = Unit
|
||||
@@ -24,7 +36,11 @@ class BottomSheetCollapseCallback(
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
behavior.state = STATE_COLLAPSED
|
||||
}
|
||||
override fun handleOnBackPressed() = behavior.handleBackInvoked()
|
||||
|
||||
override fun handleOnBackCancelled() = behavior.cancelBackProgress()
|
||||
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
|
||||
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@@ -24,7 +26,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||
}
|
||||
private val chipOnCloseListener = OnClickListener {
|
||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipStyle: Int
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -48,7 +52,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
if (isInEditMode) {
|
||||
setChips(
|
||||
List(5) {
|
||||
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
|
||||
ChipModel(title = "Chip $it")
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -99,6 +103,15 @@ class ChipsView @JvmOverloads constructor(
|
||||
chip.isChipIconVisible = true
|
||||
}
|
||||
chip.isChecked = model.isChecked
|
||||
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
|
||||
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||
chip.setCloseIconResource(
|
||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
chip.tag = model.data
|
||||
}
|
||||
|
||||
@@ -106,12 +119,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val chip = Chip(context)
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.isCheckedIconVisible = true
|
||||
chip.isChipIconVisible = false
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
chip.setOnClickListener(chipOnClickListener)
|
||||
chip.isElegantTextHeight = false
|
||||
addView(chip)
|
||||
return chip
|
||||
}
|
||||
@@ -127,11 +139,12 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
@ColorRes val tint: Int,
|
||||
val title: CharSequence,
|
||||
@DrawableRes val icon: Int,
|
||||
val isCheckable: Boolean,
|
||||
val isChecked: Boolean,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val isCheckable: Boolean = false,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
val data: Any? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleComparator : Comparator<Locale> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
||||
.map { it.language }
|
||||
.distinct()
|
||||
private val deviceLocales: List<String>
|
||||
|
||||
init {
|
||||
val localeList = LocaleListCompat.getAdjustedDefault()
|
||||
deviceLocales = buildList(localeList.size() + 1) {
|
||||
add("")
|
||||
val set = HashSet<String>(localeList.size() + 1)
|
||||
set.add("")
|
||||
for (locale in localeList) {
|
||||
val lang = locale.language
|
||||
if (set.add(lang)) {
|
||||
add(lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun compare(a: Locale, b: Locale): Int {
|
||||
val indexA = deviceLocales.indexOf(a.language)
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -75,6 +76,9 @@ val Context.activityManager: ActivityManager?
|
||||
val Context.powerManager: PowerManager?
|
||||
get() = getSystemService(POWER_SERVICE) as? PowerManager
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.HttpStatusException
|
||||
@@ -42,6 +41,8 @@ fun Response.ensureSuccess() = apply {
|
||||
}
|
||||
}
|
||||
|
||||
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
|
||||
@@ -22,11 +22,10 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
|
||||
|
||||
fun String.toLocale() = Locale(this)
|
||||
|
||||
fun Locale?.getDisplayName(context: Context): String {
|
||||
if (this == null) {
|
||||
return context.getString(R.string.various_languages)
|
||||
}
|
||||
return getDisplayLanguage(this).toTitleCase(this)
|
||||
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||
null -> context.getString(R.string.all_languages)
|
||||
Locale.ROOT -> context.getString(R.string.various_languages)
|
||||
else -> getDisplayLanguage(this).toTitleCase(this)
|
||||
}
|
||||
|
||||
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun ConnectivityManager.isOnline(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } ?: false
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
val capabilities = getNetworkCapabilities(network)
|
||||
return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
@@ -60,8 +60,7 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
} catch (e: IOException) {
|
||||
local?.await()?.manga?.also { localManga ->
|
||||
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
|
||||
}
|
||||
throw e
|
||||
} ?: close(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,15 @@ import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
@@ -153,8 +154,8 @@ class DetailsActivity :
|
||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.chipsTags.onChipClickListener = this
|
||||
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
||||
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior))
|
||||
viewBinding.containerBottomSheet?.let { sheet ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||
}
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
@@ -198,6 +199,8 @@ class DetailsActivity :
|
||||
addMenuProvider(menuProvider)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
@@ -613,10 +616,7 @@ class DetailsActivity :
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -667,7 +667,7 @@ class DetailsActivity :
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FAV_LABEL_LIMIT = 10
|
||||
private const val FAV_LABEL_LIMIT = 16
|
||||
private const val AUTHOR_LABEL_LIMIT = 16
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
|
||||
@@ -19,8 +19,8 @@ import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.isFileUri
|
||||
|
||||
@@ -13,7 +13,7 @@ fun downloadChapterAD() = adapterDelegateViewBinding<DownloadChapter, DownloadCh
|
||||
val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check)
|
||||
|
||||
bind {
|
||||
binding.textViewNumber.text = item.number.toString()
|
||||
binding.textViewNumber.text = item.number
|
||||
binding.textViewTitle.text = item.name
|
||||
binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null
|
||||
}
|
||||
|
||||
@@ -6,21 +6,22 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -29,6 +30,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val isNewSourcesAssimilated = AtomicBoolean(false)
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
@@ -43,25 +45,62 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val order = settings.sourcesSortOrder
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||
}
|
||||
|
||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
val enabled = dao.findAllEnabledNames()
|
||||
for (name in enabled) {
|
||||
val source = MangaSource(name)
|
||||
val source = name.toMangaSourceOrNull() ?: continue
|
||||
result.remove(source)
|
||||
}
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
result.removeAll { it.isNsfw() }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getAvailableSources(
|
||||
isDisabledOnly: Boolean,
|
||||
isNewOnly: Boolean,
|
||||
excludeBroken: Boolean,
|
||||
types: Set<ContentType>,
|
||||
query: String?,
|
||||
locale: String?,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val entities = dao.findAll().toMutableList()
|
||||
if (isDisabledOnly) {
|
||||
entities.removeAll { it.isEnabled }
|
||||
}
|
||||
if (isNewOnly) {
|
||||
entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE }
|
||||
}
|
||||
val sources = entities.toSources(
|
||||
skipNsfwSources = settings.isNsfwContentDisabled,
|
||||
sortOrder = sortOrder,
|
||||
)
|
||||
if (locale != null) {
|
||||
sources.retainAll { it.locale == locale }
|
||||
}
|
||||
if (excludeBroken) {
|
||||
sources.removeAll { it.isBroken }
|
||||
}
|
||||
if (types.isNotEmpty()) {
|
||||
sources.retainAll { it.contentType in types }
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
sources.retainAll {
|
||||
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
||||
return dao.observeIsEnabled(source.name)
|
||||
return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeEnabledSourcesCount(): Flow<Int> {
|
||||
@@ -69,8 +108,10 @@ class MangaSourcesRepository @Inject constructor(
|
||||
observeIsNsfwDisabled(),
|
||||
dao.observeEnabled(SourcesSortOrder.MANUAL),
|
||||
) { skipNsfw, sources ->
|
||||
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
|
||||
}.distinctUntilChanged()
|
||||
sources.count {
|
||||
it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
|
||||
}
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeAvailableSourcesCount(): Flow<Int> {
|
||||
@@ -82,7 +123,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
allMangaSources.count { x ->
|
||||
x.name !in enabled && (!skipNsfw || !x.isNsfw())
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||
@@ -92,18 +133,18 @@ class MangaSourcesRepository @Inject constructor(
|
||||
dao.observeEnabled(order).map {
|
||||
it.toSources(skipNsfw, order)
|
||||
}
|
||||
}.flatMapLatest { it }
|
||||
}.flatMapLatest { it }.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||
for (entity in entities) {
|
||||
val source = MangaSource(entity.source)
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (source in remoteSources) {
|
||||
result.add(source to entity.isEnabled)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
|
||||
setSourcesEnabledImpl(sources, isEnabled)
|
||||
@@ -114,6 +155,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
||||
db.withTransaction {
|
||||
assimilateNewSources()
|
||||
for (s in remoteSources) {
|
||||
dao.setEnabled(s.name, s in sources)
|
||||
}
|
||||
@@ -135,31 +177,34 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
|
||||
if (it) {
|
||||
combine(
|
||||
dao.observeAll(),
|
||||
observeIsNsfwDisabled(),
|
||||
) { entities, skipNsfw ->
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(MangaSource(e.source))
|
||||
}
|
||||
if (skipNsfw) {
|
||||
result.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
result
|
||||
}.distinctUntilChanged()
|
||||
fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
|
||||
val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty() && sources.size != remoteSources.size
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
|
||||
settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion },
|
||||
observeIsNsfwDisabled(),
|
||||
) { version, skipNsfw ->
|
||||
if (version < BuildConfig.VERSION_CODE) {
|
||||
val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty()
|
||||
} else {
|
||||
assimilateNewSources()
|
||||
flowOf(emptySet())
|
||||
false
|
||||
}
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun clearNewSourcesBadge() {
|
||||
settings.sourcesVersion = BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
suspend fun assimilateNewSources(): Set<MangaSource> {
|
||||
private suspend fun assimilateNewSources(): Boolean {
|
||||
if (isNewSourcesAssimilated.getAndSet(true)) {
|
||||
return false
|
||||
}
|
||||
val new = getNewSources()
|
||||
if (new.isEmpty()) {
|
||||
return emptySet()
|
||||
return false
|
||||
}
|
||||
var maxSortKey = dao.getMaxSortKey()
|
||||
val entities = new.map { x ->
|
||||
@@ -167,17 +212,15 @@ class MangaSourcesRepository @Inject constructor(
|
||||
source = x.name,
|
||||
isEnabled = false,
|
||||
sortKey = ++maxSortKey,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
)
|
||||
}
|
||||
dao.insertIfAbsent(entities)
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
new.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
return new
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun isSetupRequired(): Boolean {
|
||||
return dao.findAll().isEmpty()
|
||||
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
||||
@@ -196,7 +239,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(MangaSource(e.source))
|
||||
result.remove(e.source.toMangaSourceOrNull() ?: continue)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -204,10 +247,10 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
): MutableList<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
for (entity in this) {
|
||||
val source = MangaSource(entity.source)
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
@@ -225,11 +268,9 @@ class MangaSourcesRepository @Inject constructor(
|
||||
isNsfwContentDisabled
|
||||
}
|
||||
|
||||
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
|
||||
isNewSourcesTipEnabled
|
||||
}
|
||||
|
||||
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
|
||||
sourcesSortOrder
|
||||
}
|
||||
|
||||
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -40,13 +39,11 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||
import javax.inject.Inject
|
||||
@@ -56,7 +53,7 @@ class ExploreFragment :
|
||||
BaseFragment<FragmentExploreBinding>(),
|
||||
RecyclerViewOwner,
|
||||
ExploreListEventListener,
|
||||
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -74,7 +71,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
}
|
||||
sourceSelectionController = ListSelectionController(
|
||||
@@ -124,18 +121,6 @@ class ExploreFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrimaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val intent = when (v.id) {
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
|
||||
|
||||
@@ -102,12 +102,6 @@ class ExploreViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun discardNewSources() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.assimilateNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPinShortcut(source: MangaSource) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
shortcutManager.requestPinShortcut(source)
|
||||
@@ -124,7 +118,7 @@ class ExploreViewModel @Inject constructor(
|
||||
getSuggestionFlow(),
|
||||
isGrid,
|
||||
isRandomLoading,
|
||||
sourcesRepository.observeNewSources(),
|
||||
sourcesRepository.observeHasNewSourcesForBadge(),
|
||||
) { content, suggestions, grid, randomLoading, newSources ->
|
||||
buildList(content, suggestions, grid, randomLoading, newSources)
|
||||
}.withErrorHandling()
|
||||
@@ -134,7 +128,7 @@ class ExploreViewModel @Inject constructor(
|
||||
recommendation: List<Manga>,
|
||||
isGrid: Boolean,
|
||||
randomLoading: Boolean,
|
||||
newSources: Set<MangaSource>,
|
||||
hasNewSources: Boolean,
|
||||
): List<ListModel> {
|
||||
val result = ArrayList<ListModel>(sources.size + 3)
|
||||
result += ExploreButtons(randomLoading)
|
||||
@@ -146,7 +140,7 @@ class ExploreViewModel @Inject constructor(
|
||||
result += ListHeader(
|
||||
textRes = R.string.remote_sources,
|
||||
buttonTextRes = R.string.catalog,
|
||||
badge = if (newSources.isNotEmpty()) "" else null,
|
||||
badge = if (hasNewSources) "" else null,
|
||||
)
|
||||
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
|
||||
} else {
|
||||
@@ -191,6 +185,5 @@ class ExploreViewModel @Inject constructor(
|
||||
|
||||
private const val TIP_SUGGESTIONS = "suggestions"
|
||||
private const val SUGGESTIONS_COUNT = 8
|
||||
const val TIP_NEW_SOURCES = "new_sources"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.tipAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@@ -18,7 +16,6 @@ class ExploreAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ExploreListEventListener,
|
||||
tipClickListener: TipView.OnButtonClickListener,
|
||||
clickListener: OnListItemClickListener<MangaSourceItem>,
|
||||
mangaClickListener: OnListItemClickListener<Manga>,
|
||||
) : BaseListAdapter<ListModel>() {
|
||||
@@ -34,6 +31,5 @@ class ExploreAdapter(
|
||||
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
|
||||
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.TIP, tipAD(tipClickListener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,20 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@@ -52,16 +57,21 @@ abstract class FavouritesDao {
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
arrayOf<Any>(categoryId),
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
|
||||
}
|
||||
|
||||
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
||||
|
||||
@@ -38,8 +38,8 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order)
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order)
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
|
||||
return observeOrder(categoryId)
|
||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
|
||||
}
|
||||
|
||||
fun observeMangaCount(): Flow<Int> {
|
||||
@@ -63,12 +63,6 @@ class FavouritesRepository @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun getCategories(): List<FavouriteCategory> {
|
||||
return db.getFavouriteCategoriesDao().findAll().map {
|
||||
it.toFavouriteCategory()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||
return db.getFavouriteCategoriesDao().observeAll().mapItems {
|
||||
it.toFavouriteCategory()
|
||||
|
||||
@@ -33,7 +33,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
||||
binding.recyclerView.isVP2BugWorkaroundEnabled = true
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onFilterClick(view: View?) {
|
||||
val menu = PopupMenu(view?.context ?: return, view)
|
||||
|
||||
@@ -32,8 +32,11 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
@@ -46,6 +49,8 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
private val refreshTrigger = MutableStateFlow(Any())
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
|
||||
@@ -61,13 +66,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
override val content = combine(
|
||||
if (categoryId == NO_ID) {
|
||||
sortOrder.filterNotNull().flatMapLatest {
|
||||
repository.observeAll(it)
|
||||
}
|
||||
} else {
|
||||
repository.observeAll(categoryId)
|
||||
},
|
||||
observeFavorites(),
|
||||
listMode,
|
||||
refreshTrigger,
|
||||
) { list, mode, _ ->
|
||||
@@ -85,7 +84,10 @@ class FavouritesListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> list.toUi(mode, listExtraProvider)
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
list.toUi(mode, listExtraProvider)
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
@@ -126,4 +128,19 @@ class FavouritesListViewModel @Inject constructor(
|
||||
repository.setCategoryOrder(categoryId, order)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeFavorites() = if (categoryId == NO_ID) {
|
||||
combine(sortOrder.filterNotNull(), limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAll(it.first, it.second) }
|
||||
} else {
|
||||
limit.flatMapLatest {
|
||||
repository.observeAll(categoryId, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
oldValue.copy(
|
||||
tagsExclude = newTags,
|
||||
tags = oldValue.tags - newTags
|
||||
tags = oldValue.tags - newTags,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,7 @@ class FilterCoordinator @Inject constructor(
|
||||
currentState.update { oldValue ->
|
||||
oldValue.copy(
|
||||
tags = tags,
|
||||
tagsExclude = oldValue.tagsExclude - tags
|
||||
tagsExclude = oldValue.tagsExclude - tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -391,9 +391,7 @@ class FilterCoordinator @Inject constructor(
|
||||
val result = LinkedList<ChipsView.ChipModel>()
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
@@ -406,9 +404,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
|
||||
@@ -61,10 +61,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
}
|
||||
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -122,10 +122,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.spinnerLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map {
|
||||
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
||||
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
||||
},
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
@@ -144,9 +141,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||
value.selectedItems.mapTo(chips) { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
@@ -155,9 +150,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -168,12 +161,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenres.setChips(chips)
|
||||
@@ -200,9 +189,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -213,12 +200,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
@@ -233,9 +216,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { state ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(state.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = state in value.selectedItems,
|
||||
data = state,
|
||||
@@ -253,9 +234,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { contentRating ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(contentRating.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = contentRating in value.selectedItems,
|
||||
data = contentRating,
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
|
||||
@@ -28,8 +27,7 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||
|
||||
// TODO pagination
|
||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
|
||||
val orderBy = when (order) {
|
||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||
@@ -43,13 +41,18 @@ abstract class HistoryDao {
|
||||
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
}
|
||||
|
||||
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
|
||||
|
||||
@@ -74,8 +74,8 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order).mapItems {
|
||||
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order, limit).mapItems {
|
||||
MangaWithHistory(
|
||||
it.manga.toManga(it.tags.toMangaTags()),
|
||||
it.history.toMangaHistory(),
|
||||
|
||||
@@ -32,7 +32,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
startActivity(NetworkManageIntent())
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.ui
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -43,8 +44,11 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryListViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
@@ -62,8 +66,11 @@ class HistoryListViewModel @Inject constructor(
|
||||
valueProducer = { historySortOrder },
|
||||
)
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode)
|
||||
override val listMode = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||
valueProducer = { historyListMode },
|
||||
)
|
||||
|
||||
private val isGroupingEnabled = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_HISTORY_GROUPING,
|
||||
@@ -72,6 +79,9 @@ class HistoryListViewModel @Inject constructor(
|
||||
g && s.isGroupingSupported()
|
||||
}
|
||||
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_STATS_ENABLED,
|
||||
@@ -79,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
||||
observeHistory(),
|
||||
isGroupingEnabled,
|
||||
listMode,
|
||||
networkState,
|
||||
@@ -95,7 +105,10 @@ class HistoryListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> mapList(list, grouped, mode, online, incognito)
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
mapList(list, grouped, mode, online, incognito)
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
loadingCounter.increment()
|
||||
@@ -138,6 +151,15 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
|
||||
|
||||
private suspend fun mapList(
|
||||
list: List<MangaWithHistory>,
|
||||
grouped: Boolean,
|
||||
|
||||
@@ -7,11 +7,12 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ErrorResult
|
||||
@@ -20,17 +21,26 @@ import coil.request.SuccessResult
|
||||
import coil.target.ViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
|
||||
@@ -39,27 +49,45 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var errorBinding: ItemErrorStateBinding? = null
|
||||
private val viewModel: ImageViewModel by viewModels()
|
||||
private lateinit var menuMediator: PopupMenuMediator
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||
viewBinding.buttonBack.setOnClickListener(this)
|
||||
loadImage(intent.data)
|
||||
viewBinding.buttonMenu.setOnClickListener(this)
|
||||
val imageUrl = requireNotNull(intent.data)
|
||||
|
||||
val menuProvider = ImageMenuProvider(
|
||||
activity = this,
|
||||
snackbarHost = viewBinding.root,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
menuMediator = PopupMenuMediator(menuProvider)
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
|
||||
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
|
||||
loadImage(imageUrl)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
with(viewBinding.buttonBack) {
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + marginBottom
|
||||
leftMargin = insets.left + marginBottom
|
||||
rightMargin = insets.right + marginBottom
|
||||
}
|
||||
viewBinding.buttonBack.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
}
|
||||
viewBinding.buttonMenu.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_back -> dispatchNavigateUp()
|
||||
R.id.button_menu -> menuMediator.onLongClick(v)
|
||||
else -> loadImage(intent.data)
|
||||
}
|
||||
}
|
||||
@@ -92,11 +120,34 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.lifecycle(this)
|
||||
.listener(this)
|
||||
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.target(SsivTarget(viewBinding.ssiv))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private fun onImageSaved(uri: Uri) {
|
||||
Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.share) {
|
||||
ShareHelper(this).shareImage(uri)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonMenu
|
||||
button.isClickable = !isLoading
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||
}
|
||||
}
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : ViewTarget<SubsamplingScaleImageView> {
|
||||
@@ -124,7 +175,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
||||
return Intent(context, ImageActivity::class.java)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
|
||||
class ImageMenuProvider(
|
||||
private val activity: ComponentActivity,
|
||||
private val snackbarHost: View,
|
||||
private val viewModel: ImageViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
private val permissionLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
private val saveLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("image/png"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
viewModel.saveImage(uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_image, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_save -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
saveImage()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun saveImage() {
|
||||
val name = activity.intent.data?.let {
|
||||
if (it.isZipUri()) {
|
||||
it.fragment
|
||||
} else {
|
||||
it.lastPathSegment
|
||||
}?.substringBeforeLast('.')?.plus(".png")
|
||||
}
|
||||
if (name == null || !saveLauncher.tryLaunch(name)) {
|
||||
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ImageViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val coil: ImageLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onImageSaved = MutableEventFlow<Uri>()
|
||||
|
||||
fun saveImage(destination: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.memoryCachePolicy(CachePolicy.READ_ONLY)
|
||||
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
|
||||
.build()
|
||||
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(destination)?.use { output ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
|
||||
} ?: error("Cannot open output stream")
|
||||
}
|
||||
onImageSaved.call(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,6 @@ suspend fun Manga.toListDetailedModel(
|
||||
ChipsView.ChipModel(
|
||||
tint = extraProvider?.getTagTint(it) ?: 0,
|
||||
title = it.title,
|
||||
icon = 0,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = it,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -85,10 +85,7 @@ class PreviewViewModel @Inject constructor(
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = extraProvider.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.collection.MutableLongObjectMap
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
|
||||
class LocalMangaMappingCache {
|
||||
|
||||
private val map = MutableLongObjectMap<File>()
|
||||
|
||||
suspend fun get(mangaId: Long): LocalManga? {
|
||||
val file = synchronized(this) {
|
||||
map[mangaId]
|
||||
} ?: return null
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(file).getManga()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
operator fun set(mangaId: Long, localManga: LocalManga?) = synchronized(this) {
|
||||
if (localManga == null) {
|
||||
map.remove(mangaId)
|
||||
} else {
|
||||
map[mangaId] = localManga.file
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
override val source = MangaSource.LOCAL
|
||||
private val locks = MultiMutex<Long>()
|
||||
private val localMappingCache = LocalMangaMappingCache()
|
||||
|
||||
override val isMultipleTagsSupported: Boolean = true
|
||||
override val isTagsExclusionSupported: Boolean = true
|
||||
@@ -141,6 +142,10 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
|
||||
// very fast path
|
||||
localMappingCache.get(remoteManga.id)?.let {
|
||||
return@runCatchingCancellable it
|
||||
}
|
||||
// fast path
|
||||
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
|
||||
return it.getManga()
|
||||
@@ -162,6 +167,8 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
}.firstOrNull()?.getManga()
|
||||
}.onSuccess { x: LocalManga? ->
|
||||
localMappingCache[remoteManga.id] = x
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
@@ -220,9 +227,11 @@ class LocalMangaRepository @Inject constructor(
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
||||
dir.children()
|
||||
}
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
dir.children().filterNot { it.isHidden }
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -66,7 +67,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
|
||||
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -84,7 +85,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
private val viewModel by viewModels<MainViewModel>()
|
||||
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
|
||||
private val closeSearchCallback = CloseSearchCallback()
|
||||
private val appUpdateDialog = AppUpdateDialog(this)
|
||||
private lateinit var navigationDelegate: MainNavigationDelegate
|
||||
private lateinit var appUpdateBadge: OptionsMenuBadgeHelper
|
||||
|
||||
@@ -190,9 +190,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
|
||||
R.id.action_app_update -> {
|
||||
viewModel.appUpdate.value?.also {
|
||||
appUpdateDialog.show(it)
|
||||
} != null
|
||||
startActivity(Intent(this, AppUpdateActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.main.ui.protect
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScreenshotPolicyHelper @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : DefaultActivityLifecycleCallbacks {
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
(activity as? ContentContainer)?.setupScreenshotPolicy(activity)
|
||||
}
|
||||
|
||||
private fun ContentContainer.setupScreenshotPolicy(activity: Activity) =
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }
|
||||
.flatMapLatest { policy ->
|
||||
when (policy) {
|
||||
ScreenshotsPolicy.ALLOW -> flowOf(false)
|
||||
ScreenshotsPolicy.BLOCK_NSFW -> withContext(Dispatchers.Main) {
|
||||
isNsfwContent()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
ScreenshotsPolicy.BLOCK_ALL -> flowOf(true)
|
||||
ScreenshotsPolicy.BLOCK_INCOGNITO -> settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
||||
isIncognitoModeEnabled
|
||||
}
|
||||
}
|
||||
}.collect { isSecure ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSecure) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentContainer : LifecycleOwner {
|
||||
|
||||
@MainThread
|
||||
fun isNsfwContent(): Flow<Boolean>
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.SheetWelcomeBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||
import java.util.Locale
|
||||
|
||||
@@ -58,7 +58,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
|
||||
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
is Locale -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +86,12 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale?>) {
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale>) {
|
||||
val chips = viewBinding?.chipsLocales ?: return
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages),
|
||||
icon = 0,
|
||||
title = it.getDisplayName(chips.context),
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
@@ -107,9 +105,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(it.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
@@ -27,14 +28,14 @@ class WelcomeViewModel @Inject constructor(
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val allSources = repository.allMangaSources
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale.toLocale() } }
|
||||
|
||||
private var updateJob: Job
|
||||
|
||||
val locales = MutableStateFlow(
|
||||
FilterProperty<Locale?>(
|
||||
availableItems = listOf(null),
|
||||
selectedItems = setOf(null),
|
||||
FilterProperty<Locale>(
|
||||
availableItems = listOf(Locale.ROOT),
|
||||
selectedItems = setOf(Locale.ROOT),
|
||||
isLoading = true,
|
||||
error = null,
|
||||
),
|
||||
@@ -51,22 +52,23 @@ class WelcomeViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
updateJob = launchJob(Dispatchers.Default) {
|
||||
val languages = localesGroups.keys.associateBy { x -> x?.language }
|
||||
val selectedLocales = HashSet<Locale?>(2)
|
||||
selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
val languages = localesGroups.keys.associateBy { x -> x.language }
|
||||
val selectedLocales = HashSet<Locale>(2)
|
||||
ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
.firstNotNullOfOrNull { lc -> languages[lc.language] }
|
||||
selectedLocales += null
|
||||
?.let { selectedLocales += it }
|
||||
selectedLocales += Locale.ROOT
|
||||
locales.value = locales.value.copy(
|
||||
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
|
||||
availableItems = localesGroups.keys.sortedWithSafe(LocaleComparator()),
|
||||
selectedItems = selectedLocales,
|
||||
isLoading = false,
|
||||
)
|
||||
repository.assimilateNewSources()
|
||||
repository.clearNewSourcesBadge()
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
|
||||
fun setLocaleChecked(locale: Locale, isChecked: Boolean) {
|
||||
val snapshot = locales.value
|
||||
locales.value = snapshot.copy(
|
||||
selectedItems = if (isChecked) {
|
||||
@@ -99,7 +101,7 @@ class WelcomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun commit() {
|
||||
val languages = locales.value.selectedItems.mapToSet { it?.language }
|
||||
val languages = locales.value.selectedItems.mapToSet { it.language }
|
||||
val types = types.value.selectedItems
|
||||
val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||
x.contentType in types && x.locale in languages
|
||||
|
||||
@@ -7,8 +7,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -142,7 +143,6 @@ class ReaderActivity :
|
||||
viewModel.content.observe(this) {
|
||||
onLoadingStateChanged(viewModel.isLoading.value)
|
||||
}
|
||||
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
@@ -179,6 +179,8 @@ class ReaderActivity :
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
@@ -297,14 +299,6 @@ class ReaderActivity :
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setWindowSecure(isSecure: Boolean) {
|
||||
if (isSecure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -36,6 +36,17 @@ class ReaderControlDelegate(
|
||||
}
|
||||
|
||||
fun onKeyDown(keyCode: Int): Boolean = when (keyCode) {
|
||||
|
||||
KeyEvent.KEYCODE_R -> {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_L -> {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
|
||||
@@ -11,7 +11,9 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
@@ -46,8 +48,10 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
private var insetLeft: Int = 0
|
||||
private var insetRight: Int = 0
|
||||
private var insetTop: Int = 0
|
||||
private var cutoutInsetLeft = 0
|
||||
private var cutoutInsetRight = 0
|
||||
private val insetLeftFallback: Int
|
||||
private val insetRightFallback: Int
|
||||
private val insetTopFallback: Int
|
||||
private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
private val colorText = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
|
||||
200,
|
||||
@@ -80,14 +84,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.strokeWidth = getDimension(R.styleable.ReaderInfoBarView_android_strokeWidth, 2f)
|
||||
paint.textSize = getDimension(R.styleable.ReaderInfoBarView_android_textSize, 16f)
|
||||
}
|
||||
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback)
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start")
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end")
|
||||
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
|
||||
insetLeft = if (isRtl) insetEnd else insetStart
|
||||
insetRight = if (isRtl) insetStart else insetEnd
|
||||
insetTop = minOf(insetLeft, insetRight)
|
||||
insetLeftFallback = if (isRtl) insetEnd else insetStart
|
||||
insetRightFallback = if (isRtl) insetStart else insetEnd
|
||||
insetTopFallback = minOf(insetLeftFallback, insetRightFallback)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@@ -110,12 +112,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawTextOutline(
|
||||
text,
|
||||
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
|
||||
(paddingLeft + insetLeft).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
if (isTimeVisible) {
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
var endX = (width - paddingRight - insetRight - cutoutInsetRight).toFloat()
|
||||
var endX = (width - paddingRight - insetRight).toFloat()
|
||||
canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty)
|
||||
if (batteryText.isNotEmpty()) {
|
||||
paint.getTextBounds(timeText, 0, timeText.length, textBounds)
|
||||
@@ -221,15 +223,29 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
|
||||
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
|
||||
cutoutInsetLeft = 0
|
||||
cutoutInsetRight = 0
|
||||
for (rect in cutouts) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
cutoutInsetLeft += rect.width()
|
||||
insetLeft = insetLeftFallback
|
||||
insetRight = insetRightFallback
|
||||
insetTop = insetTopFallback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && insetsCompat != null) {
|
||||
val nativeInsets = insetsCompat.toWindowInsets()
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.let { corner ->
|
||||
insetLeft += corner.radius
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
cutoutInsetRight += rect.width()
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.let { corner ->
|
||||
insetRight += corner.radius
|
||||
}
|
||||
} else {
|
||||
insetLeft += insetCornerFallback
|
||||
insetRight += insetCornerFallback
|
||||
}
|
||||
insetsCompat?.displayCutout?.let { cutout ->
|
||||
for (rect in cutout.boundingRects) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
insetLeft += rect.width()
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
insetRight += rect.width()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -38,7 +39,6 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
@@ -166,13 +166,9 @@ class ReaderViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
|
||||
)
|
||||
|
||||
val isScreenshotsBlockEnabled = combine(
|
||||
mangaFlow,
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
|
||||
) { manga, policy ->
|
||||
policy == ScreenshotsPolicy.BLOCK_ALL ||
|
||||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
val isMangaNsfw = mangaFlow.map {
|
||||
it?.isNsfw == true
|
||||
}
|
||||
|
||||
val isBookmarkAdded = currentState.flatMapLatest { state ->
|
||||
val manga = mangaData.value?.toManga()
|
||||
|
||||
@@ -15,11 +15,14 @@ import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -58,6 +61,8 @@ class MangaListActivity :
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
|
||||
private var source: MangaSource? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
@@ -66,16 +71,19 @@ class MangaListActivity :
|
||||
if (viewBinding.containerFilterHeader != null) {
|
||||
viewBinding.appbar.addOnOffsetChangedListener(this)
|
||||
}
|
||||
val source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
if (source == null) {
|
||||
source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
val src = source
|
||||
if (src == null) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
} else {
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (src == MangaSource.LOCAL) getString(R.string.local_storage) else src.title
|
||||
initList(src, tags)
|
||||
}
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title
|
||||
initList(source, tags)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(source?.isNsfw() == true)
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
|
||||
@@ -172,12 +172,8 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
|
||||
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTitle(title: CharSequence?) {
|
||||
if (!resources.getBoolean(R.bool.is_tablet)) {
|
||||
super.setTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) {
|
||||
findPreference<Preference>(key)?.summary = items.joinToString { getString(it) }
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.Preference
|
||||
@@ -21,15 +17,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isScrolledToTop
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
|
||||
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
||||
@@ -40,19 +33,21 @@ import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
class SettingsActivity :
|
||||
BaseActivity<ActivitySettingsBinding>(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
AppBarOwner,
|
||||
FragmentManager.OnBackStackChangedListener {
|
||||
|
||||
val appUpdateDialog = AppUpdateDialog(this)
|
||||
AppBarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
private val isMasterDetails
|
||||
get() = viewBinding.containerMaster != null
|
||||
|
||||
private var screenPadding = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
|
||||
screenPadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val isMasterDetails = viewBinding.containerMaster != null
|
||||
val fm = supportFragmentManager
|
||||
val currentFragment = fm.findFragmentById(R.id.container)
|
||||
if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) {
|
||||
@@ -64,61 +59,7 @@ class SettingsActivity :
|
||||
replace(R.id.container_master, RootSettingsFragment())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence?, color: Int) {
|
||||
super.onTitleChanged(title, color)
|
||||
viewBinding.collapsingToolbarLayout?.title = title
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
supportFragmentManager.addOnBackStackChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
supportFragmentManager.removeOnBackStackChangedListener(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_tracker -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity")
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "org.koitharu.workinspector.WorkInspectorActivity")
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackStackChanged() {
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
|
||||
val recyclerView = fragment.recyclerView
|
||||
recyclerView.post {
|
||||
viewBinding.appbar.setExpanded(recyclerView.isScrolledToTop, false)
|
||||
}
|
||||
addMenuProvider(SettingsMenuProvider(this))
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
@@ -137,8 +78,8 @@ class SettingsActivity :
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.cardDetails?.updateLayoutParams<MarginLayoutParams> {
|
||||
bottomMargin = marginStart + insets.bottom
|
||||
viewBinding.textViewHeader?.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = screenPadding + insets.top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,19 +91,17 @@ class SettingsActivity :
|
||||
|
||||
fun openFragment(fragment: Fragment, isFromRoot: Boolean) {
|
||||
val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null
|
||||
val isMasterDetail = viewBinding.containerMaster != null
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN)
|
||||
if (!isMasterDetail || (hasFragment && !isFromRoot)) {
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
|
||||
if (!isMasterDetails || (hasFragment && !isFromRoot)) {
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDefaultFragment() {
|
||||
val hasMaster = viewBinding.containerMaster != null
|
||||
val fragment = when (intent?.action) {
|
||||
ACTION_READER -> ReaderSettingsFragment()
|
||||
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||
@@ -184,7 +123,7 @@ class SettingsActivity :
|
||||
}
|
||||
|
||||
else -> null
|
||||
} ?: if (hasMaster) AppearanceSettingsFragment() else RootSettingsFragment()
|
||||
} ?: if (isMasterDetails) AppearanceSettingsFragment() else RootSettingsFragment()
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -41,6 +41,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
|
||||
if (!isEnabled) isChecked = true
|
||||
}
|
||||
if (!settings.isTrackerEnabled) {
|
||||
findPreference<Preference>(AppSettings.KEY_TRACKER_DEBUG)?.run {
|
||||
isEnabled = false
|
||||
setSummary(R.string.check_for_new_chapters_disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -68,6 +74,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACKER_DEBUG -> {
|
||||
startActivity(Intent(preference.context, TrackerDebugActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +89,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
(activity as SettingsActivity).appUpdateDialog.show(version)
|
||||
startActivity(Intent(requireContext(), AppUpdateActivity::class.java))
|
||||
}
|
||||
|
||||
private fun openLink(url: String, title: CharSequence?) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.noties.markwon.Markwon
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityAppUpdateBinding
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppUpdateActivity : BaseActivity<ActivityAppUpdateBinding>(), View.OnClickListener {
|
||||
|
||||
private val viewModel: AppUpdateViewModel by viewModels()
|
||||
private lateinit var downloadReceiver: UpdateDownloadReceiver
|
||||
|
||||
private val permissionRequest = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.startDownload()
|
||||
} else {
|
||||
openInBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityAppUpdateBinding.inflate(layoutInflater))
|
||||
downloadReceiver = UpdateDownloadReceiver(viewModel)
|
||||
viewModel.nextVersion.observe(this, ::onNextVersionChanged)
|
||||
viewBinding.buttonCancel.setOnClickListener(this)
|
||||
viewBinding.buttonUpdate.setOnClickListener(this)
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
downloadReceiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
ContextCompat.RECEIVER_EXPORTED,
|
||||
)
|
||||
combine(viewModel.isLoading, viewModel.downloadProgress, ::Pair)
|
||||
.observe(this, ::onProgressChanged)
|
||||
viewModel.downloadState.observe(this, ::onDownloadStateChanged)
|
||||
viewModel.onError.observeEvent(this, ::onError)
|
||||
viewModel.onDownloadDone.observeEvent(this) { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(downloadReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> finishAfterTransition()
|
||||
R.id.button_update -> doUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
viewBinding.root.setPadding(
|
||||
basePadding + insets.left,
|
||||
basePadding + insets.top,
|
||||
basePadding + insets.right,
|
||||
basePadding + insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onNextVersionChanged(version: AppVersion?) {
|
||||
viewBinding.buttonUpdate.isEnabled = version != null && !viewModel.isLoading.value
|
||||
if (version == null) {
|
||||
viewBinding.textViewContent.setText(R.string.loading_)
|
||||
return
|
||||
}
|
||||
val message = withContext(Dispatchers.Default) {
|
||||
buildSpannedString {
|
||||
append(getString(R.string.new_version_s, version.name))
|
||||
appendLine()
|
||||
append(getString(R.string.size_s, FileSize.BYTES.format(this@AppUpdateActivity, version.apkSize)))
|
||||
appendLine()
|
||||
appendLine()
|
||||
append(Markwon.create(this@AppUpdateActivity).toMarkdown(version.description))
|
||||
}
|
||||
}
|
||||
viewBinding.textViewContent.setText(message, TextView.BufferType.SPANNABLE)
|
||||
}
|
||||
|
||||
private fun doUpdate() {
|
||||
viewModel.installIntent.value?.let { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
onError(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
viewModel.startDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openInBrowser() {
|
||||
val latestVersion = viewModel.nextVersion.value ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri())
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
|
||||
}
|
||||
|
||||
private fun onProgressChanged(value: Pair<Boolean, Float>) {
|
||||
val (isLoading, downloadProgress) = value
|
||||
val indicator = viewBinding.progressBar
|
||||
indicator.showOrHide(isLoading)
|
||||
indicator.isIndeterminate = downloadProgress <= 0f
|
||||
if (downloadProgress > 0f) {
|
||||
indicator.setProgressCompat((indicator.max * downloadProgress).toInt(), true)
|
||||
}
|
||||
viewBinding.buttonUpdate.isEnabled = !isLoading && viewModel.nextVersion.value != null
|
||||
}
|
||||
|
||||
private fun onDownloadStateChanged(state: Int) {
|
||||
val message = when (state) {
|
||||
DownloadManager.STATUS_FAILED -> R.string.error_occurred
|
||||
DownloadManager.STATUS_PAUSED -> R.string.downloads_paused
|
||||
else -> 0
|
||||
}
|
||||
viewBinding.textViewError.setTextAndVisible(message)
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
viewBinding.textViewError.textAndVisible = e.getDisplayMessage(resources)
|
||||
}
|
||||
|
||||
private class UpdateDownloadReceiver(
|
||||
private val viewModel: AppUpdateViewModel,
|
||||
) : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
viewModel.onDownloadComplete(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.noties.markwon.Markwon
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
|
||||
class AppUpdateDialog(private val activity: AppCompatActivity) {
|
||||
|
||||
private lateinit var latestVersion: AppVersion
|
||||
|
||||
private val permissionRequest = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) {
|
||||
if (it) {
|
||||
downloadUpdateImpl()
|
||||
} else {
|
||||
openInBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
fun show(version: AppVersion) {
|
||||
latestVersion = version
|
||||
val message = buildSpannedString {
|
||||
append(activity.getString(R.string.new_version_s, version.name))
|
||||
appendLine()
|
||||
append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
|
||||
appendLine()
|
||||
appendLine()
|
||||
append(Markwon.create(activity).toMarkdown(version.description))
|
||||
}
|
||||
MaterialAlertDialogBuilder(activity, DIALOG_THEME_CENTERED)
|
||||
.setTitle(R.string.app_update_available)
|
||||
.setMessage(message)
|
||||
.setIcon(R.drawable.ic_app_update)
|
||||
.setNeutralButton(R.string.open_in_browser) { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, version.url.toUri())
|
||||
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
|
||||
}.setPositiveButton(R.string.update) { _, _ ->
|
||||
downloadUpdate()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun downloadUpdate() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
downloadUpdateImpl()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadUpdateImpl() = runCatching {
|
||||
val version = latestVersion
|
||||
val url = version.apkUrl.toUri()
|
||||
val dm = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val request = DownloadManager.Request(url)
|
||||
.setTitle("${activity.getString(R.string.app_name)} v${version.name}")
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setMimeType("application/vnd.android.package-archive")
|
||||
dm.enqueue(request)
|
||||
}.onSuccess {
|
||||
Toast.makeText(activity, R.string.download_started, Toast.LENGTH_SHORT).show()
|
||||
}.onFailure { e ->
|
||||
Toast.makeText(activity, e.getDisplayMessage(activity.resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun openInBrowser() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri())
|
||||
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
private val repository: AppUpdateRepository,
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val nextVersion = repository.observeAvailableUpdate()
|
||||
val downloadProgress = MutableStateFlow(-1f)
|
||||
val downloadState = MutableStateFlow(DownloadManager.STATUS_PENDING)
|
||||
val installIntent = MutableStateFlow<Intent?>(null)
|
||||
val onDownloadDone = MutableEventFlow<Intent>()
|
||||
|
||||
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
private val appName = context.getString(R.string.app_name)
|
||||
|
||||
init {
|
||||
if (nextVersion.value == null) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
repository.fetchUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val version = nextVersion.requireValue()
|
||||
val url = version.apkUrl.toUri()
|
||||
val request = DownloadManager.Request(url)
|
||||
.setTitle("$appName v${version.name}")
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setMimeType("application/vnd.android.package-archive")
|
||||
val downloadId = downloadManager.enqueue(request)
|
||||
observeDownload(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadComplete(intent: Intent) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
if (downloadId == 0L) {
|
||||
return@launchLoadingJob
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val installerIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
installerIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
installerIntent.setDataAndType(
|
||||
downloadManager.getUriForDownloadedFile(downloadId),
|
||||
downloadManager.getMimeTypeForDownloadedFile(downloadId),
|
||||
)
|
||||
installerIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
installIntent.value = installerIntent
|
||||
onDownloadDone.call(installerIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeDownload(id: Long) {
|
||||
val query = DownloadManager.Query()
|
||||
query.setFilterById(id)
|
||||
while (coroutineContext.isActive) {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytesDownloaded = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR),
|
||||
)
|
||||
val bytesTotal = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES),
|
||||
)
|
||||
downloadProgress.value = bytesDownloaded.toFloat() / bytesTotal
|
||||
val state = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
downloadState.value = state
|
||||
if (state == DownloadManager.STATUS_SUCCESSFUL || state == DownloadManager.STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
|
||||
class UpdateDownloadReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
if (downloadId == 0L) {
|
||||
return
|
||||
}
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
installIntent.setDataAndType(
|
||||
dm.getUriForDownloadedFile(downloadId),
|
||||
dm.getMimeTypeForDownloadedFile(downloadId),
|
||||
)
|
||||
installIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
try {
|
||||
context.startActivity(installIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NewSourcesDialogFragment :
|
||||
AlertDialogFragment<DialogOnboardBinding>(),
|
||||
SourceConfigListener,
|
||||
DialogInterface.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<NewSourcesViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): DialogOnboardBinding {
|
||||
return DialogOnboardBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setPositiveButton(R.string.done, this)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.remote_sources)
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.onItemEnabledChanged(item, isEnabled)
|
||||
}
|
||||
|
||||
override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "NewSources"
|
||||
|
||||
fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewSourcesViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val newSources = SuspendLazy {
|
||||
repository.assimilateNewSources()
|
||||
}
|
||||
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
|
||||
.map { sources ->
|
||||
val new = newSources.get()
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
sources.mapNotNull { (source, enabled) ->
|
||||
if (source in new) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = source,
|
||||
isEnabled = enabled,
|
||||
isDraggable = false,
|
||||
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.setSourcesEnabled(setOf(item.source), isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourcesSelectAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceConfigItem>() {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner))
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ sealed interface SourceCatalogItem : ListModel {
|
||||
|
||||
data class Source(
|
||||
val source: MangaSource,
|
||||
val showSummary: Boolean,
|
||||
) : SourceCatalogItem {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -15,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
@@ -22,28 +24,32 @@ import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun sourceCatalogItemSourceAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, SourceCatalogItem, ItemSourceCatalogBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, ListModel, ItemSourceCatalogBinding>(
|
||||
{ layoutInflater, parent ->
|
||||
ItemSourceCatalogBinding.inflate(layoutInflater, parent, false)
|
||||
},
|
||||
) {
|
||||
|
||||
binding.imageViewAdd.setOnClickListener { v ->
|
||||
listener.onItemLongClick(item, v)
|
||||
}
|
||||
binding.root.setOnClickListener { v ->
|
||||
listener.onItemClick(item, v)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
if (item.showSummary) {
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.isVisible = true
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.drawableStart = if (item.source.isBroken) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_off_small)
|
||||
} else {
|
||||
binding.textViewDescription.isVisible = false
|
||||
null
|
||||
}
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
@@ -61,7 +67,7 @@ fun sourceCatalogItemSourceAD(
|
||||
fun sourceCatalogItemHintAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, SourceCatalogItem, ItemEmptyHintBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, ListModel, ItemEmptyHintBinding>(
|
||||
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.chip.Chip
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView.ChipModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
AppBarOwner, MenuItem.OnActionExpandListener {
|
||||
AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var newSourcesSnackbar: Snackbar? = null
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
@@ -45,18 +50,20 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this)
|
||||
viewBinding.pager.adapter = pagerAdapter
|
||||
val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter)
|
||||
tabMediator.attach()
|
||||
viewModel.content.observe(this, pagerAdapter)
|
||||
viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged)
|
||||
val sourcesAdapter = SourcesCatalogAdapter(this, coil, this)
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = sourcesAdapter
|
||||
}
|
||||
viewBinding.chipsFilter.onChipClickListener = this
|
||||
viewModel.content.observe(this, sourcesAdapter)
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
ReversibleActionObserver(viewBinding.pager),
|
||||
ReversibleActionObserver(viewBinding.recyclerView),
|
||||
)
|
||||
viewModel.locale.observe(this) {
|
||||
supportActionBar?.subtitle = it?.toLocale().getDisplayName(this)
|
||||
combine(viewModel.appliedFilter, viewModel.hasNewSources, ::Pair).observe(this) {
|
||||
updateFilers(it.first, it.second)
|
||||
}
|
||||
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this))
|
||||
}
|
||||
@@ -66,51 +73,85 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setContentType(data, chip.isChecked)
|
||||
is Boolean -> viewModel.setNewOnly(chip.isChecked)
|
||||
else -> showLocalesMenu(chip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
|
||||
startActivity(MangaListActivity.newIntent(this, item.source))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean {
|
||||
viewModel.addSource(item.source)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = false
|
||||
viewBinding.pager.isUserInputEnabled = false
|
||||
val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty()
|
||||
viewModel.performSearch(sq)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = true
|
||||
viewBinding.pager.isUserInputEnabled = true
|
||||
viewModel.performSearch(null)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onHasNewSourcesChanged(hasNewSources: Boolean) {
|
||||
private fun updateFilers(
|
||||
appliedFilter: SourcesCatalogFilter,
|
||||
hasNewSources: Boolean,
|
||||
) {
|
||||
val chips = ArrayList<ChipModel>(ContentType.entries.size + 2)
|
||||
chips += ChipModel(
|
||||
title = appliedFilter.locale?.toLocale().getDisplayName(this),
|
||||
icon = R.drawable.ic_language,
|
||||
isDropdown = true,
|
||||
)
|
||||
if (hasNewSources) {
|
||||
if (newSourcesSnackbar?.isShownOrQueued == true) {
|
||||
return
|
||||
}
|
||||
val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE)
|
||||
snackbar.setAction(R.string.explore) {
|
||||
NewSourcesDialogFragment.show(supportFragmentManager)
|
||||
}
|
||||
snackbar.addCallback(
|
||||
object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
super.onDismissed(transientBottomBar, event)
|
||||
if (event == DISMISS_EVENT_SWIPE) {
|
||||
viewModel.skipNewSources()
|
||||
}
|
||||
}
|
||||
},
|
||||
chips += ChipModel(
|
||||
title = getString(R.string._new),
|
||||
icon = R.drawable.ic_updated_selector,
|
||||
isCheckable = true,
|
||||
isChecked = appliedFilter.isNewOnly,
|
||||
data = true,
|
||||
)
|
||||
snackbar.show()
|
||||
newSourcesSnackbar = snackbar
|
||||
} else {
|
||||
newSourcesSnackbar?.dismiss()
|
||||
newSourcesSnackbar = null
|
||||
}
|
||||
for (type in ContentType.entries) {
|
||||
if (type == ContentType.HENTAI && viewModel.isNsfwDisabled) {
|
||||
continue
|
||||
}
|
||||
chips += ChipModel(
|
||||
title = getString(type.titleResId),
|
||||
isCheckable = true,
|
||||
isChecked = type in appliedFilter.types,
|
||||
data = type,
|
||||
)
|
||||
}
|
||||
viewBinding.chipsFilter.setChips(chips)
|
||||
}
|
||||
|
||||
private fun showLocalesMenu(anchor: View) {
|
||||
val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) {
|
||||
it to it?.toLocale()
|
||||
}
|
||||
locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second })
|
||||
val menu = PopupMenu(this, anchor)
|
||||
for ((i, lc) in locales.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setLocale(locales.getOrNull(it.order)?.first)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,19 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class SourcesCatalogAdapter(
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceCatalogItem>(), FastScroller.SectionIndexer {
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user