Compare commits

...

84 commits

Author SHA1 Message Date
littlecodedragon
437b7ad45d ci(forgejo): raise Gradle memory for post-upstream release builds
All checks were successful
Forgejo Release Builder / release (push) Successful in 7m55s
The merged upstream tree needs more heap/metaspace during assembleRelease.
Document Obtainium Forgejo source override, SSL, and version trim regex.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:31:33 +02:00
littlecodedragon
d342a8d595 Merge upstream/master into main
Some checks failed
Forgejo Release Builder / release (push) Failing after 4m49s
Resolve conflicts: keep upstream GitHub workflows, adopt extension_store
migration as 46.sqm, move fork pages_read history column to 47.sqm, merge
DomainModule DI for ExtensionStore + fork features, and combine DE strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:13:44 +02:00
littlecodedragon
d69ce1c388 ci(upstream): track fork drift with check script and weekly workflow
Add check-upstream.sh, generated status artifacts, merge guide, and a
scheduled Forgejo job to refresh upstream sync visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:12:13 +02:00
littlecodedragon
49a0644f68 ci(forgejo): add manual release dispatch and refresh release docs
Allow rebuilding an existing tag via workflow_dispatch and document
Obtainium-friendly asset names, secrets, and runner setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:12:13 +02:00
littlecodedragon
a81d717ca3 feat(download): auto-download chapters during library update
Add configurable library-update download modes (disabled, next X unread, all)
with settings UI and enqueue downloads after chapter sync completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:12:10 +02:00
littlecodedragon
4fce1fa940 feat(reading-eta): share SeriesReadingEtaCalculator across reader and library
Extract a single ETA estimator, refresh library badges when history changes,
and expose a history revision stream for reactive updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:12:02 +02:00
AntsyLich
87a173929b Adapt network implementation for TachiyomiX 1.6 (mihonapp/mihon#3248)
(cherry picked from commit 430b13bb81c8f51331109fa3ff35296f8bde9d27)
2026-07-09 15:36:34 +07:00
Cuong-Tran
4ae732a17b fix(deps): Matching coroutines version 2026-07-09 15:36:34 +07:00
AntsyLich
eb84ab8439 Ensure old extension store is removed if index url changes (mihonapp/mihon#3408)
(cherry picked from commit c15f7bee85b8fbe258abe81433730aa5bcba58a1)
2026-07-09 03:38:41 +07:00
AntsyLich
5e809fefd4
Add auto migration support from legacy extension store index (mihonapp/mihon#3398)
(cherry picked from commit d4327c8d48608ebc6eafc4a3df226c31d6586bbd)
2026-07-09 03:16:07 +07:00
Cuong-Tran
1763d947df
chore(sync): migrate sync preference keys (#1747) 2026-07-09 02:43:00 +07:00
Cuong-Tran
ce6a0ea7fa
fix(extension-store): Migrate to extension store during restore/sync legacy backup (#1746) 2026-07-09 02:05:39 +07:00
AntsyLich
da57b3db07 Change the term "Obsolete" to "Orphaned" for extensions (mihonapp/mihon#3383)
(cherry picked from commit 5f1d76a73a7b821542d46c96935def9158e7ff1f)
2026-07-09 00:35:35 +07:00
AntsyLich
da7ee11bb3 Change extension repo to extension store and add support for newer extension index format (#1744)
PR: mihonapp/mihon#3349

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>

(cherry picked from commit b446d2a6b1844f24b2d4912ba8be8b4648549f41)
(cherry picked from commit a0ae52671fe2ddab3883b99f437d521446a8539d)
2026-07-08 23:56:17 +07:00
MajorTanya
21c1b85796 Add informative error for unapproved MAL titles (mihonapp/mihon#3155)
MAL has a concept of "titles waiting for approval". These titles
cannot be added to user lists, but they do show up on the website and
crucially, in search results.

However, trying to add such an "unapproved" title will return a 400
error response with the error "invalid_content".

Previously, the awaitSuccess() call would mean the generic "HTTP 400"
toast would be shown. Now, a dedicated informative error message is
shown instead.

(cherry picked from commit af4f17efc54a00d9abe20e0d0b701d60c3531369)
2026-07-07 18:17:31 +07:00
MajorTanya
0f556ea9b5 Address bundleOf deprecation (mihonapp/mihon#3073)
(cherry picked from commit 33faa5a3562016a53d94b02aed69d95b10af5fc5)
2026-07-07 17:42:11 +07:00
AntsyLich
06107dba9b Bump workflows JDK to 21 (mihonapp/mihon#3053)
(cherry picked from commit ce5820b496dc6526b7afbf7344753c923802f737)
2026-07-07 17:37:40 +07:00
renovate[bot]
3615dc23a9
fix(deps): update dependency com.github.skydoves:colorpicker-compose to v1.2.0 (#1720)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:03:50 +07:00
renovate[bot]
86b60f08b1
fix(deps): update dependency com.github.ben-manes:gradle-versions-plugin to v0.54.0 (#1719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:03:37 +07:00
renovate[bot]
16582cc50e
fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.8.0 (#1718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:03:28 +07:00
renovate[bot]
559a787d0f
fix(deps): update dependency app.cash.sqldelight:sqlite-3-38-dialect to v2.3.2 (#1717)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:03:13 +07:00
renovate[bot]
65d2bff8c7
fix(deps): update dependency androidx.test.uiautomator:uiautomator to v2.4.0 (#1714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:03:04 +07:00
renovate[bot]
3e07479b0e
fix(deps): update dependency androidx.compose:compose-bom to v2026.06.01 (#1713)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:02:55 +07:00
renovate[bot]
40b19479af
fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-coroutines-bom to v1.11.0 (#1712)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:02:46 +07:00
renovate[bot]
4fd2625bf7
chore(deps): update kotlin.version to v2.4.0 (#1710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 02:02:29 +07:00
renovate[bot]
453afb3edb
fix(deps): update dependency androidx.annotation:annotation to v1.10.0 (#1709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:52 +07:00
renovate[bot]
118eeaf3ee
fix(deps): update dependency androidx.activity:activity-compose to v1.13.0 (#1708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:43 +07:00
renovate[bot]
e11cd536dc
chore(deps): update plugin google-services to v4.5.0 (#1707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:36 +07:00
renovate[bot]
e95e463543
fix(deps): update moko to v0.26.4 (#1706)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:28 +07:00
renovate[bot]
7b073fed0c
fix(deps): update dependency org.jsoup:jsoup to v1.22.2 (#1705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:18 +07:00
renovate[bot]
5c84266bc7
fix(deps): update dependency io.mockk:mockk to v1.14.11 (#1704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:10 +07:00
renovate[bot]
5048e57e76
fix(deps): update dependency com.mohamedrejeb.richeditor:richeditor-compose to v1.0.0-rc14 (#1703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:32:00 +07:00
renovate[bot]
583d649d20
fix(deps): update dependency com.google.apis:google-api-services-drive to v3-rev20260624-2.0.0 (#1702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 18:31:51 +07:00
littlecodedragon
b299a9ce81 docs(reading-eta): document unified limits, long chapters, and settings
Capture library vs reader ETA behaviour, historical duplicate caps, tunable
preferences, and code map from the reading-eta work. Link from README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:56:33 +02:00
renovate[bot]
fdfd4582cb
fix(deps): update dependency androidx.work:work-runtime to v2.11.2 (#1701)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:39:57 +07:00
renovate[bot]
25b8eb889a
chore(deps): update plugin firebase-crashlytics to v3.0.7 (#1700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:39:49 +07:00
renovate[bot]
1ffeebc9a1
chore(deps): update github actions (#1524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:21:07 +07:00
renovate[bot]
3de5da42d3
fix(deps): update spotless to v8.7.0 (#1552)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:20:09 +07:00
renovate[bot]
e9bd1fc477
fix(deps): update dependency com.materialkolor:material-kolor to v5.0.0-alpha07 (#1551)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:20:00 +07:00
renovate[bot]
72a3bb6f58
fix(deps): update compose.bom to v2026.06.00 (#1546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:19:50 +07:00
renovate[bot]
0ba6598c3f
fix(deps): update firebase.bom to v34.15.0 (#1526)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:19:41 +07:00
renovate[bot]
bd13620d1f
fix(deps): update dependency io.kotest:kotest-assertions-core to v6.2.1 (#1522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:19:23 +07:00
renovate[bot]
40f1391775
fix(deps): update dependency io.coil-kt.coil3:coil-bom to v3.5.0 (#1520)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:19:13 +07:00
renovate[bot]
9761f33948
fix(deps): update dependency com.google.apis:google-api-services-drive to v3-rev20260428-2.0.0 (#1513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:19:05 +07:00
renovate[bot]
612651a076
chore(deps): update github actions (#1512)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:18:55 +07:00
Cuong-Tran
3b06366fd9
chore(download): disable download URL hash inclusion by default (#1677) 2026-06-11 03:06:27 +07:00
Cuong-Tran
582ea3e60d
chore(agent): Add mandatory rules for AI agents covering Git, i18n, and formatting (#1662)
* chore(agent): Add mandatory rules for AI agents covering Git, i18n, and formatting

* Explicit path for strings resource

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Specify both strings.xml & plurals.xml

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Also exclude non-base local folders from i18n-kmk

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 14:42:44 +07:00
Cuong-Tran
99470e8133
Revert "fix(library): notify when sync fails before library update"
This reverts commit 9ccf5ca291.
2026-05-27 14:20:56 +07:00
Cursor Agent
9ccf5ca291
fix(library): notify when sync fails before library update
Report sync failures that previously failed silently (e.g. null remote
backup) and show a library update error notification when a chained sync
blocks the update, including the sync failure reason.

Co-authored-by: Cuong-Tran <cuong-tran@users.noreply.github.com>
2026-05-23 06:42:27 +00:00
Cuong-Tran
30c114c20f
Add Cursor Cloud specific instructions to AGENTS.md (#1659)
Add development environment setup documentation for Cloud Agent VMs,
including Android SDK configuration, key Gradle commands, and gotchas
for headless development without an emulator.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Cuong-Tran <cuong-tran@users.noreply.github.com>
2026-05-23 00:31:09 +07:00
Cuong-Tran
c1806e13b3
Agent.md (#1658)
* Generate `AGENTS.md` using CoPilot

* Re-generate by Cursor & manual merge

* Review again with Cursor
2026-05-22 23:49:37 +07:00
Cuong-Tran
266947b0d7
Bump version 2026-05-19 18:41:03 +07:00
Itsmechinmoy
ca26501aef
feat(Anilist): Parse GraphQL error responses for downtime and token expiry (#1591)
* Create ALError.kt

* Update AnilistApi.kt

* Update AnilistApi.kt

* Update AnilistApi.kt

* Add error handling for AniList API responses

* Update AnilistApi.kt

* Update ALError.kt

* Update AnilistApi.kt

* fix: improve error handling and resource management in AniList API calls

* Fix interpolation sign & add comment

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-19 18:27:36 +07:00
Cuong-Tran
9a7d1b6143
Fix ANR while installing extensions (#1652)
Update foreground service type for ExtensionInstallService to dataSync
2026-05-19 15:58:12 +07:00
Cuong-Tran
94eac94ce7
ExtensionInstaller: Fix mistakenly canceling unrelated waitingInstall & avoid adding duplicate entry to installing queue (#1649)
* ExtensionInstaller: Fix mistakenly canceling unrelated `waitingInstall` & avoid adding duplicate entry to installing `queue`

- Changes the installer queue from a synchronized list to a synchronized set to prevent duplicate entries.
- Updates queue removal logic to remove specific entries instead of using index-based removal.
- Refines `cancelQueue` logic to ensure the active installation is only cancelled if its download ID matches the requested ID.

* Ensure thread-safe access to the installation queue when canceling downloads
2026-05-19 15:44:58 +07:00
Cuong-Tran
dc7dd8045e Ensure extension installer's download IDs are non-negative (#1648) 2026-05-18 20:44:17 +07:00
AntsyLich
0b285f83b1 Fix occasional crash when mass installing/uninstalling extension using PackageManager
(cherry picked from commit 42daa3f432292be5fdfb3ecae07592cb94324164)
2026-05-18 20:44:17 +07:00
Leodyver Semilla
768c3dcec5 Fix extension actions disappearing after installing and uninstalling in same session (mihonapp/mihon#3049)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 2f9edb551fb2255c11ccd8452a080e87b9c963eb)
2026-05-18 20:44:17 +07:00
littlecodedragon
f178a79195 feat(reading-eta): unify library and reader limits; make caps tunable
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m17s
Introduce ReadingEtaPreferences and ReadingEtaLimits so library badges, reader series ETA, cross-manga history mining, and page-rate estimation share one configuration. Add reader settings sliders (EN/DE strings). Register preferences in PreferenceModule and wire GetCrossMangaPageRateHistorySamples.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 01:36:28 +02:00
Cuong-Tran
19f8fa203d
Add haptic feedback before actions (#1636)
* perform haptic before doing action

* Perform haptic feedback call in UI/main thread instead of IO

* Haptic feedback on both cases of long press action
2026-05-13 14:22:35 +07:00
Cuong-Tran
21cb1921a3
Refactor AppBarActions and clean up code (#1635)
* remove parentheses

* Refactor AppBarActions to improve readability and maintainability

* Refactor callback parameters in AppBar actions for clarity
2026-05-13 13:56:39 +07:00
Cuong-Tran
dc3d034109
Remove HBrowse and Tsumino sources from delegated sources (#1622) 2026-05-13 12:55:15 +07:00
Weblate (bot)
0293507d1a
Translations update from Hosted Weblate (#1567)
* Translated using Weblate (Turkish)

Currently translated at 70.4% (148 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/tr/

* Translated using Weblate (Turkish)

Currently translated at 70.4% (148 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/tr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 84.2% (177 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 84.2% (177 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/zh_Hans/

* Translated using Weblate (Bengali)

Currently translated at 22.3% (47 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/bn/

* Translated using Weblate (French)

Currently translated at 92.8% (195 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fr/

* Translated using Weblate (Thai)

Currently translated at 27.1% (57 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/th/

* Translated using Weblate (Thai)

Currently translated at 27.1% (57 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/th/

* Translated using Weblate (Danish)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/da/

* Translated using Weblate (Danish)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/da/

* Translated using Weblate (Czech)

Currently translated at 80.9% (170 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/cs/

* Translated using Weblate (Czech)

Currently translated at 80.9% (170 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/cs/

* Translated using Weblate (Malay)

Currently translated at 20.4% (43 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ms/

* Translated using Weblate (Ukrainian)

Currently translated at 86.1% (181 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/uk/

* Translated using Weblate (Ukrainian)

Currently translated at 86.1% (181 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/uk/

* Translated using Weblate (Spanish)

Currently translated at 85.7% (180 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/es/

* Translated using Weblate (Spanish)

Currently translated at 85.7% (180 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/es/

* Translated using Weblate (Swedish)

Currently translated at 79.5% (167 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/sv/

* Translated using Weblate (Swedish)

Currently translated at 79.5% (167 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/sv/

* Translated using Weblate (Korean)

Currently translated at 76.1% (160 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ko/

* Translated using Weblate (Korean)

Currently translated at 76.1% (160 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ko/

* Translated using Weblate (Italian)

Currently translated at 97.1% (204 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/it/

* Translated using Weblate (Italian)

Currently translated at 97.1% (204 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/it/

* Translated using Weblate (Assamese)

Currently translated at 82.8% (174 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/as/

* Translated using Weblate (Assamese)

Currently translated at 82.8% (174 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/as/

* Translated using Weblate (Vietnamese)

Currently translated at 75.2% (158 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/vi/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 89.0% (187 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 89.0% (187 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/pt_BR/

* Translated using Weblate (Romanian)

Currently translated at 78.5% (165 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ro/

* Translated using Weblate (Romanian)

Currently translated at 78.5% (165 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ro/

* Translated using Weblate (Filipino)

Currently translated at 95.7% (201 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fil/

* Translated using Weblate (Filipino)

Currently translated at 95.7% (201 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fil/

* Translated using Weblate (German)

Currently translated at 92.8% (195 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/de/

* Translated using Weblate (German)

Currently translated at 92.8% (195 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/de/

* Translated using Weblate (Greek)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/el/

* Translated using Weblate (Greek)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/el/

* Translated using Weblate (Croatian)

Currently translated at 30.4% (64 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/hr/

* Translated using Weblate (Croatian)

Currently translated at 30.4% (64 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/hr/

* Translated using Weblate (Arabic)

Currently translated at 92.3% (194 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ar/

* Translated using Weblate (Arabic)

Currently translated at 92.3% (194 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ar/

* Translated using Weblate (Dutch)

Currently translated at 79.5% (167 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/nl/

* Translated using Weblate (Dutch)

Currently translated at 79.5% (167 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/nl/

* Translated using Weblate (Japanese)

Currently translated at 84.7% (178 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ja/

* Translated using Weblate (Japanese)

Currently translated at 84.7% (178 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ja/

* Translated using Weblate (Finnish)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fi/

* Translated using Weblate (Finnish)

Currently translated at 79.0% (166 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fi/

* Translated using Weblate (Hindi)

Currently translated at 27.1% (57 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/hi/

* Translated using Weblate (Portuguese)

Currently translated at 87.1% (183 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/pt/

* Translated using Weblate (Indonesian)

Currently translated at 92.8% (195 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/id/

* Translated using Weblate (Tamil)

Currently translated at 35.2% (74 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ta/

* Translated using Weblate (Polish)

Currently translated at 80.9% (170 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/pl/

* Translated using Weblate (Polish)

Currently translated at 80.9% (170 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/pl/

* Translated using Weblate (Catalan)

Currently translated at 74.7% (157 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ca/

* Translated using Weblate (Catalan)

Currently translated at 74.7% (157 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ca/

* Translated using Weblate (Korean)

Currently translated at 76.1% (160 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/ko/

* Translated using Weblate (Filipino)

Currently translated at 96.6% (203 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fil/

* Translated using Weblate (Filipino)

Currently translated at 97.1% (204 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/fil/

* Translated using Weblate (Assamese)

Currently translated at 100.0% (210 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/as/

* Translated using Weblate (Assamese)

Currently translated at 100.0% (210 of 210 strings)

Translation: Komikku-App/Komikku
Translate-URL: https://hosted.weblate.org/projects/komikku-app/komikku/as/

* [ar] `connections_discord_info`: placeholder for app name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ar] `pref_discord_idle`: don't use emoji tag

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ro] `pref_discord_idle`: don't use emoji tag

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [zh-rCN] `connections_discord_info`: placeholder for app name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ro] `connections_discord_info`: placeholder for app name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [pt-rBR] `discord_status_description`: placeholder

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [bn] `action_scroll_to_top`: remove quote sign

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ca] `pref_discord_show_download_button`: translate

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* [da] `connections_discord`: correct Discord name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [es] `connections_discord`: correct Discord name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [fi] `connections_discord_info`: placeholder for app name

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [hi] `pref_discord_idle`: correct use of word

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [hi] `pref_theme_cover_based_style_fruitsalad`: type

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [hr] `action_open_folder`: use folder instead of file

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [it] `pref_fetch_manga_chapters_on_add_description`: correct translation

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ja] `pref_discord_idle`: translate

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [ko] `pref_discord_show_timestamp`: correct use of words

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* [ms] `download_cache_renew_interval_12hour`: remove AM/PM

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* [nl] `connections_discord`: correct Discord

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* [ta] `scale_type_16_9`: remove space

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [tr] `updating`: grammar

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [tr] `pref_auto_sync_progress_from_trackers`: translate

* [uk] `pref_discord_custom_message`: correct translation

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [zh-rCN] `discord_status_description`: add space

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: 안세훈 <on9686@gmail.com>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-05-13 10:55:28 +07:00
Jobobby04
3344fd0755 Fix blank page on cloudflare guard
(cherry picked from commit 07599ade3aeb127f643b45e84a6b9ccdb1d0ba6d)

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-13 09:30:39 +07:00
Jobobby04
f5e1c1e5ce Copy last page read in migrate
(cherry picked from commit 3a3b719b8bdc3c9ec95120ddca3a87183b40cd4d)
2026-05-12 18:52:35 +07:00
Jobobby04
5c0ca305a9 Add recommended proguard rules
(cherry picked from commit 5c26bb3a52c1f209facfda0b4e99d0a1feba9d76)
2026-05-12 18:52:35 +07:00
Cuong-Tran
1f904815fa Clean up some build warnings 2026-05-12 18:52:35 +07:00
KaiserBh
38acd6f110 feat: Add sync events to SyncYomi (jobobby04/TachiyomiSY#1558)
* feat: Add sync events to SyncYomi

Now it will send the events back to `SyncYomi` server and then forward those to the notifications services that are enabled, such as discord, telegram, and etc.

* chore: fix build error.

(cherry picked from commit 2776e411272499eba2f91acfea3cb40dc0d4c622)

* Improve client management and response handling

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-12 18:49:10 +07:00
AntsyLich
bad1dd264d Fix extension install/update stuck at pending (#1630)
* Fix extension install/update stuck at pending (mihonapp/mihon#3000)

Co-authored-by: p
(cherry picked from commit 84265febf3ce24d71994ced2b81215f858430d4e)

* Fix lint

* Throw exception when Shizuku shell interface is unavailable during installation instead of silently doing nothing

* Properly close OkHttp response and streams

* Delete temporary extension file if failed to install

* Use ConcurrentHashMap for active jobs and steps to ensure thread safety when managing extension installation states across multiple coroutines.

* Use SupervisorJob to ensure that a failure in one installation job doesn't cancel the entire scope

* Don't use `:` in package name for safer filename

* Throw exception if asset file descriptor fails to open during extension installation

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-12 15:27:49 +07:00
Weblate (bot)
6124efa164 Translations update from Hosted Weblate (mihonapp/mihon#2980)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/
Translation: Mihon/Mihon

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Jakub Szafranek13 Fabijan <jakubfabijan@tuta.io>
Co-authored-by: KraXen <dpelech1@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
(cherry picked from commit 565379779f82ee7dfdad45e251d82ff73dc3b7b2)
2026-05-12 09:52:53 +07:00
Weblate (bot)
0830cd32b5 Translations update from Hosted Weblate (jobobby04/TachiyomiSY#1536)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hant/
Translation: Mihon/TachiyomiSY
Translation: Mihon/TachiyomiSY Plurals

Co-authored-by: Champ0999 <champ0999@users.noreply.hosted.weblate.org>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Erick Alejandro <erickux@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Luis Antonio <getcyonic+zaorinu@gmail.com>
Co-authored-by: Metin <durmus38metin@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: NormalRandomPeople <normal.scribe833@silomails.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: momoehab <momoehab@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
(cherry picked from commit 724a61f51310b297296aab3c5c5d6d3a47b92491)

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-11 23:56:02 +07:00
Weblate (bot)
2b4caa9f97 Translations update from Hosted Weblate (mihonapp/mihon#2997)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/
Translation: Mihon/Mihon

Co-authored-by: Filip Jaruška <filip.jaruska@gmail.com>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
(cherry picked from commit cc3f640faa7df9f2a64976d2639e6dfd60ebe213)
2026-05-11 23:56:02 +07:00
AntsyLich
0f815ba662 Run automatic library updates even when connected to a VPN (mihonapp/mihon#2996)
Co-authored-by: jeremiejig <3978761+jeremiejig@users.noreply.github.com>
(cherry picked from commit 039471427448347a1c12c39a488a1127a3ea1497)

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-11 20:14:28 +07:00
AntsyLich
1c9c35156d Add all pages of adjacent chapters in the UI instead of only the first or last three (mihonapp/mihon#2995)
(cherry picked from commit 0cc724108b4f29a3d1d33ac4666a14873460a657)
2026-05-11 19:53:26 +07:00
AntsyLich
7c5fe16b52 Fix migration dialog not showing for consecutive prompts from the same screen (mihonapp/mihon#2994)
(cherry picked from commit 8b2d35f3068a3f9c5e1bba77ed825ae51531c91c)
2026-05-11 19:23:39 +07:00
AntsyLich
e04565bc46 Fix migration's selected sources order not preserved (mihonapp/mihon#2993)
* Fix migration's selected sources order not preserved

(cherry picked from commit 47816d4b218e6a62ef9fcd3097b6b0b8f2f95b17)

* Use indexed map for improved sorting performance

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-11 15:56:01 +07:00
littlecodedragon
e0f4262407 ci(forgejo): verify signed APKs with unzip + aapt before upload
All checks were successful
Forgejo Release Builder / release (push) Successful in 4m48s
Fails the job if an artifact is not a valid zip or manifest (same class
of errors as Android 'package could not be parsed' after download).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 07:06:23 +02:00
littlecodedragon
6316b85a09 fix(release): tolerate Forgejo null body/assets in release JSON
Gitea may emit null for empty release notes or assets; enable
Json.coerceInputValues and defaults so update checks do not fail
deserialization (unrelated to release-note workflow length).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 07:04:04 +02:00
littlecodedragon
f91af6fc72 ci(forgejo): shorten release notes to since-last-tag + cap
- Build What's Changed from previous v* tag..HEAD only (not upstream..HEAD).
- List at most 50 newest commits with pointer to Full Changelog compare.
- Drop fork-delta footer block to reduce scrolling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 06:57:58 +02:00
NarwhalHorns
e50767709a Utilize tracker for library duplicate detection (mihonapp/mihon#2978)
* Utilize tracker for library duplicate detection (#2978)

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 89bbdb17fb4ed1cbe99c14f389940e0f91093a10)

* Avoid crash due to duplicate manga ID when there are more than 1 trackers

* Add index for duplicate library manga detection in manga_sync

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
2026-05-08 19:27:41 +07:00
NarwhalHorns
c290472012 Add option for bookmarked chapters to download dropdown (mihonapp/mihon#2891)
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

(cherry picked from commit 3c6f0f1697ccab055ee7af47da84b2161d406f0c)

(cherry picked from commit 185cd923c0bf838d55b38ce0ebfb5c010760b880)
2026-05-08 14:52:04 +07:00
NarwhalHorns
aa7198f082 Optimize tracked library filter (mihonapp-mihon#2977)
Co-authored-by: NarwhalHorns <onefailedgamer@gamil.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

(cherry picked from commit ab214526c6f24466a0432b5c5c7d254a244cd958)
2026-05-08 13:24:41 +07:00
Cuong-Tran
a52609b549 Remove redundant userSelected from selection methods (mihonapp-mihon#2976)
(cherry picked from commit 9867c160f781b52cd297f7b1202cc6963b375df0)
2026-05-08 12:52:53 +07:00
Cuong-Tran
4c21f019c0
chore: Refactor selection state management and improve performance across various screens (#1607)
* chore: Refactor selection state management and improve performance across various screens

*   **MigrateMangaScreen**: Refactor state management to use a `Set<Long>` of IDs for selection instead of a list of wrapper items.
*   **LibraryUpdateErrorScreen**: Simplify `onErrorSelected` signature by removing the unused `userSelected` parameter and clean up related UI components.
*   **Performance**: Use `remember(state.selection)` in `MigrateMangaScreen` for efficient selection status lookups.
*   **Code Quality**: Fix minor typos (e.g., `inbetweenItem` to `inBetweenItem`) and optimize imports.

* Refactor selection logic to prevent redundant state updates

* Move early exit checks for item selection status outside or to the beginning of state update blocks to avoid unnecessary recompositions.
* Improve safety and correctness of item selection by returning early from state updates when an item index is not found.
* Standardize selection checks across `HistoryScreenModel`, `LibraryUpdateErrorScreenModel`, `MigrateMangaScreenModel`, `UpdatesScreenModel`, and `MangaScreenModel`.

* update selection when migration list got updated

* Update state management to reset selection and loading status on fetch failure
2026-05-08 12:41:46 +07:00
247 changed files with 6243 additions and 3354 deletions

View file

@ -5,6 +5,12 @@ on:
push: push:
tags: tags:
- v* - v*
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. v1.13.6). Must already exist on the remote.'
required: true
type: string
permissions: permissions:
contents: write contents: write
@ -21,6 +27,7 @@ jobs:
shell: bash shell: bash
env: env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf .git rm -rf .git
@ -28,7 +35,11 @@ jobs:
origin_url="${GITHUB_SERVER_URL#https://}" origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}" origin_url="${origin_url#http://}"
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
git -c http.sslVerify=false fetch --tags --prune origin "${GITHUB_SHA}" if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
git -c http.sslVerify=false fetch --tags --prune origin "refs/tags/${DISPATCH_TAG}"
else
git -c http.sslVerify=false fetch --tags --prune origin "${GITHUB_SHA}"
fi
git checkout --force FETCH_HEAD git checkout --force FETCH_HEAD
- name: Set up JDK - name: Set up JDK
@ -86,7 +97,11 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
version_tag="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}" if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
version_tag="${{ github.event.inputs.tag }}"
else
version_tag="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
fi
version_display="$(printf '%s' "$version_tag" | sed -E 's/^v//' )" version_display="$(printf '%s' "$version_tag" | sed -E 's/^v//' )"
previous_tag="$(git tag --list 'v*' --sort=-version:refname | grep -Fxv "$version_tag" | head -n 1 || true)" previous_tag="$(git tag --list 'v*' --sort=-version:refname | grep -Fxv "$version_tag" | head -n 1 || true)"
upstream_repo="komikku-app/komikku" upstream_repo="komikku-app/komikku"
@ -112,11 +127,11 @@ jobs:
git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true
fi fi
use_fork_merge_split=0 # Release notes: only commits since the *previous release tag* (short, correct scope).
# Do not use upstream_base..HEAD here — that lists the whole fork delta and is huge.
changelog_commit_limit=50
log_range="" log_range=""
if [ -n "$upstream_base" ] && git cat-file -e "${upstream_base}^{commit}" 2>/dev/null; then if [ -n "$previous_tag" ]; then
use_fork_merge_split=1
elif [ -n "$previous_tag" ]; then
log_range="${previous_tag}..HEAD" log_range="${previous_tag}..HEAD"
fi fi
@ -209,37 +224,22 @@ jobs:
[ "$any" -eq 1 ] [ "$any" -eq 1 ]
} }
if [ "$use_fork_merge_split" -eq 1 ]; then if [ -n "$log_range" ]; then
{ total_in_range="$(git rev-list --count "${log_range}" 2>/dev/null || printf '0')"
printf '%s\n' "#### Fork changes" ""
clear_buckets
fill_buckets_from_git "${upstream_base}..HEAD" --no-merges
if [ ${#new_lines[@]} -eq 0 ] && [ ${#fix_lines[@]} -eq 0 ] && [ ${#improve_lines[@]} -eq 0 ] && [ ${#other_lines[@]} -eq 0 ]; then
printf '%s\n' "_No direct commits in this range._" ""
else
emit_buckets_from_arrays
fi
clear_buckets
fill_buckets_from_git "${upstream_base}..HEAD" --merges
if [ ${#new_lines[@]} -gt 0 ] || [ ${#fix_lines[@]} -gt 0 ] || [ ${#improve_lines[@]} -gt 0 ] || [ ${#other_lines[@]} -gt 0 ]; then
printf '%s\n' "#### Merge commits" ""
emit_buckets_from_arrays
printf '\n'
fi
} > "${commit_log_file}"
elif [ -n "$log_range" ]; then
clear_buckets clear_buckets
fill_buckets_from_git "$log_range" fill_buckets_from_git "$log_range" "-n${changelog_commit_limit}"
{ {
if emit_buckets_from_arrays; then if emit_buckets_from_arrays; then
: if [ "${total_in_range}" -gt "${changelog_commit_limit}" ] 2>/dev/null; then
printf '%s\n' "" "_Showing the ${changelog_commit_limit} most recent commits since ${previous_tag}._" \
"_Older commits in this release: use **Full Changelog** below._" ""
fi
else else
printf '%s\n' "##### Other" "" "- Initial release" "" printf '%s\n' "##### Other" "" "- No commit messages in this tag range." ""
fi fi
} > "${commit_log_file}" } > "${commit_log_file}"
else else
printf '%s\n' "##### Other" "" "- Initial release" "" > "${commit_log_file}" printf '%s\n' "##### Other" "" "- First release tag, or no prior \`v*\` tag found — see repository history." "" > "${commit_log_file}"
fi fi
# Do not pass multiline commit text via step outputs / env (breaks some Forgejo/act_runner # Do not pass multiline commit text via step outputs / env (breaks some Forgejo/act_runner
@ -298,12 +298,12 @@ jobs:
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
chmod +x ./gradlew chmod +x ./gradlew
./gradlew --no-daemon --max-workers=1 \ ./gradlew --no-daemon --max-workers=1 \
"-Dorg.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=640m -XX:ReservedCodeCacheSize=192m -Dfile.encoding=UTF-8" \ "-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8" \
-Dorg.gradle.parallel=false \ -Dorg.gradle.parallel=false \
-Dorg.gradle.configureondemand=false \ -Dorg.gradle.configureondemand=false \
-Dorg.gradle.vfs.watch=false \ -Dorg.gradle.vfs.watch=false \
-Dkotlin.daemon.enabled=false \ -Dkotlin.daemon.enabled=false \
"-Dkotlin.daemon.jvmargs=-Xmx512m,-XX:MaxMetaspaceSize=256m,-XX:ReservedCodeCacheSize=128m" \ "-Dkotlin.daemon.jvmargs=-Xmx2048m,-XX:MaxMetaspaceSize=512m,-XX:ReservedCodeCacheSize=256m" \
-Dkotlin.compiler.execution.strategy=in-process \ -Dkotlin.compiler.execution.strategy=in-process \
-Pkotlin.incremental=true \ -Pkotlin.incremental=true \
--version --version
@ -314,12 +314,12 @@ jobs:
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
fi fi
./gradlew --no-daemon --max-workers=1 \ ./gradlew --no-daemon --max-workers=1 \
"-Dorg.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=640m -XX:ReservedCodeCacheSize=192m -Dfile.encoding=UTF-8" \ "-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8" \
-Dorg.gradle.parallel=false \ -Dorg.gradle.parallel=false \
-Dorg.gradle.configureondemand=false \ -Dorg.gradle.configureondemand=false \
-Dorg.gradle.vfs.watch=false \ -Dorg.gradle.vfs.watch=false \
-Dkotlin.daemon.enabled=false \ -Dkotlin.daemon.enabled=false \
"-Dkotlin.daemon.jvmargs=-Xmx512m,-XX:MaxMetaspaceSize=256m,-XX:ReservedCodeCacheSize=128m" \ "-Dkotlin.daemon.jvmargs=-Xmx2048m,-XX:MaxMetaspaceSize=512m,-XX:ReservedCodeCacheSize=256m" \
-Dkotlin.compiler.execution.strategy=in-process \ -Dkotlin.compiler.execution.strategy=in-process \
-Pkotlin.incremental=true \ -Pkotlin.incremental=true \
--stacktrace \ --stacktrace \
@ -386,6 +386,33 @@ jobs:
"${apksigner_bin}" verify "${signed_apk}" "${apksigner_bin}" verify "${signed_apk}"
done done
# Catch corrupt / non-APK artifacts before upload (e.g. truncated build output). Same
# manifest parse path the installer uses for "Paket konnte nicht geparst werden".
aapt_bin=""
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}/build-tools" ]; then
aapt_bin="$(ls -1 "${ANDROID_HOME}"/build-tools/*/aapt 2>/dev/null | sort -V | tail -n 1 || true)"
fi
if [ -z "${aapt_bin}" ] || [ ! -x "${aapt_bin}" ]; then
echo "aapt not found under ANDROID_HOME; cannot verify APK manifests."
exit 1
fi
for signed_apk in Komikku-*.apk; do
case "${signed_apk}" in *-unsigned.apk) continue ;; esac
echo "Verifying installable APK: ${signed_apk}"
if ! unzip -tqq "${signed_apk}" >/dev/null 2>&1; then
echo "ERROR: ${signed_apk} is not a valid zip (truncated or corrupt build output?)."
exit 1
fi
first_line="$("${aapt_bin}" dump badging "${signed_apk}" 2>/dev/null | head -n 1 || true)"
if ! printf '%s\n' "${first_line}" | grep -q '^package:'; then
echo "ERROR: ${signed_apk} failed manifest parse (first line: ${first_line:-<empty>})."
echo "First 64 bytes (expect PK zip header 504b0304):"
head -c 64 "${signed_apk}" | hexdump -C || true
exit 1
fi
sha256sum "${signed_apk}"
done
- name: Create Obtainium-friendly aliases - name: Create Obtainium-friendly aliases
shell: bash shell: bash
run: | run: |
@ -421,9 +448,6 @@ jobs:
VERSION_TAG: ${{ steps.prepare.outputs.VERSION_TAG }} VERSION_TAG: ${{ steps.prepare.outputs.VERSION_TAG }}
VERSION_DISPLAY: ${{ steps.prepare.outputs.VERSION_DISPLAY }} VERSION_DISPLAY: ${{ steps.prepare.outputs.VERSION_DISPLAY }}
PREV_TAG_NAME: ${{ steps.prepare.outputs.PREV_TAG_NAME }} PREV_TAG_NAME: ${{ steps.prepare.outputs.PREV_TAG_NAME }}
UPSTREAM_REPO: ${{ steps.prepare.outputs.UPSTREAM_REPO }}
UPSTREAM_REF: ${{ steps.prepare.outputs.UPSTREAM_REF }}
UPSTREAM_BASE: ${{ steps.prepare.outputs.UPSTREAM_BASE }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }} REPO_URL: ${{ github.server_url }}/${{ github.repository }}
REPOSITORY_NAME: ${{ github.repository }} REPOSITORY_NAME: ${{ github.repository }}
FORGEJO_CURL_INSECURE: "true" FORGEJO_CURL_INSECURE: "true"
@ -522,10 +546,6 @@ jobs:
if [ -n "${PREV_TAG_NAME:-}" ]; then if [ -n "${PREV_TAG_NAME:-}" ]; then
full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})" full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})"
fi fi
fork_delta_label=""
if [ -n "${UPSTREAM_BASE:-}" ] && [ -n "${UPSTREAM_REF:-}" ]; then
fork_delta_label="Fork delta vs ${UPSTREAM_REPO}@${UPSTREAM_REF} (${UPSTREAM_BASE})"
fi
release_body_file="$(mktemp)" release_body_file="$(mktemp)"
{ {
@ -533,13 +553,11 @@ jobs:
"#### What's Changed" \ "#### What's Changed" \
"" \ "" \
"${commit_logs}" \ "${commit_logs}" \
"" \ ""
"##### Based on" \ if [ -n "${full_changelog}" ]; then
"" \ printf '%s\n' "${full_changelog}" ""
"${fork_delta_label}" \ fi
"" \ printf '%s\n' \
"${full_changelog}" \
"" \
"<!-->" \ "<!-->" \
"> [!TIP]" \ "> [!TIP]" \
">" \ ">" \

View file

@ -0,0 +1,100 @@
name: Upstream sync status
# Lightweight job: fetch upstream from GitHub, write status files, optional issue.
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: write
issues: write
concurrency:
group: upstream-status
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout and check upstream
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BEHIND_WARN: '10'
run: |
set -euo pipefail
rm -rf .git
git init .
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git remote add origin "https://x-access-token:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
git -c http.sslVerify=false fetch --prune origin "${GITHUB_REF_NAME}"
git checkout --force FETCH_HEAD
git remote add upstream https://github.com/komikku-app/komikku.git
chmod +x scripts/check-upstream.sh
./scripts/check-upstream.sh --write
behind="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["behind"])')"
ahead="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["ahead"])')"
status="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["status"])')"
echo "BEHIND=$behind" >> "$GITHUB_ENV"
echo "AHEAD=$ahead" >> "$GITHUB_ENV"
echo "STATUS=$status" >> "$GITHUB_ENV"
- name: Commit status files
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "forgejo-actions[bot]"
git config user.email "forgejo-actions[bot]@users.noreply.local"
git add UPSTREAM_STATUS.md docs/upstream-status.json
if git diff --staged --quiet; then
echo "No upstream status changes."
exit 0
fi
git commit -m "chore(ci): update upstream sync status"
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git push "https://x-access-token:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
- name: Open issue when far behind
if: env.STATUS == 'behind'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
title="Upstream sync: ${BEHIND} commits behind komikku-app/komikku"
body=$(cat <<EOF
The weekly upstream check found this fork is **${BEHIND} commits behind** upstream and **${AHEAD} commits ahead** with fork-specific changes.
See [UPSTREAM_STATUS.md](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/${GITHUB_REF_NAME}/UPSTREAM_STATUS.md) and [docs/upstream-sync.md](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/${GITHUB_REF_NAME}/docs/upstream-sync.md).
To merge upstream locally:
\`\`\`sh
git fetch upstream
git merge upstream/master
\`\`\`
EOF
)
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&labels=upstream-sync"
existing="$(curl -fsSL -H "Authorization: token ${GH_TOKEN}" "$api" | python3 -c 'import json,sys; issues=json.load(sys.stdin); print(next((str(i["number"]) for i in issues if i.get("title")==sys.argv[1]), ""))' "$title")"
if [[ -n "$existing" ]]; then
curl -fsSL -X PATCH \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"body": sys.argv[1]}))' "$body")" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${existing}"
echo "Updated issue #${existing}"
exit 0
fi
curl -fsSL -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"title": sys.argv[1], "body": sys.argv[2], "labels": ["upstream-sync"]}))' "$title" "$body")" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues"
echo "Created upstream sync issue"

1
.github/.java-version vendored Normal file
View file

@ -0,0 +1 @@
21

12
.github/workflows/build_benchmark.yml vendored Executable file → Normal file
View file

@ -29,7 +29,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }}) - name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@ -57,18 +57,18 @@ jobs:
needs: prepare-build needs: prepare-build
steps: steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.TAG_NAME }}) - name: Clone Repository (${{ needs.prepare-build.outputs.TAG_NAME }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 # Forgejo-compatible (node20) uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
java-version: 17 java-version-file: .github/.java-version
distribution: temurin distribution: temurin
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
- name: Check code format - name: Check code format
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
@ -101,7 +101,7 @@ jobs:
mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-benchmark-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-benchmark-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
path: "**/*.apk" path: "**/*.apk"
retention-days: 30 retention-days: 30

4
.github/workflows/build_dispatch_preview.yml vendored Executable file → Normal file
View file

@ -30,7 +30,7 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@ -53,7 +53,7 @@ jobs:
uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2 uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2
- name: Invoke workflow in preview repo - name: Invoke workflow in preview repo
uses: benc-uk/workflow-dispatch@v1.2.2 # Forgejo-compatible (node16) uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with: with:
workflow: build_app.yml workflow: build_app.yml
repo: komikku-app/komikku-preview repo: komikku-app/komikku-preview

16
.github/workflows/build_preview.yml vendored Executable file → Normal file
View file

@ -43,7 +43,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }}) - name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@ -119,14 +119,14 @@ jobs:
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
steps: steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }} - ${{ needs.prepare-build.outputs.TAG_NAME }}) - name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }} - ${{ needs.prepare-build.outputs.TAG_NAME }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 # Forgejo-compatible (node20) uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
java-version: 17 java-version-file: .github/.java-version
distribution: temurin distribution: temurin
- name: Write google-services.json - name: Write google-services.json
@ -144,7 +144,7 @@ jobs:
write-mode: overwrite write-mode: overwrite
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
- name: Check code format - name: Check code format
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
@ -156,7 +156,7 @@ jobs:
run: ./gradlew testReleaseUnitTest run: ./gradlew testReleaseUnitTest
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
path: "**/*.apk" path: "**/*.apk"
retention-days: 1 retention-days: 1
@ -168,7 +168,7 @@ jobs:
- build-app - build-app
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v4 # Forgejo-compatible (node20) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
merge-multiple: true merge-multiple: true
@ -195,7 +195,7 @@ jobs:
mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-preview-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-preview-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk
- name: Create release - name: Create release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with: with:
tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }} tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }}
name: Komikku Preview ${{ needs.prepare-build.outputs.TAG_NAME }} (${{ needs.prepare-build.outputs.VERSION_TAG }}) name: Komikku Preview ${{ needs.prepare-build.outputs.TAG_NAME }} (${{ needs.prepare-build.outputs.VERSION_TAG }})

18
.github/workflows/build_pull_request.yml vendored Executable file → Normal file
View file

@ -30,20 +30,20 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 # Forgejo-compatible (node20) uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
java-version: 17 java-version-file: .github/.java-version
distribution: temurin distribution: temurin
- name: Write google-services.json - name: Write google-services.json
@ -63,7 +63,7 @@ jobs:
write-mode: overwrite write-mode: overwrite
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
- name: Check code format - name: Check code format
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
@ -82,7 +82,7 @@ jobs:
- name: Upload test report - name: Upload test report
if: steps.unit_tests.outcome == 'failure' if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: test-report-${{ github.sha }} name: test-report-${{ github.sha }}
path: app/build/reports/tests/testReleaseUnitTest path: app/build/reports/tests/testReleaseUnitTest
@ -119,13 +119,13 @@ jobs:
mv app/build/outputs/apk/preview/app-universal-preview${{ steps.signed_filename.outputs.SIGNED_TRAIL }}.apk Komikku-$version_tag-r$commit_count.apk mv app/build/outputs/apk/preview/app-universal-preview${{ steps.signed_filename.outputs.SIGNED_TRAIL }}.apk Komikku-$version_tag-r$commit_count.apk
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
path: ./*.apk path: ./*.apk
- name: Upload mapping - name: Upload mapping
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: mapping-${{ github.sha }} name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/preview path: app/build/outputs/mapping/preview

14
.github/workflows/build_push.yml vendored Executable file → Normal file
View file

@ -27,14 +27,14 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 # Forgejo-compatible (node20) uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
java-version: 17 java-version-file: .github/.java-version
distribution: temurin distribution: temurin
- name: Write google-services.json - name: Write google-services.json
@ -52,7 +52,7 @@ jobs:
write-mode: overwrite write-mode: overwrite
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
- name: Check code format - name: Check code format
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
@ -66,7 +66,7 @@ jobs:
- name: Upload test report - name: Upload test report
if: steps.unit_tests.outcome == 'failure' if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: test-report-${{ github.sha }} name: test-report-${{ github.sha }}
path: app/build/reports/tests/testReleaseUnitTest path: app/build/reports/tests/testReleaseUnitTest
@ -95,13 +95,13 @@ jobs:
mv app/build/outputs/apk/preview/app-universal-preview-signed.apk Komikku-$version_tag-r$commit_count.apk mv app/build/outputs/apk/preview/app-universal-preview-signed.apk Komikku-$version_tag-r$commit_count.apk
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
path: ./*.apk path: ./*.apk
- name: Upload mapping - name: Upload mapping
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: mapping-${{ github.sha }} name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/preview path: app/build/outputs/mapping/preview

18
.github/workflows/build_release.yml vendored Executable file → Normal file
View file

@ -31,7 +31,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }}) - name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@ -87,14 +87,14 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }}) - name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }})
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 # Forgejo-compatible (node20) uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
java-version: 17 java-version-file: .github/.java-version
distribution: temurin distribution: temurin
- name: Write google-services.json - name: Write google-services.json
@ -112,7 +112,7 @@ jobs:
write-mode: overwrite write-mode: overwrite
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20) uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
- name: Check code format - name: Check code format
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
@ -124,13 +124,13 @@ jobs:
run: ./gradlew testReleaseUnitTest run: ./gradlew testReleaseUnitTest
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
path: "**/*.apk" path: "**/*.apk"
retention-days: 1 retention-days: 1
- name: Upload mapping - name: Upload mapping
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: mapping-${{ github.sha }} name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release path: app/build/outputs/mapping/release
@ -142,7 +142,7 @@ jobs:
- build-app - build-app
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v4 # Forgejo-compatible (node20) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
merge-multiple: true merge-multiple: true
@ -169,7 +169,7 @@ jobs:
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.VERSION_TAG }}.apk mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.VERSION_TAG }}.apk
- name: Create release - name: Create release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with: with:
tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }} tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }}
name: Komikku ${{ needs.prepare-build.outputs.VERSION_TAG }} name: Komikku ${{ needs.prepare-build.outputs.VERSION_TAG }}

2
.github/workflows/codeberg_mirror.yml vendored Executable file → Normal file
View file

@ -13,7 +13,7 @@ jobs:
if: github.repository == 'komikku-app/komikku' if: github.repository == 'komikku-app/komikku'
runs-on: 'ubuntu-24.04' runs-on: 'ubuntu-24.04'
steps: steps:
- uses: actions/checkout@v4 # Forgejo-compatible (node20) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup SSH key - name: Setup SSH key

2
.github/workflows/delete_merged_branch.yml vendored Executable file → Normal file
View file

@ -13,7 +13,7 @@ jobs:
if: github.event.pull_request.merged == true && github.repository == github.event.pull_request.head.repo.full_name if: github.event.pull_request.merged == true && github.repository == github.event.pull_request.head.repo.full_name
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 # Forgejo-compatible (node20) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Delete branch - name: Delete branch
run: | run: |

2
.github/workflows/pr_label.yml vendored Executable file → Normal file
View file

@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Check PR and Add Label - name: Check PR and Add Label
uses: actions/github-script@v7 # Forgejo-compatible (node20) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
const prAuthor = context.payload.pull_request.user.login; const prAuthor = context.payload.pull_request.user.login;

2
.github/workflows/update_website.yml vendored Executable file → Normal file
View file

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Update website on release - name: Update website on release
uses: benc-uk/workflow-dispatch@v1.2.2 # Forgejo-compatible (node16) uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with: with:
workflow: Deploy workflow: Deploy
repo: komikku-app/komikku-app.github.io repo: komikku-app/komikku-app.github.io

218
AGENTS.md Normal file
View file

@ -0,0 +1,218 @@
# Komikku AI Agent Guide
Komikku is an Android manga reader (min SDK 26, target SDK 36, JVM 17 / Kotlin) forked from **Mihon** + **TachiyomiSY**. Stack: Jetpack Compose + Material3, Voyager navigation, SQLDelight, Injekt DI. `applicationId`: `app.komikku`.
---
## Mandatory rules for AI agents
**Read this section before every change.** These rules override shortcuts (e.g. copying nearby `MR` imports or only running `compileDebugKotlin`).
### Git
| Rule | Required behavior |
|------|-------------------|
| Branch | Create a **feature branch** for the task (`git checkout -b <type>/<short-description>`). |
| Commit | **OK** on a feature branch when work is ready. **Never** commit directly to `master` / `main` unless the user explicitly asks. |
| Push | **OK** to push the **current feature branch** when work is ready. **Never** push to `master` / `main` unless the user explicitly asks. |
Before `git push`, confirm the current branch is not `master` or `main` (`git branch --show-current`).
### Internationalization (strings)
| String kind | Module | Resource class | Base folder only |
|-------------|--------|----------------|------------------|
| Komikku-only (new features, KMK UI, library-update errors, WebDAV, Discord, etc.) | `i18n-kmk/` | **`KMR`** | `i18n-kmk/src/commonMain/moko-resources/base/` |
| Shared Mihon / upstream behavior | `i18n/` | **`MR`** | `i18n/src/commonMain/moko-resources/base/` |
| TachiyomiSY-only | `i18n-sy/` | **`SYMR`** | `i18n-sy/src/commonMain/moko-resources/base/` |
**Hard rules:**
- **Never** add Komikku-specific strings to `i18n/` or `i18n-sy/`.
- **Never** edit non-`base` locale `strings.xml` or `plurals.xml` files in `i18n-kmk/`, `i18n/`, or `i18n-sy/` (Weblate owns translations).
- Import: `import tachiyomi.i18n.kmk.KMR` for Komikku strings.
- If a change is inside `// KMK -->``// KMK <--` or adds Komikku-only behavior, default to **`KMR` + `i18n-kmk`**.
**Self-check before finishing:** `git diff` must not add new `<string name="…">` or `<plurals name="…">` entries under non-`base` locales in `i18n-kmk/src/`, `i18n/src/`, or `i18n-sy/src/`.
### Formatting & build verification
**“Build passes” is not enough.** After Kotlin/XML edits, run **in this order** before marking work complete:
```bash
./gradlew spotlessApply # fix formatting
./gradlew spotlessCheck # must pass (same as CI)
./gradlew assembleDebug # or :app:compileDebugKotlin for a faster compile-only check
```
- **Do not** skip `spotlessCheck` when verifying changes.
- If `spotlessCheck` fails, run `spotlessApply` and re-run `spotlessCheck`.
- On Cloud VM, export `ANDROID_HOME` and `JAVA_HOME` first (see [Cursor Cloud](#cursor-cloud-specific-instructions)).
---
## Module layout
| Module | Purpose |
|--------|---------|
| `app/` | UI (`eu.kanade.*`, `exh/`, `mihon/`), DI, workers, build variants |
| `domain/` | Use cases in `…/interactor/` (e.g. `GetManga`), models, repo interfaces |
| `data/` | SQLDelight DB, `*RepositoryImpl` (`tachiyomi.data.*`) |
| `core:common/` | Network (OkHttp), security, storage, shared utils |
| `core:archive/` | CBZ/archive reading with optional encryption |
| `core-metadata/` | Comic-info metadata parsing |
| `source-api/` / `source-local/` | Extension `Source` API + local source |
| `presentation-core/` | Shared Compose components |
| `presentation-widget/` | Home-screen Glance widget |
| `i18n/` | Mihon strings → `MR` (moko-resources) |
| `i18n-kmk/` | Komikku strings → `KMR` |
| `i18n-sy/` | TachiyomiSY strings → `SYMR` |
| `flagkit/` | Country-flag drawables |
| `telemetry/` | Firebase/Crashlytics (noop unless `-Pinclude-telemetry`) |
| `macrobenchmark/` | Macrobenchmark tests |
Dependency flow: `app``domain``source-api`; `data` implements `domain` repos.
Version catalogs: `gradle/libs.versions.toml`, `kotlinx.versions.toml`, `androidx.versions.toml`, `compose.versions.toml`, `sy.versions.toml`.
---
## Architecture
**DI** `uy.kohesive.injekt` (not Hilt). Register in `AppModule.kt`, `DomainModule.kt`, `KMKDomainModule.kt`, `SYDomainModule.kt` via `addSingleton` / `addSingletonFactory`. Resolve with `Injekt.get<T>()` or `injectLazy<T>()`.
**UI & navigation** [Voyager](https://voyager.adriel.cafe/): `Screen` in `eu.kanade.tachiyomi.ui.*`, composables in `eu.kanade.presentation.*`. Base type: `eu.kanade.presentation.util.Screen`. State via `rememberScreenModel { … }`; most models extend `StateScreenModel<State>` or bases like `SearchScreenModel`; some use plain `ScreenModel`. Prefer `screenModelScope` and `ioCoroutineScope`; use `launchIO` / `withIOContext` from `tachiyomi.core.common.util.lang`. `rememberCoroutineScope()` is fine in Compose; long-lived services may use their own `CoroutineScope`.
**Activities (not Voyager)** `MainActivity` (shell), `ReaderActivity` + `ReaderViewModel`, `WebViewActivity`, `UnlockActivity`, OAuth login activities, `DeepLinkActivity`. Reader: `ReaderActivity.newIntent(context, mangaId, chapterId)`. Web: both `WebViewScreen` (Voyager) and `WebViewActivity.newIntent(...)`.
Example: `DeepLinkScreen` + `DeepLinkScreenModel` in `app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/`.
**Domain / data** One class per operation under `domain/…/interactor/` (verb names, not `*Interactor` suffix). Also `app/src/main/java/eu/kanade/domain/…/interactor/` for app-specific cases. Wire repos in `eu.kanade.domain.DomainModule.kt` (+ `KMKDomainModule`, `SYDomainModule`).
**Database** SQLDelight in `data/src/main/sqldelight/tachiyomi/` (`.sq` queries, `migrations/*.sqm`). After schema changes add a new `.sqm` and often `// KMK` blocks in `.sq` / mappers. Regenerate: `./gradlew :data:generateSqlDelightInterface` (or any compile that touches `:data`).
**App preference migrations** `app/src/main/java/mihon/core/migration/migrations/` (`mihon.core.migration.Migration`).
**Images** Coil 3 (`coil3.*`, `context.imageLoader`). No Glide/Picasso.
---
## Komikku-specific work
- **Strings:** see [Mandatory rules Internationalization](#mandatory-rules-for-ai-agents). Summary: Komikku → **`KMR`** / `i18n-kmk/…/base/` only.
- Do not edit locale `strings.xml` in `i18n/` or `i18n-sy/` except when syncing upstream; translations via [Weblate](https://hosted.weblate.org/engage/komikku-app/).
- Komikku code/DI: search `// KMK` (e.g. `KMKDomainModule`, `HideCategory`, library-update errors).
- Prefs: `eu.kanade.domain.*.service.*Preferences` (e.g. `SourcePreferences.relatedMangas()`).
**Examples (Komikku → `i18n-kmk`, not `i18n`):** library update error UI, sync-before-update messages, WebDAV/Discord settings, updater notifications, `mihon/feature/*` Komikku screens.
---
## Extensions & sources
- Catalog sources: installable APK extensions (not in this repo).
- In-repo: delegated sources and metadata in `exh/` (E-Hentai, NHentai, MangaDex, `exh/recs/`).
- `source-api`: `eu.kanade.tachiyomi.source.*` — avoid breaking extension ABI.
---
## Build & CI
Build types: `debug` (`.dev`), `release`, `releaseTest` (`.rt`), `foss` (`.foss`), `preview` (`.beta`, CI default), `benchmark`.
Gradle `-P` flags (`buildSrc/.../BuildConfig.kt`):
| Flag | Effect |
|------|--------|
| `include-telemetry` | Firebase Analytics + Crashlytics |
| `enable-updater` | In-app update checker |
| `disable-code-shrink` | Skip R8 minification |
| `include-dependency-info` | Dependency metadata in APK |
```bash
./gradlew spotlessApply # format (run before spotlessCheck)
./gradlew spotlessCheck # REQUIRED before considering work done (CI gate)
./gradlew assemblePreview # main CI/dev APK
./gradlew assemblePreview -Pinclude-telemetry -Penable-updater # full upstream CI build
./gradlew testReleaseUnitTest # CI unit tests (or ./gradlew test for all modules)
./gradlew installDebug # device install
./gradlew :data:generateSqlDelightInterface # after .sq / .sqm changes
```
**Agent verification checklist (minimum):** `spotlessApply``spotlessCheck``assembleDebug` (or `compileDebugKotlin` only if the user asked for a quick compile check—but still run Spotless).
JDK **17**.
---
## Fork-origin markers
Preserve inline blocks when editing:
```kotlin
// KMK --> … // KMK <-- Komikku
// SY --> … // SY <-- TachiyomiSY
// EXH --> … // EXH <-- E-Hentai / exh (existing); prefer KMK for new Komikku-only code
```
Package roots: `eu.kanade.tachiyomi.*` (legacy UI), `tachiyomi.*` (domain/data), `mihon.*` (Mihon upstream), `exh.*` (enhanced sources).
---
## Tests
- Unit tests: `domain/src/test/`; app: `app/src/test/.../MigratorTest.kt`. No broad UI test suite.
---
## Conventions
- **Logging** Prefer `xLogE()` / `xLog()` helpers from `exh.log` for Komikku code, Mihon uses `logcat { }` from `tachiyomi.core.common.util.system`. Avoid raw `android.util.Log`.
- **Formatting** Spotless + ktlint (`buildSrc/.../mihon.code.lint.gradle.kts`). Agents **must** run `spotlessApply` and `spotlessCheck` (see [Mandatory rules](#mandatory-rules-for-ai-agents)).
- **Fork edits** New Komikku features inside `// KMK` islands; keep `// SY` / `// EXH` blocks intact when merging upstream.
---
## Key files
- `App.kt` Injekt bootstrap, logging setup
- `MainActivity.kt` Voyager host
- `app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt` core DI
- `app/src/main/java/eu/kanade/domain/DomainModule.kt` domain interactors
- `buildSrc/.../BuildConfig.kt`, `AndroidConfig.kt` flags, SDK versions
- `app/build.gradle.kts`, `settings.gradle.kts`
---
## Cursor Cloud specific instructions
### Environment
The VM update script installs the Android SDK (platform 36, build-tools 35.0.1, platform-tools, cmdline-tools) into `/opt/android-sdk` and writes `local.properties` with `sdk.dir`. JDK 21 is pre-installed and works fine for compiling to JVM target 17. `ANDROID_HOME`, `JAVA_HOME`, and `PATH` are set in `~/.bashrc`.
### Running key commands
All Gradle commands require the environment variables set above. Export them before invoking `./gradlew` if running in a fresh shell:
```bash
export ANDROID_HOME=/opt/android-sdk
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
```
| Task | Command |
|------|---------|
| **Required format fix** | `./gradlew spotlessApply` (run first after code edits) |
| **Required format gate** | `./gradlew spotlessCheck` (must pass before task is done) |
| Debug APK build | `./gradlew assembleDebug` |
| Preview APK build (CI) | `./gradlew assemblePreview` |
| Unit tests (CI) | `./gradlew testReleaseUnitTest` |
| All module tests | `./gradlew test` |
| SQLDelight codegen | `./gradlew :data:generateSqlDelightInterface` |
### Gotchas
- First Gradle build downloads ~1 GB of dependencies; subsequent builds use the Gradle cache and are much faster.
- `local.properties` is `.gitignore`d — it must be recreated if missing (the update script handles this).
- No Android emulator or device is available on the Cloud VM, so `installDebug` will fail. Build verification is done via `assembleDebug`.
- `google-services.json` and `client_secrets.json` are not present (CI secrets); builds without `-Pinclude-telemetry` succeed without them.
- Gradle daemon may use significant memory (`-Xmx4g` in `gradle.properties`). If OOM occurs, kill and restart the daemon with `./gradlew --stop`.

View file

@ -12,9 +12,54 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
- Change the term "Obsolete" to "Orphaned" for extensions ([@AntsyLich](https://github.com/AntsyLich)) ([#3383](https://github.com/mihonapp/mihon/pull/3383))
### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
## [v0.19.9] - 2026-04-11
### Fixed
- Regression with installing/updating extension ([@AntsyLich](https://github.com/AntsyLich))
## [v0.19.8] - 2026-04-11
### Added
- Add dedicated "Support Us" screen ([@AntsyLich](https://github.com/AntsyLich)) ([#3200](https://github.com/mihonapp/mihon/pull/3200))
- Add a one time popup asking for donation from long time users ([@AntsyLich](https://github.com/AntsyLich)) ([#3203](https://github.com/mihonapp/mihon/pull/3203))
### Improved
- Show informative error when trying to add unapproved titles to list on MAL ([@MajorTanya](https://github.com/MajorTanya)) ([#3155](https://github.com/mihonapp/mihon/pull/3155))
## [v0.19.7] - 2026-03-23
Same as v0.19.6
## [v0.19.6] - 2026-03-23
### Fixed
- Fix app crashing when trying to add extension repo with existing signature ([@AntsyLich](https://github.com/AntsyLich)) ([`cb0e329`](https://github.com/mihonapp/mihon/commit/cb0e329))
- Potentially fix 'database is locked' crash ([@AntsyLich](https://github.com/AntsyLich)) ([`f8e82b9`](https://github.com/mihonapp/mihon/commit/f8e82b9))
- Fix occasional crash when mass installing/uninstalling extension using `PackageManager` ([@AntsyLich](https://github.com/AntsyLich)) ([`42daa3f`](https://github.com/mihonapp/mihon/commit/42daa3f))
- Fix app crash on startup on some Android TV ([@AntsyLich](https://github.com/AntsyLich)) ([`b40f1d3`](https://github.com/mihonapp/mihon/commit/b40f1d3))
## [v0.19.5] - 2026-03-20
### Changed
- Retry in reader now redownloads image ([@AntsyLich](https://github.com/AntsyLich)) ([#3089](https://github.com/mihonapp/mihon/pull/3089))
### Fixed
- Fix performance regression introduced in v0.19.4 ([@AntsyLich](https://github.com/AntsyLich)) ([#3082](https://github.com/mihonapp/mihon/pull/3082))
- Fix duplicate key crash in duplicate detection ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3040](https://github.com/mihonapp/mihon/pull/3040))
- Fix MangaUpdates HTTP 4XX errors ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3021](https://github.com/mihonapp/mihon/pull/3021))
- Fix WebView JavaScript dialogs popup after screen is closed ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3041](https://github.com/mihonapp/mihon/pull/3041))
- Fix extension actions disappearing after installing and uninstalling in same session ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3049](https://github.com/mihonapp/mihon/pull/3049))
## [v0.19.4] - 2026-02-25
### Added
- Automatically remove downloads on Suwayomi after reading, configurable via extension settings ([@cpiber](https://github.com/cpiber)) ([#2673](https://github.com/mihonapp/mihon/pull/2673)) - Automatically remove downloads on Suwayomi after reading, configurable via extension settings ([@cpiber](https://github.com/cpiber)) ([#2673](https://github.com/mihonapp/mihon/pull/2673))
- Display author & artist name in MAL search results ([@MajorTanya](https://github.com/MajorTanya)) ([#2833](https://github.com/mihonapp/mihon/pull/2833)) - Display author & artist name in MAL search results ([@MajorTanya](https://github.com/MajorTanya)) ([#2833](https://github.com/mihonapp/mihon/pull/2833))
- Add filter options to Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#2851](https://github.com/mihonapp/mihon/pull/2851)) - Add filter options to Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#2851](https://github.com/mihonapp/mihon/pull/2851))
- Add bookmarked chapters to chapter download options ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2891](https://github.com/mihonapp/mihon/pull/2891))
- Add `src:` prefix to search the library by source ID ([@MajorTanya](https://github.com/MajorTanya)) ([#2927](https://github.com/mihonapp/mihon/pull/2927)) - Add `src:` prefix to search the library by source ID ([@MajorTanya](https://github.com/MajorTanya)) ([#2927](https://github.com/mihonapp/mihon/pull/2927))
- Add `src:local` as a way to search for Local Source entries ([@MajorTanya](https://github.com/MajorTanya)) ([#2928](https://github.com/mihonapp/mihon/pull/2928)) - Add `src:local` as a way to search for Local Source entries ([@MajorTanya](https://github.com/MajorTanya)) ([#2928](https://github.com/mihonapp/mihon/pull/2928))
@ -22,10 +67,16 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Minimize memory usage by reducing in-memory cover cache size ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2266](https://github.com/mihonapp/mihon/pull/2266)) - Minimize memory usage by reducing in-memory cover cache size ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2266](https://github.com/mihonapp/mihon/pull/2266))
- Optimize MAL search queries ([@MajorTanya](https://github.com/MajorTanya)) ([#2832](https://github.com/mihonapp/mihon/pull/2832)) - Optimize MAL search queries ([@MajorTanya](https://github.com/MajorTanya)) ([#2832](https://github.com/mihonapp/mihon/pull/2832))
- Reword download reindexing message to avoid confusion ([@MajorTanya](https://github.com/MajorTanya)) ([#2874](https://github.com/mihonapp/mihon/pull/2874)) - Reword download reindexing message to avoid confusion ([@MajorTanya](https://github.com/MajorTanya)) ([#2874](https://github.com/mihonapp/mihon/pull/2874))
- Rework internals for better performance ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2955](https://github.com/mihonapp/mihon/pull/2955))
- Optimize tracked library filter ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2977](https://github.com/mihonapp/mihon/pull/2977))
- Utilize tracker for library duplicate detection ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2977](https://github.com/mihonapp/mihon/pull/2978))
### Changed ### Changed
- Update tracker icons ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773)) - Update tracker icons ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773))
- Add a small increment to chapter number before comparison to fix progress sync issues for Suwayomi ([@cpiber](https://github.com/cpiber)) ([#2657](https://github.com/mihonapp/mihon/pull/2675)) - Add a small increment to chapter number before comparison to fix progress sync issues for Suwayomi ([@cpiber](https://github.com/cpiber)) ([#2657](https://github.com/mihonapp/mihon/pull/2675))
- Add all pages of adjacent chapters in the UI instead of only the first or last three ([@AntsyLich](https://github.com/AntsyLich)) ([#2995](https://github.com/mihonapp/mihon/pull/2995))
- Going back now first clears search query on browse extension tab ([@cuong-tran](https://github.com/cuong-tran)) ([#2906](https://github.com/mihonapp/mihon/pull/2906))
- Automatic library updates now run even when connected to a VPN ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773))
### Fixed ### Fixed
- Fix reader tap zones triggering after scrolling is stopped by tapping ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2680](https://github.com/mihonapp/mihon/pull/2680)) - Fix reader tap zones triggering after scrolling is stopped by tapping ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2680](https://github.com/mihonapp/mihon/pull/2680))
@ -34,6 +85,10 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Fix reader not saving read duration when changing chapter ([@AntsyLich](https://github.com/AntsyLich), [@KotlinHero](https://github.com/KotlinHero)) ([#2784](https://github.com/mihonapp/mihon/pull/2784)) - Fix reader not saving read duration when changing chapter ([@AntsyLich](https://github.com/AntsyLich), [@KotlinHero](https://github.com/KotlinHero)) ([#2784](https://github.com/mihonapp/mihon/pull/2784))
- Fix pre-1970 upload date display in chapter list ([@MajorTanya](https://github.com/MajorTanya)) ([#2779](https://github.com/mihonapp/mihon/pull/2779)) - Fix pre-1970 upload date display in chapter list ([@MajorTanya](https://github.com/MajorTanya)) ([#2779](https://github.com/mihonapp/mihon/pull/2779))
- Fix crash when trying to install/update extensions while shizuku is not running ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2837](https://github.com/mihonapp/mihon/pull/2837)) - Fix crash when trying to install/update extensions while shizuku is not running ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2837](https://github.com/mihonapp/mihon/pull/2837))
- Fix Add Repo input not taking up the full dialog width ([@cuong-tran](https://github.com/cuong-tran)) ([#2816](https://github.com/mihonapp/mihon/pull/2816))
- Fix migration's selected sources order not preserved ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2993))
- Fix migration dialog not showing for consecutive prompts from the same screen ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2994))
- Fix extension install/update stuck at pending ([@AntsyLich](https://github.com/AntsyLich)) ([#3000](https://github.com/mihonapp/mihon/pull/3000))
### Other ### Other
- Enable logcat logging on stable and debug builds without enabling verbose logging ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2836](https://github.com/mihonapp/mihon/pull/2836)) - Enable logcat logging on stable and debug builds without enabling verbose logging ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2836](https://github.com/mihonapp/mihon/pull/2836))

View file

@ -24,6 +24,10 @@
*Requires Android 8.0 or higher.* *Requires Android 8.0 or higher.*
Self-hosted Forgejo releases (including [Obtainium](https://github.com/ImranR98/Obtainium) install) are documented in [docs/forgejo-release-runner.md](docs/forgejo-release-runner.md).
**Fork upstream status:** see [UPSTREAM_STATUS.md](UPSTREAM_STATUS.md) (updated weekly; manual check: `./scripts/check-upstream.sh`). Merge guide: [docs/upstream-sync.md](docs/upstream-sync.md).
[![Sponsor me on GitHub](https://custom-icon-badges.demolab.com/badge/-Sponsor-ea4aaa?style=for-the-badge&logo=heart&logoColor=white)](https://github.com/sponsors/cuong-tran "Sponsor me on GitHub") [![Sponsor me on GitHub](https://custom-icon-badges.demolab.com/badge/-Sponsor-ea4aaa?style=for-the-badge&logo=heart&logoColor=white)](https://github.com/sponsors/cuong-tran "Sponsor me on GitHub")
<div align="left"> <div align="left">
@ -59,7 +63,7 @@ A free and open source manga reader which is based off TachiyomiSY & Mihon/Tachi
- Long-click to add/remove single entry to/from library, everywhere. - Long-click to add/remove single entry to/from library, everywhere.
- Docking Read/Resume button to left/right. - Docking Read/Resume button to left/right.
- In-app progress banner shows Library syncing / Backup restoring / Library updating progress. - In-app progress banner shows Library syncing / Backup restoring / Library updating progress.
- `Series ETA` / reading time estimate in the reader, with optional persistence across sessions. - `Series ETA` / reading time estimate in the reader, with optional persistence across sessions; library time-left badges use the same tuning. See [docs/reading-eta.md](docs/reading-eta.md).
- Auto-install app update. - Auto-install app update.
- Configurable interval to refresh entries from downloaded storage. - Configurable interval to refresh entries from downloaded storage.
- Forked from SY so everything from SY. - Forked from SY so everything from SY.
@ -122,7 +126,6 @@ Custom sources:
Additional features for some extensions, features include custom description, opening in app, batch add to library, and a bunch of other things based on the source: Additional features for some extensions, features include custom description, opening in app, batch add to library, and a bunch of other things based on the source:
* 8Muses (EroMuse) * 8Muses (EroMuse)
* HBrowse
* Mangadex * Mangadex
* NHentai * NHentai
* Puruin * Puruin

48
UPSTREAM_STATUS.md Normal file
View file

@ -0,0 +1,48 @@
# Upstream sync status
> Auto-generated by [`scripts/check-upstream.sh`](scripts/check-upstream.sh). Last check: **2026-07-10 07:56 UTC**.
| | |
|---|---|
| Status | 🔴 **Behind upstream** |
| Behind upstream | **73** commits on [`upstream/master`](https://github.com/komikku-app/komikku.git) |
| Ahead of upstream | **66** fork-only commits on `main` |
| Merge base | `bdf81596e9` |
| Fork HEAD | `b299a9ce81` |
| Upstream HEAD | `87a173929b` |
## Action recommended
This fork is **73 commits behind** [komikku-app/komikku](https://github.com/komikku-app/komikku.git). Consider merging upstream soon.
See [docs/upstream-sync.md](docs/upstream-sync.md) for the manual sync procedure.
## Recent upstream commits not in fork
```
87a173929b Adapt network implementation for TachiyomiX 1.6 (mihonapp/mihon#3248)
4ae732a17b fix(deps): Matching coroutines version
eb84ab8439 Ensure old extension store is removed if index url changes (mihonapp/mihon#3408)
5e809fefd4 Add auto migration support from legacy extension store index (mihonapp/mihon#3398)
1763d947df chore(sync): migrate sync preference keys (#1747)
ce6a0ea7fa fix(extension-store): Migrate to extension store during restore/sync legacy backup (#1746)
da57b3db07 Change the term "Obsolete" to "Orphaned" for extensions (mihonapp/mihon#3383)
da7ee11bb3 Change extension repo to extension store and add support for newer extension index format (#1744)
21c1b85796 Add informative error for unapproved MAL titles (mihonapp/mihon#3155)
0f556ea9b5 Address bundleOf deprecation (mihonapp/mihon#3073)
```
## Recent fork-only commits
```
b299a9ce81 docs(reading-eta): document unified limits, long chapters, and settings
f178a79195 feat(reading-eta): unify library and reader limits; make caps tunable
e0f4262407 ci(forgejo): verify signed APKs with unzip + aapt before upload
6316b85a09 fix(release): tolerate Forgejo null body/assets in release JSON
f91af6fc72 ci(forgejo): shorten release notes to since-last-tag + cap
c15d280cb4 feat(eta): cross-manga page-rate priors for reading time left
07019175d1 fix(eta): tame reading-time estimates (idle + cumulative history)
9ce3f0ba29 ci(forgejo): group release notes by prefix and fork vs merges
7fab10c8ef CI: build release JSON with jq --rawfile (avoid ARG_MAX on large bodies
e89f342881 CI: fix release step env — multiline COMMIT_LOGS broke act_runner
```

View file

@ -28,7 +28,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "app.komikku" applicationId = "app.komikku"
versionCode = 78 versionCode = 79
versionName = "1.13.6" versionName = "1.13.6"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View file

@ -307,3 +307,5 @@
-dontwarn org.ietf.jgss.GSSManager -dontwarn org.ietf.jgss.GSSManager
-dontwarn org.ietf.jgss.GSSName -dontwarn org.ietf.jgss.GSSName
-dontwarn org.ietf.jgss.Oid -dontwarn org.ietf.jgss.Oid
-dontwarn com.google.re2j.Matcher
-dontwarn com.google.re2j.Pattern

View file

@ -73,8 +73,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep link to add repos --> <!-- Legacy deeplink to add extension store -->
<intent-filter android:label="@string/action_add_repo"> <intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -84,6 +84,17 @@
<data android:host="add-repo" /> <data android:host="add-repo" />
</intent-filter> </intent-filter>
<!-- Deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mihon" />
<data android:host="extension-store" />
</intent-filter>
<!-- Open backup files --> <!-- Open backup files -->
<intent-filter android:label="@string/pref_restore_backup"> <intent-filter android:label="@string/pref_restore_backup">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -241,7 +252,7 @@
<service <service
android:name=".extension.util.ExtensionInstallService" android:name=".extension.util.ExtensionInstallService"
android:exported="false" android:exported="false"
android:foregroundServiceType="shortService" /> android:foregroundServiceType="dataSync" />
<service <service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@ -333,22 +344,6 @@
<data android:pathPattern="/g/..*" /> <data android:pathPattern="/g/..*" />
</intent-filter> </intent-filter>
<!-- Tsumino -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="tsumino.com" />
<data android:host="www.tsumino.com" />
<data android:pathPattern="/Read/View/..*" />
<data android:pathPattern="/Book/Info/..*" />
</intent-filter>
<!-- Pururin --> <!-- Pururin -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -363,21 +358,6 @@
<data android:pathPattern="/gallery/..*" /> <data android:pathPattern="/gallery/..*" />
</intent-filter> </intent-filter>
<!-- HBrowse -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="hbrowse.com" />
<data android:host="www.hbrowse.com" />
<!--<data android:pathPattern="/gallery/..*" />-->
</intent-filter>
<!-- Mangadex --> <!-- Mangadex -->
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -16701,9 +16701,7 @@ HSPLeu/kanade/tachiyomi/source/online/all/MergedSource$special$$inlined$injectLa
PLeu/kanade/tachiyomi/source/online/all/MergedSource$special$$inlined$injectLazy$9;-><init>()V PLeu/kanade/tachiyomi/source/online/all/MergedSource$special$$inlined$injectLazy$9;-><init>()V
Leu/kanade/tachiyomi/source/online/all/NHentai; Leu/kanade/tachiyomi/source/online/all/NHentai;
Leu/kanade/tachiyomi/source/online/english/EightMuses; Leu/kanade/tachiyomi/source/online/english/EightMuses;
Leu/kanade/tachiyomi/source/online/english/HBrowse;
Leu/kanade/tachiyomi/source/online/english/Pururin; Leu/kanade/tachiyomi/source/online/english/Pururin;
Leu/kanade/tachiyomi/source/online/english/Tsumino;
Leu/kanade/tachiyomi/ui/base/activity/BaseActivity; Leu/kanade/tachiyomi/ui/base/activity/BaseActivity;
HSPLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V HSPLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V
PLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V PLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V

View file

@ -25,16 +25,16 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extensionrepo.interactor.GetExtensionRepo import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
@ -58,6 +58,7 @@ import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.interactor.SetSortModeForCategory import tachiyomi.domain.category.interactor.SetSortModeForCategory
import tachiyomi.domain.category.interactor.UpdateCategory import tachiyomi.domain.category.interactor.UpdateCategory
import tachiyomi.domain.category.repository.CategoryRepository import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@ -67,6 +68,7 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetHistoryRevision
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration import tachiyomi.domain.history.interactor.GetTotalReadDuration
@ -163,6 +165,7 @@ class DomainModule : InjektModule {
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) } addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) } addFactory { GetChapter(get()) }
addFactory { GetChaptersByMangaId(get()) } addFactory { GetChaptersByMangaId(get()) }
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
addFactory { GetChapterByUrlAndMangaId(get()) } addFactory { GetChapterByUrlAndMangaId(get()) }
addFactory { UpdateChapter(get()) } addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get(), get()) } addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
@ -170,14 +173,16 @@ class DomainModule : InjektModule {
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) } addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { GetAvailableScanlators(get()) } addFactory { GetAvailableScanlators(get()) }
addFactory { FilterChaptersForDownload(get(), get(), get(), get()) } addFactory { FilterChaptersForDownload(get(), get(), get(), get()) }
addFactory { GetChaptersForLibraryUpdateDownload(get(), get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) } addFactory { GetHistory(get()) }
addFactory { GetHistoryRevision(get()) }
addFactory { UpsertHistory(get()) } addFactory { UpsertHistory(get()) }
addFactory { RemoveHistory(get()) } addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) } addFactory { GetTotalReadDuration(get()) }
addFactory { GetReadDurationEntriesByMangaIds(get()) } addFactory { GetReadDurationEntriesByMangaIds(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get()) } addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
addFactory { DeleteDownload(get(), get()) } addFactory { DeleteDownload(get(), get()) }
@ -201,14 +206,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) } addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) } addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) } addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addFactory { ExtensionRepoService(get(), get()) } addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { GetExtensionRepo(get()) } addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionRepoCount(get()) } addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { CreateExtensionRepo(get(), get()) } addFactory { GetExtensionStores(get()) }
addFactory { DeleteExtensionRepo(get()) } addFactory { RemoveExtensionStore(get()) }
addFactory { ReplaceExtensionRepo(get()) } addFactory { UpdateExtensionStores(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addFactory { ToggleIncognito(get()) } addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) } addFactory { GetIncognitoState(get(), get(), get()) }
} }

View file

@ -28,11 +28,7 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions -> ) { enabledLanguage, availableExtensions ->
availableExtensions availableExtensions
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) { ext.sources.map { it.lang }
listOf(ext.lang)
} else {
ext.sources.map { it.lang }
}
} }
.distinct() .distinct()
.sortedWith( .sortedWith(

View file

@ -50,9 +50,6 @@ class GetExtensionsByType(
(showNsfwSources || !extension.isNsfw) (showNsfwSources || !extension.isNsfw)
} }
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in enabledLanguages } ext.sources.filter { it.lang in enabledLanguages }
.map { .map {
ext.copy( ext.copy(

View file

@ -4,21 +4,21 @@ import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.isDebugBuildType import eu.kanade.tachiyomi.util.system.isDebugBuildType
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
class TrustExtension( class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository, private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences, private val preferences: SourcePreferences,
) { ) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean { suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
// KMK --> // KMK -->
if (isDebugBuildType) return true if (isDebugBuildType) return true
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
// KMK <-- // KMK <--
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet() val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}" val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get() return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
} }

View file

@ -10,8 +10,8 @@ import java.util.UUID
class SyncPreferences( class SyncPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
) { ) {
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org") fun clientHost() = preferenceStore.getString("connection_sync_client_host", "https://sync.tachiyomi.org")
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "") fun clientAPIKey() = preferenceStore.getString("connection_sync_client_api_key", "")
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L) fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "") fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
@ -20,19 +20,19 @@ class SyncPreferences(
fun syncService() = preferenceStore.getInt("sync_service", 0) fun syncService() = preferenceStore.getInt("sync_service", 0)
// KMK --> // KMK -->
fun webDavUrl() = preferenceStore.getString("webdav_url", "") fun webDavUrl() = preferenceStore.getString("connection_webdav_url", "")
fun webDavUsername() = preferenceStore.getString("webdav_username", "") fun webDavUsername() = preferenceStore.getString("connection_webdav_username", "")
fun webDavPassword() = preferenceStore.getString("webdav_password", "") fun webDavPassword() = preferenceStore.getString("connection_webdav_password", "")
fun webDavFolder() = preferenceStore.getString("webdav_folder", "komikku") fun webDavFolder() = preferenceStore.getString("connection_webdav_folder", "komikku")
// KMK <-- // KMK <--
fun googleDriveAccessToken() = preferenceStore.getString( fun googleDriveAccessToken() = preferenceStore.getString(
Preference.appStateKey("google_drive_access_token"), Preference.appStateKey("connection_google_drive_access_token"),
"", "",
) )
fun googleDriveRefreshToken() = preferenceStore.getString( fun googleDriveRefreshToken() = preferenceStore.getString(
Preference.appStateKey("google_drive_refresh_token"), Preference.appStateKey("connection_google_drive_refresh_token"),
"", "",
) )
@ -55,39 +55,39 @@ class SyncPreferences(
fun getSyncSettings(): SyncSettings { fun getSyncSettings(): SyncSettings {
return SyncSettings( return SyncSettings(
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(), libraryEntries = preferenceStore.getBoolean("sync_library_entries", true).get(),
categories = preferenceStore.getBoolean("categories", true).get(), categories = preferenceStore.getBoolean("sync_categories", true).get(),
chapters = preferenceStore.getBoolean("chapters", true).get(), chapters = preferenceStore.getBoolean("sync_chapters", true).get(),
tracking = preferenceStore.getBoolean("tracking", true).get(), tracking = preferenceStore.getBoolean("sync_tracking", true).get(),
history = preferenceStore.getBoolean("history", true).get(), history = preferenceStore.getBoolean("sync_history", true).get(),
appSettings = preferenceStore.getBoolean("appSettings", true).get(), appSettings = preferenceStore.getBoolean("sync_appSettings", true).get(),
extensionRepoSettings = preferenceStore.getBoolean("extensionRepoSettings", true).get(), extensionStores = preferenceStore.getBoolean("sync_extensionStores", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(), sourceSettings = preferenceStore.getBoolean("sync_sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(), privateSettings = preferenceStore.getBoolean("sync_privateSettings", true).get(),
// SY --> // SY -->
customInfo = preferenceStore.getBoolean("customInfo", true).get(), customInfo = preferenceStore.getBoolean("sync_customInfo", true).get(),
readEntries = preferenceStore.getBoolean("readEntries", true).get(), readEntries = preferenceStore.getBoolean("sync_readEntries", true).get(),
savedSearchesFeeds = preferenceStore.getBoolean("savedSearchesFeeds", true).get(), savedSearchesFeeds = preferenceStore.getBoolean("sync_savedSearchesFeeds", true).get(),
// SY <-- // SY <--
) )
} }
fun setSyncSettings(syncSettings: SyncSettings) { fun setSyncSettings(syncSettings: SyncSettings) {
preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries) preferenceStore.getBoolean("sync_library_entries", true).set(syncSettings.libraryEntries)
preferenceStore.getBoolean("categories", true).set(syncSettings.categories) preferenceStore.getBoolean("sync_categories", true).set(syncSettings.categories)
preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters) preferenceStore.getBoolean("sync_chapters", true).set(syncSettings.chapters)
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking) preferenceStore.getBoolean("sync_tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("history", true).set(syncSettings.history) preferenceStore.getBoolean("sync_history", true).set(syncSettings.history)
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings) preferenceStore.getBoolean("sync_appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("extensionRepoSettings", true).set(syncSettings.extensionRepoSettings) preferenceStore.getBoolean("sync_extensionStores", true).set(syncSettings.extensionStores)
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings) preferenceStore.getBoolean("sync_sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings) preferenceStore.getBoolean("sync_privateSettings", true).set(syncSettings.privateSettings)
// SY --> // SY -->
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo) preferenceStore.getBoolean("sync_customInfo", true).set(syncSettings.customInfo)
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries) preferenceStore.getBoolean("sync_readEntries", true).set(syncSettings.readEntries)
preferenceStore.getBoolean("savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds) preferenceStore.getBoolean("sync_savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
// SY <-- // SY <--
} }

View file

@ -7,7 +7,7 @@ data class SyncSettings(
val tracking: Boolean = true, val tracking: Boolean = true,
val history: Boolean = true, val history: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,

View file

@ -86,12 +86,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val url = remember(state.extension) { val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex() val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.repoUrl.orEmpty()) regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let { ?.let {
val (user, repo) = it.destructured val (user, repo) = it.destructured
"https://github.com/$user/$repo" "https://github.com/$user/$repo"
} }
?: state.extension?.repoUrl ?: state.extension?.store?.indexUrl
} }
Scaffold( Scaffold(
@ -270,14 +270,17 @@ private fun DetailsHeader(
if (extension is Extension.Installed) { if (extension is Extension.Installed) {
append("\n\n") append("\n\n")
append( appendLine(
""" """
Update available: ${extension.hasUpdate} Update available: ${extension.hasUpdate}
Obsolete: ${extension.isObsolete} Orphaned: ${extension.isObsolete}
Shared: ${extension.isShared} Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(), """.trimIndent(),
) )
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
} }
} }
context.copyToClipboard("Extension Debug information", extDebugInfo) context.copyToClipboard("Extension Debug information", extDebugInfo)

View file

@ -48,7 +48,7 @@ import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
@ -58,6 +58,8 @@ import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
@ -110,9 +112,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
actions = persistentListOf( actions = persistentListOf(
EmptyScreenAction( EmptyScreenAction(
stringRes = MR.strings.label_extension_repos, stringRes = MR.strings.extensionStores,
icon = Icons.Outlined.Settings, icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
) )
@ -195,9 +197,9 @@ private fun ExtensionContent(
// KMK --> // KMK -->
KMR.strings.extensions_page_more -> { KMR.strings.extensions_page_more -> {
{ {
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) { Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
Text( Text(
text = stringResource(MR.strings.action_add_repo), text = stringResource(MR.strings.action_addExtensionStore),
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
), ),
@ -401,7 +403,7 @@ private fun ExtensionItemContent(
} }
// KMK --> // KMK -->
Text(text = extension.repoName?.let { "@$it" } ?: "(?)") Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
// KMK <-- // KMK <--
val warning = when { val warning = when {
@ -604,11 +606,11 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
sources = emptyList(), sources = emptyList(),
apkName = "Test", apkUrl = "Test",
iconUrl = "", iconUrl = "",
repoUrl = "", store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
) )
val extInstalled = Extension.Installed( val extInstalled = Extension.Installed(
name = "Tachiyomi", name = "Tachiyomi",
@ -619,9 +621,9 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
sources = emptyList(), sources = emptyList(),
repoUrl = "", store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
pkgFactory = null, pkgFactory = null,
icon = null, icon = null,
hasUpdate = false, hasUpdate = false,
@ -638,12 +640,12 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
) )
Column { Column {
ExtensionItemContent( ExtensionItemContent(
extension = extAvail.copy( extension = extAvail.copy(
repoName = "Repository extensions minion multiple languages various sources", storeName = "Repository extensions minion multiple languages various sources",
), ),
installStep = InstallStep.Idle, installStep = InstallStep.Idle,
) )

View file

@ -336,47 +336,46 @@ fun SourceFeedToolbar(
// KMK --> // KMK -->
actions = { actions = {
AppBarActions( AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder() actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
.apply { add(bulkSelectionButton(isRunning, toggleSelectionMode))
add(bulkSelectionButton(isRunning, toggleSelectionMode))
onWebViewClick?.let { onWebViewClick?.let { func ->
add(
AppBar.Action(
title = stringResource(MR.strings.action_web_view),
onClick = { onWebViewClick() },
icon = Icons.Outlined.Public,
),
)
}
// KMK -->
add( add(
AppBar.OverflowAction( AppBar.Action(
title = stringResource(MR.strings.pref_incognito_mode), title = stringResource(MR.strings.action_web_view),
onClick = onToggleIncognito, onClick = { func() },
icon = Icons.Outlined.Public,
), ),
) )
// KMK <--
onSortFeedClick?.let {
add(
AppBar.OverflowAction(
title = stringResource(KMR.strings.action_sort_feed),
onClick = { onSortFeedClick() },
),
)
}
onSourceSettingClick?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.label_settings),
onClick = { onSourceSettingClick() },
),
)
}
} }
// KMK -->
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.pref_incognito_mode),
onClick = onToggleIncognito,
),
)
// KMK <--
onSortFeedClick?.let { func ->
add(
AppBar.OverflowAction(
title = stringResource(KMR.strings.action_sort_feed),
onClick = { func() },
),
)
}
onSourceSettingClick?.let { func ->
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.label_settings),
onClick = { func() },
),
)
}
}
.build(), .build(),
) )
}, },

View file

@ -209,7 +209,7 @@ fun BrowseSourceEHentaiListItem(
MangaCoverHide.Book( MangaCoverHide.Book(
modifier = Modifier modifier = Modifier
.fillMaxHeight(), .fillMaxHeight(),
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
) )
} else { } else {
@ -219,7 +219,7 @@ fun BrowseSourceEHentaiListItem(
.fillMaxHeight(), .fillMaxHeight(),
// KMK --> // KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha, alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
// KMK <-- // KMK <--
data = coverData, data = coverData,

View file

@ -39,27 +39,26 @@ fun BrowseSourceSimpleToolbar(
var selectingDisplayMode by remember { mutableStateOf(false) } var selectingDisplayMode by remember { mutableStateOf(false) }
// KMK --> // KMK -->
AppBarActions( AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder() actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
.apply { displayMode?.let { mode ->
displayMode?.let { add(
add( AppBar.Action(
AppBar.Action( title = stringResource(MR.strings.action_display_mode),
title = stringResource(MR.strings.action_display_mode), icon = if (mode == LibraryDisplayMode.List) {
icon = if (displayMode == LibraryDisplayMode.List) { Icons.AutoMirrored.Filled.ViewList
Icons.AutoMirrored.Filled.ViewList } else {
} else { Icons.Filled.ViewModule
Icons.Filled.ViewModule },
}, onClick = { selectingDisplayMode = true },
onClick = { selectingDisplayMode = true }, ),
), )
)
}
toggleSelectionMode?.let {
add(
bulkSelectionButton(isRunning, toggleSelectionMode),
)
}
} }
toggleSelectionMode?.let { mode ->
add(
bulkSelectionButton(isRunning, mode),
)
}
}
.build(), .build(),
) )
// KMK <-- // KMK <--

View file

@ -30,53 +30,51 @@ fun BulkSelectionToolbar(
titleContent = { Text(text = "$selectedCount") }, titleContent = { Text(text = "$selectedCount") },
actions = { actions = {
AppBarActions( AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>() actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
.builder() if (onSelectAll != null) {
.apply { add(
if (onSelectAll != null) { AppBar.Action(
add( title = stringResource(MR.strings.action_select_all),
AppBar.Action( icon = Icons.Filled.SelectAll,
title = stringResource(MR.strings.action_select_all), onClick = onSelectAll,
icon = Icons.Filled.SelectAll, ),
onClick = onSelectAll, )
), }
) if (onReverseSelection != null) {
} add(
if (onReverseSelection != null) { AppBar.Action(
add( title = stringResource(MR.strings.action_select_inverse),
AppBar.Action( icon = Icons.Outlined.FlipToBack,
title = stringResource(MR.strings.action_select_inverse), onClick = onReverseSelection,
icon = Icons.Outlined.FlipToBack, ),
onClick = onReverseSelection, )
), }
) if (isRunning) {
} add(
if (isRunning) { AppBar.ActionCompose(
add( title = stringResource(KMR.strings.action_bulk_select),
AppBar.ActionCompose( ) {
title = stringResource(KMR.strings.action_bulk_select), CircularProgressIndicator(
) { modifier = Modifier
CircularProgressIndicator( .size(24.dp),
modifier = Modifier strokeWidth = 2.dp,
.size(24.dp), )
strokeWidth = 2.dp, },
) )
} else {
add(
AppBar.Action(
title = stringResource(MR.strings.add_to_library),
icon = Icons.Filled.Favorite,
onClick = {
if (selectedCount > 0) {
onChangeCategoryClick()
}
}, },
) ),
} else { )
add( }
AppBar.Action( }.build(),
title = stringResource(MR.strings.add_to_library),
icon = Icons.Filled.Favorite,
onClick = {
if (selectedCount > 0) {
onChangeCategoryClick()
}
},
),
)
}
}.build(),
) )
}, },
isActionMode = true, isActionMode = true,

View file

@ -44,6 +44,7 @@ private fun DownloadDropdownMenuItems(
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10), DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25), DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread), DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
) )
options.forEach { (downloadAction, string) -> options.forEach { (downloadAction, string) ->

View file

@ -105,7 +105,7 @@ fun MangaCompactGridItem(
MangaCoverHide.Book( MangaCoverHide.Book(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
) )
} else { } else {
@ -119,7 +119,7 @@ fun MangaCompactGridItem(
data = coverData, data = coverData,
// KMK --> // KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha, alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
// KMK <-- // KMK <--
) )
@ -241,7 +241,7 @@ fun MangaComfortableGridItem(
MangaCoverHide.Book( MangaCoverHide.Book(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
) )
} else { } else {
@ -255,7 +255,7 @@ fun MangaComfortableGridItem(
data = coverData, data = coverData,
// KMK --> // KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha, alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
onCoverLoaded = { _, result -> onCoverLoaded = { _, result ->
val image = result.result.image val image = result.result.image
@ -274,7 +274,7 @@ fun MangaComfortableGridItem(
data = coverData, data = coverData,
// KMK --> // KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha, alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
onCoverLoaded = { _, result -> onCoverLoaded = { _, result ->
val image = result.result.image val image = result.result.image
@ -461,7 +461,7 @@ fun MangaListItem(
MangaCoverHide.Square( MangaCoverHide.Square(
modifier = Modifier modifier = Modifier
.fillMaxHeight(), .fillMaxHeight(),
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
) )
} else { } else {
@ -475,7 +475,7 @@ fun MangaListItem(
data = coverData, data = coverData,
// KMK --> // KMK -->
alpha = coverAlpha, alpha = coverAlpha,
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
tint = onBgColor, tint = onBgColor,
size = MangaCover.Size.Big, size = MangaCover.Size.Big,
// KMK <-- // KMK <--

View file

@ -67,7 +67,7 @@ fun LibraryUpdateErrorScreen(
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
onErrorsDelete: () -> Unit, onErrorsDelete: () -> Unit,
onErrorDelete: (Long) -> Unit, onErrorDelete: (Long) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit, onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) }) BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })

View file

@ -44,7 +44,7 @@ import tachiyomi.presentation.core.util.selectedBackground
internal fun LazyListScope.libraryUpdateErrorUiItems( internal fun LazyListScope.libraryUpdateErrorUiItems(
uiModels: List<LibraryUpdateErrorUiModel>, uiModels: List<LibraryUpdateErrorUiModel>,
selectionMode: Boolean, selectionMode: Boolean,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit, onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
onClick: (LibraryUpdateErrorItem) -> Unit, onClick: (LibraryUpdateErrorItem) -> Unit,
onClickCover: (LibraryUpdateErrorItem) -> Unit, onClickCover: (LibraryUpdateErrorItem) -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
@ -81,7 +81,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
selectionMode -> onErrorSelected( selectionMode -> onErrorSelected(
libraryUpdateErrorItem, libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected, !libraryUpdateErrorItem.selected,
true,
false, false,
) )
@ -93,7 +92,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
libraryUpdateErrorItem, libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected, !libraryUpdateErrorItem.selected,
true, true,
true,
) )
}, },
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode }, onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
@ -144,8 +142,8 @@ private fun LibraryUpdateErrorUiItem(
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = { onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}, },
) )
.padding(horizontal = MaterialTheme.padding.medium), .padding(horizontal = MaterialTheme.padding.medium),

View file

@ -94,9 +94,7 @@ import eu.kanade.tachiyomi.source.online.all.Lanraragi
import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.NHentai import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.english.EightMuses import eu.kanade.tachiyomi.source.online.english.EightMuses
import eu.kanade.tachiyomi.source.online.english.HBrowse
import eu.kanade.tachiyomi.source.online.english.Pururin import eu.kanade.tachiyomi.source.online.english.Pururin
import eu.kanade.tachiyomi.source.online.english.Tsumino
import eu.kanade.tachiyomi.ui.manga.ChapterList import eu.kanade.tachiyomi.ui.manga.ChapterList
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel import eu.kanade.tachiyomi.ui.manga.MangaScreenModel
import eu.kanade.tachiyomi.ui.manga.MergedMangaData import eu.kanade.tachiyomi.ui.manga.MergedMangaData
@ -108,12 +106,10 @@ import exh.source.getMainSource
import exh.source.isEhBasedManga import exh.source.isEhBasedManga
import exh.ui.metadata.adapters.EHentaiDescription import exh.ui.metadata.adapters.EHentaiDescription
import exh.ui.metadata.adapters.EightMusesDescription import exh.ui.metadata.adapters.EightMusesDescription
import exh.ui.metadata.adapters.HBrowseDescription
import exh.ui.metadata.adapters.LanraragiDescription import exh.ui.metadata.adapters.LanraragiDescription
import exh.ui.metadata.adapters.MangaDexDescription import exh.ui.metadata.adapters.MangaDexDescription
import exh.ui.metadata.adapters.NHentaiDescription import exh.ui.metadata.adapters.NHentaiDescription
import exh.ui.metadata.adapters.PururinDescription import exh.ui.metadata.adapters.PururinDescription
import exh.ui.metadata.adapters.TsuminoDescription
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.service.missingChaptersCount import tachiyomi.domain.chapter.service.missingChaptersCount
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
@ -194,7 +190,7 @@ fun MangaScreen(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
@ -404,7 +400,7 @@ private fun MangaScreenSmallImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
@ -868,7 +864,7 @@ private fun MangaScreenLargeImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
@ -1307,7 +1303,7 @@ private fun LazyListScope.sharedChapterItems(
// SY <-- // SY <--
onChapterClicked: (Chapter) -> Unit, onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?, onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
) { ) {
items( items(
@ -1376,14 +1372,14 @@ private fun LazyListScope.sharedChapterItems(
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
onLongClick = { onLongClick = {
onChapterSelected(item, !item.selected, true, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onChapterSelected(item, !item.selected, true)
}, },
onClick = { onClick = {
onChapterItemClick( onChapterItemClick(
chapterItem = item, chapterItem = item,
isAnyChapterSelected = isAnyChapterSelected, isAnyChapterSelected = isAnyChapterSelected,
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) }, onToggleSelection = { onChapterSelected(item, !item.selected, false) },
onChapterClicked = onChapterClicked, onChapterClicked = onChapterClicked,
) )
}, },
@ -1438,15 +1434,9 @@ fun metadataDescription(source: Source): MetadataDescriptionComposable? {
is EightMuses -> { state, openMetadataViewer, _ -> is EightMuses -> { state, openMetadataViewer, _ ->
EightMusesDescription(state, openMetadataViewer) EightMusesDescription(state, openMetadataViewer)
} }
is HBrowse -> { state, openMetadataViewer, _ ->
HBrowseDescription(state, openMetadataViewer)
}
is Pururin -> { state, openMetadataViewer, _ -> is Pururin -> { state, openMetadataViewer, _ ->
PururinDescription(state, openMetadataViewer) PururinDescription(state, openMetadataViewer)
} }
is Tsumino -> { state, openMetadataViewer, _ ->
TsuminoDescription(state, openMetadataViewer)
}
is Lanraragi -> { state, openMetadataViewer, _ -> is Lanraragi -> { state, openMetadataViewer, _ ->
LanraragiDescription(state, openMetadataViewer) LanraragiDescription(state, openMetadataViewer)
} }

View file

@ -6,6 +6,7 @@ enum class DownloadAction {
NEXT_10_CHAPTERS, NEXT_10_CHAPTERS,
NEXT_25_CHAPTERS, NEXT_25_CHAPTERS,
UNREAD_CHAPTERS, UNREAD_CHAPTERS,
BOOKMARKED_CHAPTERS,
} }
enum class EditCoverAction { enum class EditCoverAction {

View file

@ -43,8 +43,8 @@ fun BaseMangaListItem(
onClick = onClickItem, onClick = onClickItem,
// KMK --> // KMK -->
onLongClick = { onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}, },
// KMK <-- // KMK <--
) )

View file

@ -14,11 +14,11 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
@ -43,9 +43,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() } val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() } val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0) val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
// SY --> // SY -->
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items), title = stringResource(MR.strings.pref_hide_in_library_items),
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount), subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
onClick = { onClick = {
navigator.push(ExtensionReposScreen()) navigator.push(ExtensionStoresScreen())
}, },
), ),
), ),

View file

@ -18,6 +18,7 @@ import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.download.service.LibraryUpdateDownloadMode
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
@ -77,6 +78,7 @@ object SettingsDownloadScreen : SearchableSettings {
allCategories = allCategories, allCategories = allCategories,
), ),
getDownloadAheadGroup(downloadPreferences = downloadPreferences), getDownloadAheadGroup(downloadPreferences = downloadPreferences),
getLibraryUpdateDownloadGroup(downloadPreferences = downloadPreferences),
// KMK --> // KMK -->
getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences), getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences),
// KMK <-- // KMK <--
@ -216,6 +218,39 @@ object SettingsDownloadScreen : SearchableSettings {
) )
} }
@Composable
private fun getLibraryUpdateDownloadGroup(
downloadPreferences: DownloadPreferences,
): Preference.PreferenceGroup {
val modePref = downloadPreferences.libraryUpdateDownloadMode()
val amountPref = downloadPreferences.libraryUpdateDownloadAmount()
val mode by modePref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(KMR.strings.library_update_download),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
preference = modePref,
entries = persistentMapOf(
LibraryUpdateDownloadMode.DISABLED to stringResource(MR.strings.disabled),
LibraryUpdateDownloadMode.NEXT_X to stringResource(KMR.strings.library_update_download_mode_next_x),
LibraryUpdateDownloadMode.ALL to stringResource(KMR.strings.library_update_download_mode_all),
),
title = stringResource(KMR.strings.library_update_download_mode),
),
Preference.PreferenceItem.ListPreference(
preference = amountPref,
entries = listOf(2, 3, 5, 10)
.associateWith { pluralStringResource(MR.plurals.next_unread_chapters, count = it, it) }
.toImmutableMap(),
title = stringResource(KMR.strings.library_update_download_amount),
enabled = mode == LibraryUpdateDownloadMode.NEXT_X,
),
Preference.PreferenceItem.InfoPreference(stringResource(KMR.strings.library_update_download_info)),
),
)
}
// KMK --> // KMK -->
@Composable @Composable
private fun getDownloadCacheRenewInterval( private fun getDownloadCacheRenewInterval(

View file

@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
), ),
SY <-- */ SY <-- */
getDisplayGroup(readerPreferences = readerPref), getDisplayGroup(readerPreferences = readerPref),
getReadingEtaTuningGroup(),
getEInkGroup(readerPreferences = readerPref), getEInkGroup(readerPreferences = readerPref),
getReadingGroup(readerPreferences = readerPref), getReadingGroup(readerPreferences = readerPref),
getPagedGroup(readerPreferences = readerPref), getPagedGroup(readerPreferences = readerPref),
@ -177,6 +179,56 @@ object SettingsReaderScreen : SearchableSettings {
) )
} }
@Composable
private fun getReadingEtaTuningGroup(): Preference.PreferenceGroup {
val readingEtaPrefs = remember { Injekt.get<ReadingEtaPreferences>() }
val etaMaxStatsPref = readingEtaPrefs.maxStatsDurationMinutes()
val etaMaxTrustedPref = readingEtaPrefs.maxTrustedSessionMinutes()
val etaSamplePref = readingEtaPrefs.sampleChapterLimit()
val etaMaxPagesPref = readingEtaPrefs.maxPagesReadForPageRate()
val etaMaxStats by etaMaxStatsPref.collectAsState()
val etaMaxTrusted by etaMaxTrustedPref.collectAsState()
val etaSample by etaSamplePref.collectAsState()
val etaMaxPages by etaMaxPagesPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(KMR.strings.pref_reading_eta_group_title),
preferenceItems = persistentListOf(
Preference.PreferenceItem.SliderPreference(
value = etaMaxStats,
valueRange = 5..180,
title = stringResource(KMR.strings.pref_reading_eta_max_stats_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_stats_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_stats_min_value, etaMaxStats),
onValueChanged = { etaMaxStatsPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaMaxTrusted,
valueRange = 60..1440,
title = stringResource(KMR.strings.pref_reading_eta_max_trusted_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_value, etaMaxTrusted),
onValueChanged = { etaMaxTrustedPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaSample,
valueRange = 5..100,
title = stringResource(KMR.strings.pref_reading_eta_sample_chapters),
subtitle = stringResource(KMR.strings.pref_reading_eta_sample_chapters_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_sample_chapters_value, etaSample),
onValueChanged = { etaSamplePref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = (etaMaxPages / 100).coerceIn(10, 500),
valueRange = 10..500,
title = stringResource(KMR.strings.pref_reading_eta_max_pages_sample),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_value, etaMaxPages),
onValueChanged = { etaMaxPagesPref.set(it * 100) },
),
),
)
}
@Composable @Composable
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val flashPageState by readerPreferences.flashOnPageChange().collectAsState() val flashPageState by readerPreferences.flashOnPageChange().collectAsState()

View file

@ -1,205 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
val events = _events.receiveAsFlow()
init {
screenModelScope.launchIO {
getExtensionRepo.subscribeAll()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
repos = repos.toImmutableSet(),
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is RepoScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(baseUrl: String) {
screenModelScope.launchIO {
when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.Success -> extensionManager.findAvailableExtensions()
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
}
else -> {}
}
}
}
/**
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
*
* @param newRepo The repo to insert
*/
fun replaceRepo(newRepo: ExtensionRepo) {
screenModelScope.launchIO {
replaceExtensionRepo.await(newRepo)
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is RepoScreenState.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
// KMK -->
// Remove repo from disabled list
enableRepo(baseUrl)
// KMK <--
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == baseUrl }.toSet(),
)
}
}
fun disableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + baseUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun showDialog(dialog: RepoDialog) {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
data class Confirm(val url: String) : RepoDialog()
}
sealed class RepoScreenState {
@Immutable
data object Loading : RepoScreenState()
@Immutable
data class Success(
val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View file

@ -8,20 +8,18 @@ import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConfirmDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreConfirmDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionReposScreen( class ExtensionStoresScreen(
private val url: String? = null, private val url: String? = null,
) : Screen() { ) : Screen() {
@ -30,80 +28,72 @@ class ExtensionReposScreen(
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionReposScreenModel() } val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
LaunchedEffect(url) { LaunchedEffect(url) {
url?.let { screenModel.showDialog(RepoDialog.Confirm(it)) } url?.let { screenModel.addFromDeeplink(url) }
} }
if (state is RepoScreenState.Loading) { if (state is ExtensionStoreScreenState.Loading) {
LoadingScreen() LoadingScreen()
return return
} }
val successState = state as RepoScreenState.Success val successState = state as ExtensionStoreScreenState.Success
ExtensionReposScreen( ExtensionStoresScreen(
state = successState, state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
onOpenWebsite = { context.openInBrowser(it.website) }, onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
// KMK --> // KMK -->
onClickEnable = { onClickEnable = {
screenModel.enableRepo(it) screenModel.enableStore(it.indexUrl)
screenModel.refreshExtensionList() screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh) context.toast(KMR.strings.extensions_page_need_refresh)
}, },
onClickDisable = { onClickDisable = {
screenModel.disableRepo(it) screenModel.disableStore(it.indexUrl)
screenModel.refreshExtensionList() screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh) context.toast(KMR.strings.extensions_page_need_refresh)
}, },
// KMK <-- // KMK <--
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() }, onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop, navigateUp = navigator::pop,
) )
when (val dialog = successState.dialog) { when (val dialog = successState.dialog) {
null -> {} null -> {}
is RepoDialog.Create -> { is ExtensionStoreDialog.Create -> {
ExtensionRepoCreateDialog( ExtensionStoreCreateDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) }, onCreate = { screenModel.createRepo(it) },
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(), storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
processing = dialog.processing,
errorMessage = dialog.errorMessage,
) )
} }
is RepoDialog.Delete -> { is ExtensionStoreDialog.Delete -> {
ExtensionRepoDeleteDialog( ExtensionStoreDeleteDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.repo) }, onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
repo = dialog.repo, storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
) )
} }
is RepoDialog.Conflict -> { is ExtensionStoreDialog.Confirm -> {
ExtensionRepoConflictDialog( ExtensionStoreConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
is RepoDialog.Confirm -> {
ExtensionRepoConfirmDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) }, onCreate = { screenModel.createRepo(dialog.url) },
repo = dialog.url, storeIndexUrl = dialog.url,
storeAlreadyExists = dialog.alreadyExists,
processing = dialog.processing,
errorMessage = dialog.errorMessage,
) )
} }
} }
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
} }
} }

View file

@ -0,0 +1,223 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresScreenModel(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
private val addExtensionStore: AddExtensionStore = Injekt.get(),
private val removeExtensionStore: RemoveExtensionStore = Injekt.get(),
private val updateExtensionStores: UpdateExtensionStores = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<ExtensionStoreScreenState>(ExtensionStoreScreenState.Loading) {
private inline fun updateSuccessState(
func: (ExtensionStoreScreenState.Success) -> ExtensionStoreScreenState.Success,
) {
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> it
is ExtensionStoreScreenState.Success -> func(it)
}
}
}
init {
screenModelScope.launchIO {
getExtensionStores.subscribe()
.collectLatest { stores ->
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> ExtensionStoreScreenState.Success(
stores = stores,
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
is ExtensionStoreScreenState.Success -> it.copy(stores = stores)
}
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is ExtensionStoreScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param indexUrl The baseUrl of the repo to create.
*/
fun createRepo(indexUrl: String) {
// KMK -->
screenModelScope.launchIO {
// KMK <--
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(processing = true)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(processing = true)
else -> it.dialog
},
)
}
addExtensionStore(indexUrl)
.onSuccess {
extensionManager.findAvailableExtensions()
dismissDialog()
}
.onFailure { throwable ->
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
else -> it.dialog
},
)
}
}
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is ExtensionStoreScreenState.Success) {
screenModelScope.launchIO {
updateExtensionStores()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(indexUrl: String) {
// KMK -->
// Remove repo from disabled list
enableStore(indexUrl)
// KMK <--
screenModelScope.launchIO {
removeExtensionStore(indexUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == indexUrl }.toSet(),
)
}
}
fun disableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + indexUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun addFromDeeplink(storeIndexUrl: String) {
updateSuccessState { state ->
state.copy(
dialog = ExtensionStoreDialog.Confirm(
url = storeIndexUrl,
alreadyExists = state.stores.any { it.indexUrl == storeIndexUrl },
),
)
}
}
fun showDialog(dialog: ExtensionStoreDialog) {
updateSuccessState { state ->
state.copy(dialog = dialog)
}
}
fun dismissDialog() {
updateSuccessState {
it.copy(dialog = null)
}
}
}
sealed class ExtensionStoreDialog {
data class Create(val processing: Boolean = false, val errorMessage: String? = null) : ExtensionStoreDialog()
data class Delete(val store: ExtensionStore) : ExtensionStoreDialog()
data class Confirm(
val url: String,
val alreadyExists: Boolean = false,
val processing: Boolean = false,
val errorMessage: String? = null,
) : ExtensionStoreDialog()
}
sealed class ExtensionStoreScreenState {
@Immutable
data object Loading : ExtensionStoreScreenState()
@Immutable
data class Success(
val stores: List<ExtensionStore>,
val dialog: ExtensionStoreDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : ExtensionStoreScreenState() {
val isEmpty: Boolean
get() = stores.isEmpty()
}
}

View file

@ -1,188 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
repoUrls: ImmutableSet<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
enabled = name.isNotEmpty() && !nameAlreadyExists,
onClick = {
onCreate(name)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Column {
Text(text = stringResource(MR.strings.action_add_repo_message, stringResource(MR.strings.app_name)))
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = {
Text(text = stringResource(MR.strings.label_add_repo_input))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_repo_exists
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
)
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionRepoDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_delete_repo))
},
text = {
Text(text = stringResource(MR.strings.delete_repo_confirmation, repo))
},
)
}
@Composable
fun ExtensionRepoConflictDialog(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onDismissRequest: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onMigrate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
@Composable
fun ExtensionRepoConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Text(text = stringResource(MR.strings.add_repo_confirmation, repo))
},
confirmButton = {
TextButton(
onClick = {
onCreate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
@ -26,33 +26,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.copyToClipboard import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.ImmutableSet import mihon.domain.extension.model.ExtensionStore
import kotlinx.collections.immutable.persistentSetOf import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE import mihon.domain.extension.model.REPO_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable @Composable
fun ExtensionReposContent( fun ExtensionStoresContent(
repos: ImmutableSet<ExtensionRepo>, repos: List<ExtensionStore>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK --> // KMK -->
onClickEnable: (String) -> Unit, onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (String) -> Unit, onClickDisable: (ExtensionStore) -> Unit,
disabledRepos: Set<String>, disabledRepos: Set<String>,
// KMK <-- // KMK <--
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -65,15 +65,17 @@ fun ExtensionReposContent(
) { ) {
repos.forEach { repos.forEach {
item { item {
ExtensionRepoListItem( ExtensionStoresListItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
repo = it, store = it,
onOpenWebsite = { onOpenWebsite(it) }, onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) }, onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
// KMK --> // KMK -->
onEnable = { onClickEnable(it.baseUrl) }, onEnable = { onClickEnable(it) },
onDisable = { onClickDisable(it.baseUrl) }, onDisable = { onClickDisable(it) },
isDisabled = it.baseUrl in disabledRepos, isDisabled = it.indexUrl in disabledRepos,
// KMK <-- // KMK <--
) )
} }
@ -82,9 +84,11 @@ fun ExtensionReposContent(
} }
@Composable @Composable
private fun ExtensionRepoListItem( private fun ExtensionStoresListItem(
repo: ExtensionRepo, store: ExtensionStore,
onOpenWebsite: () -> Unit, onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// KMK --> // KMK -->
@ -93,8 +97,6 @@ private fun ExtensionRepoListItem(
onDisable: () -> Unit, onDisable: () -> Unit,
// KMK <-- // KMK <--
) { ) {
val context = LocalContext.current
ElevatedCard( ElevatedCard(
modifier = modifier, modifier = modifier,
) { ) {
@ -103,9 +105,9 @@ private fun ExtensionRepoListItem(
modifier = Modifier modifier = Modifier
.padding(start = MaterialTheme.padding.medium), .padding(start = MaterialTheme.padding.medium),
) { ) {
val resId = repoResId(repo.signingKeyFingerprint) val resId = repoResId(store.signingKey)
Image( Image(
bitmap = ImageBitmap.imageResource(id = resId), painter = painterResource(id = resId),
contentDescription = null, contentDescription = null,
alpha = if (isDisabled) 0.4f else 1f, alpha = if (isDisabled) 0.4f else 1f,
modifier = Modifier modifier = Modifier
@ -126,7 +128,7 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = repo.name, text = store.name,
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium), // KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
// KMK --> // KMK -->
@ -142,17 +144,21 @@ private fun ExtensionRepoListItem(
) { ) {
IconButton(onClick = onOpenWebsite) { IconButton(onClick = onOpenWebsite) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew, imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_browser), contentDescription = stringResource(MR.strings.action_open_in_browser),
) )
} }
IconButton( if (store.contact.discord != null) {
onClick = { IconButton(onClick = onOpenDiscord) {
val url = "${repo.baseUrl}/index.min.json" Icon(
context.copyToClipboard(url, url) imageVector = CustomIcons.Discord,
}, contentDescription = null,
) { )
}
}
IconButton(onClick = onCopy) {
Icon( Icon(
imageVector = Icons.Outlined.ContentCopy, imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard), contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@ -190,16 +196,18 @@ fun repoResId(signKey: String) = when (signKey) {
@Preview @Preview
@Composable @Composable
fun ExtensionReposContentPreview() { fun ExtensionReposContentPreview() {
val repos = persistentSetOf( val repos = persistentListOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE), ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE), ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Other", "", "", "key2"), ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
) )
ExtensionReposContent( ExtensionStoresContent(
repos = repos, repos = repos,
lazyListState = LazyListState(), lazyListState = LazyListState(),
paddingValues = PaddingValues(), paddingValues = PaddingValues(),
onCopy = {},
onOpenWebsite = {}, onOpenWebsite = {},
onOpenDiscord = {},
onClickDelete = {}, onClickDelete = {},
onClickEnable = {}, onClickEnable = {},
onClickDisable = {}, onClickDisable = {},

View file

@ -0,0 +1,192 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionStoreCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
storeIndexUrls: Set<String>,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState()
val storeAlreadyExists by remember(storeIndexUrls) {
derivedStateOf {
val indexUrl = state.text.toString()
storeIndexUrls.contains(indexUrl)
}
}
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
state = state,
label = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreInput_inputLabel))
},
supportingText = {
val msgRes = if (storeAlreadyExists) {
MR.strings.extensionStoresScreen_addStore_alreadyExists
} else {
MR.strings.information_required_plain
}
Text(text = errorMessage ?: stringResource(msgRes))
},
isError = errorMessage != null || storeAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
lineLimits = TextFieldLineLimits.SingleLine,
)
},
confirmButton = {
TextButton(
onClick = { onCreate(state.text.toString()) },
enabled = !processing && state.text.isNotEmpty() && !storeAlreadyExists,
) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionStoreDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
storeName: String,
storeIndexUrl: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_title))
},
text = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_body, storeName, storeIndexUrl))
},
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
@Composable
fun ExtensionStoreConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
storeIndexUrl: String,
storeAlreadyExists: Boolean,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState(initialText = storeIndexUrl)
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreDeeplink_bodyText))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = state,
readOnly = true,
supportingText = when {
storeAlreadyExists -> {
{
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_alreadyExists))
}
}
errorMessage != null -> {
{
Text(text = errorMessage)
}
}
else -> null
},
isError = errorMessage != null || storeAlreadyExists,
)
}
},
confirmButton = {
TextButton(onClick = onCreate, enabled = !storeAlreadyExists && !processing) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -1,5 +1,3 @@
@file:JvmName("ExtensionReposScreenKt")
package eu.kanade.presentation.more.settings.screen.browse.components package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -21,13 +19,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_HELP import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE import mihon.domain.extension.model.REPO_HELP
import mihon.domain.extensionrepo.model.ExtensionRepo import mihon.domain.extension.model.REPO_SIGNATURE
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@ -37,14 +35,16 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
@Composable @Composable
fun ExtensionReposScreen( fun ExtensionStoresScreen(
state: RepoScreenState.Success, state: ExtensionStoreScreenState.Success,
onClickCreate: () -> Unit, onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK --> // KMK -->
onClickEnable: (String) -> Unit, onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (String) -> Unit, onClickDisable: (ExtensionStore) -> Unit,
// KMK <-- // KMK <--
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
@ -54,7 +54,7 @@ fun ExtensionReposScreen(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
AppBar( AppBar(
navigateUp = navigateUp, navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = { actions = {
IconButton(onClick = onClickRefresh) { IconButton(onClick = onClickRefresh) {
@ -76,7 +76,7 @@ fun ExtensionReposScreen(
if (state.isEmpty) { if (state.isEmpty) {
val context = LocalContext.current val context = LocalContext.current
EmptyScreen( EmptyScreen(
MR.strings.information_empty_repos, MR.strings.extensionStoresScreen_emptyLabel,
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
// KMK --> // KMK -->
help = { help = {
@ -94,12 +94,14 @@ fun ExtensionReposScreen(
return@Scaffold return@Scaffold
} }
ExtensionReposContent( ExtensionStoresContent(
repos = state.repos, repos = state.stores,
lazyListState = lazyListState, lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues + paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium), PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite, onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete, onClickDelete = onClickDelete,
// KMK --> // KMK -->
onClickEnable = onClickEnable, onClickEnable = onClickEnable,
@ -113,19 +115,21 @@ fun ExtensionReposScreen(
// KMK --> // KMK -->
@Preview @Preview
@Composable @Composable
private fun ExtensionReposScreenPreview() { private fun ExtensionStoresScreenPreview() {
val state = RepoScreenState.Success( val state = ExtensionStoreScreenState.Success(
repos = persistentSetOf( stores = persistentListOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE), ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE), ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Other", "", "", "key2"), ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
), ),
disabledRepos = setOf("https://repo"), disabledRepos = setOf("https://repo"),
) )
ExtensionReposScreen( ExtensionStoresScreen(
state = state, state = state,
onClickCreate = { }, onClickCreate = { },
onCopy = { },
onOpenWebsite = { }, onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { }, onClickDelete = { },
onClickEnable = { }, onClickEnable = { },
onClickDisable = { }, onClickDisable = { },
@ -136,12 +140,14 @@ private fun ExtensionReposScreenPreview() {
@Preview @Preview
@Composable @Composable
private fun ExtensionReposScreenEmptyPreview() { private fun ExtensionStoresScreenEmptyPreview() {
val state = RepoScreenState.Success(repos = persistentSetOf()) val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
ExtensionReposScreen( ExtensionStoresScreen(
state = state, state = state,
onClickCreate = { }, onClickCreate = { },
onCopy = { },
onOpenWebsite = { }, onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { }, onClickDelete = { },
onClickEnable = { }, onClickEnable = { },
onClickDisable = { }, onClickDisable = { },

View file

@ -114,7 +114,7 @@ private class SyncSettingsSelectorModel(
tracking = syncSettings.tracking, tracking = syncSettings.tracking,
history = syncSettings.history, history = syncSettings.history,
appSettings = syncSettings.appSettings, appSettings = syncSettings.appSettings,
extensionRepoSettings = syncSettings.extensionRepoSettings, extensionStores = syncSettings.extensionStores,
sourceSettings = syncSettings.sourceSettings, sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings, privateSettings = syncSettings.privateSettings,
@ -134,7 +134,7 @@ private class SyncSettingsSelectorModel(
tracking = backupOptions.tracking, tracking = backupOptions.tracking,
history = backupOptions.history, history = backupOptions.history,
appSettings = backupOptions.appSettings, appSettings = backupOptions.appSettings,
extensionRepoSettings = backupOptions.extensionRepoSettings, extensionStores = backupOptions.extensionStores,
sourceSettings = backupOptions.sourceSettings, sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings, privateSettings = backupOptions.privateSettings,

View file

@ -171,7 +171,6 @@ internal fun LazyListScope.updatesUiItems(
// KMK --> // KMK -->
UpdateSelectionOptions( UpdateSelectionOptions(
selected = !updatesItem.selected, selected = !updatesItem.selected,
userSelected = true,
fromLongPress = true, fromLongPress = true,
isGroup = isLeader && item.isExpandable, isGroup = isLeader && item.isExpandable,
isExpanded = isExpanded, isExpanded = isExpanded,
@ -186,7 +185,6 @@ internal fun LazyListScope.updatesUiItems(
// KMK --> // KMK -->
UpdateSelectionOptions( UpdateSelectionOptions(
selected = !updatesItem.selected, selected = !updatesItem.selected,
userSelected = true,
fromLongPress = false, fromLongPress = false,
isGroup = isLeader && item.isExpandable, isGroup = isLeader && item.isExpandable,
isExpanded = isExpanded, isExpanded = isExpanded,
@ -288,8 +286,8 @@ private fun UpdatesUiItem(
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = { onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}, },
) )
.padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp) .padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp)
@ -311,7 +309,7 @@ private fun UpdatesUiItem(
MangaCoverHide.Book( MangaCoverHide.Book(
modifier = Modifier modifier = Modifier
.width(UpdateItemWidth), .width(UpdateItemWidth),
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { selected }), bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { selected },
tint = onBgColor, tint = onBgColor,
size = MangaCover.Size.Medium, size = MangaCover.Size.Medium,
) )

View file

@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
) )
is LoadingState.Loading -> { is LoadingState.Loading -> {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
(loadingState as? LoadingState.Loading)?.progress ?: 1f, loadingState.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "webview_loading", label = "webview_loading",
) )

View file

@ -6,7 +6,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionRepoBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.FeedBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.FeedBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.backup.create.creators.SavedSearchBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupFeed import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
@ -54,7 +54,7 @@ class BackupCreator(
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(), private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(), private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(), private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(), private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(), private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
// KMK --> // KMK -->
private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(), private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(),
@ -101,7 +101,7 @@ class BackupCreator(
backupCategories = backupCategories(options), backupCategories = backupCategories(options),
backupSources = backupSources(backupManga), backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options), backupPreferences = backupAppPreferences(options),
backupExtensionRepo = backupExtensionRepos(options), backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options), backupSourcePreferences = backupSourcePreferences(options),
// SY --> // SY -->
@ -159,16 +159,16 @@ class BackupCreator(
return sourcesBackupCreator(mangas) return sourcesBackupCreator(mangas)
} }
/* KMK --> */ suspend /* KMK <-- */ fun backupAppPreferences(options: BackupOptions): List<BackupPreference> { internal fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (!options.appSettings) return emptyList() if (!options.appSettings) return emptyList()
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings) return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
} }
suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> { internal suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
if (!options.extensionRepoSettings) return emptyList() if (!options.extensionStores) return emptyList()
return extensionRepoBackupCreator() return extensionStoresBackupCreator()
} }
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> { fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {

View file

@ -14,7 +14,7 @@ data class BackupOptions(
val history: Boolean = true, val history: Boolean = true,
val readEntries: Boolean = true, val readEntries: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,
// SY --> // SY -->
@ -31,7 +31,7 @@ data class BackupOptions(
history, history,
readEntries, readEntries,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
privateSettings, privateSettings,
// SY --> // SY -->
@ -41,7 +41,7 @@ data class BackupOptions(
) )
fun canCreate() = fun canCreate() =
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds libraryEntries || categories || appSettings || extensionStores || sourceSettings || savedSearchesFeeds
companion object { companion object {
val libraryOptions = persistentListOf( val libraryOptions = persistentListOf(
@ -103,9 +103,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = BackupOptions::extensionRepoSettings, getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@ -128,7 +128,7 @@ data class BackupOptions(
history = array[4], history = array[4],
readEntries = array[5], readEntries = array[5],
appSettings = array[6], appSettings = array[6],
extensionRepoSettings = array[7], extensionStores = array[7],
sourceSettings = array[8], sourceSettings = array[8],
privateSettings = array[9], privateSettings = array[9],
// SY --> // SY -->

View file

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.backupExtensionReposMapper
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoBackupCreator(
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionRepos> {
return getExtensionRepos.getAll()
.map(backupExtensionReposMapper)
}
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.backupExtensionStoreMapper
import mihon.domain.extension.interactor.GetExtensionStores
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresBackupCreator(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionStore> {
return getExtensionStores.get()
.map(backupExtensionStoreMapper)
}
}

View file

@ -11,7 +11,7 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(), @ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(), @ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
@ProtoNumber(106) var backupExtensionRepo: List<BackupExtensionRepos> = emptyList(), @ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
// SY specific values // SY specific values
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(), @ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
// KMK --> // KMK -->

View file

@ -1,24 +0,0 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extensionrepo.model.ExtensionRepo
@Serializable
class BackupExtensionRepos(
@ProtoNumber(1) var baseUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var shortName: String?,
@ProtoNumber(4) var website: String,
@ProtoNumber(5) var signingKeyFingerprint: String,
)
val backupExtensionReposMapper = { repo: ExtensionRepo ->
BackupExtensionRepos(
baseUrl = repo.baseUrl,
name = repo.name,
shortName = repo.shortName,
website = repo.website,
signingKeyFingerprint = repo.signingKeyFingerprint,
)
}

View file

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extension.model.ExtensionStore
@Serializable
class BackupExtensionStore(
@ProtoNumber(1) var indexUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var badgeLabel: String?,
@ProtoNumber(5) var signingKey: String,
@ProtoNumber(4) var contactWebsite: String,
@ProtoNumber(6) var contactDiscord: String?,
@ProtoNumber(7) var isLegacy: Boolean?,
)
val backupExtensionStoreMapper = { store: ExtensionStore ->
BackupExtensionStore(
indexUrl = store.indexUrl,
name = store.name,
badgeLabel = store.badgeLabel,
signingKey = store.signingKey,
contactWebsite = store.contact.website,
contactDiscord = store.contact.discord,
isLegacy = store.isLegacy,
)
}

View file

@ -5,14 +5,14 @@ import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupFeed import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionRepoRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.FeedRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.FeedRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
@ -38,7 +38,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(), private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync), private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
// SY --> // SY -->
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(), private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
@ -96,8 +96,8 @@ class BackupRestorer(
if (options.appSettings) { if (options.appSettings) {
restoreAmount += 1 restoreAmount += 1
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreAmount += backup.backupExtensionRepo.size restoreAmount += backup.backupExtensionStores.size
} }
if (options.sourceSettings) { if (options.sourceSettings) {
restoreAmount += 1 restoreAmount += 1
@ -126,8 +126,8 @@ class BackupRestorer(
if (options.libraryEntries) { if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList()) restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreExtensionRepos(backup.backupExtensionRepo) restoreExtensionStores(backup.backupExtensionStores)
} }
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
@ -248,23 +248,25 @@ class BackupRestorer(
} }
} }
private fun CoroutineScope.restoreExtensionRepos( private fun CoroutineScope.restoreExtensionStores(
backupExtensionRepo: List<BackupExtensionRepos>, backupExtensionStores: List<BackupExtensionStore>,
) = launch { ) = launch {
backupExtensionRepo backupExtensionStores
.forEach { .forEach {
ensureActive() ensureActive()
try { try {
extensionRepoRestorer(it) extensionStoreRestorer(it)
} catch (e: Exception) { } catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") errors.add(Date() to "Error adding extension store: ${it.name} : ${e.message}")
} }
restoreProgress += 1 restoreProgress += 1
// KMK -->
with(notifier) { with(notifier) {
// KMK <--
showRestoreProgress( showRestoreProgress(
context.stringResource(MR.strings.extensionRepo_settings), context.stringResource(MR.strings.extensionStores),
restoreProgress, restoreProgress,
restoreAmount, restoreAmount,
isSync, isSync,

View file

@ -9,7 +9,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true, val libraryEntries: Boolean = true,
val categories: Boolean = true, val categories: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
// SY --> // SY -->
val savedSearchesFeeds: Boolean = true, val savedSearchesFeeds: Boolean = true,
@ -20,7 +20,7 @@ data class RestoreOptions(
libraryEntries, libraryEntries,
categories, categories,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
// SY --> // SY -->
savedSearchesFeeds, savedSearchesFeeds,
@ -31,7 +31,7 @@ data class RestoreOptions(
libraryEntries || libraryEntries ||
categories || categories ||
appSettings || appSettings ||
extensionRepoSettings || extensionStores ||
sourceSettings /* SY --> */ || sourceSettings /* SY --> */ ||
savedSearchesFeeds /* SY <-- */ savedSearchesFeeds /* SY <-- */
@ -53,9 +53,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = RestoreOptions::extensionRepoSettings, getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@ -77,7 +77,7 @@ data class RestoreOptions(
libraryEntries = array[0], libraryEntries = array[0],
categories = array[1], categories = array[1],
appSettings = array[2], appSettings = array[2],
extensionRepoSettings = array[3], extensionStores = array[3],
sourceSettings = array[4], sourceSettings = array[4],
// SY --> // SY -->
savedSearchesFeeds = array[5], savedSearchesFeeds = array[5],

View file

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import tachiyomi.data.DatabaseHandler
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoRestorer(
private val handler: DatabaseHandler = Injekt.get(),
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(
backupRepo: BackupExtensionRepos,
) {
val dbRepos = getExtensionRepos.getAll()
val existingReposBySHA = dbRepos.associateBy { it.signingKeyFingerprint }
val existingReposByUrl = dbRepos.associateBy { it.baseUrl }
val urlExists = existingReposByUrl[backupRepo.baseUrl]
val shaExists = existingReposBySHA[backupRepo.signingKeyFingerprint]
if (urlExists != null && urlExists.signingKeyFingerprint != backupRepo.signingKeyFingerprint) {
error("Already Exists with different signing key fingerprint")
} else if (shaExists != null) {
error("${shaExists.name} has the same signing key fingerprint")
} else {
handler.await {
extension_reposQueries.insert(
backupRepo.baseUrl,
backupRepo.name,
backupRepo.shortName,
backupRepo.website,
backupRepo.signingKeyFingerprint,
)
}
}
}
}

View file

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import tachiyomi.data.Database
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoreRestorer(
private val database: Database = Injekt.get(),
) {
suspend operator fun invoke(
backupStore: BackupExtensionStore,
) {
// KMK -->
val indexUrl = if (backupStore.isLegacy == null) {
backupStore.indexUrl.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json"
} else {
backupStore.indexUrl
}
// KMK <--
database.extension_storeQueries.upsert(
indexUrl = indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
contactWebsite = backupStore.contactWebsite,
contactDiscord = backupStore.contactDiscord,
isLegacy = backupStore.isLegacy ?: true,
)
}
}

View file

@ -58,6 +58,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority import logcat.LogPriority
import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
@ -119,6 +120,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get() private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
private val fetchInterval: FetchInterval = Injekt.get() private val fetchInterval: FetchInterval = Injekt.get()
private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get() private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get()
private val getChaptersForLibraryUpdateDownload: GetChaptersForLibraryUpdateDownload = Injekt.get()
// SY --> // SY -->
private val getFavorites: GetFavorites = Injekt.get() private val getFavorites: GetFavorites = Injekt.get()
@ -465,6 +467,12 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
// Convert to the manga that contains new chapters // Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray()) newUpdates.add(manga to newChapters.toTypedArray())
} }
val libraryUpdateChapters = getChaptersForLibraryUpdateDownload.await(manga)
if (libraryUpdateChapters.isNotEmpty()) {
downloadChapters(manga, libraryUpdateChapters)
hasDownloads.store(true)
}
clearErrorFromDB(mangaId = manga.id) clearErrorFromDB(mangaId = manga.id)
} catch (e: Throwable) { } catch (e: Throwable) {
val errorMessage = when (e) { val errorMessage = when (e) {
@ -493,9 +501,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
if (newUpdates.isNotEmpty()) { if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates) notifier.showUpdateNotifications(newUpdates)
if (hasDownloads.load()) { }
downloadManager.startDownloads()
} if (hasDownloads.load()) {
downloadManager.startDownloads()
} }
if (failedUpdates.isNotEmpty()) { if (failedUpdates.isNotEmpty()) {
@ -748,10 +757,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private const val KEY_MANGA_IDS = "manga_ids" private const val KEY_MANGA_IDS = "manga_ids"
// KMK <-- // KMK <--
fun cancelAllWorks(context: Context) {
context.workManager.cancelAllWorkByTag(TAG)
}
fun setupTask( fun setupTask(
context: Context, context: Context,
prefInterval: Int? = null, prefInterval: Int? = null,
@ -765,16 +770,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} else { } else {
NetworkType.CONNECTED NetworkType.CONNECTED
} }
val networkRequestBuilder = NetworkRequest.Builder() val networkRequest = NetworkRequest.Builder().apply {
if (DEVICE_ONLY_ON_WIFI in restrictions) { removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI) if (DEVICE_ONLY_ON_WIFI in restrictions) {
} addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
if (DEVICE_NETWORK_NOT_METERED in restrictions) { }
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) if (DEVICE_NETWORK_NOT_METERED in restrictions) {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
} }
.build()
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used // 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType) .setRequiredNetworkRequest(networkRequest, networkType)
.setRequiresCharging(DEVICE_CHARGING in restrictions) .setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(true) .setRequiresBatteryNotLow(true)
.build() .build()

View file

@ -88,7 +88,7 @@ class SyncManager(
chapters = syncOptions.chapters, chapters = syncOptions.chapters,
tracking = syncOptions.tracking, tracking = syncOptions.tracking,
history = syncOptions.history, history = syncOptions.history,
extensionRepoSettings = syncOptions.extensionRepoSettings, extensionStores = syncOptions.extensionStores,
appSettings = syncOptions.appSettings, appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings, sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings, privateSettings = syncOptions.privateSettings,
@ -107,8 +107,8 @@ class SyncManager(
backupCategories = backupCreator.backupCategories(backupOptions), backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(backupManga), backupSources = backupCreator.backupSources(backupManga),
backupPreferences = backupCreator.backupAppPreferences(backupOptions), backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupExtensionRepo = backupCreator.backupExtensionRepos(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions), backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
backupExtensionStores = backupCreator.backupExtensionStores(backupOptions),
// SY --> // SY -->
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions), backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
@ -192,7 +192,7 @@ class SyncManager(
backupSources = remoteBackup.backupSources, backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences, backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences, backupSourcePreferences = remoteBackup.backupSourcePreferences,
backupExtensionRepo = remoteBackup.backupExtensionRepo, backupExtensionStores = remoteBackup.backupExtensionStores,
// SY --> // SY -->
backupSavedSearches = remoteBackup.backupSavedSearches, backupSavedSearches = remoteBackup.backupSavedSearches,
@ -222,7 +222,7 @@ class SyncManager(
appSettings = true, appSettings = true,
sourceSettings = true, sourceSettings = true,
libraryEntries = true, libraryEntries = true,
extensionRepoSettings = true, extensionStores = true,
), ),
) )

View file

@ -5,8 +5,14 @@ import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
@ -21,7 +27,6 @@ import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class SyncYomiSyncService( class SyncYomiSyncService(
context: Context, context: Context,
@ -32,9 +37,34 @@ class SyncYomiSyncService(
private val protoBuf: ProtoBuf = Injekt.get(), private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) { ) : SyncService(context, json, syncPreferences) {
// KMK -->
companion object {
private val client by lazy { OkHttpClient() }
}
// KMK <--
private class SyncYomiException(message: String?) : Exception(message) private class SyncYomiException(message: String?) : Exception(message)
@Serializable
private data class SyncEvent(
val event: SyncEventStatus,
@SerialName("device_name")
val deviceName: String? = null,
val message: String? = null,
)
@Serializable
private enum class SyncEventStatus {
SYNC_STARTED,
SYNC_SUCCESS,
SYNC_FAILED,
SYNC_ERROR,
SYNC_CANCELLED,
}
override suspend fun doSync(syncData: SyncData): Backup? { override suspend fun doSync(syncData: SyncData): Backup? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
try { try {
val (remoteData, etag) = pullSyncData() val (remoteData, etag) = pullSyncData()
@ -52,11 +82,26 @@ class SyncYomiSyncService(
syncData syncData
} }
pushSyncData(finalSyncData, etag) val success = pushSyncData(finalSyncData, etag)
if (success) {
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
} else {
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
// KMK -->
return null
// KMK <--
}
return finalSyncData.backup return finalSyncData.backup
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" } logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message) notifier.showSyncError(e.message)
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
return null return null
} }
} }
@ -78,7 +123,6 @@ class SyncYomiSyncService(
headers = headers, headers = headers,
) )
val client = OkHttpClient()
val response = client.newCall(downloadRequest).await() val response = client.newCall(downloadRequest).await()
if (response.code == HttpStatus.SC_NOT_MODIFIED) { if (response.code == HttpStatus.SC_NOT_MODIFIED) {
@ -123,13 +167,12 @@ class SyncYomiSyncService(
/** /**
* Return true if update success * Return true if update success
*/ */
private suspend fun pushSyncData(syncData: SyncData, eTag: String) { private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
val backup = syncData.backup ?: return val backup = syncData.backup ?: return true
val host = syncPreferences.clientHost().get() val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get() val apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/content" val uploadUrl = "$host/api/sync/content"
val timeout = 30L
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey) val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
if (eTag.isNotEmpty()) { if (eTag.isNotEmpty()) {
@ -137,13 +180,6 @@ class SyncYomiSyncService(
} }
val headers = headersBuilder.build() val headers = headersBuilder.build()
// Set timeout to 30 seconds
val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS)
.build()
val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup) val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) { if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error)) throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
@ -156,20 +192,58 @@ class SyncYomiSyncService(
body = body, body = body,
) )
val response = client.newCall(uploadRequest).await() client.newCall(uploadRequest).await()
// KMK -->
.use { response ->
// KMK <--
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
return true
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
return false
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
return false
}
}
}
if (response.isSuccessful) { private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
val newETag = response.headers["ETag"] withContext(NonCancellable) {
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") try {
syncPreferences.lastSyncEtag().set(newETag) val host = syncPreferences.clientHost().get()
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" } val apiKey = syncPreferences.clientAPIKey().get()
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) { val url = "$host/api/sync/event"
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" } val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
} else { val headers = headersBuilder.build()
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody") val bodyObj = SyncEvent(
logcat(LogPriority.ERROR) { "SyncError: $responseBody" } event = event,
deviceName = android.os.Build.MODEL,
message = message,
)
val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST(
url = url,
headers = headers,
body = requestBody,
)
client.newCall(request).await().close()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" }
}
} }
} }
} }

View file

@ -5,6 +5,7 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALError
import eu.kanade.tachiyomi.data.track.anilist.dto.ALIdSearchResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALIdSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
@ -26,6 +27,7 @@ import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.time.Instant import java.time.Instant
@ -43,6 +45,25 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.rateLimit(permits = 85, period = 1.minutes) .rateLimit(permits = 85, period = 1.minutes)
.build() .build()
// KMK -->
private fun Response.parseALError() {
val bodyString = peekBody(1024 * 1024).string()
val errorObj = try {
json.decodeFromString<ALError>(bodyString)
} catch (_: Exception) {
null
}
errorObj?.errors?.firstOrNull()?.let {
val msg = it.message
if (msg.contains("Invalid token") || it.status == 401) {
throw Exception("AniList token expired, please login again")
}
throw Exception(msg)
}
}
// KMK <--
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val query = $$""" val query = $$"""
@ -71,6 +92,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALAddMangaResult>() .parseAs<ALAddMangaResult>()
.let { .let {
track.library_id = it.data.entry.id track.library_id = it.data.entry.id
@ -112,6 +136,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
} }
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess() .awaitSuccess()
// KMK -->
.use { it.parseALError() }
// KMK <--
track track
} }
} }
@ -134,6 +161,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
} }
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess() .awaitSuccess()
// KMK -->
.use { it.parseALError() }
// KMK <--
} }
} }
@ -192,6 +222,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALSearchResult>() .parseAs<ALSearchResult>()
.data.page.media .data.page.media
.map { it.toALManga().toTrack() } .map { it.toALManga().toTrack() }
@ -271,6 +304,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALUserListMangaQueryResult>() .parseAs<ALUserListMangaQueryResult>()
.data.page.mediaList .data.page.mediaList
.map { it.toALUserManga() } .map { it.toALUserManga() }
@ -312,6 +348,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALCurrentUserResult>() .parseAs<ALCurrentUserResult>()
.let { .let {
val viewer = it.data.viewer val viewer = it.data.viewer
@ -323,9 +362,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata { suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|query (${'$'}mangaId: Int!) { |query ($mangaId: Int!) {
|Media (id: ${'$'}mangaId) { |Media (id: $mangaId) {
|id |id
|title { |title {
|userPreferred |userPreferred
@ -365,9 +404,12 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALMangaMetadata>() .parseAs<ALMangaMetadata>()
.let { .let { metadata ->
val media = it.data.media val media = metadata.data.media
TrackMangaMetadata( TrackMangaMetadata(
remoteId = media.id, remoteId = media.id,
title = media.title.userPreferred, title = media.title.userPreferred,
@ -392,9 +434,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
// SY --> // SY -->
suspend fun searchById(id: String): TrackSearch { suspend fun searchById(id: String): TrackSearch {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|query (${'$'}mangaId: Int!) { |query ($mangaId: Int!) {
|Media (id: ${'$'}mangaId) { |Media (id: $mangaId) {
|id |id
|title { |title {
|userPreferred |userPreferred
@ -430,6 +472,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALIdSearchResult>() .parseAs<ALIdSearchResult>()
.data.media .data.media
.toALManga() .toALManga()

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.Serializable
@Serializable
data class ALError(
val errors: List<ALErrorItem>? = null,
)
@Serializable
data class ALErrorItem(
val message: String,
val status: Int? = null,
)

View file

@ -14,7 +14,9 @@ import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.PkceUtil
@ -124,8 +126,23 @@ class MyAnimeListApi(
.put(formBodyBuilder.build()) .put(formBodyBuilder.build())
.build() .build()
with(json) { with(json) {
authClient.newCall(request) val response = authClient
.awaitSuccess() .newCall(request)
.await()
if (!response.isSuccessful) {
if (response.body.string().contains("invalid_content")) {
// MAL returns unapproved titles in search but does not allow adding them to the list
// returns 400 with this body: {"message":"Invalid content","error":"invalid_content"}
// These unapproved titles cannot be filtered out in search and are also returned by the
// endpoint we use for id prefix search
throw MALTitleNotApproved()
} else {
throw HttpException(response.code)
}
}
response
.parseAs<MALListItemStatus>() .parseAs<MALListItemStatus>()
.let { parseMangaItem(it, track) } .let { parseMangaItem(it, track) }
} }

View file

@ -80,5 +80,6 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
} }
} }
class MALTitleNotApproved : IOException("MAL: This title can't be added because it is waiting for approval.")
class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token") class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token")
class MALTokenExpired : IOException("MAL: Login has expired") class MALTokenExpired : IOException("MAL: Login has expired")

View file

@ -128,6 +128,8 @@ class AppModule(val app: Application) : InjektModule {
Json { Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
// Gitea/Forgejo often uses JSON null for empty release body or assets array.
coerceInputValues = true
} }
} }
addSingletonFactory { addSingletonFactory {

View file

@ -19,6 +19,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.service.HistoryPreferences import tachiyomi.domain.history.service.HistoryPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory { addSingletonFactory {
ReaderPreferences(get()) ReaderPreferences(get())
} }
addSingletonFactory {
ReadingEtaPreferences(get())
}
addSingletonFactory { addSingletonFactory {
TrackPreferences(get()) TrackPreferences(get())
} }

View file

@ -200,7 +200,7 @@ class ExtensionManager(
availableExtensionMapFlow.value = extensions.associateBy { availableExtensionMapFlow.value = extensions.associateBy {
it.pkgName + it.pkgName +
// KMK --> // KMK -->
":${it.signatureHash}" "_${it.signatureHash}"
// KMK <-- // KMK <--
} }
updatedInstalledExtensionsStatuses(extensions) updatedInstalledExtensionsStatuses(extensions)
@ -273,14 +273,14 @@ class ExtensionManager(
// Ext found: Update installed extensions with new information from repo // Ext found: Update installed extensions with new information from repo
// Also clear isObsolete and set new repo Name if needed // Also clear isObsolete and set new repo Name if needed
val hasUpdate = extension.updateExists(availableExt) val hasUpdate = extension.updateExists(availableExt)
// KMK -->
installedExtensionsMap[pkgName] = extension.copy( installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate, hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl, store = availableExt.store,
// KMK -->
isObsolete = false, isObsolete = false,
repoName = extension.repoName ?: availableExt.repoName, storeName = extension.storeName ?: availableExt.storeName,
// KMK <--
) )
// KMK <--
changed = true changed = true
} }
} }
@ -298,7 +298,7 @@ class ExtensionManager(
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Flow<InstallStep> { fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(extension.apkUrl, extension)
} }
/** /**
@ -312,7 +312,7 @@ class ExtensionManager(
val availableExt = availableExtensionMapFlow.value[ val availableExt = availableExtensionMapFlow.value[
extension.pkgName + extension.pkgName +
// KMK --> // KMK -->
":${extension.signatureHash}", "_${extension.signatureHash}",
// KMK <-- // KMK <--
] ?: return emptyFlow() ] ?: return emptyFlow()
return installExtension(availableExt) return installExtension(availableExt)
@ -322,7 +322,7 @@ class ExtensionManager(
installer.cancelInstall( installer.cancelInstall(
extension.pkgName + extension.pkgName +
// KMK --> // KMK -->
":${extension.signatureHash}", "_${extension.signatureHash}",
// KMK <-- // KMK <--
) )
} }

View file

@ -6,24 +6,13 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import exh.source.BlacklistedSources import exh.source.BlacklistedSources
import exh.source.ExhPreferences import exh.source.ExhPreferences
import kotlinx.coroutines.async import mihon.domain.extension.interactor.UpdateExtensionStores
import kotlinx.coroutines.awaitAll import mihon.domain.extension.repository.ExtensionStoreRepository
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -32,19 +21,16 @@ import kotlin.time.Duration.Companion.days
internal class ExtensionApi { internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy() private val repository: ExtensionStoreRepository by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy() private val preferenceStore: PreferenceStore by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy() private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
// SY --> // SY -->
private val sourcePreferences: SourcePreferences by injectLazy() private val sourcePreferences: SourcePreferences by injectLazy()
// SY <-- // SY <--
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy { private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0) preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
} }
@ -54,37 +40,11 @@ internal class ExtensionApi {
val disabledRepos = sourcePreferences.disabledRepos().get() val disabledRepos = sourcePreferences.disabledRepos().get()
// KMK <-- // KMK <--
return withIOContext { return withIOContext {
getExtensionRepo.getAll() repository.fetchExtensions(
// KMK --> // KMK -->
.filterNot { it.baseUrl in disabledRepos } disabledRepos,
// KMK <-- // KMK <--
.map { async { getExtensions(it) } } )
.awaitAll()
.flatten()
}
}
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
val repoBaseUrl = extRepo.baseUrl
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(
repoBaseUrl,
// KMK -->
signature = extRepo.signingKeyFingerprint,
repoName = extRepo.shortName ?: extRepo.name,
// KMK <--
)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
} }
} }
@ -99,8 +59,7 @@ internal class ExtensionApi {
return null return null
} }
// Update extension repo details updateExtensionStores()
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) { val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value extensionManager.availableExtensionsFlow.value
@ -138,47 +97,6 @@ internal class ExtensionApi {
return extensionsWithUpdate return extensionsWithUpdate
} }
private fun List<ExtensionJsonObject>.toExtensions(
repoUrl: String,
// KMK -->
signature: String,
repoName: String,
// KMK <--
): List<Extension.Available> {
return this
.filter {
val libVersion = it.extractLibVersion()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
}
.map {
Extension.Available(
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
libVersion = it.extractLibVersion(),
lang = it.lang,
isNsfw = it.nsfw == 1,
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
apkName = it.apk,
iconUrl = "$repoUrl/icon/${it.pkg}.png",
repoUrl = repoUrl,
// KMK -->
signatureHash = signature,
repoName = repoName,
// KMK <--
)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${extension.repoUrl}/apk/${extension.apkName}"
}
private fun ExtensionJsonObject.extractLibVersion(): Double {
return version.substringBeforeLast('.').toDouble()
}
// SY --> // SY -->
private fun Extension.isBlacklisted( private fun Extension.isBlacklisted(
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(), blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
@ -194,32 +112,3 @@ internal class ExtensionApi {
} }
// SY <-- // SY <--
} }
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val sources: List<ExtensionSourceJsonObject>?,
)
@Serializable
private data class ExtensionSourceJsonObject(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
private val extensionSourceMapper: (ExtensionSourceJsonObject) -> Extension.Available.Source = {
Extension.Available.Source(
id = it.id,
lang = it.lang,
name = it.name,
baseUrl = it.baseUrl,
)
}

View file

@ -24,7 +24,9 @@ abstract class Installer(private val service: Service) {
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
private var waitingInstall = AtomicReference<Entry?>(null) private var waitingInstall = AtomicReference<Entry?>(null)
private val queue = Collections.synchronizedList(mutableListOf<Entry>()) // KMK -->
private val queue = Collections.synchronizedSet(mutableSetOf<Entry>())
// KMK <--
private val cancelReceiver = object : BroadcastReceiver() { private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -104,7 +106,9 @@ abstract class Installer(private val service: Service) {
} }
val nextEntry = queue.first() val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) { if (waitingInstall.compareAndSet(null, nextEntry)) {
queue.removeAt(0) // KMK -->
queue.remove(nextEntry)
// KMK <--
processEntry(nextEntry) processEntry(nextEntry)
} }
} }
@ -129,7 +133,11 @@ abstract class Installer(private val service: Service) {
*/ */
private fun cancelQueue(downloadId: Long) { private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.load() val waitingInstall = this.waitingInstall.load()
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return // KMK -->
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
?: waitingInstall?.takeIf { it.downloadId == downloadId }
// KMK <--
?: return
if (cancelEntry(toCancel)) { if (cancelEntry(toCancel)) {
queue.remove(toCancel) queue.remove(toCancel)
if (waitingInstall == toCancel) { if (waitingInstall == toCancel) {

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.installer package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -14,8 +15,7 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
import eu.kanade.tachiyomi.util.system.getUriSize import eu.kanade.tachiyomi.util.system.getUriSize
import logcat.LogPriority import exh.log.xLogE
import tachiyomi.core.common.util.system.logcat
class PackageInstallerInstaller(private val service: Service) : Installer(service) { class PackageInstallerInstaller(private val service: Service) : Installer(service) {
@ -41,7 +41,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
.sanitizeByFiltering(this) .sanitizeByFiltering(this)
} }
if (userAction == null) { if (userAction == null) {
logcat(LogPriority.ERROR) { "Fatal error for $intent" } xLogE("Fatal error for $intent")
continueQueue(InstallStep.Error) continueQueue(InstallStep.Error)
return return
} }
@ -82,6 +82,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
session.fsync(outputStream) session.fsync(outputStream)
} }
service.contentResolver.delete(entry.uri, null, null)
val intentSender = PendingIntent.getBroadcast( val intentSender = PendingIntent.getBroadcast(
service, service,
@ -89,10 +90,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
Intent(INSTALL_ACTION).setPackage(service.packageName), Intent(INSTALL_ACTION).setPackage(service.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender ).intentSender
@SuppressLint("RequestInstallPackagesPolicy")
session.commit(intentSender) session.commit(intentSender)
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } xLogE("Failed to install extension ${entry.downloadId} ${entry.uri}", e)
activeSession?.let { (_, sessionId) -> activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId) packageInstaller.abandonSession(sessionId)
} }
@ -103,8 +105,13 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
override fun cancelEntry(entry: Entry): Boolean { override fun cancelEntry(entry: Entry): Boolean {
activeSession?.let { (activeEntry, sessionId) -> activeSession?.let { (activeEntry, sessionId) ->
if (activeEntry == entry) { if (activeEntry == entry) {
packageInstaller.abandonSession(sessionId) return try {
return false packageInstaller.abandonSession(sessionId)
false
} catch (_: SecurityException) {
// Highly likely the session has succeeded
true
}
} }
} }
return true return true

View file

@ -111,20 +111,17 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) { override fun processEntry(entry: Entry) {
super.processEntry(entry) super.processEntry(entry)
// KMK -->
val installer = shellInterface
if (installer == null) {
logcat(LogPriority.ERROR) { "Shizuku shell interface not available for ${entry.downloadId}" }
continueQueue(InstallStep.Error)
return
}
try { try {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r")?.use { service.contentResolver.openAssetFileDescriptor(entry.uri, "r")?.use {
installer.install(it) shellInterface?.install(it)
// KMK -->
?: throw Exception("Shell interface is not available")
// KMK <--
} }
// KMK -->
?: throw Exception("Failed to open asset file descriptor")
// KMK <-- // KMK <--
service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error) continueQueue(InstallStep.Error)

View file

@ -64,6 +64,11 @@ class ExtensionInstallActivity : Activity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
intent.data?.let { contentResolver.delete(it, null, null) }
}
private fun checkInstallationResult(resultCode: Int) { private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>() val extensionManager = Injekt.get<ExtensionManager>()

View file

@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.extension.util
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
@ -16,11 +18,15 @@ import eu.kanade.tachiyomi.extension.installer.ShizukuInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationBuilder
import logcat.LogPriority import exh.log.xLogE
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
/**
* Foreground service that stages and installs extension APKs via [PackageInstallerInstaller] or
* [ShizukuInstaller]. Uses [ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC] because installs can
* wait on multiple system confirmation dialogs; `shortService` times out after ~3 minutes.
*/
class ExtensionInstallService : Service() { class ExtensionInstallService : Service() {
private var installer: Installer? = null private var installer: Installer? = null
@ -36,7 +42,17 @@ class ExtensionInstallService : Service() {
setContentTitle(stringResource(MR.strings.ext_install_service_notif)) setContentTitle(stringResource(MR.strings.ext_install_service_notif))
setProgress(100, 100, true) setProgress(100, 100, true)
}.build() }.build()
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification) // KMK -->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
Notifications.ID_EXTENSION_INSTALLER,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
// KMK <--
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
}
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -53,7 +69,7 @@ class ExtensionInstallService : Service() {
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this) BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this) BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
else -> { else -> {
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" } xLogE("Not implemented for installer $installerUsed")
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }

View file

@ -1,66 +1,52 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.installer.Installer import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext import okhttp3.OkHttpClient
import okhttp3.Request
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.seconds import java.util.concurrent.ConcurrentHashMap
/** /**
* The installer which installs, updates and uninstalls the extensions. * The installer which installs, updates and uninstalls the extensions.
* *
* @param context The application context. * @param context The application context.
*/ */
internal class ExtensionInstaller(private val context: Context) { internal class ExtensionInstaller(
private val context: Context,
/** ) {
* The system's download manager
*/
private val downloadManager = context.getSystemService<DownloadManager>()!!
/**
* The broadcast receiver which listens to download completion events.
*/
private val downloadReceiver = DownloadCompletionReceiver()
/**
* The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager.
*/
private val activeDownloads = hashMapOf<String, Long>()
private val downloadsStateFlows = hashMapOf<Long, MutableStateFlow<InstallStep>>()
// KMK -->
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeJobs = ConcurrentHashMap<String, Job>()
private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
// KMK <--
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller() private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
private val httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
/** /**
* Adds the given extension to the downloads queue and returns an observable containing its * Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process. * step in the installation process.
@ -71,130 +57,97 @@ internal class ExtensionInstaller(private val context: Context) {
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> { fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
val pkgName = extension.pkgName + val pkgName = extension.pkgName +
// KMK --> // KMK -->
":${extension.signatureHash}" "_${extension.signatureHash}"
val downloadId = pkgName.toDownloadId()
// KMK <-- // KMK <--
cancelInstall(pkgName)
val oldDownload = activeDownloads[pkgName] val step = MutableStateFlow(InstallStep.Pending)
if (oldDownload != null) { activeSteps[downloadId] = step
deleteDownload(pkgName)
}
// Register the receiver after removing (and unregistering) the previous download val job = scope.launch {
downloadReceiver.register() val tmpFile = File(context.cacheDir, "extension_$pkgName.apk")
try {
step.value = InstallStep.Downloading
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute()
// KMK -->
.use { response ->
// KMK <--
val downloadUri = url.toUri() if (!response.isSuccessful) {
val request = DownloadManager.Request(downloadUri) throw Exception("Failed to download extension")
.setTitle(extension.name) }
.setMimeType(APK_MIME) // KMK -->
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) tmpFile.outputStream().use { output ->
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) response.body.byteStream().use { input ->
// KMK <--
input.copyTo(output)
}
}
}
val id = downloadManager.enqueue(request) step.value = InstallStep.Installing
activeDownloads[pkgName] = id installApk(downloadId, tmpFile)
} catch (e: Exception) {
val downloadStateFlow = MutableStateFlow(InstallStep.Pending) if (e is InterruptedException) {
downloadsStateFlows[id] = downloadStateFlow // Canceled
} else {
// Poll download status logcat(LogPriority.ERROR, e)
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus -> step.value = InstallStep.Error
// Map to our model }
when (downloadStatus) { // KMK -->
DownloadManager.STATUS_PENDING -> InstallStep.Pending tmpFile.delete()
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading // KMK <--
else -> null
} }
} }
return merge(downloadStateFlow, pollStatusFlow).transformWhile { activeJobs[pkgName] = job
emit(it)
// Stop when the application is installed or errors return step.asStateFlow()
!it.isCompleted() .onCompletion {
}.onCompletion { activeJobs.remove(pkgName)
// Always notify on main thread activeSteps.remove(downloadId)
withUIContext { job.cancel()
// Always remove the download when unsubscribed
deleteDownload(pkgName)
} }
}
} }
/**
* Returns a flow that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes.
*
* @param id The id of the download to poll.
*/
private fun downloadStatusFlow(id: Long): Flow<Int> = flow {
val query = DownloadManager.Query().setFilterById(id)
while (true) {
// Get the current download status
val downloadStatus = downloadManager.query(query).use { cursor ->
if (!cursor.moveToFirst()) return@flow
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
}
emit(downloadStatus)
// Stop polling when the download fails or finishes
if (
downloadStatus == DownloadManager.STATUS_SUCCESSFUL ||
downloadStatus == DownloadManager.STATUS_FAILED
) {
return@flow
}
delay(1.seconds)
}
}
// Ignore duplicate results
.distinctUntilChanged()
/** /**
* Starts an intent to install the extension at the given uri. * Starts an intent to install the extension at the given uri.
* *
* @param uri The uri of the extension to install. * @param tempFile The file of the extension to install. Delete after use.
*/ */
fun installApk(downloadId: Long, uri: Uri) { private fun installApk(downloadId: Long, tempFile: File) {
when (val installer = extensionInstaller.get()) { when (val installer = extensionInstaller.get()) {
BasePreferences.ExtensionInstaller.LEGACY -> { BasePreferences.ExtensionInstaller.LEGACY -> {
val intent = Intent(context, ExtensionInstallActivity::class.java) val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME) .setDataAndType(tempFile.getUriCompat(context), APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId) .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent) context.startActivity(intent)
} }
BasePreferences.ExtensionInstaller.PRIVATE -> { BasePreferences.ExtensionInstaller.PRIVATE -> {
val extensionManager = Injekt.get<ExtensionManager>()
val tempFile = File(context.cacheDir, "temp_$downloadId")
if (tempFile.exists() && !tempFile.delete()) {
// Unlikely but just in case
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
return
}
try { try {
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) { if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
extensionManager.updateInstallStep(downloadId, InstallStep.Installed) updateInstallStep(downloadId, InstallStep.Installed)
} else { } else {
extensionManager.updateInstallStep(downloadId, InstallStep.Error) updateInstallStep(downloadId, InstallStep.Error)
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
extensionManager.updateInstallStep(downloadId, InstallStep.Error) updateInstallStep(downloadId, InstallStep.Error)
} }
tempFile.delete() tempFile.delete()
} }
else -> { else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer) val intent = ExtensionInstallService.getIntent(
context,
downloadId,
tempFile.getUriCompat(context),
installer,
)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }
@ -204,9 +157,8 @@ internal class ExtensionInstaller(private val context: Context) {
* Cancels extension install and remove from download manager and installer. * Cancels extension install and remove from download manager and installer.
*/ */
fun cancelInstall(pkgName: String) { fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return activeJobs.remove(pkgName)?.cancel()
downloadManager.remove(downloadId) Installer.cancelInstallQueue(context, /* KMK --> */ pkgName.toDownloadId() /* KMK <-- */)
Installer.cancelInstallQueue(context, downloadId)
} }
/** /**
@ -233,91 +185,16 @@ internal class ExtensionInstaller(private val context: Context) {
* @param step New install step. * @param step New install step.
*/ */
fun updateInstallStep(downloadId: Long, step: InstallStep) { fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsStateFlows[downloadId]?.let { it.value = step } activeSteps[downloadId]?.let { it.value = step }
}
/**
* Deletes the download for the given package name.
*
* @param pkgName The package name of the download to delete together with its signature.
*/
private fun deleteDownload(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName)
if (downloadId != null) {
downloadManager.remove(downloadId)
downloadsStateFlows.remove(downloadId)
}
if (activeDownloads.isEmpty()) {
downloadReceiver.unregister()
}
}
/**
* Receiver that listens to download status events.
*/
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
/**
* Whether this receiver is currently registered.
*/
private var isRegistered = false
/**
* Registers this receiver if it's not already.
*/
fun register() {
if (isRegistered) return
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**
* Unregisters this receiver if it's not already.
*/
fun unregister() {
if (!isRegistered) return
isRegistered = false
context.unregisterReceiver(this)
}
/**
* Called when a download event is received. It looks for the download in the current active
* downloads and notifies its installation step.
*/
override fun onReceive(context: Context, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
// Avoid events for downloads we didn't request
if (id !in activeDownloads.values) return
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
if (uri == null) {
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
updateInstallStep(id, InstallStep.Error)
return
}
val query = DownloadManager.Query().setFilterById(id)
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val localUri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
).removePrefix(FILE_SCHEME)
installApk(id, File(localUri).getUriCompat(context))
}
}
}
} }
companion object { companion object {
const val APK_MIME = "application/vnd.android.package-archive" const val APK_MIME = "application/vnd.android.package-archive"
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID" const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
const val FILE_SCHEME = "file://"
// KMK -->
/** Convert packageName to download ID avoiding negative number */
private fun String.toDownloadId(): Long = hashCode().toLong() and 0xFFFFFFFFL
// KMK <--
} }
} }

View file

@ -20,8 +20,8 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extensionrepo.model.ExtensionRepo import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
@ -46,7 +46,7 @@ internal object ExtensionLoader {
private val trustExtension: TrustExtension by injectLazy() private val trustExtension: TrustExtension by injectLazy()
// KMK --> // KMK -->
private val getExtensionRepo: GetExtensionRepo by injectLazy() private val getExtensionStores: GetExtensionStores by injectLazy()
// KMK <-- // KMK <--
private val loadNsfwSource by lazy { private val loadNsfwSource by lazy {
@ -169,7 +169,7 @@ internal object ExtensionLoader {
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return runBlocking {
// KMK --> // KMK -->
val extRepos = getExtensionRepo.getAll() val extStores = getExtensionStores.get()
// KMK <-- // KMK <--
val deferred = extPkgs.map { val deferred = extPkgs.map {
async { async {
@ -177,7 +177,7 @@ internal object ExtensionLoader {
context, context,
it, it,
// KMK --> // KMK -->
extRepos, extStores,
// KMK <-- // KMK <--
) )
} }
@ -228,7 +228,7 @@ internal object ExtensionLoader {
isShared = true, isShared = true,
) )
} }
} catch (error: PackageManager.NameNotFoundException) { } catch (_: PackageManager.NameNotFoundException) {
null null
} }
@ -245,11 +245,11 @@ internal object ExtensionLoader {
context: Context, context: Context,
extensionInfo: ExtensionInfo, extensionInfo: ExtensionInfo,
// KMK --> // KMK -->
extRepos: List<ExtensionRepo>? = null, extStores: List<ExtensionStore>? = null,
// KMK <-- // KMK <--
): LoadResult { ): LoadResult {
// KMK --> // KMK -->
val repos = extRepos ?: getExtensionRepo.getAll() val stores = extStores ?: getExtensionStores.get()
// KMK <-- // KMK <--
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
@ -288,10 +288,10 @@ internal object ExtensionLoader {
libVersion, libVersion,
signatures.last(), signatures.last(),
// KMK --> // KMK -->
repoName = repos.firstOrNull { repo -> storeName = stores.firstOrNull { store ->
signatures.all { it == repo.signingKeyFingerprint } signatures.all { it == store.signingKey }
}?.let { repo -> }?.let { store ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
}, },
// KMK <-- // KMK <--
) )
@ -358,10 +358,10 @@ internal object ExtensionLoader {
isShared = extensionInfo.isShared, isShared = extensionInfo.isShared,
// KMK --> // KMK -->
signatureHash = signatures.last(), signatureHash = signatures.last(),
repoName = repos.firstOrNull { repo -> storeName = stores.firstOrNull { store ->
signatures.all { it == repo.signingKeyFingerprint } signatures.all { it == store.signingKey }
}?.let { repo -> }?.let { store ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
}, },
// KMK <-- // KMK <--
) )

View file

@ -13,9 +13,7 @@ import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.source.online.all.NHentai import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.english.EightMuses import eu.kanade.tachiyomi.source.online.english.EightMuses
import eu.kanade.tachiyomi.source.online.english.HBrowse
import eu.kanade.tachiyomi.source.online.english.Pururin import eu.kanade.tachiyomi.source.online.english.Pururin
import eu.kanade.tachiyomi.source.online.english.Tsumino
import exh.log.xLogD import exh.log.xLogD
import exh.source.BlacklistedSources import exh.source.BlacklistedSources
import exh.source.DelegatedHttpSource import exh.source.DelegatedHttpSource
@ -24,10 +22,8 @@ import exh.source.EIGHTMUSES_SOURCE_ID
import exh.source.EXHENTAI_EXT_SOURCES import exh.source.EXHENTAI_EXT_SOURCES
import exh.source.EnhancedHttpSource import exh.source.EnhancedHttpSource
import exh.source.ExhPreferences import exh.source.ExhPreferences
import exh.source.HBROWSE_SOURCE_ID
import exh.source.MERGED_SOURCE_ID import exh.source.MERGED_SOURCE_ID
import exh.source.PURURIN_SOURCE_ID import exh.source.PURURIN_SOURCE_ID
import exh.source.TSUMINO_SOURCE_ID
import exh.source.handleSourceLibrary import exh.source.handleSourceLibrary
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -282,12 +278,6 @@ class AndroidSourceManager(
"eu.kanade.tachiyomi.extension.en.pururin.Pururin", "eu.kanade.tachiyomi.extension.en.pururin.Pururin",
Pururin::class, Pururin::class,
), ),
DelegatedSource(
"Tsumino",
TSUMINO_SOURCE_ID,
"eu.kanade.tachiyomi.extension.en.tsumino.Tsumino",
Tsumino::class,
),
DelegatedSource( DelegatedSource(
"MangaDex", "MangaDex",
fillInSourceId, fillInSourceId,
@ -295,12 +285,6 @@ class AndroidSourceManager(
MangaDex::class, MangaDex::class,
true, true,
), ),
DelegatedSource(
"HBrowse",
HBROWSE_SOURCE_ID,
"eu.kanade.tachiyomi.extension.en.hbrowse.HBrowse",
HBrowse::class,
),
DelegatedSource( DelegatedSource(
"8Muses", "8Muses",
EIGHTMUSES_SOURCE_ID, EIGHTMUSES_SOURCE_ID,

View file

@ -1,95 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.HBrowseSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.util.urlImportFetchSearchManga
import exh.util.urlImportFetchSearchMangaSuspend
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
class HBrowse(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
MetadataSource<HBrowseSearchMetadata, Document>,
UrlImportableSource,
NamespaceSource {
override val metaClass = HBrowseSearchMetadata::class
override fun newMetaInstance() = HBrowseSearchMetadata()
override val lang = "en"
// Support direct URL importing
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
urlImportFetchSearchManga(context, query) {
@Suppress("DEPRECATION")
super<DelegatedHttpSource>.fetchSearchManga(page, query, filters)
}
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
return urlImportFetchSearchMangaSuspend(context, query) {
super<DelegatedHttpSource>.getSearchManga(page, query, filters)
}
}
override suspend fun getMangaDetails(manga: SManga): SManga {
val response = client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
return parseToManga(manga, response.asJsoup())
}
override suspend fun parseIntoMetadata(metadata: HBrowseSearchMetadata, input: Document) {
val tables = parseIntoTables(input)
with(metadata) {
hbUrl = input.location().removePrefix("$baseUrl/thumbnails")
hbId = hbUrl!!.removePrefix("/").substringBefore("/").toLong()
tags.clear()
((tables[""] ?: error("")) + (tables["categories"] ?: error(""))).forEach { (k, v) ->
when (val lowercaseNs = k.lowercase()) {
"title" -> title = v.text()
"length" -> length = v.text().substringBefore(" ").toInt()
else -> {
v.getElementsByTag("a").forEach {
tags += RaisedTag(
lowercaseNs,
it.text(),
HBrowseSearchMetadata.TAG_TYPE_DEFAULT,
)
}
}
}
}
}
}
private fun parseIntoTables(doc: Document): Map<String, Map<String, Element>> {
return doc.select("#main > .listTable").associate { ele ->
val tableName = ele.previousElementSibling()?.text()?.lowercase().orEmpty()
tableName to ele.select("tr")
.filter { element -> element.childrenSize() > 1 }
.associate {
it.child(0).text() to it.child(1)
}
}
}
override val matchingHosts = listOf(
"www.hbrowse.com",
"hbrowse.com",
)
override suspend fun mapUrlToMangaUrl(uri: Uri): String? {
return uri.pathSegments.firstOrNull()?.let { "/$it/c00001/" }
}
}

View file

@ -1,151 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.util.dropBlank
import exh.util.trimAll
import exh.util.urlImportFetchSearchManga
import exh.util.urlImportFetchSearchMangaSuspend
import org.jsoup.nodes.Document
import rx.Observable
import java.text.SimpleDateFormat
import java.util.Locale
class Tsumino(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
MetadataSource<TsuminoSearchMetadata, Document>,
UrlImportableSource,
NamespaceSource {
override val metaClass = TsuminoSearchMetadata::class
override fun newMetaInstance() = TsuminoSearchMetadata()
override val lang = "en"
// Support direct URL importing
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
urlImportFetchSearchManga(context, query) {
@Suppress("DEPRECATION")
super<DelegatedHttpSource>.fetchSearchManga(page, query, filters)
}
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
return urlImportFetchSearchMangaSuspend(context, query) {
super<DelegatedHttpSource>.getSearchManga(page, query, filters)
}
}
override suspend fun mapUrlToMangaUrl(uri: Uri): String? {
val lcFirstPathSegment = uri.pathSegments.firstOrNull()?.lowercase(Locale.ROOT) ?: return null
if (lcFirstPathSegment != "read" && lcFirstPathSegment != "book" && lcFirstPathSegment != "entry") {
return null
}
return "https://tsumino.com/Book/Info/${uri.lastPathSegment}"
}
override suspend fun getMangaDetails(manga: SManga): SManga {
val response = client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
return parseToManga(manga, response.asJsoup())
}
override suspend fun parseIntoMetadata(metadata: TsuminoSearchMetadata, input: Document) {
with(metadata) {
tmId = TsuminoSearchMetadata.tmIdFromUrl(input.location())!!.toInt()
tags.clear()
input.select("meta[property=og:title]").firstOrNull()?.attr("content")?.let {
title = it.trim()
}
input.getElementById("Artist")?.children()?.first()?.attr("data-define")?.trim()?.let { artistString ->
artistString.split("|").trimAll().dropBlank().forEach {
tags.add(RaisedTag("artist", it, TAG_TYPE_DEFAULT))
}
tags.add(RaisedTag("artist", artistString, TAG_TYPE_VIRTUAL))
artist = artistString
}
input.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
uploader = it
}
input.getElementById("Uploaded")?.text()?.let {
uploadDate = TM_DATE_FORMAT.parse(it.trim())!!.time
}
input.getElementById("Pages")?.text()?.let {
length = it.trim().toIntOrNull()
}
input.getElementById("Rating")?.text()?.let {
ratingString = it.trim()
val ratingString = ratingString
if (!ratingString.isNullOrBlank()) {
averageRating = RATING_FLOAT_REGEX.find(ratingString)?.groups?.get(1)?.value?.toFloatOrNull()
userRatings = RATING_USERS_REGEX.find(ratingString)?.groups?.get(1)?.value?.toLongOrNull()
favorites = RATING_FAVORITES_REGEX.find(ratingString)?.groups?.get(1)?.value?.toLongOrNull()
}
}
input.getElementById("Category")?.children()?.first()?.attr("data-define")?.let {
category = it.trim()
tags.add(RaisedTag("genre", it, TAG_TYPE_VIRTUAL))
}
input.getElementById("Collection")?.children()?.first()?.attr("data-define")?.let {
collection = it.trim()
tags.add(RaisedTag("collection", it, TAG_TYPE_DEFAULT))
}
input.getElementById("Group")?.children()?.first()?.attr("data-define")?.let {
group = it.trim()
tags.add(RaisedTag("group", it, TAG_TYPE_DEFAULT))
}
parody = input.getElementById("Parody")?.children()?.map {
val entry = it.attr("data-define").trim()
tags.add(RaisedTag("parody", entry, TAG_TYPE_DEFAULT))
entry
}.orEmpty()
character = input.getElementById("Character")?.children()?.map {
val entry = it.attr("data-define").trim()
tags.add(RaisedTag("character", entry, TAG_TYPE_DEFAULT))
entry
}.orEmpty()
input.getElementById("Tag")?.children()?.let { tagElements ->
tags.addAll(
tagElements.map {
RaisedTag("tags", it.attr("data-define").trim(), TAG_TYPE_DEFAULT)
},
)
}
}
}
override val matchingHosts = listOf(
"www.tsumino.com",
"tsumino.com",
)
companion object {
val TM_DATE_FORMAT = SimpleDateFormat("yyyy MMM dd", Locale.US)
val RATING_FLOAT_REGEX = "([0-9].*) \\(".toRegex()
val RATING_USERS_REGEX = "\\(([0-9].*) users".toRegex()
val RATING_FAVORITES_REGEX = "/ ([0-9].*) favs".toRegex()
}
}

View file

@ -59,7 +59,7 @@ class ExtensionsScreen(private val searchSource: String? = null) : Screen() {
/* /*
* This will redo the searching for [searchSource] every times the screen is launched, for example when * This will redo the searching for [searchSource] every times the screen is launched, for example when
* back from the [ExtensionFilterScreen] or from the [ExtensionReposScreen]. * back from the [ExtensionFilterScreen] or from the [ExtensionStoresScreen].
* Not really desired but let's accept it. * Not really desired but let's accept it.
*/ */
if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource) if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource)

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
@ -53,7 +54,7 @@ class ExtensionsScreenModel(
map[ map[
it.pkgName + it.pkgName +
// KMK --> // KMK -->
":${it.signatureHash}", "_${it.signatureHash}",
// KMK <-- // KMK <--
] ?: InstallStep.Idle, ] ?: InstallStep.Idle,
) )
@ -207,7 +208,7 @@ class ExtensionsScreenModel(
it + Pair( it + Pair(
extension.pkgName + extension.pkgName +
// KMK --> // KMK -->
":${extension.signatureHash}", "_${extension.signatureHash}",
// KMK <-- // KMK <--
installStep, installStep,
) )
@ -219,7 +220,7 @@ class ExtensionsScreenModel(
it - ( it - (
extension.pkgName + extension.pkgName +
// KMK --> // KMK -->
":${extension.signatureHash}" "_${extension.signatureHash}"
// KMK <-- // KMK <--
) )
} }
@ -228,6 +229,7 @@ class ExtensionsScreenModel(
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: Extension) = private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: Extension) =
this this
.onEach { installStep -> addDownloadState(extension, installStep) } .onEach { installStep -> addDownloadState(extension, installStep) }
.takeWhile { installStep -> installStep != InstallStep.Installed }
.onCompletion { removeDownloadState(extension) } .onCompletion { removeDownloadState(extension) }
.collect() .collect()

View file

@ -20,7 +20,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionScreen import eu.kanade.presentation.browse.ExtensionScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
@ -62,8 +62,8 @@ fun extensionsTab(
onClick = { navigator.push(ExtensionFilterScreen()) }, onClick = { navigator.push(ExtensionFilterScreen()) },
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
content = { contentPadding, _ -> content = { contentPadding, _ ->

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
fun getInstance(sourceId: Long): SourcePreferencesFragment { fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply { return SourcePreferencesFragment().apply {
arguments = bundleOf(SOURCE_ID to sourceId) arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
} }
} }
} }

View file

@ -128,11 +128,9 @@ data class MigrateMangaScreen(
// KMK --> // KMK -->
bottomBar = { bottomBar = {
MigrateMangaBottomBar( MigrateMangaBottomBar(
selected = state.selection, selectionMode = state.selectionMode,
onMultiMigrateClicked = { onMultiMigrateClicked = {
val selection = state.selection navigator.push(MigrationConfigScreen(state.selection))
.map { it.manga.id }
navigator.push(MigrationConfigScreen(selection))
}, },
enableScrollToTop = enableScrollToTop, enableScrollToTop = enableScrollToTop,
enableScrollToBottom = enableScrollToBottom, enableScrollToBottom = enableScrollToBottom,
@ -187,7 +185,7 @@ data class MigrateMangaScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
state: MigrateMangaScreenModel.State, state: MigrateMangaScreenModel.State,
// KMK --> // KMK -->
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit, onMangaSelected: (Manga, Boolean, Boolean) -> Unit,
// KMK <-- // KMK <--
onClickItem: (Manga) -> Unit, onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit, onClickCover: (Manga) -> Unit,
@ -197,20 +195,23 @@ data class MigrateMangaScreen(
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
items(items = state.titles) { manga -> items(items = state.titles) { manga ->
// KMK -->
val isSelected = manga.id in state.selection
// KMK <--
MigrateMangaItem( MigrateMangaItem(
manga = manga.manga, manga = manga,
isSelected = manga.selected, isSelected = isSelected,
onClickItem = { onClickItem = {
// KMK --> // KMK -->
when { when {
state.selectionMode -> onMangaSelected(manga, !manga.selected, true, false) state.selectionMode -> onMangaSelected(manga, !isSelected, false)
// KMK <-- // KMK <--
else -> onClickItem(it) else -> onClickItem(it)
} }
}, },
onClickCover = onClickCover, onClickCover = onClickCover,
// KMK --> // KMK -->
onLongClick = { onMangaSelected(manga, !manga.selected, true, true) }, onLongClick = { onMangaSelected(manga, !isSelected, true) },
modifier = Modifier.animateItemFastScroll(), modifier = Modifier.animateItemFastScroll(),
// KMK <-- // KMK <--
) )
@ -293,7 +294,7 @@ data class MigrateMangaScreen(
@Composable @Composable
private fun MigrateMangaBottomBar( private fun MigrateMangaBottomBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selected: List<MigrateMangaItem>, selectionMode: Boolean,
onMultiMigrateClicked: () -> Unit, onMultiMigrateClicked: () -> Unit,
enableScrollToTop: Boolean, enableScrollToTop: Boolean,
enableScrollToBottom: Boolean, enableScrollToBottom: Boolean,
@ -302,7 +303,7 @@ data class MigrateMangaScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val animatedElevation by animateDpAsState( val animatedElevation by animateDpAsState(
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp, targetValue = if (selectionMode) 3.dp else 0.dp,
label = "elevation", label = "elevation",
) )
Surface( Surface(
@ -350,7 +351,7 @@ data class MigrateMangaScreen(
toConfirm = confirm[1], toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) }, onLongClick = { onLongClickItem(1) },
onClick = onMultiMigrateClicked, onClick = onMultiMigrateClicked,
enabled = selected.isNotEmpty(), enabled = selectionMode,
) )
Button( Button(
title = stringResource(KMR.strings.action_scroll_to_bottom), title = stringResource(KMR.strings.action_scroll_to_bottom),

View file

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.browse.migration.manga
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.addOrRemove
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.core.common.utils.mutate
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -36,7 +36,6 @@ class MigrateMangaScreenModel(
// KMK --> // KMK -->
// First and last selected index in list // First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1) private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedMangaIds: HashSet<Long> = HashSet()
// KMK <-- // KMK <--
init { init {
@ -50,120 +49,133 @@ class MigrateMangaScreenModel(
logcat(LogPriority.ERROR, it) logcat(LogPriority.ERROR, it)
_events.send(MigrationMangaEvent.FailedFetchingFavorites) _events.send(MigrationMangaEvent.FailedFetchingFavorites)
mutableState.update { state -> mutableState.update { state ->
state.copy(titleList = persistentListOf()) state.copy(
titleList = persistentListOf(),
// KMK -->
selection = emptySet(),
// KMK <--
)
} }
} }
// KMK -->
.map { manga ->
toMigrationMangaScreenItems(manga)
}
// KMK <--
.map { manga -> .map { manga ->
manga manga
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.manga.title }) .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
.toImmutableList() .toImmutableList()
} }
.collectLatest { list -> .collectLatest { list ->
mutableState.update { it.copy(titleList = list) } // KMK -->
mutableState.update { state ->
val titleIds = list.map { it.id }.toSet()
val selection = state.selection.intersect(titleIds).toMutableSet()
updateSelectedPositions(list, selection)
state.copy(
titleList = list,
selection = selection,
)
}
// KMK <--
} }
} }
} }
// KMK -->
private fun toMigrationMangaScreenItems(mangas: List<Manga>): List<MigrateMangaItem> {
return mangas.map { manga ->
MigrateMangaItem(
manga = manga,
selected = manga.id in selectedMangaIds,
)
}
}
fun toggleSelection( fun toggleSelection(
item: MigrateMangaItem, item: Manga,
// KMK -->
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.toMutableList().apply { if (item.id in state.selection == selected) return@update state
val selectedIndex = indexOfFirst { it.manga.id == item.manga.id } val selectedIndex = state.titles.indexOfFirst { it.id == item.id }
if (selectedIndex < 0) return@apply if (selectedIndex < 0) return@update state
val selectedItem = get(selectedIndex) val selection = state.selection.mutate { list ->
if (selectedItem.selected == selected) return@apply state.titles.run {
val firstSelection = list.isEmpty()
if (selected) list.add(item.id) else list.remove(item.id)
val firstSelection = none { it.selected } if (selected && fromLongPress) {
set(selectedIndex, selectedItem.copy(selected = selected)) if (firstSelection) {
selectedMangaIds.addOrRemove(item.manga.id, selected)
if (selected && userSelected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1 until selectedPositions[0]
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1) until selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
} else { } else {
// Just select itself // Try to select the items in-between when possible
range = IntRange.EMPTY val range: IntRange
} if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1 until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1) until selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
range.forEach { range.forEach {
val inBetweenItem = get(it) val inBetweenItem = get(it)
if (!inBetweenItem.selected) { if (inBetweenItem.id !in list) {
selectedMangaIds.add(inBetweenItem.manga.id) list.add(inBetweenItem.id)
set(it, inBetweenItem.copy(selected = true)) }
} }
} }
} } else if (!fromLongPress) {
} else if (userSelected && !fromLongPress) { if (!selected) {
if (!selected) { if (selectedIndex == selectedPositions[0]) {
if (selectedIndex == selectedPositions[0]) { selectedPositions[0] = indexOfFirst { it.id in list }
selectedPositions[0] = indexOfFirst { it.selected } } else if (selectedIndex == selectedPositions[1]) {
} else if (selectedIndex == selectedPositions[1]) { selectedPositions[1] = indexOfLast { it.id in list }
selectedPositions[1] = indexOfLast { it.selected } }
} } else {
} else { if (selectedIndex < selectedPositions[0]) {
if (selectedIndex < selectedPositions[0]) { selectedPositions[0] = selectedIndex
selectedPositions[0] = selectedIndex } else if (selectedIndex > selectedPositions[1]) {
} else if (selectedIndex > selectedPositions[1]) { selectedPositions[1] = selectedIndex
selectedPositions[1] = selectedIndex }
} }
} }
} }
} }
state.copy(titleList = newItems.toImmutableList()) // KMK <--
state.copy(selection = selection)
} }
} }
// KMK -->
private fun updateSelectedPositions(titles: List<Manga>, selection: Set<Long>) {
if (selection.isEmpty()) {
selectedPositions[0] = -1
selectedPositions[1] = -1
return
}
selectedPositions[0] = titles.indexOfFirst { it.id in selection }
selectedPositions[1] = titles.indexOfLast { it.id in selection }
}
fun toggleAllSelection(selected: Boolean = true) { fun toggleAllSelection(selected: Boolean = true) {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.map { val selection = if (selected) {
selectedMangaIds.addOrRemove(it.manga.id, selected) state.titles.mapTo(mutableSetOf()) { it.id }
it.copy(selected = selected) } else {
emptySet()
} }
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
state.copy(titleList = newItems.toImmutableList()) state.copy(selection = selection)
} }
} }
fun invertSelection() { fun invertSelection() {
mutableState.update { state -> mutableState.update { state ->
val newItems = state.titles.map { val selection = state.selection.mutate { list ->
selectedMangaIds.addOrRemove(it.manga.id, !it.selected) state.titles.forEach { item ->
it.copy(selected = !it.selected) if (!list.remove(item.id)) list.add(item.id)
}
} }
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
state.copy(titleList = newItems.toImmutableList()) state.copy(selection = selection)
} }
} }
// KMK <-- // KMK <--
@ -177,13 +189,11 @@ class MigrateMangaScreenModel(
@Immutable @Immutable
data class State( data class State(
val source: Source? = null, val source: Source? = null,
private val titleList: ImmutableList<MigrateMangaItem>? = null, val selection: Set<Long> = emptySet(),
private val titleList: ImmutableList<Manga>? = null,
) { ) {
// KMK -->
val selection = titles.filter { it.selected }
// KMK <--
val titles: ImmutableList<MigrateMangaItem> val titles: ImmutableList<Manga>
get() = titleList ?: persistentListOf() get() = titleList ?: persistentListOf()
val isLoading: Boolean val isLoading: Boolean
@ -199,11 +209,3 @@ class MigrateMangaScreenModel(
sealed interface MigrationMangaEvent { sealed interface MigrationMangaEvent {
data object FailedFetchingFavorites : MigrationMangaEvent data object FailedFetchingFavorites : MigrationMangaEvent
} }
// KMK -->
@Immutable
data class MigrateMangaItem(
val manga: Manga,
val selected: Boolean,
)
// KMK <--

View file

@ -375,6 +375,7 @@ data class BrowseSourceScreen(
}, },
onMangaLongClick = { manga -> onMangaLongClick = { manga ->
// KMK --> // KMK -->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (bulkFavoriteState.selectionMode) { if (bulkFavoriteState.selectionMode) {
navigator.push(MangaScreen(manga.id, true)) navigator.push(MangaScreen(manga.id, true))
} else { } else {
@ -388,7 +389,6 @@ data class BrowseSourceScreen(
) )
else -> screenModel.addFavorite(manga) else -> screenModel.addFavorite(manga)
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} }
} }
}, },

View file

@ -298,14 +298,14 @@ class HistoryScreenModel(
selectionOptions: HistorySelectionOptions, selectionOptions: HistorySelectionOptions,
) { ) {
val (selected, fromLongPress) = selectionOptions val (selected, fromLongPress) = selectionOptions
if (item.chapterId in state.value.selection == selected) return
mutableState.update { state -> mutableState.update { state ->
if (item.chapterId in state.selection == selected) return@update state
val selectedIndex = state.list.indexOfFirst { it.chapterId == item.chapterId }
if (selectedIndex < 0) return@update state
val selection = state.selection.mutate { list -> val selection = state.selection.mutate { list ->
state.list.run { state.list.run {
val selectedIndex = indexOfFirst { it.chapterId == item.chapterId }
if (selectedIndex < 0) return@run
val firstSelection = list.isEmpty() val firstSelection = list.isEmpty()
if (selected) list.add(item.chapterId) else list.remove(item.chapterId) if (selected) list.add(item.chapterId) else list.remove(item.chapterId)

View file

@ -94,10 +94,14 @@ import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.category.model.Category.Companion.UNCATEGORIZED_ID import tachiyomi.domain.category.model.Category.Companion.UNCATEGORIZED_ID
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.chapter.service.getChapterSort
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistoryRevision
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
@ -106,7 +110,9 @@ import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.library.model.LibrarySort import tachiyomi.domain.library.model.LibrarySort
import tachiyomi.domain.library.model.sort import tachiyomi.domain.library.model.sort
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaLimits
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -137,6 +143,7 @@ class LibraryScreenModel(
private val getTracksPerManga: GetTracksPerManga = Injekt.get(), private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(), private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(),
@ -152,6 +159,8 @@ class LibraryScreenModel(
private val sourcePreferences: SourcePreferences = Injekt.get(), private val sourcePreferences: SourcePreferences = Injekt.get(),
private val getMergedMangaById: GetMergedMangaById = Injekt.get(), private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(), private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(),
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
private val getHistoryRevision: GetHistoryRevision = Injekt.get(),
private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(), private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(), private val getTracks: GetTracks = Injekt.get(),
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(), private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
@ -164,6 +173,7 @@ class LibraryScreenModel(
// SY <-- // SY <--
// KMK --> // KMK -->
private val smartSearchMerge: SmartSearchMerge = Injekt.get(), private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// KMK <-- // KMK <--
) : StateScreenModel<LibraryScreenModel.State>(State()) { ) : StateScreenModel<LibraryScreenModel.State>(State()) {
@ -195,8 +205,9 @@ class LibraryScreenModel(
), ),
// KMK <-- // KMK <--
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences -> readingEtaPreferences.limitsFlow(),
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites) ) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
val favoritesWithEstimatedTimeLeft = favorites.map { item -> val favoritesWithEstimatedTimeLeft = favorites.map { item ->
item.copy( item.copy(
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) { estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
@ -440,63 +451,61 @@ class LibraryScreenModel(
private suspend fun computeEstimatedTimeLeftByMangaId( private suspend fun computeEstimatedTimeLeftByMangaId(
favorites: List<LibraryItem>, favorites: List<LibraryItem>,
limits: ReadingEtaLimits,
): Map<Long, Long?> { ): Map<Long, Long?> {
if (favorites.isEmpty()) return emptyMap() if (favorites.isEmpty()) return emptyMap()
val chapterDurationsByMangaId = favorites.associate { item ->
item.id to getReadDurationEntriesByMangaIds.await(item.id).map { it.readDuration }
}
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
chapterDurationsByMangaId.values.flatten(),
)
return favorites.associate { item -> return favorites.associate { item ->
item.id to calculateEstimatedTimeLeftMs( item.id to computeEstimatedTimeLeftMs(item, limits)
item = item,
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
)
} }
} }
private fun calculateEstimatedTimeLeftMs( private suspend fun computeEstimatedTimeLeftMs(
item: LibraryItem, item: LibraryItem,
chapterDurations: List<Long>, limits: ReadingEtaLimits,
globalAverageChapterDurationMs: Long?,
): Long? { ): Long? {
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L) if (item.libraryManga.unreadCount <= 0L) return null
if (unreadCount == 0L) return null
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations) val manga = item.libraryManga.manga
?: globalAverageChapterDurationMs val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
?: return null getMergedMangaById.await(manga.id).associateBy { it.id }
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
1L
} else { } else {
unreadCount emptyMap()
} }
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L) val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
} getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
private fun calculateAverageChapterDurationMs(
chapterDurations: List<Long>,
): Long? {
val sampledDurations = chapterDurations
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else { } else {
sortedDurations getChaptersByMangaId.await(manga.id, applyFilter = true)
} }
return trimmedDurations.sum() / trimmedDurations.size val orderedChapters = if (manga.source == MERGED_SOURCE_ID) {
getMergedChaptersByMangaId.await(manga.id, applyFilter = false)
} else {
getChaptersByMangaId.await(manga.id, applyFilter = false)
}.sortedWith(getChapterSort(manga, sortDescending = false))
if (orderedChapters.isEmpty()) return null
val nextUnread = rawChapters.getNextUnread(manga, downloadManager, mergedManga) ?: return null
val histories = getReadDurationEntriesByMangaIds.await(manga.id)
val crossMangaSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
val currentPage = if (nextUnread.lastPageRead > 0L) {
(nextUnread.lastPageRead + 1L).toInt()
} else {
1
}
return SeriesReadingEtaCalculator.estimate(
SeriesReadingEtaCalculator.Input(
orderedChapterIds = orderedChapters.map { it.id },
chapterReadById = orderedChapters.associate { it.id to it.read },
currentChapterId = nextUnread.id,
currentPage = currentPage,
totalPages = -1,
chapterDurations = histories.associate { it.chapterId to it.readDuration },
chapterPages = histories.associate { it.chapterId to it.readPages },
crossMangaPageRateSamples = crossMangaSamples,
limits = limits,
),
)?.etaMillis
} }
private fun compareEstimatedTimeLeft( private fun compareEstimatedTimeLeft(
@ -542,15 +551,6 @@ class LibraryScreenModel(
val filterLewd = preferences.filterLewd val filterLewd = preferences.filterLewd
// SY <-- // SY <--
// KMK -->
// Pre-compute track mappings for better performance
val mangaTrackIds = if (!isNotLoggedInAnyTrack && !trackFiltersIsIgnored) {
trackMap.mapValues { entry -> entry.value.map { it.trackerId } }
} else {
emptyMap()
}
// KMK <--
val filterFnDownloaded: suspend (LibraryItem) -> Boolean = { val filterFnDownloaded: suspend (LibraryItem) -> Boolean = {
applyFilter(filterDownloaded) { applyFilter(filterDownloaded) {
it.libraryManga.manga.isLocal() || it.libraryManga.manga.isLocal() ||
@ -602,15 +602,12 @@ class LibraryScreenModel(
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
// KMK --> // KMK -->
val mangaTracks = mangaTrackIds[item.id].orEmpty() val mangaTracks = trackMap[item.id].orEmpty()
// Early return val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it.trackerId in excludedTracks }
if (mangaTracks.isEmpty() && includedTracks.isNotEmpty()) return@tracking false val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it.trackerId in includedTracks }
// KMK <-- // KMK <--
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
!isExcluded && isIncluded !isExcluded && isIncluded
} }
@ -898,7 +895,8 @@ class LibraryScreenModel(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
downloadCache.changes, downloadCache.changes,
) { libraryManga, preferences, _ -> getHistoryRevision.subscribe(),
) { libraryManga, preferences, _, _ ->
libraryManga.map { manga -> libraryManga.map { manga ->
// Display mode based on user preference: take it from global library setting or category // Display mode based on user preference: take it from global library setting or category
// KMK --> // KMK -->
@ -1011,15 +1009,19 @@ class LibraryScreenModel(
* Queues the amount specified of unread chapters from the list of selected manga * Queues the amount specified of unread chapters from the list of selected manga
*/ */
fun performDownloadAction(action: DownloadAction) { fun performDownloadAction(action: DownloadAction) {
val mangas = state.value.selectedManga when (action) {
val amount = when (action) { DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
DownloadAction.NEXT_1_CHAPTER -> 1 DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
DownloadAction.NEXT_5_CHAPTERS -> 5 DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
DownloadAction.NEXT_10_CHAPTERS -> 10 DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
DownloadAction.NEXT_25_CHAPTERS -> 25 DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
DownloadAction.UNREAD_CHAPTERS -> null DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
} }
clearSelection() clearSelection()
}
private fun downloadNextChapters(amount: Int?) {
val mangas = state.value.selectedManga
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
// SY --> // SY -->
@ -1069,6 +1071,54 @@ class LibraryScreenModel(
} }
} }
private fun downloadBookmarkedChapters() {
val mangas = state.value.selectedManga
screenModelScope.launchNonCancellable {
mangas.forEach { manga ->
// SY -->
if (manga.source == MERGED_SOURCE_ID) {
val mergedMangas = getMergedMangaById.await(manga.id)
.associateBy { it.id }
getBookmarkedChaptersByMangaId.await(manga.id)
.groupBy { it.mangaId }
.forEach ab@{ (mangaId, chapters) ->
val mergedManga = mergedMangas[mangaId] ?: return@ab
val downloadChapters = chapters.fastFilterNot { chapter ->
downloadManager.queueState.value.fastAny { chapter.id == it.chapter.id } ||
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
mergedManga.ogTitle,
mergedManga.source,
)
}
downloadManager.downloadChapters(mergedManga, downloadChapters)
}
return@forEach
}
// SY <--
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
.fastFilterNot { chapter ->
downloadManager.getQueuedDownloadOrNull(chapter.id) != null ||
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
// SY -->
manga.ogTitle,
// SY <--
manga.source,
)
}
downloadManager.downloadChapters(manga, chapters)
}
}
}
// SY --> // SY -->
fun cleanTitles() { fun cleanTitles() {
val regex1 = "\\[.*?]".toRegex() val regex1 = "\\[.*?]".toRegex()
@ -1878,10 +1928,6 @@ class LibraryScreenModel(
// KMK --> // KMK -->
companion object { companion object {
private const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
private const val MAX_TRACKED_CHAPTER_DURATION_MS = 3_600_000L
private const val ETA_SAMPLE_CHAPTER_LIMIT = 60
/** List of MangaDex UUIDs subject to DMCA takedowns */ /** List of MangaDex UUIDs subject to DMCA takedowns */
@Volatile @Volatile
private var mangaDexDmcaUuids = hashSetOf<String>() private var mangaDexDmcaUuids = hashSetOf<String>()

View file

@ -329,8 +329,8 @@ data object LibraryTab : Tab {
}.takeIf { state.showMangaContinueButton }, }.takeIf { state.showMangaContinueButton },
onToggleSelection = screenModel::toggleSelection, onToggleSelection = screenModel::toggleSelection,
onToggleRangeSelection = { category, manga -> onToggleRangeSelection = { category, manga ->
screenModel.toggleRangeSelection(category, manga)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
screenModel.toggleRangeSelection(category, manga)
}, },
onRefresh = { onClickRefresh(state.activeCategory) }, onRefresh = { onClickRefresh(state.activeCategory) },
onGlobalSearchClicked = { onGlobalSearchClicked = {

View file

@ -62,22 +62,20 @@ class LibraryUpdateErrorScreenModel(
fun toggleSelection( fun toggleSelection(
item: LibraryUpdateErrorItem, item: LibraryUpdateErrorItem,
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> mutableState.update { state ->
val selectedIndex = state.items.indexOfFirst { it.error.errorId == item.error.errorId }
if (selectedIndex < 0) return@update state
val selectedItem = state.items[selectedIndex]
if (selectedItem.selected == selected) return@update state
val newItems = state.items.toMutableList().apply { val newItems = state.items.toMutableList().apply {
val selectedIndex = indexOfFirst { it.error.errorId == item.error.errorId }
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex)
if (selectedItem.selected == selected) return@apply
val firstSelection = none { it.selected } val firstSelection = none { it.selected }
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedErrorIds.addOrRemove(item.error.errorId, selected) selectedErrorIds.addOrRemove(item.error.errorId, selected)
if (selected && userSelected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
@ -96,14 +94,14 @@ class LibraryUpdateErrorScreenModel(
} }
range.forEach { range.forEach {
val inbetweenItem = get(it) val inBetweenItem = get(it)
if (!inbetweenItem.selected) { if (!inBetweenItem.selected) {
selectedErrorIds.add(inbetweenItem.error.errorId) selectedErrorIds.add(inBetweenItem.error.errorId)
set(it, inbetweenItem.copy(selected = true)) set(it, inBetweenItem.copy(selected = true))
} }
} }
} }
} else if (userSelected && !fromLongPress) { } else if (!fromLongPress) {
if (!selected) { if (!selected) {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = indexOfFirst { it.selected }

View file

@ -66,7 +66,7 @@ import eu.kanade.presentation.components.UpdatingBannerBackgroundColor
import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog
import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes
import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
@ -693,11 +693,11 @@ class MainActivity : BaseActivity() {
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(RestoreBackupScreen(intent.data.toString())) navigator.push(RestoreBackupScreen(intent.data.toString()))
} }
// Deep link to add extension repo // Deep link to add extension store
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") { else if (intent.isAddExtensionStoreIntent()) {
intent.data?.getQueryParameter("url")?.let { repoUrl -> intent.data?.getQueryParameter("url")?.let { repoUrl ->
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(ExtensionReposScreen(repoUrl)) navigator.push(ExtensionStoresScreen(repoUrl))
} }
} }
null null
@ -713,6 +713,11 @@ class MainActivity : BaseActivity() {
return true return true
} }
private fun Intent.isAddExtensionStoreIntent(): Boolean {
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
(scheme == "mihon" && data?.host == "extension-store")
}
companion object { companion object {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query" const val INTENT_SEARCH_QUERY = "query"

View file

@ -278,8 +278,8 @@ class MangaScreen(
onChapterClicked = { openChapter(context, it) }, onChapterClicked = { openChapter(context, it) },
onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() }, onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() },
onAddToLibraryClicked = { onAddToLibraryClicked = {
screenModel.toggleFavorite()
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
screenModel.toggleFavorite()
}, },
// SY --> // SY -->
onWebViewClicked = { onWebViewClicked = {

View file

@ -555,8 +555,8 @@ class MangaScreenModel(
} }
val vibrantColor = it.getBestColor() ?: return@launchIO val vibrantColor = it.getBestColor() ?: return@launchIO
mangaCover.vibrantCoverColor = vibrantColor mangaCover.vibrantCoverColor = vibrantColor
updateSuccessState { updateSuccessState { state ->
it.copy(seedColor = Color(vibrantColor)) state.copy(seedColor = Color(vibrantColor))
} }
} }
} }
@ -1322,6 +1322,13 @@ class MangaScreenModel(
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
} }
private fun getBookmarkedChapters(): List<Chapter> {
val chapterItems = if (skipFiltered) filteredChapters.orEmpty() else allChapters.orEmpty()
return chapterItems
.filter { (chapter, dlStatus) -> chapter.bookmark && dlStatus == Download.State.NOT_DOWNLOADED }
.map { it.chapter }
}
private fun startDownload( private fun startDownload(
chapters: List<Chapter>, chapters: List<Chapter>,
startNow: Boolean, startNow: Boolean,
@ -1384,6 +1391,7 @@ class MangaScreenModel(
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10) DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25) DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters() DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
DownloadAction.BOOKMARKED_CHAPTERS -> getBookmarkedChapters()
} }
if (chaptersToDownload.isNotEmpty()) { if (chaptersToDownload.isNotEmpty()) {
startDownload(chaptersToDownload, false) startDownload(chaptersToDownload, false)
@ -1725,22 +1733,22 @@ class MangaScreenModel(
fun toggleSelection( fun toggleSelection(
item: ChapterList.Item, item: ChapterList.Item,
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
updateSuccessState { successState -> updateSuccessState { successState ->
// KMK -->
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
if (selectedIndex < 0) return@updateSuccessState successState
val selectedItem = successState.processedChapters[selectedIndex]
if (selectedItem.selected == selected) return@updateSuccessState successState
// KMK <--
val newChapters = successState.processedChapters.toMutableList().apply { val newChapters = successState.processedChapters.toMutableList().apply {
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex)
if ((selectedItem.selected && selected) || (!selectedItem.selected && !selected)) return@apply
val firstSelection = none { it.selected } val firstSelection = none { it.selected }
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.id, selected) selectedChapterIds.addOrRemove(item.id, selected)
if (selected && userSelected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
@ -1759,14 +1767,14 @@ class MangaScreenModel(
} }
range.forEach { range.forEach {
val inbetweenItem = get(it) val inBetweenItem = get(it)
if (!inbetweenItem.selected) { if (!inBetweenItem.selected) {
selectedChapterIds.add(inbetweenItem.id) selectedChapterIds.add(inBetweenItem.id)
set(it, inbetweenItem.copy(selected = true)) set(it, inBetweenItem.copy(selected = true))
} }
} }
} }
} else if (userSelected && !fromLongPress) { } else if (!fromLongPress) {
if (!selected) { if (!selected) {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = indexOfFirst { it.selected }

View file

@ -99,8 +99,10 @@ import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.ReadingEtaHistorySample import tachiyomi.domain.reading.ReadingEtaHistorySample
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.reading.ReadingEtaLimits
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
private val getIncognitoState: GetIncognitoState = Injekt.get(), private val getIncognitoState: GetIncognitoState = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// SY --> // SY -->
private val syncPreferences: SyncPreferences = Injekt.get(), private val syncPreferences: SyncPreferences = Injekt.get(),
private val uiPreferences: UiPreferences = Injekt.get(), private val uiPreferences: UiPreferences = Injekt.get(),
@ -266,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor(
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList() private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
private var orderedSeriesChapterIds = emptyList<Long>() private var orderedSeriesChapterIds = emptyList<Long>()
private var seriesChapterIndexById = emptyMap<Long, Int>()
private var chapterToDownload: Download? = null private var chapterToDownload: Download? = null
@ -935,16 +937,17 @@ class ReaderViewModel @JvmOverloads constructor(
getCurrentChapter()?.let { readerChapter -> getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let if (incognitoMode) return@let
val limits = readingEtaPreferences.currentLimits()
val chapterId = readerChapter.chapter.id!! val chapterId = readerChapter.chapter.id!!
val endTime = Date() val endTime = Date()
val sessionReadDuration = chapterReadStartTime val sessionReadDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it } ?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration() ?.sanitizeTrackedDuration(limits)
?: 0 ?: 0
val sessionReadPages = chapterReadStartPageIndex val sessionReadPages = chapterReadStartPageIndex
?.let { startPageIndex -> ?.let { startPageIndex ->
val endPageIndex = readerChapter.chapter.last_page_read val endPageIndex = readerChapter.chapter.last_page_read
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages() (endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
} }
?: 0L ?: 0L
@ -968,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor(
orderedSeriesChapterIds = unfilteredChapterList orderedSeriesChapterIds = unfilteredChapterList
.sortedWith(getChapterSort(manga, sortDescending = false)) .sortedWith(getChapterSort(manga, sortDescending = false))
.mapNotNull { it.id } .mapNotNull { it.id }
seriesChapterIndexById = orderedSeriesChapterIds
.withIndex()
.associate { (index, chapterId) -> chapterId to index }
chapterReadDurationsSession.clear() chapterReadDurationsSession.clear()
chapterReadDurationsPersistent.clear() chapterReadDurationsPersistent.clear()
@ -996,90 +996,49 @@ class ReaderViewModel @JvmOverloads constructor(
return return
} }
val currentChapterIndex = seriesChapterIndexById[currentChapterId] val limits = readingEtaPreferences.currentLimits()
if (currentChapterIndex == null) { val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
val chapterPages = getEtaChapterPages(currentChapterId, limits)
val activeSession = chapterReadStartTime?.let { startTime ->
chapterReadStartPageIndex?.let { startPageIndex ->
val totalPages = state.value.totalPages
if (totalPages <= 1) {
null
} else {
SeriesReadingEtaCalculator.ActiveSession(
sessionDurationMs = SystemClock.elapsedRealtime() - startTime,
startPageIndex = startPageIndex,
currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex),
totalPages = totalPages,
)
}
}
}
val result = SeriesReadingEtaCalculator.estimate(
SeriesReadingEtaCalculator.Input(
orderedChapterIds = orderedSeriesChapterIds,
chapterReadById = unfilteredChapterList.associate { it.id to it.read },
currentChapterId = currentChapterId,
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
chapterDurations = chapterDurations,
chapterPages = chapterPages,
crossMangaPageRateSamples = crossMangaPageRateSamples,
limits = limits,
activeSession = activeSession,
),
) ?: run {
mutableState.update { it.copy(seriesReadingProgress = null) } mutableState.update { it.copy(seriesReadingProgress = null) }
return return
} }
val totalChapters = orderedSeriesChapterIds.size
val isCurrentChapterRead = state.value.currentChapter?.chapter?.read == true
val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
val chapterDurations = getEtaChapterDurations(currentChapterId)
val chapterPages = getEtaChapterPages(currentChapterId)
val currentChapterProgress = calculateChapterProgress(
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
)
val projectedCurrentChapterDuration =
estimateFullChapterMsFromActiveSession()
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
)
val pageBasedEta = calculatePageBasedEtaMillis(
chapterDurations = chapterDurations,
chapterPages = chapterPages,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
totalPages = state.value.totalPages,
currentPage = state.value.currentPage,
remainingChapters = remainingChapters,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
val chapterBasedEta = when {
totalChapters == 1 -> {
if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter
}
}
else -> {
when {
remainingChapters == 0 -> 0L
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
val currentChapterRemainingMs = if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter ?: 0L
}
val remainingWholeChapters = (
remainingChapters - if (isCurrentChapterRead) 0 else 1
).coerceAtLeast(0)
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
}
}
}
}
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
mutableState.update { mutableState.update {
it.copy( it.copy(
seriesReadingProgress = SeriesReadingProgress( seriesReadingProgress = SeriesReadingProgress(
currentChapter = currentChapterIndex + 1, currentChapter = result.currentChapter,
totalChapters = totalChapters, totalChapters = result.totalChapters,
remainingChapters = remainingChapters, remainingChapters = result.remainingChapters,
etaMillis = etaMillis, etaMillis = result.etaMillis,
), ),
) )
} }
@ -1089,7 +1048,7 @@ class ReaderViewModel @JvmOverloads constructor(
} }
} }
private fun getEtaChapterDurations(currentChapterId: Long): Map<Long, Long> { private fun getEtaChapterDurations(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) { val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadDurationsPersistent.toMutableMap() chapterReadDurationsPersistent.toMutableMap()
} else { } else {
@ -1098,7 +1057,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressDuration = chapterReadStartTime val inProgressDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it } ?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration() ?.sanitizeTrackedDuration(limits)
?: 0L ?: 0L
if (inProgressDuration > 0L) { if (inProgressDuration > 0L) {
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus) trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
@ -1107,7 +1066,7 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedDurations return trackedDurations
} }
private fun getEtaChapterPages(currentChapterId: Long): Map<Long, Long> { private fun getEtaChapterPages(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) { val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadPagesPersistent.toMutableMap() chapterReadPagesPersistent.toMutableMap()
} else { } else {
@ -1116,7 +1075,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressPages = chapterReadStartPageIndex val inProgressPages = chapterReadStartPageIndex
?.let { startPageIndex -> ?.let { startPageIndex ->
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex) val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages() (currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
} }
?: 0L ?: 0L
if (inProgressPages > 0L) { if (inProgressPages > 0L) {
@ -1125,158 +1084,12 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedPages return trackedPages
} }
private fun calculatePageBasedEtaMillis( private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
chapterDurations: Map<Long, Long>, return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
chapterPages: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
totalPages: Int,
currentPage: Int,
remainingChapters: Int,
): Long? {
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
if (duration <= 0L || pages <= 0L) return@mapNotNull null
duration.toDouble() / pages.toDouble()
}
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
val targetChapterPages = if (totalPages > 0) {
totalPages
} else {
avgPagesPerChapter.toInt().coerceAtLeast(1)
}
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
targetChapterPages = targetChapterPages,
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
crossMangaSamples = crossMangaPageRateSamples,
) ?: return null
val currentRemainingPages = if (isCurrentChapterRead) {
0L
} else if (totalPages > 0 && currentPage > 0) {
(totalPages - currentPage).toLong().coerceAtLeast(0L)
} else {
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
}
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
} }
private fun calculateAverageChapterDurationMs( private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
chapterDurations: Map<Long, Long>, return if (this in ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE..limits.maxPagesReadForPageRate) this else 0L
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
): Long? {
if (currentChapterIndex < 0) return null
val sampledDurations = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
val normalizedDuration = when {
chapterId == currentChapterId && !isCurrentChapterRead -> {
calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress,
)
}
else -> chapterDuration
} ?: return@mapNotNull null
normalizedDuration
}
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else {
sortedDurations
}
return trimmedDurations.sum() / trimmedDurations.size
}
private fun calculateChapterProgress(
currentPage: Int,
totalPages: Int,
): Double? {
if (currentPage <= 0 || totalPages <= 1) return null
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
}
private fun calculateProjectedChapterDurationMs(
chapterDuration: Long,
chapterProgress: Double?,
): Long? {
if (chapterDuration <= 0L) return null
val progress = chapterProgress
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
?.coerceIn(0.0, 1.0)
?: return null
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L)
.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
}
/**
* Estimates full-chapter duration from the current session's ms/page rate.
* Prefer over scaling cumulative DB read time by absolute chapter progress history times are
* summed across revisits, which inflates `duration / progress` for rereads.
*/
private fun estimateFullChapterMsFromActiveSession(): Long? {
val totalPages = state.value.totalPages
val startPageIndex = chapterReadStartPageIndex ?: return null
val startTime = chapterReadStartTime ?: return null
if (totalPages <= 1) return null
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration()
if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) return null
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
val estimated = (msPerPage * totalPages.toDouble()).toLong()
return estimated.takeIf { it > 0L }
}
private fun calculateRemainingChapterTimeMs(
chapterDuration: Long?,
chapterProgress: Double?,
): Long? {
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
}
private fun Long.sanitizeTrackedDuration(): Long {
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
}
private fun Long.sanitizeTrackedPages(): Long {
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
} }
/** /**
@ -1829,15 +1642,6 @@ class ReaderViewModel @JvmOverloads constructor(
} }
} }
private companion object {
const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
const val MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
const val MIN_TRACKED_PAGES = 1L
const val MAX_TRACKED_PAGES = 2_000L
}
@Immutable @Immutable
data class State( data class State(
val manga: Manga? = null, val manga: Manga? = null,

Some files were not shown because too many files have changed in this diff Show more