Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e8e87b03 | ||
|
|
09590cfab0 | ||
|
|
5d91e20844 | ||
|
|
d918b1e274 | ||
|
|
7fc2d2f36f | ||
|
|
7a01fdd04c | ||
|
|
8724f5b30c | ||
|
|
fcc05e5e5d | ||
|
|
f6284e7107 | ||
|
|
5e9dc87470 | ||
|
|
b2f0da9245 | ||
|
|
b27d6dbe9a | ||
|
|
a7caf9848e | ||
|
|
8d44ad8866 | ||
|
|
e98f5b9d54 | ||
|
|
30d1d47cdc | ||
|
|
1fa470fd00 | ||
|
|
c835ebff3f | ||
|
|
0e76e69aab | ||
|
|
1857d9f4e9 | ||
|
|
34f13ebd52 | ||
|
|
2c4a71cbaa | ||
|
|
48515a13da | ||
|
|
f42cda1584 | ||
|
|
3fbbb01e27 | ||
|
|
182b8abd7a | ||
|
|
dd0445ee79 | ||
|
|
d10b64c17f | ||
|
|
ea13c7dbd8 | ||
|
|
fc5ad9ff90 | ||
|
|
4cee432a82 | ||
|
|
d7c8b12d66 | ||
|
|
87a05ed28a | ||
|
|
87c242e2bb | ||
|
|
1a7b9c6969 | ||
|
|
2786e1a2d5 | ||
|
|
05c37da667 | ||
|
|
ed9ed8e964 | ||
|
|
cb36772fd9 | ||
|
|
cc8e31995b | ||
|
|
82cd8024e9 | ||
|
|
ffc7222b3f | ||
|
|
08c48e9997 | ||
|
|
32b6db7343 | ||
|
|
4dfe0f0d88 | ||
|
|
0e2224eaf7 | ||
|
|
151777cf61 | ||
|
|
47a22064a5 | ||
|
|
1b8d35d424 | ||
|
|
604efef832 | ||
|
|
6f67bd7542 | ||
|
|
52592ba765 | ||
|
|
9bb97c72a1 | ||
|
|
b44cf370aa | ||
|
|
74900970e1 | ||
|
|
4ee52e149e | ||
|
|
4148f4a4b9 | ||
|
|
a59b6a418d | ||
|
|
d6ae67ba07 | ||
|
|
be455bc897 | ||
|
|
129035bda3 | ||
|
|
d558c2fcc0 | ||
|
|
cb5df0d73f | ||
|
|
19e8e3a618 | ||
|
|
5f0514638a | ||
|
|
28ae785142 | ||
|
|
8c59f97b02 | ||
|
|
8a26587250 | ||
|
|
bb68869fe1 | ||
|
|
e60ca7115a | ||
|
|
ee4a780acf | ||
|
|
8dd6ce2739 | ||
|
|
41ad7e90d3 | ||
|
|
31be55d67a | ||
|
|
a50b83d876 | ||
|
|
68ce789db1 | ||
|
|
4702862af3 | ||
|
|
c403705b48 | ||
|
|
22a5f2d5ee | ||
|
|
0f1d8d2835 | ||
|
|
1a6b1672b3 | ||
|
|
8e6006177b | ||
|
|
4e2f010260 | ||
|
|
8a191f8f04 | ||
|
|
88aca02234 | ||
|
|
9189307d00 | ||
|
|
00fc579824 | ||
|
|
7f8d78e0c9 | ||
|
|
13bd3e918e | ||
|
|
e1c46f0604 | ||
|
|
79f3fe196c | ||
|
|
87a47207ab | ||
|
|
772fd5cc6a | ||
|
|
26a2ffcbb1 | ||
|
|
cfecfce51e | ||
|
|
23c814bf53 | ||
|
|
8ca11b214c | ||
|
|
008f2d705a | ||
|
|
c37f795dac | ||
|
|
5d74bdd3b4 | ||
|
|
382b44accc | ||
|
|
78cd0eff09 | ||
|
|
12c954600f | ||
|
|
a7d4f3b784 | ||
|
|
91f9feba59 | ||
|
|
e03a200c32 | ||
|
|
8713faa487 | ||
|
|
15e99c03a9 | ||
|
|
b3933848e9 | ||
|
|
e39e5bf9c4 | ||
|
|
3a42dce45f | ||
|
|
169539f42f | ||
|
|
6360731f34 | ||
|
|
914dd9670a | ||
|
|
498b9aed26 | ||
|
|
b425f3e779 | ||
|
|
c6a51d4d08 | ||
|
|
b8b601821a | ||
|
|
5e8aa4cec7 | ||
|
|
54bb02937d | ||
|
|
74fe786c00 | ||
|
|
503bff292c | ||
|
|
0aa78c0d7e | ||
|
|
8e1d02f356 | ||
|
|
e8f9d22128 | ||
|
|
fa60ae2947 | ||
|
|
663602282a | ||
|
|
98dbc20cb0 | ||
|
|
cc28293bed | ||
|
|
1e90d5541b | ||
|
|
04c7ca7291 | ||
|
|
8d52cab6d8 | ||
|
|
efa13df106 | ||
|
|
8bc29ac331 | ||
|
|
7991f9ca97 | ||
|
|
eb1eee1681 | ||
|
|
b3f748c000 | ||
|
|
58a9f7b25a |
BIN
.github/assets/vtuber.png
vendored
Normal file
BIN
.github/assets/vtuber.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,4 +26,4 @@
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -6,14 +6,13 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
106
README.md
106
README.md
@@ -1,57 +1,107 @@
|
||||
# Kotatsu
|
||||
<div align="center">
|
||||
|
||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||
<a href="https://kotatsu.app">
|
||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||
</a>
|
||||
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
# [Kotatsu](https://kotatsu.app)
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
||||
<div align="left">
|
||||
|
||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
||||
|
||||
</div>
|
||||
|
||||
### Main Features
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||
<div align="left">
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
||||
* Search manga by name, genres, and more filters
|
||||
* Reading history and bookmarks
|
||||
* Favorites organized by user-defined categories
|
||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||
* Tablet-optimized Material You UI
|
||||
* Standard and Webtoon-optimized customizable reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* Reading history, bookmarks, and incognito mode support
|
||||
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
||||
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password/fingerprint-protected access to the app
|
||||
* Password / fingerprint-protected access to the app
|
||||
* Automatically sync app data with other devices on the same account
|
||||
* Support for older devices running Android 5+
|
||||
|
||||
### Screenshots
|
||||
</div>
|
||||
|
||||
|  |  |  |
|
||||
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||
|  |  |  |
|
||||
### In-App Screenshots
|
||||
|
||||
|  |  |
|
||||
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||
<div align="center">
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
||||
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
||||
</div>
|
||||
|
||||
### Localization
|
||||
|
||||
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
||||
**📌 If you would like to help improve these or add new languages,
|
||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
||||
|
||||
### Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/KotatsuApp/Kotatsu">
|
||||
<picture>
|
||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
||||
</picture>
|
||||
</a>
|
||||
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
||||
<picture>
|
||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
||||
</picture>
|
||||
</a><br></br>
|
||||
|
||||
</br>
|
||||
|
||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
|
||||
### License
|
||||
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||
install instructions.
|
||||
<div align="left">
|
||||
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||
|
||||
</div>
|
||||
|
||||
### DMCA disclaimer
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app.
|
||||
It collects content from sources that are freely available through any web browser
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ plugins {
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'kotlin-parcelize'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
id 'androidx.room'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -18,13 +19,12 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 700
|
||||
versionName = '8.0-a1'
|
||||
versionCode = 1003
|
||||
versionName = '8.0-b3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
arg('room.generateKotlin', 'true')
|
||||
arg('room.schemaLocation', "$projectDir/schemas")
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
@@ -77,6 +77,9 @@ android {
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
]
|
||||
}
|
||||
room {
|
||||
schemaDirectory "$projectDir/schemas"
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.core.BaseApp
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
@@ -13,9 +16,23 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
class KotatsuApp : BaseApp() {
|
||||
|
||||
var isLeakCanaryEnabled: Boolean
|
||||
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
|
||||
set(value) {
|
||||
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
|
||||
configureLeakCanary()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
enableStrictMode()
|
||||
configureLeakCanary()
|
||||
}
|
||||
|
||||
private fun configureLeakCanary() {
|
||||
LeakCanary.config = LeakCanary.config.copy(
|
||||
dumpHeap = isLeakCanaryEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
@@ -55,7 +72,7 @@ class KotatsuApp : BaseApp() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build()
|
||||
}.build(),
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||
detectWrongFragmentContainer()
|
||||
@@ -70,4 +87,13 @@ class KotatsuApp : BaseApp() {
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val PREFS_DEBUG = "_debug"
|
||||
const val KEY_LEAK_CANARY = "leak_canary"
|
||||
|
||||
fun getDebugPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class StrictModeNotifier(
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
violation.hashCode(),
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import leakcanary.AppWatcher
|
||||
|
||||
abstract class BaseService : LifecycleService() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
AppWatcher.objectWatcher.watch(
|
||||
watchedObject = this,
|
||||
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
@@ -13,10 +14,18 @@ class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
private val application: KotatsuApp
|
||||
get() = context.applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
@@ -28,6 +37,13 @@ class SettingsMenuProvider(
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_leakcanary -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
application.isLeakCanaryEnabled = checked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<item
|
||||
android:id="@id/action_leaks"
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:checkable="true"
|
||||
android:title="LeakCanary"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_leaks"
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@id/action_works"
|
||||
android:id="@+id/action_works"
|
||||
android:title="@string/wi_lib_name"
|
||||
app:showAsAction="never" />
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
@@ -209,6 +210,7 @@
|
||||
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/sync" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
|
||||
@@ -279,6 +281,10 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||
android:label="@string/local_manga_processing" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/restore_backup" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
|
||||
@@ -3,32 +3,28 @@ package org.koitharu.kotatsu.alternatives.domain
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||
|
||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
@@ -36,27 +32,23 @@ class AlternativesUseCase @Inject constructor(
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val searchHelper = searchHelperFactory.create(source)
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga, matchThreshold)) {
|
||||
send(item)
|
||||
}.getOrNull()
|
||||
list?.forEach {
|
||||
launch {
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
send(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,18 +60,6 @@ class AlternativesUseCase @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||
return matchesTitles(title, ref.title, threshold) ||
|
||||
matchesTitles(title, ref.altTitle, threshold) ||
|
||||
matchesTitles(altTitle, ref.title, threshold) ||
|
||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoFixUseCase @Inject constructor(
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||
val replacement = alternativesUseCase(seed)
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
@@ -51,10 +53,22 @@ fun alternativeAD(
|
||||
binding.chipSource.setOnClickListener(clickListener)
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
binding.textViewTitle.text = item.mangaModel.title
|
||||
with(binding.iconsView) {
|
||||
clearIcons()
|
||||
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
|
||||
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
|
||||
isVisible = iconsCount > 0
|
||||
}
|
||||
binding.textViewSubtitle.text = buildSpannedString {
|
||||
if (item.chaptersCount > 0) {
|
||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||
append(
|
||||
context.resources.getQuantityStringSafe(
|
||||
R.plurals.chapters,
|
||||
item.chaptersCount,
|
||||
item.chaptersCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
append(context.getString(R.string.no_chapters))
|
||||
}
|
||||
@@ -70,7 +84,10 @@ fun alternativeAD(
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||
binding.progressView.setProgress(
|
||||
item.mangaModel.progress,
|
||||
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
||||
)
|
||||
binding.chipSource.also { chip ->
|
||||
chip.text = item.manga.source.getTitle(chip.context)
|
||||
ImageRequest.Builder(context)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -16,6 +15,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
@@ -50,6 +50,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
with(viewBinding.recyclerView) {
|
||||
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||
adapter = listAdapter
|
||||
@@ -64,16 +65,6 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||
|
||||
@@ -15,17 +15,17 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
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.require
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -36,8 +36,7 @@ class AlternativesViewModel @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
@@ -55,8 +54,7 @@ class AlternativesViewModel @Inject constructor(
|
||||
alternativesUseCase(ref)
|
||||
.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = getProgress(it.id),
|
||||
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||
@@ -88,8 +86,4 @@ class AlternativesViewModel @Inject constructor(
|
||||
onMigrated.call(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
||||
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,10 @@ import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -51,12 +53,14 @@ class AutoFixService : CoroutineIntentService() {
|
||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||
startForeground(this)
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
powerManager.withPartialWakeLock(TAG) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaAlternativeModel(
|
||||
val manga: Manga,
|
||||
val progress: ReadingProgress?,
|
||||
val mangaModel: MangaGridModel,
|
||||
private val referenceChapters: Int,
|
||||
) : ListModel {
|
||||
|
||||
val manga: Manga
|
||||
get() = mangaModel.manga
|
||||
|
||||
val chaptersCount = manga.chaptersCount()
|
||||
|
||||
val chaptersDiff: Int
|
||||
@@ -19,4 +21,10 @@ data class MangaAlternativeModel(
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
|
||||
mangaModel.getChangePayload(previousState.mangaModel)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AllBookmarksActivity :
|
||||
BaseActivity<ActivityContainerBinding>(),
|
||||
AppBarOwner,
|
||||
SnackbarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val snackbarHost: CoordinatorLayout
|
||||
get() = viewBinding.root
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
}
|
||||
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
@@ -9,9 +10,6 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil3.ImageLoader
|
||||
@@ -28,6 +26,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
@@ -86,6 +85,7 @@ class AllBookmarksFragment :
|
||||
)
|
||||
val spanSizeLookup = SpanSizeLookup()
|
||||
with(binding.recyclerView) {
|
||||
consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
|
||||
setHasFixedSize(true)
|
||||
val spanResolver = GridSpanResolver(resources)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
@@ -177,16 +177,6 @@ class AllBookmarksFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val rv = requireViewBinding().recyclerView
|
||||
rv.updatePadding(
|
||||
bottom = insets.bottom + rv.paddingTop,
|
||||
)
|
||||
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
||||
|
||||
init {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.allowRgb565
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
|
||||
// TODO check usages
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
defaultPlaceholders(context)
|
||||
allowRgb565(true)
|
||||
bookmarkExtra(item)
|
||||
decodeRegion(item.scroll)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
@@ -25,6 +26,7 @@ class BookmarksAdapter(
|
||||
init {
|
||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -42,6 +42,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
@@ -114,15 +115,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@@ -29,6 +30,9 @@ class CaptchaNotifier(
|
||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(R.string.captcha_required))
|
||||
|
||||
@@ -3,13 +3,12 @@ package org.koitharu.kotatsu.browser.cloudflare
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import javax.inject.Inject
|
||||
@@ -58,6 +58,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
}
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
|
||||
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
@@ -83,17 +84,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
viewBinding.webView.stopLoading()
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Build
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.WorkManager
|
||||
import coil3.ImageLoader
|
||||
@@ -76,6 +77,12 @@ interface AppModule {
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@LocalizedAppContext
|
||||
fun provideLocalizedContext(
|
||||
@ApplicationContext context: Context,
|
||||
): Context = ContextCompat.getContextForLanguage(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
|
||||
@@ -13,7 +13,6 @@ import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
@@ -26,12 +25,14 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.os.RomCompat
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import java.security.Security
|
||||
import javax.inject.Inject
|
||||
@@ -82,17 +83,14 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
return
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
// TLS 1.3 support for Android < 10
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
}
|
||||
setupActivityLifecycleCallbacks()
|
||||
processLifecycleScope.launch {
|
||||
val isOriginalApp = withContext(Dispatchers.Default) {
|
||||
appValidator.isOriginalApp
|
||||
}
|
||||
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
|
||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
|
||||
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
|
||||
}
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Target(
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY_GETTER,
|
||||
AnnotationTarget.PROPERTY_SETTER,
|
||||
AnnotationTarget.VALUE_PARAMETER,
|
||||
AnnotationTarget.FIELD,
|
||||
)
|
||||
annotation class LocalizedAppContext
|
||||
@@ -16,6 +16,7 @@ class BackupEntry(
|
||||
CATEGORIES("categories"),
|
||||
FAVOURITES("favourites"),
|
||||
SETTINGS("settings"),
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,6 +21,7 @@ private const val PAGE_SIZE = 10
|
||||
class BackupRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
) {
|
||||
|
||||
suspend fun dumpHistory(): BackupEntry {
|
||||
@@ -103,6 +107,14 @@ class BackupRepository @Inject constructor(
|
||||
return entry
|
||||
}
|
||||
|
||||
fun dumpReaderGridSettings(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
||||
val settingsDump = tapGridSettings.getAllValues()
|
||||
val json = JsonSerializer(settingsDump).toJson()
|
||||
entry.data.put(json)
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpSources(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
||||
val all = db.getSourcesDao().findAll()
|
||||
@@ -128,9 +140,11 @@ class BackupRepository @Inject constructor(
|
||||
return if (timestamp == 0L) null else Date(timestamp)
|
||||
}
|
||||
|
||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -144,6 +158,7 @@ class BackupRepository @Inject constructor(
|
||||
db.getHistoryDao().upsert(history)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -159,9 +174,11 @@ class BackupRepository @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -175,6 +192,7 @@ class BackupRepository @Inject constructor(
|
||||
db.getFavouritesDao().upsert(favourite)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -221,4 +239,14 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
result += runCatchingCancellable {
|
||||
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ class CompositeResult {
|
||||
}
|
||||
}
|
||||
|
||||
operator fun plusAssign(error: Throwable) {
|
||||
errors.add(error)
|
||||
}
|
||||
|
||||
operator fun plusAssign(other: CompositeResult) {
|
||||
this.successCount += other.successCount
|
||||
this.errors += other.errors
|
||||
|
||||
@@ -28,15 +28,16 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
fun toMangaEntity() = MangaEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
altTitle = json.getStringOrNull("alt_title"),
|
||||
altTitles = json.getStringOrNull("alt_title"),
|
||||
url = json.getString("url"),
|
||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
||||
rating = json.getDouble("rating").toFloat(),
|
||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||
contentRating = json.getStringOrNull("content_rating"),
|
||||
coverUrl = json.getString("cover_url"),
|
||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||
state = json.getStringOrNull("state"),
|
||||
author = json.getStringOrNull("author"),
|
||||
authors = json.getStringOrNull("author"),
|
||||
source = json.getString("source"),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,15 +58,16 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
JSONObject().apply {
|
||||
put("id", e.id)
|
||||
put("title", e.title)
|
||||
put("alt_title", e.altTitle)
|
||||
put("alt_title", e.altTitles)
|
||||
put("url", e.url)
|
||||
put("public_url", e.publicUrl)
|
||||
put("rating", e.rating)
|
||||
put("nsfw", e.isNsfw)
|
||||
put("content_rating", e.contentRating)
|
||||
put("cover_url", e.coverUrl)
|
||||
put("large_cover_url", e.largeCoverUrl)
|
||||
put("state", e.state)
|
||||
put("author", e.author)
|
||||
put("author", e.authors)
|
||||
put("source", e.source)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -90,4 +90,4 @@ class TelegramBackupUploader @Inject constructor(
|
||||
.host("api.telegram.org")
|
||||
.addPathSegment("bot$botToken")
|
||||
.addPathSegment(method)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -66,7 +68,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 = 24
|
||||
const val DATABASE_VERSION = 25
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -134,6 +136,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
Migration23To24(),
|
||||
Migration24To23(),
|
||||
Migration24To25(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
|
||||
data class ChapterEntity(
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
@ColumnInfo(name = "name") val title: String,
|
||||
@ColumnInfo(name = "number") val number: Float,
|
||||
@ColumnInfo(name = "volume") val volume: Int,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
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.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.toArraySet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
private const val VALUES_DIVIDER = '\n'
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
@@ -25,15 +30,16 @@ fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||
state = this.state?.let { MangaState(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
contentRating = ContentRating(this.contentRating)
|
||||
?: if (isNsfw) ContentRating.ADULT else null,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||
source = MangaSource(this.source),
|
||||
tags = tags,
|
||||
chapters = chapters?.toMangaChapters(),
|
||||
@@ -45,7 +51,7 @@ fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||
|
||||
fun ChapterEntity.toMangaChapter() = MangaChapter(
|
||||
id = chapterId,
|
||||
name = name,
|
||||
title = title.nullIfEmpty(),
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = url,
|
||||
@@ -65,13 +71,14 @@ fun Manga.toEntity() = MangaEntity(
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
coverUrl = coverUrl.orEmpty(),
|
||||
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
contentRating = contentRating?.name,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
authors = authors.joinToString(VALUES_DIVIDER.toString()),
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
@@ -87,7 +94,7 @@ fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (inde
|
||||
ChapterEntity(
|
||||
chapterId = chapter.id,
|
||||
mangaId = mangaId,
|
||||
name = chapter.name,
|
||||
title = chapter.title.orEmpty(),
|
||||
number = chapter.number,
|
||||
volume = chapter.volume,
|
||||
url = chapter.url,
|
||||
@@ -108,3 +115,7 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
MangaState.valueOf(name)
|
||||
}.getOrNull()
|
||||
|
||||
fun ContentRating(name: String?): ContentRating? = runCatching {
|
||||
ContentRating.valueOf(name ?: return@runCatching null)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -10,14 +10,15 @@ data class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "alt_title") val altTitles: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "content_rating") val contentRating: String?,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "author") val authors: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration24To23 : Migration(24, 23) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS `chapters`")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration24To25 : Migration(24, 25) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -85,8 +86,8 @@ class AppUpdateRepository @Inject constructor(
|
||||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
fun isUpdateSupported(): Boolean {
|
||||
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
|
||||
suspend fun isUpdateSupported(): Boolean {
|
||||
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
|
||||
}
|
||||
|
||||
suspend fun getCurrentVersionChangelog(): String? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import org.koitharu.kotatsu.core.util.ext.digits
|
||||
import org.koitharu.kotatsu.parsers.util.digits
|
||||
import java.util.Locale
|
||||
|
||||
data class VersionId(
|
||||
|
||||
@@ -23,8 +23,9 @@ class CbzFetcher(
|
||||
override suspend fun fetch() = runInterruptible {
|
||||
val filePath = uri.schemeSpecificPart.toPath()
|
||||
val entryName = requireNotNull(uri.fragment)
|
||||
val fs = options.fileSystem.openZip(filePath)
|
||||
SourceFetchResult(
|
||||
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
|
||||
source = ImageSource(entryName.toPath(), fs, closeable = fs),
|
||||
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.DrawableRes
|
||||
@@ -168,3 +169,24 @@ private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String {
|
||||
title?.let {
|
||||
if (it.isNotBlank()) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
val num = numberString()
|
||||
val vol = volumeString()
|
||||
return when {
|
||||
num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num)
|
||||
num != null -> resources.getString(R.string.chapter_number, num)
|
||||
index > 0 -> resources.getString(
|
||||
R.string.chapters_time_pattern,
|
||||
resources.getString(R.string.unnamed_chapter),
|
||||
index.toString(),
|
||||
)
|
||||
|
||||
else -> resources.getString(R.string.unnamed_chapter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ data class ParcelableChapter(
|
||||
override fun create(parcel: Parcel) = ParcelableChapter(
|
||||
MangaChapter(
|
||||
id = parcel.readLong(),
|
||||
name = parcel.readString().orEmpty(),
|
||||
title = parcel.readString(),
|
||||
number = parcel.readFloat(),
|
||||
volume = parcel.readInt(),
|
||||
url = parcel.readString().orEmpty(),
|
||||
@@ -30,7 +30,7 @@ data class ParcelableChapter(
|
||||
|
||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||
parcel.writeLong(id)
|
||||
parcel.writeString(name)
|
||||
parcel.writeString(title)
|
||||
parcel.writeFloat(number)
|
||||
parcel.writeInt(volume)
|
||||
parcel.writeString(url)
|
||||
|
||||
@@ -2,12 +2,13 @@ package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readStringSet
|
||||
import org.koitharu.kotatsu.core.util.ext.writeStringSet
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@Parcelize
|
||||
@@ -21,17 +22,17 @@ data class ParcelableManga(
|
||||
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
|
||||
parcel.writeLong(id)
|
||||
parcel.writeString(title)
|
||||
parcel.writeString(altTitle)
|
||||
parcel.writeStringSet(altTitles)
|
||||
parcel.writeString(url)
|
||||
parcel.writeString(publicUrl)
|
||||
parcel.writeFloat(rating)
|
||||
ParcelCompat.writeBoolean(parcel, isNsfw)
|
||||
parcel.writeSerializable(contentRating)
|
||||
parcel.writeString(coverUrl)
|
||||
parcel.writeString(largeCoverUrl)
|
||||
parcel.writeString(description.takeIf { withDescription })
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
parcel.writeSerializable(state)
|
||||
parcel.writeString(author)
|
||||
parcel.writeStringSet(authors)
|
||||
parcel.writeString(source.name)
|
||||
}
|
||||
|
||||
@@ -39,17 +40,17 @@ data class ParcelableManga(
|
||||
Manga(
|
||||
id = parcel.readLong(),
|
||||
title = requireNotNull(parcel.readString()),
|
||||
altTitle = parcel.readString(),
|
||||
altTitles = parcel.readStringSet(),
|
||||
url = requireNotNull(parcel.readString()),
|
||||
publicUrl = requireNotNull(parcel.readString()),
|
||||
rating = parcel.readFloat(),
|
||||
isNsfw = ParcelCompat.readBoolean(parcel),
|
||||
coverUrl = requireNotNull(parcel.readString()),
|
||||
contentRating = parcel.readSerializableCompat(),
|
||||
coverUrl = parcel.readString(),
|
||||
largeCoverUrl = parcel.readString(),
|
||||
description = parcel.readString(),
|
||||
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
|
||||
state = parcel.readSerializableCompat(),
|
||||
author = parcel.readString(),
|
||||
authors = parcel.readStringSet(),
|
||||
chapters = null,
|
||||
source = MangaSource(parcel.readString()),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||
parcel.writeInt(year)
|
||||
parcel.writeInt(yearFrom)
|
||||
parcel.writeInt(yearTo)
|
||||
parcel.writeString(author)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = MangaListFilter(
|
||||
@@ -45,6 +46,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||
year = parcel.readInt(),
|
||||
yearFrom = parcel.readInt(),
|
||||
yearTo = parcel.readInt(),
|
||||
author = parcel.readString(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.accounts.Account
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -17,7 +19,9 @@ import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.findFragment
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
@@ -25,15 +29,20 @@ import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToArray
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
@@ -55,18 +64,22 @@ import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
|
||||
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
|
||||
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
|
||||
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
@@ -93,22 +106,27 @@ class AppRouter private constructor(
|
||||
|
||||
constructor(fragment: Fragment) : this(null, fragment)
|
||||
|
||||
/** Activities **/
|
||||
|
||||
fun openList(source: MangaSource, filter: MangaListFilter?) {
|
||||
startActivity(listIntent(contextOrNull() ?: return, source, filter))
|
||||
private val settings: AppSettings by lazy {
|
||||
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
|
||||
}
|
||||
|
||||
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
|
||||
/** Activities **/
|
||||
|
||||
fun openSearch(query: String) {
|
||||
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
|
||||
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
|
||||
}
|
||||
|
||||
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
|
||||
|
||||
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
|
||||
startActivity(
|
||||
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||
.putExtra(KEY_QUERY, query),
|
||||
.putExtra(KEY_QUERY, query)
|
||||
.putExtra(KEY_KIND, kind),
|
||||
)
|
||||
}
|
||||
|
||||
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
|
||||
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
|
||||
|
||||
fun openDetails(manga: Manga) {
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||
@@ -118,6 +136,13 @@ class AppRouter private constructor(
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||
}
|
||||
|
||||
fun openDetails(link: Uri) {
|
||||
startActivity(
|
||||
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
|
||||
.setData(link),
|
||||
)
|
||||
}
|
||||
|
||||
fun openReader(manga: Manga, anchor: View? = null) {
|
||||
openReader(
|
||||
ReaderIntent.Builder(contextOrNull() ?: return)
|
||||
@@ -326,6 +351,44 @@ class AppRouter private constructor(
|
||||
}.showDistinct()
|
||||
}
|
||||
|
||||
fun showTagDialog(tag: MangaTag) {
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setTitle(tag.title)
|
||||
setItems(
|
||||
arrayOf(
|
||||
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
|
||||
context.getString(R.string.search_everywhere),
|
||||
),
|
||||
) { _, which ->
|
||||
when (which) {
|
||||
0 -> openList(tag)
|
||||
1 -> openSearch(tag.title, SearchKind.TAG)
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.close, null)
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun showAuthorDialog(author: String, source: MangaSource) {
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setTitle(author)
|
||||
setItems(
|
||||
arrayOf(
|
||||
context.getString(R.string.search_on_s, source.getTitle(context)),
|
||||
context.getString(R.string.search_everywhere),
|
||||
),
|
||||
) { _, which ->
|
||||
when (which) {
|
||||
0 -> openList(source, MangaListFilter(author = author), null)
|
||||
1 -> openSearch(author, SearchKind.AUTHOR)
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.close, null)
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun showErrorDialog(error: Throwable, url: String? = null) {
|
||||
ErrorDetailsDialog().withArgs(2) {
|
||||
putSerializable(KEY_ERROR, error)
|
||||
@@ -413,6 +476,45 @@ class AppRouter private constructor(
|
||||
TrackerCategoriesConfigSheet().showDistinct()
|
||||
}
|
||||
|
||||
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
|
||||
val context = contextOrNull() ?: return
|
||||
when (settings.allowDownloadOnMeteredNetwork) {
|
||||
TriStateOption.ENABLED -> onConfirmed(true)
|
||||
TriStateOption.DISABLED -> onConfirmed(false)
|
||||
TriStateOption.ASK -> {
|
||||
if (!context.connectivityManager.isActiveNetworkMetered) {
|
||||
onConfirmed(true)
|
||||
return
|
||||
}
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEUTRAL -> {
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||
onConfirmed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
BigButtonsAlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_network_cellular)
|
||||
.setTitle(R.string.download_cellular_confirm)
|
||||
.setPositiveButton(R.string.allow_always, listener)
|
||||
.setNeutralButton(R.string.allow_once, listener)
|
||||
.setNegativeButton(R.string.dont_allow, listener)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Public utils **/
|
||||
|
||||
fun isFilterSupported(): Boolean = when {
|
||||
@@ -427,10 +529,16 @@ class AppRouter private constructor(
|
||||
}
|
||||
|
||||
fun closeWelcomeSheet(): Boolean {
|
||||
val fm = fragment?.parentFragmentManager ?: activity?.supportFragmentManager ?: return false
|
||||
val sheet = fm.findFragmentByTag(fragmentTag<WelcomeSheet>()) as? WelcomeSheet ?: return false
|
||||
sheet.dismissAllowingStateLoss()
|
||||
return true
|
||||
val tag = fragmentTag<WelcomeSheet>()
|
||||
val sheet = fragment?.findFragmentByTagRecursive(tag)
|
||||
?: activity?.supportFragmentManager?.findFragmentByTag(tag)
|
||||
?: return false
|
||||
return if (sheet is WelcomeSheet) {
|
||||
sheet.dismissAllowingStateLoss()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Private utils **/
|
||||
@@ -455,6 +563,7 @@ class AppRouter private constructor(
|
||||
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||
}
|
||||
|
||||
@UiContext
|
||||
private fun contextOrNull(): Context? = activity ?: fragment?.context
|
||||
|
||||
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
|
||||
@@ -477,6 +586,18 @@ class AppRouter private constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? {
|
||||
childFragmentManager.findFragmentByTag(fragmentTag)?.let {
|
||||
return it
|
||||
}
|
||||
val parent = parentFragment
|
||||
return if (parent != null) {
|
||||
parent.findFragmentByTagRecursive(fragmentTag)
|
||||
} else {
|
||||
parentFragmentManager.findFragmentByTag(fragmentTag)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(view: View): AppRouter? = runCatching {
|
||||
@@ -491,7 +612,7 @@ class AppRouter private constructor(
|
||||
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
||||
.putExtra(KEY_ID, mangaId)
|
||||
|
||||
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent =
|
||||
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
|
||||
Intent(context, MangaListActivity::class.java)
|
||||
.setAction(ACTION_MANGA_EXPLORE)
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
@@ -499,19 +620,24 @@ class AppRouter private constructor(
|
||||
if (!filter.isNullOrEmpty()) {
|
||||
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||
}
|
||||
if (sortOrder != null) {
|
||||
putExtra(KEY_SORT_ORDER, sortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
||||
Intent(context, CloudFlareActivity::class.java).apply {
|
||||
data = exception.url.toUri()
|
||||
putExtra(KEY_SOURCE, exception.source?.name)
|
||||
exception.headers.get(CommonHeaders.USER_AGENT)?.let {
|
||||
exception.headers[CommonHeaders.USER_AGENT]?.let {
|
||||
putExtra(KEY_USER_AGENT, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||
|
||||
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
||||
|
||||
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
|
||||
|
||||
fun readerSettingsIntent(context: Context) =
|
||||
@@ -561,24 +687,27 @@ class AppRouter private constructor(
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
}
|
||||
|
||||
const val KEY_DATA = "data"
|
||||
const val KEY_ENTRIES = "entries"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_EXCLUDE = "exclude"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_FILTER = "filter"
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_KIND = "kind"
|
||||
const val KEY_LIST_SECTION = "list_section"
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_MANGA_LIST = "manga_list"
|
||||
const val KEY_PAGES = "pages"
|
||||
const val KEY_QUERY = "query"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SOURCE = "source"
|
||||
const val KEY_TAB = "tab"
|
||||
const val KEY_TITLE = "title"
|
||||
const val KEY_USER_AGENT = "user_agent"
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_DATA = "data"
|
||||
const val KEY_USER_AGENT = "user_agent"
|
||||
|
||||
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
|
||||
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.nav
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AppRouterEntryPoint {
|
||||
|
||||
val settings: AppSettings
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread
|
||||
import androidx.core.util.Predicate
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.newBuilder
|
||||
import org.koitharu.kotatsu.parsers.util.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ 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.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.Collections
|
||||
|
||||
@@ -182,7 +182,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(AppRouter.listIntent(context, source, null))
|
||||
.setIntent(AppRouter.listIntent(context, source, null, null))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.os
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -11,8 +14,8 @@ import javax.inject.Singleton
|
||||
class AppValidator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
@Suppress("NewApi")
|
||||
val isOriginalApp by lazy {
|
||||
@SuppressLint("InlinedApi")
|
||||
val isOriginalApp = suspendLazy(Dispatchers.Default) {
|
||||
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
|
||||
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class NetworkState(
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } ?: false
|
||||
activeNetwork?.let { isOnline(it) } == true
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.core.os
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
|
||||
class OpenDocumentTreeHelper(
|
||||
activityResultCaller: ActivityResultCaller,
|
||||
flags: Int,
|
||||
callback: ActivityResultCallback<Uri?>
|
||||
) : ActivityResultLauncher<Uri?>() {
|
||||
|
||||
constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback<Uri?>) : this(
|
||||
activityResultCaller,
|
||||
0,
|
||||
callback,
|
||||
)
|
||||
|
||||
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
|
||||
contract = OpenDocumentTreeContractLegacy(flags),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
||||
if (pickFileTreeLauncherQ == null) {
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
return
|
||||
}
|
||||
try {
|
||||
pickFileTreeLauncherQ.launch(input, options)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregister() {
|
||||
pickFileTreeLauncherQ?.unregister()
|
||||
pickFileTreeLauncherLegacy.unregister()
|
||||
}
|
||||
|
||||
override val contract: ActivityResultContract<Uri?, *>
|
||||
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
|
||||
|
||||
private open class OpenDocumentTreeContractLegacy(
|
||||
private val flags: Int,
|
||||
) : ActivityResultContracts.OpenDocumentTree() {
|
||||
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
val intent = super.createIntent(context, input)
|
||||
intent.addFlags(flags)
|
||||
return intent
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class OpenDocumentTreeContractQ(
|
||||
private val flags: Int,
|
||||
) : OpenDocumentTreeContractLegacy(flags) {
|
||||
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
||||
?.primaryStorageVolume
|
||||
?.createOpenDocumentTreeIntent()
|
||||
if (intent == null) { // fallback
|
||||
return super.createIntent(context, input)
|
||||
}
|
||||
intent.addFlags(flags)
|
||||
if (input != null) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.os
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.io.InputStreamReader
|
||||
|
||||
object RomCompat {
|
||||
|
||||
val isMiui = suspendLazy<Boolean>(Dispatchers.IO) {
|
||||
getProp("ro.miui.ui.version.name").isNotEmpty()
|
||||
}
|
||||
|
||||
@Blocking
|
||||
private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use {
|
||||
it.reader().use(InputStreamReader::readText).trim()
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,22 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
|
||||
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
@@ -25,14 +25,14 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
override val searchQueryCapabilities: MangaSearchQueryCapabilities
|
||||
get() = MangaSearchQueryCapabilities()
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import dagger.Reusable
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor(
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun isValidLink(str: String): Boolean {
|
||||
return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import androidx.collection.ArraySet
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
@@ -22,6 +20,7 @@ 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.find
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||
import java.util.EnumSet
|
||||
@@ -82,7 +81,7 @@ class ExternalPluginContentSource(
|
||||
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||
rating = maxOf(details.rating, manga.rating),
|
||||
isNsfw = details.isNsfw,
|
||||
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||
coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
tags = details.tags + manga.tags,
|
||||
state = details.state ?: manga.state,
|
||||
author = details.author.ifNullOrEmpty { manga.author },
|
||||
@@ -230,7 +229,7 @@ class ExternalPluginContentSource(
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(COLUMN_ID),
|
||||
name = cursor.getString(COLUMN_NAME),
|
||||
title = cursor.getStringOrNull(COLUMN_NAME),
|
||||
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
|
||||
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
|
||||
url = cursor.getString(COLUMN_URL),
|
||||
@@ -253,7 +252,7 @@ class ExternalPluginContentSource(
|
||||
publicUrl = getString(COLUMN_PUBLIC_URL),
|
||||
rating = getFloat(COLUMN_RATING),
|
||||
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
|
||||
coverUrl = getString(COLUMN_COVER_URL),
|
||||
coverUrl = getStringOrNull(COLUMN_COVER_URL),
|
||||
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
|
||||
@@ -20,7 +20,7 @@ class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Curs
|
||||
return when {
|
||||
columnIndex < 0 -> null
|
||||
isNull(columnIndex) -> null
|
||||
else -> getString(columnIndex)
|
||||
else -> getString(columnIndex).takeUnless { it == "null" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ import androidx.core.os.LocaleListCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putAll
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
@@ -44,6 +45,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val connectivityManager = context.connectivityManager
|
||||
private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges))
|
||||
|
||||
var listMode: ListMode
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
|
||||
@@ -141,6 +143,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderOptimizationEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
|
||||
|
||||
val readerControls: Set<ReaderControl>
|
||||
get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) {
|
||||
ReaderControl.entries.find(it)
|
||||
} ?: ReaderControl.DEFAULT
|
||||
|
||||
val isOfflineCheckDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
|
||||
|
||||
@@ -367,6 +374,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderBarEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR, true)
|
||||
|
||||
val isReaderBarTransparent: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
||||
|
||||
val isReaderKeepScreenOn: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||
|
||||
@@ -538,6 +548,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
|
||||
}
|
||||
|
||||
fun getMangaListBadges(): Int {
|
||||
val raw = prefs.getStringSet(KEY_MANGA_LIST_BADGES, mangaListBadgesDefault).orEmpty()
|
||||
var result = 0
|
||||
for (item in raw) {
|
||||
result = result or item.toInt()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
@@ -550,20 +569,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
fun upsertAll(m: Map<String, *>) {
|
||||
prefs.edit {
|
||||
m.forEach { e ->
|
||||
when (val v = e.value) {
|
||||
is Boolean -> putBoolean(e.key, v)
|
||||
is Int -> putInt(e.key, v)
|
||||
is Long -> putLong(e.key, v)
|
||||
is Float -> putFloat(e.key, v)
|
||||
is String -> putString(e.key, v)
|
||||
is JSONArray -> putStringSet(e.key, v.toStringSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
|
||||
|
||||
private fun isBackgroundNetworkRestricted(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
@@ -573,15 +579,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
val len = length()
|
||||
val result = ArraySet<String>(len)
|
||||
for (i in 0 until len) {
|
||||
result.add(getString(i))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TRACK_HISTORY = "history"
|
||||
@@ -628,6 +625,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
|
||||
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
|
||||
const val KEY_READER_ANIMATION = "reader_animation2"
|
||||
const val KEY_READER_CONTROLS = "reader_controls"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_READER_CROP = "reader_crop"
|
||||
@@ -671,6 +669,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SYNC = "sync"
|
||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||
const val KEY_READER_BAR = "reader_bar"
|
||||
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
|
||||
const val KEY_READER_BACKGROUND = "reader_background"
|
||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||
@@ -725,12 +724,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_QUICK_FILTER = "quick_filter"
|
||||
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
|
||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
||||
|
||||
// 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_APP_UPDATE = "app_update"
|
||||
const val KEY_LINK_WEBLATE = "about_app_translation"
|
||||
const val KEY_LINK_TELEGRAM = "about_telegram"
|
||||
const val KEY_LINK_GITHUB = "about_github"
|
||||
@@ -741,6 +740,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
|
||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
||||
const val KEY_STORAGE_USAGE = "storage_usage"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.isNightMode
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@Keep
|
||||
@@ -13,7 +15,7 @@ enum class ReaderBackground {
|
||||
|
||||
DEFAULT, LIGHT, DARK, WHITE, BLACK;
|
||||
|
||||
fun resolve(context: Context) = when (this) {
|
||||
fun resolve(context: Context): Drawable? = when (this) {
|
||||
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
|
||||
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
|
||||
.getThemeDrawable(android.R.attr.windowBackground)
|
||||
@@ -24,4 +26,14 @@ enum class ReaderBackground {
|
||||
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
|
||||
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
|
||||
}
|
||||
|
||||
fun isLight(context: Context): Boolean = when (this) {
|
||||
DEFAULT -> !context.resources.isNightMode
|
||||
|
||||
LIGHT,
|
||||
WHITE -> true
|
||||
|
||||
DARK,
|
||||
BLACK -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import java.util.EnumSet
|
||||
|
||||
enum class ReaderControl {
|
||||
|
||||
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE;
|
||||
|
||||
companion object {
|
||||
|
||||
val DEFAULT: Set<ReaderControl> = EnumSet.of(
|
||||
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
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.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
|
||||
@@ -25,6 +25,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
val isSlowdownEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
|
||||
|
||||
val isCaptchaNotificationsDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_NO_CAPTCHA, false)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> get(key: ConfigKey<T>): T {
|
||||
return when (key) {
|
||||
@@ -65,5 +68,6 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
const val KEY_NO_CAPTCHA = "no_captcha"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -23,15 +25,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
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
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
ScreenshotPolicyHelper.ContentContainer,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
private var isAmoledTheme = false
|
||||
|
||||
@@ -41,16 +41,20 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
@JvmField
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
|
||||
private var defaultStatusBarColor = Color.TRANSPARENT
|
||||
private lateinit var entryPoint: BaseActivityEntryPoint
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(newBase.applicationContext)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
AppCompatDelegate.setApplicationLocales(entryPoint.settings.appLocales)
|
||||
}
|
||||
super.attachBaseContext(newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
|
||||
val settings = entryPoint.settings
|
||||
isAmoledTheme = settings.isAmoledTheme
|
||||
setTheme(settings.colorScheme.styleResId)
|
||||
@@ -59,10 +63,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
insetsDelegate.handleImeInsets = true
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
@@ -96,7 +98,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
super.setContentView(binding.root)
|
||||
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
|
||||
toolbar?.let(this::setSupportActionBar)
|
||||
insetsDelegate.onViewCreated(binding.root)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
|
||||
@@ -10,12 +10,10 @@ import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
Fragment(),
|
||||
ExceptionResolver.Host,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
ExceptionResolver.Host {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
@@ -23,9 +21,6 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
protected val actionModeDelegate: ActionModeDelegate
|
||||
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||
|
||||
@@ -47,15 +42,11 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
insetsDelegate.onViewCreated(view)
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
onViewBindingCreated(requireViewBinding(), savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewBinding = null
|
||||
insetsDelegate.removeInsetsListener(this)
|
||||
insetsDelegate.onDestroyView()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
@@ -17,7 +15,7 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
@@ -28,7 +26,6 @@ import com.google.android.material.R as materialR
|
||||
@AndroidEntryPoint
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
WindowInsetsDelegate.WindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
|
||||
@@ -38,10 +35,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
|
||||
override val recyclerView: RecyclerView
|
||||
override val recyclerView: RecyclerView?
|
||||
get() = listView
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
@@ -55,14 +49,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
val themedContext = (view.parentView ?: view).context
|
||||
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
|
||||
listView.clipToPadding = false
|
||||
insetsDelegate.onViewCreated(view)
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
insetsDelegate.removeInsetsListener(this)
|
||||
insetsDelegate.onDestroyView()
|
||||
super.onDestroyView()
|
||||
listView.consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -74,13 +61,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
listView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun setTitle(title: CharSequence?) {
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleService
|
||||
|
||||
abstract class BaseService : LifecycleService()
|
||||
@@ -14,6 +14,7 @@ import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,6 +43,8 @@ abstract class CoroutineIntentService : BaseService() {
|
||||
intentJobContext.processIntent(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
intentJobContext.onError(e)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
|
||||
@AndroidEntryPoint
|
||||
abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fragment>) :
|
||||
BaseActivity<ActivityContainerBinding>(),
|
||||
AppBarOwner,
|
||||
SnackbarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val snackbarHost: CoordinatorLayout
|
||||
get() = viewBinding.root
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragmentClass, getFragmentExtras())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getFragmentExtras(): Bundle? = intent.extras
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
inline fun buildAlertDialog(
|
||||
context: Context,
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
): AlertDialog = MaterialAlertDialogBuilder(
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import dagger.Lazy
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import javax.inject.Inject
|
||||
|
||||
class CommonAlertDialogs @Inject constructor(
|
||||
private val settings: Lazy<AppSettings>,
|
||||
) {
|
||||
|
||||
fun askForDownloadOverMeteredNetwork(
|
||||
@UiContext context: Context,
|
||||
onConfirmed: (allow: Boolean) -> Unit
|
||||
) {
|
||||
when (settings.get().allowDownloadOnMeteredNetwork) {
|
||||
TriStateOption.ENABLED -> onConfirmed(true)
|
||||
TriStateOption.DISABLED -> onConfirmed(false)
|
||||
TriStateOption.ASK -> {
|
||||
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
|
||||
onConfirmed(true)
|
||||
return
|
||||
}
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEUTRAL -> {
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||
onConfirmed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
BigButtonsAlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_network_cellular)
|
||||
.setTitle(R.string.download_cellular_confirm)
|
||||
.setPositiveButton(R.string.allow_always, listener)
|
||||
.setNeutralButton(R.string.allow_once, listener)
|
||||
.setNegativeButton(R.string.dont_allow, listener)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
@@ -14,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
@@ -53,7 +51,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setTitle(R.string.error_occurred)
|
||||
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
|
||||
copyToClipboard()
|
||||
context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString())
|
||||
}
|
||||
if (exception.isReportable()) {
|
||||
builder.setPositiveButton(R.string.report) { _, _ ->
|
||||
@@ -63,12 +61,4 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun copyToClipboard() {
|
||||
val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
?: return
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.view.View
|
||||
import android.view.View.OnLayoutChangeListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||
import android.widget.ImageView
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Size
|
||||
import coil3.size.ViewSizeResolver
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.roundToInt
|
||||
@@ -20,31 +19,67 @@ class CoverSizeResolver(
|
||||
) : ViewSizeResolver<ImageView> {
|
||||
|
||||
override suspend fun size(): Size {
|
||||
// Fast path: the view is already measured.
|
||||
getSize()?.let { return it }
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val layoutListener = LayoutListener(cont)
|
||||
view.addOnLayoutChangeListener(layoutListener)
|
||||
cont.invokeOnCancellation {
|
||||
view.removeOnLayoutChangeListener(layoutListener)
|
||||
|
||||
// Slow path: wait for the view to be measured.
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val viewTreeObserver = view.viewTreeObserver
|
||||
|
||||
val preDrawListener = object : OnPreDrawListener {
|
||||
private var isResumed = false
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
val size = getSize()
|
||||
if (size != null) {
|
||||
viewTreeObserver.removePreDrawListenerSafe(this)
|
||||
|
||||
if (!isResumed) {
|
||||
isResumed = true
|
||||
continuation.resume(size)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
viewTreeObserver.removePreDrawListenerSafe(preDrawListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSize(): Size? {
|
||||
val lp = view.layoutParams
|
||||
var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight)
|
||||
var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom)
|
||||
if (width == null && height == null) {
|
||||
return null
|
||||
}
|
||||
if (height == null && width != null) {
|
||||
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
|
||||
} else if (width == null && height != null) {
|
||||
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
|
||||
var width = getWidth()
|
||||
var height = getHeight()
|
||||
when {
|
||||
width == null && height == null -> {
|
||||
return null
|
||||
}
|
||||
height == null && width != null -> {
|
||||
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
|
||||
}
|
||||
width == null && height != null -> {
|
||||
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
|
||||
}
|
||||
}
|
||||
return Size(checkNotNull(width), checkNotNull(height))
|
||||
}
|
||||
|
||||
private fun getWidth() = getDimension(
|
||||
paramSize = view.layoutParams?.width ?: -1,
|
||||
viewSize = view.width,
|
||||
paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0
|
||||
)
|
||||
|
||||
private fun getHeight() = getDimension(
|
||||
paramSize = view.layoutParams?.height ?: -1,
|
||||
viewSize = view.height,
|
||||
paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0
|
||||
)
|
||||
|
||||
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
|
||||
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
return null
|
||||
@@ -60,24 +95,11 @@ class CoverSizeResolver(
|
||||
return null
|
||||
}
|
||||
|
||||
private inner class LayoutListener(
|
||||
private val continuation: CancellableContinuation<Size>,
|
||||
) : OnLayoutChangeListener {
|
||||
|
||||
override fun onLayoutChange(
|
||||
v: View,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int,
|
||||
) {
|
||||
val size = getSize() ?: return
|
||||
v.removeOnLayoutChangeListener(this)
|
||||
continuation.resume(size)
|
||||
private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) {
|
||||
if (isAlive) {
|
||||
removeOnPreDrawListener(victim)
|
||||
} else {
|
||||
view.viewTreeObserver.removeOnPreDrawListener(victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
||||
init {
|
||||
fastScroller.id = R.id.fast_scroller
|
||||
fastScroller.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
LayoutParams.WRAP_CONTENT,
|
||||
LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DimenRes
|
||||
@@ -162,7 +164,7 @@ class FastScroller @JvmOverloads constructor(
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
|
||||
super.onSizeChanged(w, h, oldW, oldH)
|
||||
viewHeight = h
|
||||
viewHeight = h - paddingTop - paddingBottom
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -229,6 +231,7 @@ class FastScroller @JvmOverloads constructor(
|
||||
*
|
||||
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
|
||||
*/
|
||||
@Suppress("RemoveRedundantQualifierName")
|
||||
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
|
||||
params.width = LayoutParams.WRAP_CONTENT
|
||||
super.setLayoutParams(params)
|
||||
@@ -529,7 +532,7 @@ class FastScroller @JvmOverloads constructor(
|
||||
|
||||
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
|
||||
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
|
||||
p as ViewGroup
|
||||
p
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package org.koitharu.kotatsu.core.ui.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toMillis
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
sealed class DateTimeAgo {
|
||||
|
||||
abstract fun format(resources: Resources): String
|
||||
abstract fun format(context: Context): String
|
||||
|
||||
object JustNow : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.just_now)
|
||||
override fun format(context: Context): String {
|
||||
return context.getString(R.string.just_now)
|
||||
}
|
||||
|
||||
override fun toString() = "just_now"
|
||||
@@ -20,24 +22,32 @@ sealed class DateTimeAgo {
|
||||
}
|
||||
|
||||
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
|
||||
override fun format(context: Context): String {
|
||||
return context.resources.getQuantityStringSafe(
|
||||
R.plurals.minutes_ago,
|
||||
minutes,
|
||||
minutes,
|
||||
)
|
||||
}
|
||||
|
||||
override fun toString() = "minutes_ago_$minutes"
|
||||
}
|
||||
|
||||
data class HoursAgo(val hours: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
|
||||
override fun format(context: Context): String {
|
||||
return context.resources.getQuantityStringSafe(
|
||||
R.plurals.hours_ago,
|
||||
hours,
|
||||
hours,
|
||||
)
|
||||
}
|
||||
|
||||
override fun toString() = "hours_ago_$hours"
|
||||
}
|
||||
|
||||
object Today : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.today)
|
||||
override fun format(context: Context): String {
|
||||
return context.getString(R.string.today)
|
||||
}
|
||||
|
||||
override fun toString() = "today"
|
||||
@@ -46,8 +56,8 @@ sealed class DateTimeAgo {
|
||||
}
|
||||
|
||||
object Yesterday : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.yesterday)
|
||||
override fun format(context: Context): String {
|
||||
return context.getString(R.string.yesterday)
|
||||
}
|
||||
|
||||
override fun toString() = "yesterday"
|
||||
@@ -56,44 +66,46 @@ sealed class DateTimeAgo {
|
||||
}
|
||||
|
||||
data class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
||||
override fun format(context: Context): String {
|
||||
return context.resources.getQuantityStringSafe(R.plurals.days_ago, days, days)
|
||||
}
|
||||
|
||||
override fun toString() = "days_ago_$days"
|
||||
}
|
||||
|
||||
data class MonthsAgo(val months: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
override fun format(context: Context): String {
|
||||
return if (months == 0) {
|
||||
resources.getString(R.string.this_month)
|
||||
context.getString(R.string.this_month)
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.months_ago, months, months)
|
||||
context.resources.getQuantityStringSafe(
|
||||
R.plurals.months_ago,
|
||||
months,
|
||||
months,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Absolute(private val date: LocalDate) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
override fun format(context: Context): String {
|
||||
return if (date == EPOCH_DATE) {
|
||||
resources.getString(R.string.unknown)
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
date.format(formatter)
|
||||
DateUtils.formatDateTime(context, date.toMillis(), DateUtils.FORMAT_SHOW_DATE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = "abs_${date.toEpochDay()}"
|
||||
|
||||
companion object {
|
||||
// TODO: Use Java 9's LocalDate.EPOCH.
|
||||
private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
|
||||
private val formatter = DateTimeFormatter.ofPattern("d MMMM")
|
||||
private companion object {
|
||||
val EPOCH_DATE: LocalDate = LocalDate.of(1970, 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
object LongAgo : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.long_ago)
|
||||
override fun format(context: Context): String {
|
||||
return context.getString(R.string.long_ago)
|
||||
}
|
||||
|
||||
override fun toString() = "long_ago"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.GravityInt
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeRelative
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
|
||||
class InsetsToMarginsListener(
|
||||
@GravityInt
|
||||
private val sides: Int,
|
||||
private val baseMargins: Insets,
|
||||
) : OnApplyWindowInsetsListener {
|
||||
|
||||
private val insetType = WindowInsetsCompat.Type.systemBars()
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(insetType)
|
||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
if (sides and Gravity.START == Gravity.START) {
|
||||
marginStart = barsInsets.start(v) + baseMargins.start(v)
|
||||
}
|
||||
if (sides and Gravity.TOP == Gravity.TOP) {
|
||||
topMargin = barsInsets.top + baseMargins.top
|
||||
}
|
||||
if (sides and Gravity.END == Gravity.END) {
|
||||
marginEnd = barsInsets.end(v) + baseMargins.end(v)
|
||||
}
|
||||
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
|
||||
bottomMargin = barsInsets.bottom + baseMargins.bottom
|
||||
}
|
||||
}
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(
|
||||
insetType,
|
||||
barsInsets.consumeRelative(
|
||||
v,
|
||||
start = sides and Gravity.START == Gravity.START,
|
||||
top = sides and Gravity.TOP == Gravity.TOP,
|
||||
end = sides and Gravity.END == Gravity.END,
|
||||
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.annotation.GravityInt
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeRelative
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
|
||||
class InsetsToPaddingListener(
|
||||
@GravityInt
|
||||
private val sides: Int,
|
||||
private val basePaddings: Insets,
|
||||
) : OnApplyWindowInsetsListener {
|
||||
|
||||
private val insetType = WindowInsetsCompat.Type.systemBars()
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(insetType)
|
||||
v.setPaddingRelative(
|
||||
/* start = */
|
||||
if (sides and Gravity.START == Gravity.START) {
|
||||
barsInsets.start(v) + basePaddings.start(v)
|
||||
} else {
|
||||
v.paddingStart
|
||||
},
|
||||
/* top = */
|
||||
if (sides and Gravity.TOP == Gravity.TOP) {
|
||||
barsInsets.top + basePaddings.top
|
||||
} else {
|
||||
v.paddingTop
|
||||
},
|
||||
/* end = */
|
||||
if (sides and Gravity.END == Gravity.END) {
|
||||
barsInsets.end(v) + basePaddings.end(v)
|
||||
} else {
|
||||
v.paddingEnd
|
||||
},
|
||||
/* bottom = */
|
||||
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
|
||||
barsInsets.bottom + basePaddings.bottom
|
||||
} else {
|
||||
v.paddingBottom
|
||||
},
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(
|
||||
insetType,
|
||||
barsInsets.consumeRelative(
|
||||
v,
|
||||
start = sides and Gravity.START == Gravity.START,
|
||||
top = sides and Gravity.TOP == Gravity.TOP,
|
||||
end = sides and Gravity.END == Gravity.END,
|
||||
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
interface RecyclerViewOwner {
|
||||
|
||||
val recyclerView: RecyclerView
|
||||
val recyclerView: RecyclerView?
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import java.util.LinkedList
|
||||
|
||||
class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
|
||||
|
||||
@JvmField
|
||||
var handleImeInsets: Boolean = false
|
||||
|
||||
@JvmField
|
||||
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
|
||||
|
||||
private val listeners = LinkedList<WindowInsetsListener>()
|
||||
private var lastInsets: Insets? = null
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
|
||||
val newInsets = if (handleImeInsets) {
|
||||
Insets.max(
|
||||
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()),
|
||||
handledInsets.getInsets(WindowInsetsCompat.Type.ime()),
|
||||
)
|
||||
} else {
|
||||
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
if (newInsets != lastInsets) {
|
||||
listeners.forEach { it.onWindowInsetsChanged(newInsets) }
|
||||
lastInsets = newInsets
|
||||
}
|
||||
return handledInsets
|
||||
}
|
||||
|
||||
override fun onLayoutChange(
|
||||
view: View,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int,
|
||||
) {
|
||||
view.removeOnLayoutChangeListener(this)
|
||||
if (lastInsets == null) { // Listener may not be called
|
||||
onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
fun addInsetsListener(listener: WindowInsetsListener) {
|
||||
listeners.add(listener)
|
||||
lastInsets?.let { listener.onWindowInsetsChanged(it) }
|
||||
}
|
||||
|
||||
fun removeInsetsListener(listener: WindowInsetsListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun onViewCreated(view: View) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
view.addOnLayoutChangeListener(this)
|
||||
}
|
||||
|
||||
fun onDestroyView() {
|
||||
lastInsets = null
|
||||
}
|
||||
|
||||
fun interface WindowInsetsListener {
|
||||
|
||||
fun onWindowInsetsChanged(insets: Insets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.isVisible
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class IconsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : LinearLayout(context, attrs) {
|
||||
|
||||
private var iconSize = LayoutParams.WRAP_CONTENT
|
||||
private var iconSpacing = 0
|
||||
|
||||
val iconsCount: Int
|
||||
get() {
|
||||
var count = 0
|
||||
repeat(childCount) { i ->
|
||||
if (getChildAt(i).isVisible) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.IconsView) {
|
||||
iconSize = getDimensionPixelSize(R.styleable.IconsView_iconSize, iconSize)
|
||||
iconSpacing = getDimensionPixelOffset(R.styleable.IconsView_iconSpacing, iconSpacing)
|
||||
}
|
||||
}
|
||||
|
||||
fun setIcons(icons: Iterable<Drawable>) {
|
||||
var index = 0
|
||||
for (icon in icons) {
|
||||
val imageView = (getChildAt(index) as ImageView?) ?: addImageView()
|
||||
imageView.setImageDrawable(icon)
|
||||
imageView.isVisible = true
|
||||
index++
|
||||
}
|
||||
for (i in index until childCount) {
|
||||
val imageView = getChildAt(i) as? ImageView ?: continue
|
||||
imageView.setImageDrawable(null)
|
||||
imageView.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
fun clearIcons() {
|
||||
repeat(childCount) { i ->
|
||||
getChildAt(i).isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
fun addIcon(drawable: Drawable) {
|
||||
val imageView = getNextImageView()
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.isVisible = true
|
||||
}
|
||||
|
||||
fun addIcon(@DrawableRes resId: Int) {
|
||||
val imageView = getNextImageView()
|
||||
imageView.setImageResource(resId)
|
||||
imageView.isVisible = true
|
||||
}
|
||||
|
||||
private fun getNextImageView(): ImageView {
|
||||
repeat(childCount) { i ->
|
||||
val child = getChildAt(i)
|
||||
if (child is ImageView && !child.isVisible) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return addImageView()
|
||||
}
|
||||
|
||||
private fun addImageView() = ImageView(context).also {
|
||||
it.scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
val lp = LayoutParams(iconSize, iconSize)
|
||||
if (childCount != 0) {
|
||||
lp.marginStart = iconSpacing
|
||||
}
|
||||
addView(it, lp)
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.children
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@Deprecated("")
|
||||
class ProgressButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener {
|
||||
|
||||
private val textViewTitle = TextView(context)
|
||||
private val textViewSubtitle = TextView(context)
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private var progress = 0f
|
||||
private var targetProgress = 0f
|
||||
private var colorBase: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
private var colorProgress: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
private var progressAnimator: ValueAnimator? = null
|
||||
|
||||
private var colorBaseCurrent = colorProgress.defaultColor
|
||||
private var colorProgressCurrent = colorProgress.defaultColor
|
||||
|
||||
var title: CharSequence?
|
||||
get() = textViewTitle.textAndVisible
|
||||
set(value) {
|
||||
textViewTitle.textAndVisible = value
|
||||
}
|
||||
|
||||
var subtitle: CharSequence?
|
||||
get() = textViewSubtitle.textAndVisible
|
||||
set(value) {
|
||||
textViewSubtitle.textAndVisible = value
|
||||
}
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
outlineProvider = OutlineProvider()
|
||||
clipToOutline = true
|
||||
|
||||
context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) {
|
||||
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
|
||||
TextViewCompat.setTextAppearance(
|
||||
textViewTitle,
|
||||
getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
TextViewCompat.setTextAppearance(
|
||||
textViewSubtitle,
|
||||
getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
textViewTitle.text = getText(R.styleable.ProgressButton_title)
|
||||
textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle)
|
||||
colorBase = getColorStateList(R.styleable.ProgressButton_baseColor)
|
||||
?: context.getThemeColorStateList(materialR.attr.colorPrimaryContainer) ?: colorBase
|
||||
colorProgress = getColorStateList(R.styleable.ProgressButton_progressColor)
|
||||
?: context.getThemeColorStateList(materialR.attr.colorPrimary) ?: colorProgress
|
||||
getColorStateList(R.styleable.ProgressButton_android_textColor)?.let { colorText ->
|
||||
textViewTitle.setTextColor(colorText)
|
||||
textViewSubtitle.setTextColor(colorText)
|
||||
}
|
||||
progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() /
|
||||
getInt(R.styleable.ProgressButton_android_max, 100).toFloat()
|
||||
}
|
||||
|
||||
addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||
addView(
|
||||
textViewSubtitle,
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp ->
|
||||
lp.topMargin = context.resources.resolveDp(2)
|
||||
},
|
||||
)
|
||||
|
||||
paint.style = Paint.Style.FILL
|
||||
applyGravity()
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawColor(colorBaseCurrent)
|
||||
if (progress > 0f) {
|
||||
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawableStateChanged() {
|
||||
super.drawableStateChanged()
|
||||
val state = drawableState
|
||||
colorBaseCurrent = colorBase.getColorForState(state, colorBase.defaultColor)
|
||||
colorProgressCurrent = colorProgress.getColorForState(state, colorProgress.defaultColor)
|
||||
colorProgressCurrent = ColorUtils.setAlphaComponent(colorProgressCurrent, 84 /* 255 * 0.33F */)
|
||||
paint.color = colorProgressCurrent
|
||||
}
|
||||
|
||||
override fun setGravity(gravity: Int) {
|
||||
super.setGravity(gravity)
|
||||
if (childCount != 0) {
|
||||
applyGravity()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
children.forEach { it.isEnabled = enabled }
|
||||
}
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
if (animation === progressAnimator) {
|
||||
progress = animation.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int) {
|
||||
textViewTitle.setTextAndVisible(titleResId)
|
||||
}
|
||||
|
||||
fun setSubtitle(@StringRes titleResId: Int) {
|
||||
textViewSubtitle.setTextAndVisible(titleResId)
|
||||
}
|
||||
|
||||
fun setProgress(value: Float, animate: Boolean) {
|
||||
val prevAnimator = progressAnimator
|
||||
if (animate && context.isAnimationsEnabled) {
|
||||
if (value == targetProgress) {
|
||||
return
|
||||
}
|
||||
targetProgress = value
|
||||
progressAnimator = ValueAnimator.ofFloat(progress, value).apply {
|
||||
duration = context.getAnimationDuration(android.R.integer.config_mediumAnimTime)
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
addUpdateListener(this@ProgressButton)
|
||||
}
|
||||
progressAnimator?.start()
|
||||
} else {
|
||||
progressAnimator = null
|
||||
progress = value
|
||||
targetProgress = value
|
||||
invalidate()
|
||||
}
|
||||
prevAnimator?.cancel()
|
||||
}
|
||||
|
||||
private fun applyGravity() {
|
||||
val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL
|
||||
textViewTitle.gravity = value
|
||||
textViewSubtitle.gravity = value
|
||||
}
|
||||
|
||||
private class OutlineProvider : ViewOutlineProvider() {
|
||||
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,11 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMinimumHeight(minHeight: Int) {
|
||||
super.setMinimumHeight(minHeight)
|
||||
getChildAt(0)?.minimumHeight = minHeight
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (currentState == STATE_UP) {
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlin.math.absoluteValue
|
||||
object KotatsuColors {
|
||||
|
||||
@ColorInt
|
||||
@Deprecated("")
|
||||
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
||||
val colorHex = String.format("%06x", context.getThemeColor(resId))
|
||||
val hue = getHue(colorHex)
|
||||
@@ -21,6 +22,13 @@ object KotatsuColors {
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun segmentColorRandom(context: Context, seed: Any): Int {
|
||||
val color = random(seed)
|
||||
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun random(seed: Any): Int {
|
||||
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.graphics.Paint
|
||||
import androidx.core.graphics.PaintCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import java.util.Locale
|
||||
|
||||
object LocaleUtils {
|
||||
@@ -13,6 +13,8 @@ object LocaleUtils {
|
||||
val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) {
|
||||
"EN" -> "GB"
|
||||
"JA" -> "JP"
|
||||
"VI" -> "VN"
|
||||
"ZH" -> "CN"
|
||||
else -> c
|
||||
}
|
||||
val emoji = countryCodeToEmojiFlag(code)
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.LocaleConfig
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
@@ -13,19 +15,16 @@ import android.content.Context.POWER_SERVICE
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.Window
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -37,7 +36,6 @@ import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -46,15 +44,8 @@ import androidx.lifecycle.coroutineScope
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import androidx.work.CoroutineWorker
|
||||
import com.google.android.material.elevation.ElevationOverlayProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
@@ -67,8 +58,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.roundToLong
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
val Context.activityManager: ActivityManager?
|
||||
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
|
||||
@@ -101,25 +92,6 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
|
||||
e.printStackTraceDebug()
|
||||
}.isSuccess
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) {
|
||||
coroutineScope.launch {
|
||||
delay(delay)
|
||||
@@ -139,23 +111,6 @@ fun SyncResult.onError(error: Throwable) {
|
||||
error.printStackTraceDebug()
|
||||
}
|
||||
|
||||
fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, alphaFactor: Float = 0.7f) {
|
||||
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
!context.getSystemBoolean("config_navBarNeedsScrim", true)
|
||||
) {
|
||||
Color.TRANSPARENT
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
|
||||
val baseColor = context.getThemeColor(android.R.attr.navigationBarColor)
|
||||
ColorUtils.setAlphaComponent(baseColor, (Color.alpha(baseColor) * alphaFactor).toInt())
|
||||
} else {
|
||||
// Set navbar scrim 70% of navigationBarColor
|
||||
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
|
||||
context.getThemeColor(materialR.attr.colorSurfaceContainer, alphaFactor),
|
||||
elevation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val Context.animatorDurationScale: Float
|
||||
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
|
||||
|
||||
@@ -276,3 +231,26 @@ fun Context.restartApplication() {
|
||||
startActivity(intent)
|
||||
activity?.finishAndRemoveTask()
|
||||
}
|
||||
|
||||
internal inline fun <R> PowerManager?.withPartialWakeLock(tag: String, body: (PowerManager.WakeLock?) -> R): R {
|
||||
val wakeLock = newPartialWakeLock(tag)
|
||||
return try {
|
||||
wakeLock?.acquire(TimeUnit.HOURS.toMillis(1))
|
||||
body(wakeLock)
|
||||
} finally {
|
||||
wakeLock?.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PowerManager?.newPartialWakeLock(tag: String): PowerManager.WakeLock? {
|
||||
return if (this != null && isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)) {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.copyToClipboard(label: String, content: String) {
|
||||
val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(label, content))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import org.koitharu.kotatsu.parsers.util.toArraySet
|
||||
import java.io.Serializable
|
||||
import java.util.EnumSet
|
||||
|
||||
@@ -84,6 +85,14 @@ fun <E : Enum<E>> Parcel.readEnumSet(cls: Class<E>): Set<E>? {
|
||||
return set
|
||||
}
|
||||
|
||||
fun Parcel.writeStringSet(set: Set<String>?) {
|
||||
writeStringArray(set?.toTypedArray().orEmpty())
|
||||
}
|
||||
|
||||
fun Parcel.readStringSet(): Set<String> {
|
||||
return this.createStringArray()?.toArraySet().orEmpty()
|
||||
}
|
||||
|
||||
fun <T> SavedStateHandle.require(key: String): T {
|
||||
return checkNotNull(get(key)) {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
@@ -116,8 +116,8 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui
|
||||
0.25f,
|
||||
)
|
||||
return placeholder(AnimatedPlaceholderDrawable(context))
|
||||
.fallback(ColorDrawable(context.getThemeColor(materialR.attr.colorSurfaceContainer)))
|
||||
.error(ColorDrawable(errorColor))
|
||||
.fallback(context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable())
|
||||
.error(errorColor.toDrawable())
|
||||
}
|
||||
|
||||
private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) {
|
||||
|
||||
@@ -4,21 +4,8 @@ import androidx.collection.ArrayMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.LongSet
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
|
||||
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
|
||||
val set = ArraySet<T>(size)
|
||||
repeat(size) { index -> set.add(init(index)) }
|
||||
return set
|
||||
}
|
||||
|
||||
inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
|
||||
0 -> emptySet()
|
||||
1 -> Collections.singleton(init(0))
|
||||
else -> MutableSet(size, init)
|
||||
}
|
||||
|
||||
fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
||||
this as ArrayList<T>
|
||||
} else {
|
||||
@@ -76,15 +63,6 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
|
||||
}
|
||||
}
|
||||
|
||||
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
|
||||
val result = arrayOfNulls<R>(size)
|
||||
forEachIndexed { index, t -> result[index] = transform(t) }
|
||||
return result as Array<R>
|
||||
}
|
||||
|
||||
fun LongSet.toLongArray(): LongArray {
|
||||
val result = LongArray(size)
|
||||
var i = 0
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toFile
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.removeSuffix
|
||||
import java.io.File
|
||||
@@ -31,6 +34,21 @@ fun Uri.resolveFile(context: Context): File? {
|
||||
)
|
||||
}
|
||||
|
||||
fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching {
|
||||
if (uri.isFileUri()) {
|
||||
return@runCatching uri.toFile().name
|
||||
}
|
||||
query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private fun getVolumePath(volumeId: String, context: Context): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getVolumePathForAndroid11AndAbove(volumeId, context)
|
||||
@@ -63,7 +81,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.R)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
|
||||
|
||||
@@ -35,6 +35,7 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
|
||||
|
||||
fun Resources.formatDurationShort(millis: Long): String? {
|
||||
@@ -50,3 +51,5 @@ fun Resources.formatDurationShort(millis: Long): String? {
|
||||
else -> getString(R.string.seconds_short, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun LocalDate.toMillis(): Long = atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -135,3 +136,9 @@ suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
|
||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||
|
||||
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }
|
||||
|
||||
suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
|
||||
if (item != null) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
@@ -27,12 +25,6 @@ fun Response.parseJsonOrNull(): JSONObject? {
|
||||
}
|
||||
}
|
||||
|
||||
val HttpUrl.isHttpOrHttps: Boolean
|
||||
get() {
|
||||
val s = scheme.lowercase()
|
||||
return s == "https" || s == "http"
|
||||
}
|
||||
|
||||
fun Response.ensureSuccess() = apply {
|
||||
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
|
||||
closeQuietly()
|
||||
@@ -40,26 +32,6 @@ fun Response.ensureSuccess() = apply {
|
||||
}
|
||||
}
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
if (persistent) {
|
||||
c.expiresAt(expiresAt)
|
||||
}
|
||||
if (hostOnly) {
|
||||
c.hostOnlyDomain(domain)
|
||||
} else {
|
||||
c.domain(domain)
|
||||
}
|
||||
c.path(path)
|
||||
if (secure) {
|
||||
c.secure()
|
||||
}
|
||||
if (httpOnly) {
|
||||
c.httpOnly()
|
||||
}
|
||||
}
|
||||
|
||||
fun String.sanitizeHeaderValue(): String {
|
||||
return if (all(Char::isValidForHeaderValue)) {
|
||||
this // fast path
|
||||
|
||||
@@ -10,3 +10,29 @@ fun Insets.end(view: View): Int {
|
||||
fun Insets.start(view: View): Int {
|
||||
return if (view.isRtl) right else left
|
||||
}
|
||||
|
||||
fun Insets.consume(
|
||||
left: Boolean = false,
|
||||
top: Boolean = false,
|
||||
right: Boolean = false,
|
||||
bottom: Boolean = false,
|
||||
): Insets = Insets.of(
|
||||
/* left = */ if (left) 0 else this.left,
|
||||
/* top = */ if (top) 0 else this.top,
|
||||
/* right = */ if (right) 0 else this.right,
|
||||
/* bottom = */ if (bottom) 0 else this.bottom,
|
||||
)
|
||||
|
||||
|
||||
fun Insets.consumeRelative(
|
||||
view: View,
|
||||
start: Boolean = false,
|
||||
top: Boolean = false,
|
||||
end: Boolean = false,
|
||||
bottom: Boolean = false,
|
||||
): Insets = Insets.of(
|
||||
/* left = */ if (if (view.isRtl) end else start) 0 else this.left,
|
||||
/* top = */ if (top) 0 else this.top,
|
||||
/* right = */ if (if (view.isRtl) start else end) 0 else this.right,
|
||||
/* bottom = */ if (bottom) 0 else this.bottom,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
import android.content.Context
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.Set
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import org.koitharu.kotatsu.core.io.NullOutputStream
|
||||
import java.io.ObjectOutputStream
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> Class<T>.castOrNull(obj: Any?): T? {
|
||||
if (obj == null || !isInstance(obj)) {
|
||||
@@ -7,3 +10,9 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
|
||||
}
|
||||
return obj as T
|
||||
}
|
||||
|
||||
fun Any.isSerializable() = runCatching {
|
||||
val oos = ObjectOutputStream(NullOutputStream())
|
||||
oos.writeObject(this)
|
||||
oos.flush()
|
||||
}.isSuccess
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.json.JSONArray
|
||||
|
||||
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
|
||||
if (value == null) {
|
||||
@@ -9,6 +18,10 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun MultiSelectListPreference.setDefaultValueCompat(defaultValue: Set<String>) {
|
||||
setDefaultValue(defaultValue) // FIXME not working
|
||||
}
|
||||
|
||||
fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, enumClass: Class<E>): E? {
|
||||
val stringValue = getString(key, null) ?: return null
|
||||
return enumClass.enumConstants?.find {
|
||||
@@ -23,3 +36,44 @@ fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E):
|
||||
fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
|
||||
putString(key, value?.name)
|
||||
}
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun SharedPreferences.Editor.putAll(values: Map<String, *>) {
|
||||
values.forEach { e ->
|
||||
when (val v = e.value) {
|
||||
is Boolean -> putBoolean(e.key, v)
|
||||
is Int -> putInt(e.key, v)
|
||||
is Long -> putLong(e.key, v)
|
||||
is Float -> putFloat(e.key, v)
|
||||
is String -> putString(e.key, v)
|
||||
is JSONArray -> putStringSet(e.key, v.toStringSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
val len = length()
|
||||
val result = ArraySet<String>(len)
|
||||
for (i in 0 until len) {
|
||||
result.add(getString(i))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
|
||||
|
||||
fun longOf(a: Int, b: Int): Long {
|
||||
return a.toLong() shl 32 or (b.toLong() and 0xffffffffL)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
@@ -52,7 +56,7 @@ fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
|
||||
|
||||
val RecyclerView.isScrolledToTop: Boolean
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
if (isEmpty()) {
|
||||
return true
|
||||
}
|
||||
val holder = findViewHolderForAdapterPosition(0)
|
||||
@@ -72,3 +76,35 @@ val RecyclerView.LayoutManager?.isLayoutReversed
|
||||
is StaggeredGridLayoutManager -> reverseLayout
|
||||
else -> false
|
||||
}
|
||||
|
||||
// https://medium.com/flat-pack-tech/quickly-scroll-to-the-top-of-a-recyclerview-da15b717f3c4
|
||||
fun RecyclerView.smoothScrollToTop() {
|
||||
val layoutManager = layoutManager as? LinearLayoutManager ?: return
|
||||
|
||||
if (!context.isAnimationsEnabled) {
|
||||
layoutManager.scrollToPositionWithOffset(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val smoothScroller = object : LinearSmoothScroller(context) {
|
||||
init {
|
||||
targetPosition = 0
|
||||
}
|
||||
|
||||
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?) =
|
||||
super.calculateSpeedPerPixel(displayMetrics) / DEFAULT_SPEED_FACTOR
|
||||
}
|
||||
|
||||
val jumpBeforeScroll = layoutManager.findFirstVisibleItemPosition() > DEFAULT_JUMP_THRESHOLD
|
||||
if (jumpBeforeScroll) {
|
||||
layoutManager.scrollToPositionWithOffset(DEFAULT_JUMP_THRESHOLD, 0)
|
||||
doOnNextLayout {
|
||||
layoutManager.startSmoothScroll(smoothScroller)
|
||||
}
|
||||
} else {
|
||||
layoutManager.startSmoothScroll(smoothScroller)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_JUMP_THRESHOLD = 30
|
||||
private const val DEFAULT_SPEED_FACTOR = 1f
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user