Compare commits

...
101 Commits
Author SHA1 Message Date
AntsyLich 81e0754c0d Release v0.20.3 2026-08-04 16:58:34 +06:00
MajorTanyaandAntsyLich e287f08789 Fix MAL error when list entries have partial dates (#3573)
This has the side effect of forcing the date to something concrete.

For Year-only: 1st of January that year
Ex: 2025 -> 2025-01-01

For Year+Month: 1st of said month
Ex: 2024-11 -> 2024-11-01

MAL allows setting Day+Month and Day+Year, but both of those are sent
as `null` in the API, which means Mihon will not show a date at all.
This has the side effect of Mihon sending up the date as `null` the
next time info is sent to MAL, which will clear the date fields
completely.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-08-04 16:48:08 +06:00
MajorTanya a73271aefa Add category filters to Upcoming calendar (#3607) 2026-08-04 16:30:23 +06:00
AntsyLich f7a1ecd251 Fix reader loading indefinitely in some scenarios (#3686)
Init the reader from the view model instead of the activity so it isn't
cancelled by a configuration change mid init, which left the retained
view model half initialized and needsInit() returning false.

Assisted-by: Claude Code:claude-opus-5
2026-08-03 23:51:17 +06:00
Mend Renovate f80c91081f Update gradle/actions action to v6.3.0 (#3681) 2026-08-03 16:06:15 +06:00
AntsyLich 55be95dd5d Improve WebView spoofing (#3678)
- Spoof `Sec-CH-UA` client hints to match the user agent
- Fix `X-Requested-With` spoofing leaking to unrelated callers
- Update default user agent to Chrome 149
2026-08-02 20:05:21 +06:00
NGB-Was-Taken d7f3ceef5c Ensure R8 keeps methods called externally by Shizuku (#3676)
* Ensure R8 keeps methods called externally by Shizuku

* Changelog
2026-08-02 11:53:49 +00:00
MajorTanya 1d8a2b05dd Add category filters for Updates tab (#3589)
* Add category filters for Updates tab

Works just like Library Updates.

Provides a tab in the Updates tab filter dialog to allow for including
& excluding of updates belonging to a title from a given category.

Excludes overpower Includes, as with Library Updates.

A little helpful text informs users about this.

* Changelog

* [skip ci] Remove unused string

Leftover from before I realized the Default category will always be
available for in-/exclusion.

* Make :includeEmpty/:excludeEmpty true Boolean params

* "include" -> "included"

Consistency with #3607.

* Remove DISTINCT from getRecentUpdatesWithFilters

Apparently not needed anymore (though it was required at some point).

* [skip ci] Fix Changelog
2026-08-02 16:59:58 +06:00
MajorTanya 6d69903a56 Replace java.time APIs with kotlin(x).(date)time (#3001)
* Replace java.time APIs with kotlin(x).(date)time

kotlinx-datetime explicitly says in its README that it does not cover
i18n, so we have to stick with using their toJavaX converters wherever
we localise dates and times.

Yes, some of these replacements are... questionable. But I wanted to
try and maximise the replacement for now.

* Bump kotlinx-datetime to 0.8.0

* More kotlin.time/kotlinx-datetime replacements

* Remove redundant init block & make attributes val

* Replace new use of java.time.Instant
2026-08-02 16:58:25 +06:00
AntsyLich 0648e2eaaf Release v0.20.2 2026-08-01 21:32:00 +06:00
Mend Renovate 7d4e1476ba Update dependency com.google.firebase:firebase-bom to v34.17.0 (#3670) 2026-08-01 21:28:01 +06:00
+9 46a2471abb Translations update from Hosted Weblate (#3563)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/he/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Acelith <joel.jon@moix.me>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Cuong Nguyen Viet <vietcuong0501@gmail.com>
Co-authored-by: CyberMageIL <CyberMageIL@Gmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Doministo <doministo@seznam.cz>
Co-authored-by: FateXBlood <fatexblood@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Hualiang <642615676@qq.com>
Co-authored-by: Infy Milliaria <ced.paltep10@gmail.com>
Co-authored-by: Lucas Correia <anicetoclucas@gmail.com>
Co-authored-by: Luiz Paulo Dias <luizpauloandrade@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: on9686 <on9686@gmail.com>
Co-authored-by: on9686 <on9686@users.noreply.hosted.weblate.org>
2026-08-01 21:27:45 +06:00
Mend Renovate f63dc799f0 Update actions/setup-java action to v5.7.0 (#3673) 2026-08-01 21:26:51 +06:00
Naji AstierandNaji Astier c114028a33 Fix backup restore dropping library entries (#3667)
* Fix backup restore dropping library entries

History restore resolved chapters by URL alone, so a backup holding the
same entry twice under one source matched multiple chapter rows and threw
`ResultSet returned more than 1 row`. Scope the lookups to the entry.

Entries are restored in chunks of 100 sharing one transaction. SQLDelight
fails the enclosing transaction when a nested one fails, so catching the
per-entry exception did not contain it and the whole chunk rolled back,
losing up to 100 entries per bad one. Retry entry by entry on failure.

Refs #647

* Update changelog

---------

Co-authored-by: Naji Astier <na-ji@users.noreply.github.com>
2026-08-01 21:26:24 +06:00
AntsyLich 4296d056b5 Regenerate baseline profiles 2026-07-30 20:47:17 +06:00
AntsyLich 7aaa891aef Improve app update prompt flow experience (#3669) 2026-07-30 20:36:19 +06:00
Mend Renovate 2ccb0ca163 Update dependency org.jsoup:jsoup to v1.23.1 (#3664) 2026-07-30 18:22:31 +06:00
Mend Renovate 44cd9a55d1 Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 (#3662) 2026-07-29 20:49:55 +00:00
Mend Renovate 2a92f0b46a Update dependency androidx.compose:compose-bom-alpha to v2026.07.01 (#3660) 2026-07-30 02:39:52 +06:00
Mend Renovate f37e81991e Update dependency androidx.baselineprofile to v1.5.0-beta01 (#3659) 2026-07-30 02:38:45 +06:00
AntsyLich d15a05602a Check for app and extension update on every cold start (#3658) 2026-07-30 01:17:10 +06:00
Mend Renovate fbf5ae82f7 Update dependency com.squareup.okio:okio to v3.18.1 (#3654) 2026-07-29 09:48:36 +06:00
Mend Renovate fdb223d657 Update spotless to v8.9.0 (#3652) 2026-07-28 13:39:33 +00:00
NGB-Was-Taken 54ae69eaa2 Remove workarounds for patched compose bugs (#3651) 2026-07-28 19:27:26 +06:00
Mohannad 45b1e781eb Add Tokyo Night theme (#3502) 2026-07-25 20:43:04 +06:00
AntsyLich 069e5c91ac Collect data on device preferred abi
Mainly to analyze if it's worth making binaries for x86 and 32Bit devices
2026-07-25 20:32:23 +06:00
NGB-Was-Taken 4ac9f3474b Fix extension installation with shizuku (#3630)
* Fix extension installation with shizuku

* Changelog

* Move changelog to end
2026-07-25 18:51:11 +06:00
AntsyLich 9c3cc1ca5f Convert :source-api and :source-local to android library module (#3636)
- There's really no plan to support other platforms on this code base
- The "KMP" modules only had android target which is rather pointless
- `i18n` involves more work but given it has no actual code keeping it as is
2026-07-23 16:47:24 +00:00
NGB-Was-Taken 47d7c0f2ea Optimize imports across all modules (#3635)
* Run optimize imports across all modules since spotless doesn't catch unused imports

* Remove import by adding qualifier in KDoc
2026-07-23 22:46:58 +06:00
Mend Renovate 0b5e9bb815 Update android.gradle to v9.3.1 (#3634) 2026-07-23 22:42:29 +06:00
Mend Renovate d72ae365cb Update dependency com.squareup.okio:okio to v3.18.0 (#3628) 2026-07-22 13:28:10 +00:00
NGB-Was-Taken e7ec672c8b Rework library search to use an AST-based approach (#3554)
* Rework library search to use an AST-based approach

* Spotless apply

* Move the filtering logic to the database

* Spotless apply

* Cleanup and separation of concerns

* Add `notes` to field and general search, and chapter read, total chapters to comparison search

* Handle searching for empty fields

* Add `language` as a field-only query option

* Clean up SQL clause generations

* Spotless apply

* Do not parse `-` as a negation when followed by a separator

* Revert moving filtering logic to database

* Changelog

* Use contains for genre instead of equals

* Add special handling for source name

* Add field-only query for source id

* Apply suggestions
2026-07-22 19:20:27 +06:00
Mend Renovate 628cb58862 Update dependency io.kotest:kotest-assertions-core to v6.2.3 (#3622) 2026-07-21 12:09:56 +00:00
MajorTanyaandAntsyLich e454cf8e9d Always use Private installer to update Private extensions (#3614)
* Use Private installer to update Private extensions

This forces the Private installer for any extension which was
installed privately, bypassing whichever setting the user selected in
Settings > Advanced > Installer.

* [skip ci] Changelog entry

* [skip ci] Update CHANGELOG.md

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-21 12:02:33 +00:00
Mend Renovate baa0fb7ecb Update dependency androidx.compose:compose-bom-alpha to v2026.07.00 (#3597) 2026-07-21 18:01:02 +06:00
Mend Renovate 2d2cf0879f Update actions/checkout action to v7.0.1 (#3620) 2026-07-21 17:56:46 +06:00
Mohannad 6981185f4b Fix incorrect progress when resuming resumable image downloads (#3616) 2026-07-21 17:46:57 +06:00
MajorTanya 47aa9190d4 Add MangaBaka to README list of trackers (#3615) [skip ci] 2026-07-21 17:44:17 +06:00
AntsyLich 98705910e7 Fix crash caused by inaccessible view model classes during creation (#3609) 2026-07-19 14:06:01 +00:00
Mend Renovate 5ce7d00eb8 Update actions/setup-java action to v5.6.0 (#3602) 2026-07-19 10:45:16 +06:00
Mend Renovate 03229a3807 Update aboutLibraries to v15.0.4 (#3604) 2026-07-18 03:04:07 +06:00
Small_KuandAntsyLich 493140c7ff Detect Shizuku with permission check (#3565)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-16 19:52:09 +06:00
AntsyLich 98bb731b4d Remove non cancellable call when trying to init reader 2026-07-16 18:23:43 +06:00
AntsyLich c3b99aea07 Migrate to AndroidX ViewModel from Voyager ScreenModel (#3594) 2026-07-15 21:19:11 +06:00
Mend RenovateandAntsyLich ef1c529672 Update android.gradle to v9.3.0 (#3593)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-14 18:32:47 +00:00
Mend Renovate 633eea4ced Update kotlin monorepo to v2.4.10 (#3592) 2026-07-14 18:03:24 +00:00
Mend Renovate 31a4d6ba39 Update softprops/action-gh-release action to v3.0.2 (#3588) 2026-07-14 23:51:37 +06:00
Mend Renovate e9b7c760a1 Update dependency org.conscrypt:conscrypt-android to v2.6.1 (#3587) 2026-07-14 23:51:18 +06:00
Mend Renovate ef1e7ba907 Update dependency org.junit.jupiter:junit-jupiter to v6.1.2 (#3582) 2026-07-12 20:27:17 +06:00
MajorTanya 526d79a975 Fix MangaBaka User-Agent using raw StringResource (#3578)
* Fix MangaBaka User-Agent using raw StringResource

Using the hardcoded app name for the time being until a better
solution can be found.

* Changelog
2026-07-12 06:46:10 +06:00
Mend Renovate b9ac9a0422 Update dependency com.materialkolor:material-kolor to v5.0.0 (#3580) 2026-07-12 06:45:37 +06:00
Draken d171768cff Add Vietnamese translation for fastlane metadata (#3576) 2026-07-11 22:12:39 +06:00
AntsyLich cb2617dee0 Add fastlane metadata (#3564) 2026-07-11 20:01:33 +06:00
AntsyLich b4635c41a8 Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider (#3567) 2026-07-10 13:35:16 +00:00
Mend Renovate 217b20123c Update dependency com.google.firebase:firebase-bom to v34.16.0 (#3562) 2026-07-10 00:09:34 +06:00
AntsyLich b2ca6bc5f3 Revert "Disable resumable image downloads"
This reverts commit 3d3bd49d1c.
2026-07-10 00:08:26 +06:00
AntsyLich a8d3e39779 Release v0.20.1 2026-07-10 00:07:57 +06:00
AntsyLich 3d3bd49d1c Disable resumable image downloads 2026-07-10 00:06:59 +06:00
AntsyLich 4a66b8b5df Fix HTTP Error 416 not being handled properly for resumable downloads 2026-07-10 00:03:07 +06:00
52f827c6ff Translations update from Hosted Weblate (#3477)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Juang <juangegiambon@gmail.com>
Co-authored-by: NecRaul <necraul@kuroneko.dev>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: Siebrenvde <siebren@siebrenvde.dev>
Co-authored-by: Szafranek13 <jakubfabijan@tuta.io>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: abc0922001 <abc0922001@hotmail.com>
2026-07-09 23:26:32 +06:00
AntsyLich aa434285b2 Remove flag to disable code shrinking
androidx.benchmark plugin adds non minified versions of the release variants. we can just reuse that.
2026-07-09 23:22:30 +06:00
AntsyLich 91e967128d Regenerate baseline profiles 2026-07-09 23:06:33 +06:00
AntsyLich abef1cb8c5 Add proguard rule for com.squareup.zstd 2026-07-09 22:46:40 +06:00
MajorTanya 1d8e5d7eb1 Fix underscore in Hikka media types (#3560)
Currently, media types like "one_shot" will turn into "One_shot" in
the search display due to Mihon capitalising the `publishing_type`
attribute of `Track` for display.

It is common for other tracker implementations to replace the
underscore with a space which results in "One shot".

This makes Hikka more consistent with other trackers.

I added the nullability check since the docs do say `string | null`
but that's neither here nor there.
2026-07-09 22:30:50 +06:00
AntsyLich 75f506836c Fix app not reading tachiyomix.extensionLib extension metadata 2 (#3559) 2026-07-09 08:31:34 +00:00
AntsyLich 44780e74b5 Add vertical chapter navigator for paged reader with height adjustment option (#3531) 2026-07-09 08:11:56 +00:00
Mend Renovate 8407439b8a Update xmlutil to v1.0.1 (#3555) 2026-07-09 12:04:43 +06:00
MajorTanya ab58a9952e Fix Hikka search failing due to unclosed response & show toast on remote-removed titles (#3548)
* Fix Hikka search failing due to unclosed response

This closes #3547.

I have also included a precautionary refactor of the `getRead` method
in HikkaApi.kt, which by the looks of it _could_ leak an unclosed
response as well.

The try/catch with awaitSuccess pattern has been used in this same way
in the Bangumi and MangaBaka implementations as well. The benefit here
is that `awaitSuccess()` closes the response on error before throwing
an `HttpException`.

* Show toast on remote-removed list entries
2026-07-08 17:41:23 +00:00
AntsyLich 0ff654205f Update aboutLibraries to v15 (#3553) 2026-07-08 17:13:18 +00:00
Mend Renovate 1d8789d800 Update dependency org.conscrypt:conscrypt-android to v2.6.0 (#3552) 2026-07-08 17:08:08 +00:00
AntsyLich a313c747f3 Fix reader navigator slider steps not updating after changing chapter (#3549) 2026-07-08 16:58:45 +00:00
AntsyLich 66b390c62a Fix app not reading tachiyomix.extensionLib extension metadata (#3545)
Android was interpreting the field as a float and failing (which is nuts imo)

Also cleaned up the lib validation logic
2026-07-08 07:43:17 +00:00
Mend Renovate 26abad0be0 Update dependency io.kotest:kotest-assertions-core to v6.2.2 (#3544) 2026-07-08 13:38:52 +06:00
Mend Renovate 68f293be45 Update actions/setup-java action to v5.5.0 (#3543) 2026-07-08 13:38:29 +06:00
41f4579c74 Add MangaBaka tracker support (#3047)
* Add support for MangaBaka

Note: missing a client ID which should be added by the project owner.

* Fix typo causing token refresh to fail

The field is called `refresh_token` not `refreshToken`, so all
token refreshes would fail with a 401.

* Use user-preferred score type

Currently, this only set once on login. When selecting a different
score type on MangaBaka, users (currently) will have to log out and
log in again in order for Mihon to refetch the score setting.

This appears to be what Mihon's AniList implementation does as well,
so I followed that.

* Fix score preference

Didn't get caught during the rebase.

* Fix finish_date

* Fix dates appearing offset

Timezone shenanigans meant Mihon was sending an ISO 8601 string for
the UTC timezone which lead to issues in non-UTC timezones.

For example:
1. User in UTC+1 selects 2026-03-22
2. Date picker returns 2026-03-22T00:00:00+01:00
3. MangaBakaApi converts that to an instant, converting it to UTC:
  2026-03-21T23:00:00Z
4. MangaBaka receives that and sets all time components to 0
5. Mihon (when opening the track sheet next) receives:
  2026-03-21T00:00:00Z
6. Mihon renders that as 2026-03-21

I figured out that MangaBaka accepts date-only strings as well, so we
just send those now.

* Remove fetching of total_chapters from MangaBaka

Currently, this information is not what Mihon needs for automatic
marking as COMPLETED since MangaBaka considers this an ongoing tally
of volume-published chapters rather than the final chapter number of a
title.

If anyone other than me used this code: Probably will have to unlink
& relink MB entries to get rid of the total_chapters data, not sure.

MangaBaka is currently considering a request from me to potentially
change this, but currently it's not what we need.

* Fix start date being reset on each read chapter

Admittedly a silly oversight. The other date-supporting trackers (AL,
MAL, Kitsu) use this check as opposed to `>= 0.0`.

* Add id: prefix search for MangaBaka

* Use new /v1/my/profile endpoint

Kindly added by the developers after my request <3.

* Change tracker ID to 11

* Remove unused import

* Identify Mihon in UA on all requests

* Use new `published.start_date` field for search

* Change Mihon's User Agent string for MangaBaka

- use MR.strings.app_name to avoid manual work for forks
- include app ID & commit sha for further distinction

* Add Changelog entry

* Add Mihon client ID

* Add OAuth state query param

This is?/will be? required by MangaBaka's auth provider and caused
linking issues for anyone who tried to link with MB in the past two
weeks or so.

* Update MangaBaka API URL

Changed as per MB announcement from 2026-05-27

* Use `titles` instead of old `title`

* Make title selection a method on MangaBakaItem

* Include ID in fallback title for easier reporting

Makes it a lot more intuitive for users.
Also reworded the message to more clearly direct users to the
Mangabaka Discord server instead of sounding like an instruction to
contact some "mangabaka" user in the Mihon Discord.

* `titles` is nullable

At least I noticed this before it was merged...

* Fix MangaBaka brand name

Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com>

* Remove Hikka comment

* Put MangaBaka at the top of the tracker list

AntsyLich decreed it to be like this.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Add display name support from #3533

* Fix changelog

* Rename MB icon to brand_mangabaka.webp

---------

Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-07 20:58:50 +06:00
Leodyver SemillaandAntsyLich 0787678c12 Don't use GMS to detect if GMS is available (#3525)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-07 10:43:14 +00:00
MajorTanya d0c79399b9 Show usernames for each tracker in settings (#3533) 2026-07-07 16:31:22 +06:00
MajorTanya 17aed02e56 Hikka: Set PLAN_TO_READ for manga with no progress (#3534)
Currently, every title will be added as READING, even if no chapter
has been read.

For consistency with every other tracker, I added the hasReadChapters
check as seen in other tracker implementations.
2026-07-07 16:29:04 +06:00
Cuong-Tran 88a9d44bb7 Fix chapter's memo field not being updated for existing chapters (#3538) 2026-07-07 16:25:31 +06:00
LorgOnandAntsyLich aa46039e6c Add Hikka tracker support (#1386)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-06 21:18:06 +00:00
AntsyLich 3b078331a0 Fix support for non-system SAF providers (#3530) 2026-07-06 23:14:24 +06:00
AntsyLich b8e5f22c01 Add proguard rules to keep Serializable writeReplace and readResolve 2026-07-05 22:21:04 +06:00
AntsyLich c3bf7a78c0 Fix crash when putting app in background (#3523) 2026-07-05 11:51:10 +06:00
AntsyLich 94b3b5eaa2 Revert "Fix crash when putting app in background while on notes screen (#3515)"
This reverts commit 76cc04c8f4.
2026-07-04 15:18:40 +06:00
AntsyLich 27284a40ab Fix crash trying to select text in notes screen (#3516) 2026-07-03 23:32:03 +06:00
AntsyLich 76cc04c8f4 Fix crash when putting app in background while on notes screen (#3515) 2026-07-03 23:25:29 +06:00
Leodyver SemillaandAntsyLich 77580cd55f Invalidate download cache after backup restore (#3096)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-03 23:21:38 +06:00
MajorTanya db095c87aa Use Shikimori's GraphQL API where possible (#3499)
User list mutation aren't available via GraphQL, so that will still be
done with the v2 API.

Shikimori API docs say to prefer the GraphQL API when possible:
https://shikimori.io/api/doc

Allows adding some additional data in the search results, namely:
- Authors & Artists
- Description

As a nice bonus, this reduces the number of requests to Shikimori
because the findLibManga method no longer needs to do 2 calls to fetch
both list and title data.
2026-07-03 22:21:22 +06:00
MajorTanya f3c6789dfb Fix downloads not working due to wrong file check (#3514)
The code as-is accepts _any_ non-.tmp file as a match for the given
page file.
Changing the condition to "not .tmp" AND (either page prefix pattern)
results in the correct behaviour and chapters download again.
2026-07-03 22:20:56 +06:00
Mend Renovate 0772f72021 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0 (#3509) 2026-07-03 13:02:04 +06:00
Mend Renovate 1623c188df Update dependency androidx.sqlite:sqlite-bundled to v2.7.0 (#3508) 2026-07-03 13:01:55 +06:00
AntsyLich dfed97edc8 Address compose and xmlutil deprecations (#3507) 2026-07-02 19:15:42 +00:00
MohannadandAntsyLich f6632940fa Fix duplicate images after resuming paused downloads (#3504)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-02 18:44:30 +00:00
Mend Renovate ca2e8cf4de Update dependency androidx.compose:compose-bom-alpha to v2026.06.01 (#3506) 2026-07-03 00:19:35 +06:00
Mend Renovate 000e2d091b Update dependency androidx.baselineprofile to v1.5.0-alpha07 (#3505) 2026-07-02 18:13:54 +00:00
Mend Renovate 40f782e10c Update spotless to v8.8.0 (#3493) 2026-07-03 00:09:36 +06:00
Mend Renovate f306e65b25 Update xmlutil to v1 (#3494) 2026-07-03 00:08:26 +06:00
Mend Renovate d0f373df22 Update dependency org.junit.jupiter:junit-jupiter to v6.1.1 (#3492) 2026-07-03 00:08:06 +06:00
MajorTanya 3fd76e4f93 Fix Shikimori URL (#3497)
There is a redirect from the .one domain to the .io domain but it uses
code 301 for redirecting on the token POST request, meaning the
followup request tries to use GET which returns a 404.
2026-07-03 00:03:24 +06:00
d8c3440d37 Support resumable image downloads if supported by source (#3167)
Co-authored-by: MajorTanya <39014446+MajorTanya@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-07-01 00:20:43 +06:00
AntsyLich a82ccea6f5 Update voyager to 2.2.21-1.10.3 (#3466) 2026-06-27 08:53:33 +06:00
404 changed files with 9289 additions and 7963 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[0.20.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.20.0"
Example: "0.20.3"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.20.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+4 -3
View File
@@ -4,6 +4,7 @@ on:
paths:
- '**'
- '!**.md'
- '!fastlane/**'
- '!i18n/src/commonMain/moko-resources/**/strings.xml'
- '!i18n/src/commonMain/moko-resources/**/plurals.xml'
- 'i18n/src/commonMain/moko-resources/base/strings.xml'
@@ -26,20 +27,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Dependency Review
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Check code format
run: ./gradlew spotlessCheck
+7 -7
View File
@@ -32,16 +32,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Build
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
@@ -80,16 +80,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-disabled: true
@@ -134,7 +134,7 @@ jobs:
mihon-foss
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.get_tag.outputs.tag }}
name: Mihon ${{ needs.get_tag.outputs.tag }}
+72 -1
View File
@@ -12,6 +12,74 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
## [v0.20.3] - 2026-08-04
### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
- Add Category filtering for the Upcoming calendar ([@MajorTanya](https://github.com/MajorTanya)) ([#3607](https://github.com/mihonapp/mihon/pull/3607))
### Changed
- Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Improved
- Spoof `Sec-CH-UA` client hints in WebView to match the user agent ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Fixed
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3676](https://github.com/mihonapp/mihon/pull/3676))
- Fixed `X-Requested-With` spoofing leaking to unrelated callers ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
- Fixed reader loading indefinitely in some scenarios ([@AntsyLich](https://github.com/AntsyLich)) ([#3686](https://github.com/mihonapp/mihon/pull/3686))
- Fix MyAnimeList error for list entries with partial start/finish reading dates by setting those dates to either Jan 1st or 1st of the month ([@MajorTanya](https://github.com/MajorTanya)) ([#3573](https://github.com/mihonapp/mihon/pull/3573))
## [v0.20.2] - 2026-08-01
### Added
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
- Fix HTTP Error 416 not being handled properly ([@AntsyLich](https://github.com/AntsyLich)) ([#3563](https://github.com/mihonapp/mihon/pull/3563))
- Fix incorrect progress when resuming downloads ([@xMohnad](https://github.com/xMohnad)) ([#3616](https://github.com/mihonapp/mihon/pull/3616))
- Add Tokyo Night Theme ([@xMohnad](https://github.com/xMohnad)) ([#3502](https://github.com/mihonapp/mihon/pull/3502))
### Changed
- Detect Shizuku with permission check ([@Small-Ku](https://github.com/Small-Ku)) ([#3565](https://github.com/mihonapp/mihon/pull/3565))
- Check for app and extension update on every cold start ([@AntsyLich](https://github.com/AntsyLich)) ([#3658](https://github.com/mihonapp/mihon/pull/3658))
### Improved
- Library search ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3554](https://github.com/mihonapp/mihon/pull/3554))
- Support logical operators (`&&`, `||`, `-`), implicit `AND` separators, and nested expressions with `()`
- Add comparison operators (`>`, `>=`, `<`, `<=`, `=`) for `id`, `added`, `fetchinterval` / `fi`, `nextupdate` / `nu`, `unread`, `read`, and `total` (Uses `yyyy-MM-dd` dates for `added` and `nextupdate`)
- Support field-specific prefixes (`title`, `author`, `artist`, `description` / `desc`, `genre` / `tag`, `source` / `src`, `source_id` / `src_id`, `notes` / `note`, `language` / `lang`) and empty field matching (`""`, `''`)
- Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614))
- App update prompt flow experience ([@AntsyLich](https://github.com/AntsyLich)) ([#3669](https://github.com/mihonapp/mihon/pull/3669))
### Fixed
- Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567))
- Fixed MangaBaka User Agent string ([@MajorTanya](https://github.com/MajorTanya)) ([#3578](https://github.com/mihonapp/mihon/pull/3578))
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3630](https://github.com/mihonapp/mihon/pull/3630))
- Fixed backup restore dropping library entries when the backup contains duplicate chapters ([@na-ji](https://github.com/na-ji)) ([#3667](https://github.com/mihonapp/mihon/pull/3667))
## [v0.20.1] - 2026-07-09
### Added
- [Hikka](https://hikka.io/) tracker support ([@Lorg0n](https://github.com/Lorg0n)) ([#1386](https://github.com/mihonapp/mihon/pull/1386))
- Fix Hikka not defaulting to "Plan to Read" for unread titles ([@MajorTanya](https://github.com/MajorTanya)) ([#3534](https://github.com/mihonapp/mihon/pull/3534))
- Fix Hikka search throwing error due to unclosed response ([@MajorTanya](https://github.com/MajorTanya)) ([#3548](https://github.com/mihonapp/mihon/pull/3548))
- Add support for [MangaBaka](https://mangabaka.org) tracker ([@MajorTanya](https://github.com/MajorTanya)) ([#3047](https://github.com/mihonapp/mihon/pull/3047))
- Display authors and description in Shikimori search results ([@MajorTanya](https://github.com/MajorTanya)) ([#3499](https://github.com/mihonapp/mihon/pull/3499))
- Invalidate download cache after backup restore ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3096](https://github.com/mihonapp/mihon/pull/3096))
- Add setting to control vertical chapter navigator height ([@AntsyLich](https://github.com/AntsyLich)) ([#3528](https://github.com/mihonapp/mihon/pull/3528))
- Add option to set which readers use vertical chapter navigator ([@AntsyLich](https://github.com/AntsyLich)) ([#3528](https://github.com/mihonapp/mihon/pull/3528))
### Changed
- Don't use GMS to detect if GMS is available ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3525](https://github.com/mihonapp/mihon/pull/3525))
### Improved
- Show usernames in Tracking settings ([@MajorTanya](https://github.com/MajorTanya)) ([#3533](https://github.com/mihonapp/mihon/pull/3533))
### Fixed
- Fix Shikimori tracking not working ([@MajorTanya](https://github.com/MajorTanya)) ([#3497](https://github.com/mihonapp/mihon/pull/3497))
- Fix crash trying to select text in notes screen ([@AntsyLich](https://github.com/AntsyLich)) ([#3516](https://github.com/mihonapp/mihon/pull/3516))
- Fix crash when putting app in background ([@AntsyLich](https://github.com/AntsyLich)) ([#3523](https://github.com/mihonapp/mihon/pull/3523))
- Fix support for non-system SAF providers ([@AntsyLich](https://github.com/AntsyLich)) ([#3530](https://github.com/mihonapp/mihon/pull/3530))
- Fix chapter's `memo` field not being updated for existing chapters ([@cuong-tran](https://github.com/cuong-tran)) ([#3538](https://github.com/mihonapp/mihon/pull/3538))
- Fix app not reading `tachiyomix.extensionLib` extension metadata ([@AntsyLich](https://github.com/AntsyLich)) ([#3545](https://github.com/mihonapp/mihon/pull/3545), [#3559](https://github.com/mihonapp/mihon/pull/3559))
- Fix reader navigator slider steps not updating after changing chapter ([@AntsyLich](https://github.com/AntsyLich)) ([#3549](https://github.com/mihonapp/mihon/pull/3549))
## [v0.20.0] - 2026-06-27
### Added
- Add support for `tachiyomix` 1.6 extensions ([@AntsyLich](https://github.com/AntsyLich))
@@ -550,7 +618,10 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.20.0...main
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.20.3...main
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
[v0.20.2]: https://github.com/mihonapp/mihon/compare/v0.20.1...v0.20.2
[v0.20.1]: https://github.com/mihonapp/mihon/compare/v0.20.0...v0.20.1
[v0.20.0]: https://github.com/mihonapp/mihon/compare/v0.19.9...v0.20.0
[v0.19.9]: https://github.com/mihonapp/mihon/compare/v0.19.8...v0.19.9
[v0.19.8]: https://github.com/mihonapp/mihon/compare/v0.19.7...v0.19.8
+1 -1
View File
@@ -29,7 +29,7 @@ Discover and read manga, webtoons, comics, and more – easier than ever on your
* Local reading of content.
* A configurable reader with multiple viewers, reading directions and other settings.
* Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) support.
* Tracker support: [MangaBaka](https://mangabaka.org), [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), [Bangumi](https://bgm.tv/), and [Hikka](https://hikka.io/) support.
* Categories to organize your library.
* Light and dark themes.
* Schedule updating your library for new chapters.
+7 -4
View File
@@ -32,8 +32,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 24
versionName = "0.20.0"
versionCode = 28
versionName = "0.20.3"
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getLatestCommitSha()}\"")
@@ -78,8 +78,8 @@ android {
isPseudoLocalesEnabled = true
}
val release = getByName("release") {
isMinifyEnabled = Config.enableCodeShrink
isShrinkResources = Config.enableCodeShrink
isMinifyEnabled = true
isShrinkResources = true
signingConfig = debug.signingConfig
@@ -209,6 +209,7 @@ dependencies {
implementation(projects.i18n)
implementation(projects.core.archive)
implementation(projects.core.common)
implementation(projects.core.viewmodel)
implementation(projects.coreMetadata)
implementation(projects.sourceApi)
implementation(projects.sourceLocal)
@@ -242,6 +243,8 @@ dependencies {
implementation(libs.sqldelight.async)
implementation(libs.kotlinx.datetime)
// AndroidX libraries
implementation(libs.androidx.annotation)
implementation(libs.androidx.appCompat)
+15
View File
@@ -16,6 +16,7 @@
-keep,allowoptimization class rx.** { public protected *; }
-keep,allowoptimization class app.cash.quickjs.** { public protected *; }
-keep,allowoptimization class uy.kohesive.injekt.** { public protected *; }
-keep,allowoptimization class com.squareup.zstd.** { public protected *; }
# From extensions-lib
-keep,allowoptimization class eu.kanade.tachiyomi.network.interceptor.RateLimitInterceptorKt { public protected *; }
@@ -25,6 +26,11 @@
-keep,allowoptimization class eu.kanade.tachiyomi.network.RequestsKt { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.AppInfo { public protected *; }
-keepclassmembers class * implements java.io.Serializable {
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
##---------------Begin: proguard configuration for RxJava 1.x ----------
-dontwarn sun.misc.**
@@ -80,3 +86,12 @@
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
# KotlinX Datetime
-keep,allowoptimization class kotlinx.datetime.** { public protected *; }
# Methods called by Shizuku only
-keepclassmembers class mihon.app.shizuku.ShellInterface {
public <init>();
public void destroy();
}
+2
View File
@@ -193,8 +193,10 @@
<data android:host="anilist-auth" />
<data android:host="bangumi-auth" />
<data android:host="mangabaka-auth" />
<data android:host="myanimelist-auth" />
<data android:host="shikimori-auth" />
<data android:host="hikka-auth" />
</intent-filter>
</activity>
@@ -1,7 +1,7 @@
package mihon.app.shizuku;
interface IShellInterface {
void install(in AssetFileDescriptor apk) = 1;
void install(in AssetFileDescriptor apk, in IntentSender intentSender) = 1;
void destroy() = 16777114;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -143,7 +143,7 @@ class DomainModule : InjektModule {
}
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) }
addFactory { GetApplicationRelease(get()) }
addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) }
addFactory { TrackChapter(get(), get(), get(), get()) }
@@ -10,6 +10,9 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.chapter.ChapterSanitizer
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
@@ -23,8 +26,8 @@ import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
import java.lang.Long.max
import java.time.ZonedDateTime
import java.util.TreeSet
import kotlin.time.Clock
class SyncChaptersWithSource(
private val downloadManager: DownloadManager,
@@ -57,8 +60,9 @@ class SyncChaptersWithSource(
throw NoChaptersException()
}
val now = ZonedDateTime.now()
val nowMillis = now.toInstant().toEpochMilli()
val timeZone = TimeZone.currentSystemDefault()
val now = Clock.System.now().toLocalDateTime(timeZone)
val nowMillis = now.toInstant(timeZone).toEpochMilliseconds()
val sourceChapters = rawSourceChapters
.distinctBy { it.url }
@@ -129,6 +133,7 @@ class SyncChaptersWithSource(
chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder,
memo = chapter.memo,
)
if (chapter.dateUpload != 0L) {
@@ -144,6 +149,7 @@ class SyncChaptersWithSource(
if (manualFetch || manga.fetchInterval == 0 || manga.nextUpdate < fetchWindow.first) {
updateManga.awaitUpdateFetchInterval(
manga,
timeZone,
now,
fetchWindow,
)
@@ -216,7 +222,7 @@ class SyncChaptersWithSource(
val chapterUpdates = updatedChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
// Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all
@@ -1,11 +1,13 @@
package eu.kanade.domain.manga.interactor
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.time.Clock
class UpdateManga(
private val mangaRepository: MangaRepository,
@@ -22,25 +24,31 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime),
timeZone: TimeZone = TimeZone.currentSystemDefault(),
dateTime: LocalDateTime = Clock.System.now().toLocalDateTime(timeZone),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime.date, timeZone),
): Boolean {
return mangaRepository.update(
fetchInterval.toMangaUpdate(manga, dateTime, window),
fetchInterval.toMangaUpdate(manga, dateTime, timeZone, window),
)
}
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Instant.now().toEpochMilli()))
return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Clock.System.now().toEpochMilliseconds()))
}
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Instant.now().toEpochMilli()))
return mangaRepository.update(
MangaUpdate(
id = mangaId,
coverLastModified = Clock.System.now().toEpochMilliseconds(),
),
)
}
suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
val dateAdded = when (favorite) {
true -> Instant.now().toEpochMilli()
true -> Clock.System.now().toEpochMilliseconds()
false -> 0
}
return mangaRepository.update(
@@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.datetime.TimeZone
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withNonCancellableContext
@@ -18,7 +19,6 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.interactor.InsertTrack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZoneOffset
class AddTracks(
private val insertTrack: InsertTrack,
@@ -62,8 +62,8 @@ class AddTracks(
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.time.convertEpochMillisZone(
ZoneOffset.systemDefault(),
ZoneOffset.UTC,
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
track = track.copy(
startDate = startDate,
@@ -3,6 +3,7 @@ package eu.kanade.domain.track.service
import eu.kanade.domain.track.model.AutoTrackState
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@@ -16,6 +17,11 @@ class TrackPreferences(
"",
)
fun trackDisplayUsername(tracker: Tracker) = preferenceStore.getString(
Preference.privateKey("pref_mangasync_displayname_${tracker.id}"),
"",
)
fun trackPassword(tracker: Tracker) = preferenceStore.getString(
Preference.privateKey("pref_mangasync_password_${tracker.id}"),
"",
@@ -36,6 +42,8 @@ class TrackPreferences(
val anilistScoreType: Preference<String> = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
val mangabakaScoreType: Preference<String> = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1)
val autoUpdateTrack: Preference<Boolean> = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
val autoUpdateTrackOnMarkRead: Preference<AutoTrackState> = preferenceStore.getEnum(
@@ -17,6 +17,7 @@ enum class AppTheme(val titleRes: StringResource?) {
TIDAL_WAVE(MR.strings.theme_tidalwave),
YINYANG(MR.strings.theme_yinyang),
YOTSUBA(MR.strings.theme_yotsuba),
TOKYONIGHT(MR.strings.theme_tokyonight),
MONOCHROME(MR.strings.theme_monochrome),
// Deprecated
@@ -53,7 +53,7 @@ import eu.kanade.presentation.more.settings.widget.TrailingWidgetBuffer
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsViewModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.i18n.MR
@@ -66,7 +66,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen
@Composable
fun ExtensionDetailsScreen(
navigateUp: () -> Unit,
state: ExtensionDetailsScreenModel.State,
state: ExtensionDetailsViewModel.State,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickEnableAll: () -> Unit,
onClickDisableAll: () -> Unit,
@@ -48,12 +48,11 @@ import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR
@@ -71,7 +70,7 @@ import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun ExtensionScreen(
state: ExtensionsScreenModel.State,
state: ExtensionsViewModel.State,
contentPadding: PaddingValues,
searchQuery: String?,
onLongClickItem: (Extension) -> Unit,
@@ -133,7 +132,7 @@ fun ExtensionScreen(
@Composable
private fun ExtensionContent(
state: ExtensionsScreenModel.State,
state: ExtensionsViewModel.State,
contentPadding: PaddingValues,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
@@ -187,14 +186,14 @@ private fun ExtensionContent(
}
ExtensionHeader(
textRes = header.textRes,
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
action = action,
)
}
is ExtensionUiModel.Header.Text -> {
ExtensionHeader(
text = header.text,
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
)
}
}
@@ -212,7 +211,7 @@ private fun ExtensionContent(
},
) { item ->
ExtensionItem(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
item = item,
onClickItem = {
when (it) {
@@ -12,7 +12,7 @@ import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchViewModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.manga.model.Manga
@@ -20,7 +20,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun GlobalSearchScreen(
state: SearchScreenModel.State,
state: SearchViewModel.State,
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
onSearch: (String) -> Unit,
@@ -4,14 +4,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchViewModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun MigrateSearchScreen(
state: SearchScreenModel.State,
state: SearchViewModel.State,
fromSourceId: Long?,
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
@@ -24,7 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceScreenModel
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceViewModel
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.domain.source.model.Source
import tachiyomi.i18n.MR
@@ -43,7 +43,7 @@ import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun MigrateSourceScreen(
state: MigrateSourceScreenModel.State,
state: MigrateSourceViewModel.State,
contentPadding: PaddingValues,
onClickItem: (Source) -> Unit,
onToggleSortingDirection: () -> Unit,
@@ -10,8 +10,7 @@ import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterScreenModel
import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterViewModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.source.model.Source
import tachiyomi.i18n.MR
@@ -23,7 +22,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen
@Composable
fun SourcesFilterScreen(
navigateUp: () -> Unit,
state: SourcesFilterScreenModel.State.Success,
state: SourcesFilterViewModel.State.Success,
onClickLanguage: (String) -> Unit,
onClickSource: (Source) -> Unit,
) {
@@ -55,7 +54,7 @@ fun SourcesFilterScreen(
@Composable
private fun SourcesFilterContent(
contentPadding: PaddingValues,
state: SourcesFilterScreenModel.State.Success,
state: SourcesFilterViewModel.State.Success,
onClickLanguage: (String) -> Unit,
onClickSource: (Source) -> Unit,
) {
@@ -69,7 +68,7 @@ private fun SourcesFilterContent(
contentType = "source-filter-header",
) {
SourcesFilterHeader(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
language = language,
enabled = enabled,
onClickItem = onClickLanguage,
@@ -82,7 +81,7 @@ private fun SourcesFilterContent(
contentType = { "source-filter-item" },
) { source ->
SourcesFilterItem(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
source = source,
enabled = "${source.id}" !in state.disabledSources,
onClickItem = onClickSource,
@@ -21,8 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.browse.source.SourcesViewModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceViewModel.Listing
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.source.model.Pin
import tachiyomi.domain.source.model.Source
@@ -40,7 +40,7 @@ import tachiyomi.source.local.isLocal
@Composable
fun SourcesScreen(
state: SourcesScreenModel.State,
state: SourcesViewModel.State,
contentPadding: PaddingValues,
onClickItem: (Source, Listing) -> Unit,
onClickPin: (Source) -> Unit,
@@ -27,13 +27,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import eu.kanade.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName
import kotlinx.coroutines.delay
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.domain.category.model.Category
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun CategoryCreateDialog(
@@ -91,8 +89,6 @@ fun CategoryCreateDialog(
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@@ -155,8 +151,6 @@ fun CategoryRenameDialog(
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@@ -5,23 +5,23 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
@Composable
fun relativeDateText(
dateEpochMillis: Long,
): String {
return relativeDateText(
localDate = LocalDate.ofInstant(
Instant.ofEpochMilli(dateEpochMillis),
ZoneId.systemDefault(),
)
localDate = Instant.fromEpochMilliseconds(dateEpochMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
.takeIf { dateEpochMillis != 0L },
)
}
@@ -58,7 +58,6 @@ fun TabbedDialog(
modifier = Modifier.weight(1f),
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
divider = {},
) {
tabTitles.fastForEachIndexed { index, tab ->
Tab(
@@ -72,7 +71,6 @@ fun TabbedDialog(
tabOverflowMenuContent?.let { MoreMenu(it) }
}
HorizontalDivider()
HorizontalPager(
modifier = Modifier.animateContentSize(),
@@ -18,8 +18,8 @@ import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.history.HistoryScreenModel
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.LocalDate
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
@@ -28,17 +28,16 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import java.time.LocalDate
@Composable
fun HistoryScreen(
state: HistoryScreenModel.State,
state: HistoryViewModel.State,
snackbarHostState: SnackbarHostState,
onSearchQueryChange: (String?) -> Unit,
onClickCover: (mangaId: Long) -> Unit,
onClickResume: (mangaId: Long, chapterId: Long) -> Unit,
onClickFavorite: (mangaId: Long) -> Unit,
onDialogChange: (HistoryScreenModel.Dialog?) -> Unit,
onDialogChange: (HistoryViewModel.Dialog?) -> Unit,
) {
Scaffold(
topBar = { scrollBehavior ->
@@ -53,7 +52,7 @@ fun HistoryScreen(
title = stringResource(MR.strings.pref_clear_history),
icon = Icons.Outlined.DeleteSweep,
onClick = {
onDialogChange(HistoryScreenModel.Dialog.DeleteAll)
onDialogChange(HistoryViewModel.Dialog.DeleteAll)
},
),
),
@@ -83,7 +82,7 @@ fun HistoryScreen(
contentPadding = contentPadding,
onClickCover = { history -> onClickCover(history.mangaId) },
onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) },
onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) },
onClickDelete = { item -> onDialogChange(HistoryViewModel.Dialog.Delete(item)) },
onClickFavorite = { history -> onClickFavorite(history.mangaId) },
)
}
@@ -116,14 +115,14 @@ private fun HistoryScreenContent(
when (item) {
is HistoryUiModel.Header -> {
ListGroupHeader(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
text = relativeDateText(item.date),
)
}
is HistoryUiModel.Item -> {
val value = item.item
HistoryItem(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
history = value,
onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) },
@@ -144,8 +143,8 @@ sealed interface HistoryUiModel {
@PreviewLightDark
@Composable
internal fun HistoryScreenPreviews(
@PreviewParameter(HistoryScreenModelStateProvider::class)
historyState: HistoryScreenModel.State,
@PreviewParameter(HistoryviewModelStateProvider::class)
historyState: HistoryViewModel.State,
) {
TachiyomiPreviewTheme {
HistoryScreen(
@@ -1,32 +1,35 @@
package eu.kanade.presentation.history
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import eu.kanade.tachiyomi.ui.history.HistoryScreenModel
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.manga.model.MangaCover
import java.time.Instant
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import java.util.Date
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenModel.State> {
class HistoryviewModelStateProvider : PreviewParameterProvider<HistoryViewModel.State> {
private val multiPage = HistoryScreenModel.State(
private val multiPage = HistoryViewModel.State(
searchQuery = null,
list =
listOf(HistoryUiModelExamples.headerToday)
.asSequence()
.plus(HistoryUiModelExamples.items().take(3))
.plus(HistoryUiModelExamples.header { it.minus(1, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.header { it.minus(1.days) })
.plus(HistoryUiModelExamples.items().take(1))
.plus(HistoryUiModelExamples.header { it.minus(2, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.header { it.minus(2.days) })
.plus(HistoryUiModelExamples.items().take(7))
.toList(),
dialog = null,
)
private val shortRecent = HistoryScreenModel.State(
private val shortRecent = HistoryViewModel.State(
searchQuery = null,
list = listOf(
HistoryUiModelExamples.headerToday,
@@ -35,7 +38,7 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
dialog = null,
)
private val shortFuture = HistoryScreenModel.State(
private val shortFuture = HistoryViewModel.State(
searchQuery = null,
list = listOf(
HistoryUiModelExamples.headerTomorrow,
@@ -44,23 +47,23 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
dialog = null,
)
private val empty = HistoryScreenModel.State(
private val empty = HistoryViewModel.State(
searchQuery = null,
list = listOf(),
dialog = null,
)
private val loadingWithSearchQuery = HistoryScreenModel.State(
private val loadingWithSearchQuery = HistoryViewModel.State(
searchQuery = "Example Search Query",
)
private val loading = HistoryScreenModel.State(
private val loading = HistoryViewModel.State(
searchQuery = null,
list = null,
dialog = null,
)
override val values: Sequence<HistoryScreenModel.State> = sequenceOf(
override val values: Sequence<HistoryViewModel.State> = sequenceOf(
multiPage,
shortRecent,
shortFuture,
@@ -72,10 +75,12 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private object HistoryUiModelExamples {
val headerToday = header()
val headerTomorrow =
HistoryUiModel.Header(LocalDate.now().plusDays(1))
HistoryUiModel.Header(Clock.System.now().plus(1.days).toLocalDateTime(TimeZone.currentSystemDefault()).date)
fun header(instantBuilder: (Instant) -> Instant = { it }) =
HistoryUiModel.Header(LocalDate.from(instantBuilder(Instant.now())))
HistoryUiModel.Header(
instantBuilder(Clock.System.now()).toLocalDateTime(TimeZone.currentSystemDefault()).date,
)
fun items() = sequence {
var count = 1
@@ -94,7 +99,7 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
mangaId = Random.nextLong(),
title = "Test Title",
chapterNumber = Random.nextDouble(),
readAt = Date.from(Instant.now()),
readAt = Date.from(Clock.System.now().toJavaInstant()),
readDuration = Random.nextLong(),
coverData = MangaCover(
mangaId = Random.nextLong(),
@@ -19,7 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.library.LibrarySettingsScreenModel
import eu.kanade.tachiyomi.ui.library.LibrarySettingsViewModel
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.core.common.preference.TriState
import tachiyomi.domain.category.model.Category
@@ -41,7 +41,7 @@ import tachiyomi.presentation.core.util.collectAsState
@Composable
fun LibrarySettingsDialog(
onDismissRequest: () -> Unit,
screenModel: LibrarySettingsScreenModel,
viewModel: LibrarySettingsViewModel,
category: Category?,
) {
TabbedDialog(
@@ -59,14 +59,14 @@ fun LibrarySettingsDialog(
) {
when (page) {
0 -> FilterPage(
screenModel = screenModel,
viewModel = viewModel,
)
1 -> SortPage(
category = category,
screenModel = screenModel,
viewModel = viewModel,
)
2 -> DisplayPage(
screenModel = screenModel,
viewModel = viewModel,
)
}
}
@@ -75,11 +75,11 @@ fun LibrarySettingsDialog(
@Composable
private fun ColumnScope.FilterPage(
screenModel: LibrarySettingsScreenModel,
viewModel: LibrarySettingsViewModel,
) {
val filterDownloaded by screenModel.libraryPreferences.filterDownloaded.collectAsState()
val downloadedOnly by screenModel.preferences.downloadedOnly.collectAsState()
val autoUpdateMangaRestrictions by screenModel.libraryPreferences.autoUpdateMangaRestrictions.collectAsState()
val filterDownloaded by viewModel.libraryPreferences.filterDownloaded.collectAsState()
val downloadedOnly by viewModel.preferences.downloadedOnly.collectAsState()
val autoUpdateMangaRestrictions by viewModel.libraryPreferences.autoUpdateMangaRestrictions.collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_downloaded),
@@ -89,64 +89,64 @@ private fun ColumnScope.FilterPage(
filterDownloaded
},
enabled = !downloadedOnly,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterDownloaded) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterDownloaded) },
)
val filterUnread by screenModel.libraryPreferences.filterUnread.collectAsState()
val filterUnread by viewModel.libraryPreferences.filterUnread.collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_unread),
state = filterUnread,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterUnread) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterUnread) },
)
val filterStarted by screenModel.libraryPreferences.filterStarted.collectAsState()
val filterStarted by viewModel.libraryPreferences.filterStarted.collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_started),
state = filterStarted,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterStarted) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterStarted) },
)
val filterBookmarked by screenModel.libraryPreferences.filterBookmarked.collectAsState()
val filterBookmarked by viewModel.libraryPreferences.filterBookmarked.collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_bookmarked),
state = filterBookmarked,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterBookmarked) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterBookmarked) },
)
val filterCompleted by screenModel.libraryPreferences.filterCompleted.collectAsState()
val filterCompleted by viewModel.libraryPreferences.filterCompleted.collectAsState()
TriStateItem(
label = stringResource(MR.strings.completed),
state = filterCompleted,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterCompleted) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterCompleted) },
)
// TODO: re-enable when custom intervals are ready for stable
if ((!isReleaseBuildType) && LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in autoUpdateMangaRestrictions) {
val filterIntervalCustom by screenModel.libraryPreferences.filterIntervalCustom.collectAsState()
val filterIntervalCustom by viewModel.libraryPreferences.filterIntervalCustom.collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_interval_custom),
state = filterIntervalCustom,
onClick = { screenModel.toggleFilter(LibraryPreferences::filterIntervalCustom) },
onClick = { viewModel.toggleFilter(LibraryPreferences::filterIntervalCustom) },
)
}
val trackers by screenModel.trackersFlow.collectAsState()
val trackers by viewModel.trackersFlow.collectAsState()
when (trackers.size) {
0 -> {
// No trackers
}
1 -> {
val service = trackers[0]
val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
val filterTracker by viewModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_tracked),
state = filterTracker,
onClick = { screenModel.toggleTracker(service.id.toInt()) },
onClick = { viewModel.toggleTracker(service.id.toInt()) },
)
}
else -> {
HeadingItem(MR.strings.action_filter_tracked)
trackers.map { service ->
val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
val filterTracker by viewModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
TriStateItem(
label = service.name,
state = filterTracker,
onClick = { screenModel.toggleTracker(service.id.toInt()) },
onClick = { viewModel.toggleTracker(service.id.toInt()) },
)
}
}
@@ -156,9 +156,9 @@ private fun ColumnScope.FilterPage(
@Composable
private fun ColumnScope.SortPage(
category: Category?,
screenModel: LibrarySettingsScreenModel,
viewModel: LibrarySettingsViewModel,
) {
val trackers by screenModel.trackersFlow.collectAsState()
val trackers by viewModel.trackersFlow.collectAsState()
val sortingMode = category.sort.type
val sortDescending = !category.sort.isAscending
@@ -189,7 +189,7 @@ private fun ColumnScope.SortPage(
icon = Icons.Default.Refresh
.takeIf { sortingMode == LibrarySort.Type.Random },
onClick = {
screenModel.setSort(category, mode, LibrarySort.Direction.Ascending)
viewModel.setSort(category, mode, LibrarySort.Direction.Ascending)
},
)
return@map
@@ -211,7 +211,7 @@ private fun ColumnScope.SortPage(
LibrarySort.Direction.Ascending
}
}
screenModel.setSort(category, mode, direction)
viewModel.setSort(category, mode, direction)
},
)
}
@@ -226,14 +226,14 @@ private val displayModes = listOf(
@Composable
private fun ColumnScope.DisplayPage(
screenModel: LibrarySettingsScreenModel,
viewModel: LibrarySettingsViewModel,
) {
val displayMode by screenModel.libraryPreferences.displayMode.collectAsState()
val displayMode by viewModel.libraryPreferences.displayMode.collectAsState()
SettingsChipRow(MR.strings.action_display_mode) {
displayModes.map { (titleRes, mode) ->
FilterChip(
selected = displayMode == mode,
onClick = { screenModel.setDisplayMode(mode) },
onClick = { viewModel.setDisplayMode(mode) },
label = { Text(stringResource(titleRes)) },
)
}
@@ -243,9 +243,9 @@ private fun ColumnScope.DisplayPage(
val configuration = LocalConfiguration.current
val columnPreference = remember {
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
screenModel.libraryPreferences.landscapeColumns
viewModel.libraryPreferences.landscapeColumns
} else {
screenModel.libraryPreferences.portraitColumns
viewModel.libraryPreferences.portraitColumns
}
}
@@ -267,32 +267,32 @@ private fun ColumnScope.DisplayPage(
HeadingItem(MR.strings.overlay_header)
CheckboxItem(
label = stringResource(MR.strings.action_display_download_badge),
pref = screenModel.libraryPreferences.downloadBadge,
pref = viewModel.libraryPreferences.downloadBadge,
)
CheckboxItem(
label = stringResource(MR.strings.action_display_unread_badge),
pref = screenModel.libraryPreferences.unreadBadge,
pref = viewModel.libraryPreferences.unreadBadge,
)
CheckboxItem(
label = stringResource(MR.strings.action_display_local_badge),
pref = screenModel.libraryPreferences.localBadge,
pref = viewModel.libraryPreferences.localBadge,
)
CheckboxItem(
label = stringResource(MR.strings.action_display_language_badge),
pref = screenModel.libraryPreferences.languageBadge,
pref = viewModel.libraryPreferences.languageBadge,
)
CheckboxItem(
label = stringResource(MR.strings.action_display_show_continue_reading_button),
pref = screenModel.libraryPreferences.showContinueReadingButton,
pref = viewModel.libraryPreferences.showContinueReadingButton,
)
HeadingItem(MR.strings.tabs_header)
CheckboxItem(
label = stringResource(MR.strings.action_display_show_tabs),
pref = screenModel.libraryPreferences.categoryTabs,
pref = viewModel.libraryPreferences.categoryTabs,
)
CheckboxItem(
label = stringResource(MR.strings.action_display_show_number_of_items),
pref = screenModel.libraryPreferences.categoryNumberOfItems,
pref = viewModel.libraryPreferences.categoryNumberOfItems,
)
}
@@ -1,15 +1,11 @@
package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import eu.kanade.presentation.category.visualName
import tachiyomi.domain.category.model.Category
import tachiyomi.presentation.core.components.material.TabText
@@ -22,29 +18,22 @@ internal fun LibraryTabs(
onTabItemClick: (Int) -> Unit,
) {
val currentPageIndex = pagerState.currentPage.coerceAtMost(categories.lastIndex)
Column(modifier = Modifier.zIndex(2f)) {
PrimaryScrollableTabRow(
selectedTabIndex = currentPageIndex,
edgePadding = 0.dp,
// TODO: use default when width is fixed upstream
// https://issuetracker.google.com/issues/242879624
divider = {},
) {
categories.forEachIndexed { index, category ->
Tab(
selected = currentPageIndex == index,
onClick = { onTabItemClick(index) },
text = {
TabText(
text = category.visualName,
badgeCount = getItemCountForCategory(category),
)
},
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
PrimaryScrollableTabRow(
selectedTabIndex = currentPageIndex,
edgePadding = 0.dp,
) {
categories.forEachIndexed { index, category ->
Tab(
selected = currentPageIndex == index,
onClick = { onTabItemClick(index) },
text = {
TabText(
text = category.visualName,
badgeCount = getItemCountForCategory(category),
)
},
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
HorizontalDivider()
}
}
@@ -60,7 +60,7 @@ import eu.kanade.presentation.util.formatChapterNumber
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.ui.manga.ChapterList
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaViewModel
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.service.missingChaptersCount
@@ -75,11 +75,11 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.shouldExpandFAB
import tachiyomi.source.local.isLocal
import java.time.Instant
import kotlin.time.Instant
@Composable
fun MangaScreen(
state: MangaScreenModel.State.Success,
state: MangaViewModel.State.Success,
snackbarHostState: SnackbarHostState,
nextUpdate: Instant?,
isTabletUi: Boolean,
@@ -210,7 +210,7 @@ fun MangaScreen(
@Composable
private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success,
state: MangaViewModel.State.Success,
snackbarHostState: SnackbarHostState,
nextUpdate: Instant?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@@ -452,7 +452,7 @@ private fun MangaScreenSmallImpl(
@Composable
fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success,
state: MangaViewModel.State.Success,
snackbarHostState: SnackbarHostState,
nextUpdate: Instant?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@@ -68,7 +68,7 @@ fun MangaCoverDialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, // Doesn't work https://issuetracker.google.com/issues/246909281
decorFitsSystemWindows = false,
),
) {
Scaffold(
@@ -20,15 +20,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Instant
@Composable
fun DeleteChaptersDialog(
@@ -72,8 +74,8 @@ fun SetIntervalDialog(
val nextUpdateDays = remember(nextUpdate) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
val now = Clock.System.now()
now.daysUntil(nextUpdate, TimeZone.currentSystemDefault()).coerceAtLeast(0)
} else {
null
}
@@ -91,6 +91,8 @@ import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.findChildOfType
@@ -105,9 +107,9 @@ import tachiyomi.presentation.core.util.clickableNoIndication
import tachiyomi.presentation.core.util.secondaryItemAlpha
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
import kotlin.time.Clock
import kotlin.time.Instant
@Composable
fun MangaInfoBox(
@@ -189,8 +191,8 @@ fun MangaActionRow(
// TODO: show something better when using custom interval
val nextUpdateDays = remember(nextUpdate) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
val now = Clock.System.now()
now.daysUntil(nextUpdate, TimeZone.currentSystemDefault()).coerceAtLeast(0)
} else {
null
}
@@ -13,10 +13,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import eu.kanade.presentation.manga.components.MarkdownRender
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.ui.more.NewUpdateScreenModel
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
@@ -27,16 +29,27 @@ import tachiyomi.presentation.core.screens.InfoScreen
fun NewUpdateScreen(
versionName: String,
changelogInfo: String,
stage: NewUpdateScreenModel.Stage,
downloadProgress: () -> Int,
onOpenInBrowser: () -> Unit,
onRejectUpdate: () -> Unit,
onAcceptUpdate: () -> Unit,
onRejectUpdate: () -> Unit,
) {
InfoScreen(
icon = Icons.Outlined.NewReleases,
headingText = stringResource(MR.strings.update_check_notification_update_available),
subtitleText = versionName,
acceptText = stringResource(MR.strings.update_check_confirm),
acceptText = when (stage) {
NewUpdateScreenModel.Stage.Available -> stringResource(MR.strings.update_check_confirm)
NewUpdateScreenModel.Stage.Downloading -> stringResource(
MR.strings.downloading_with_progress,
downloadProgress(),
)
NewUpdateScreenModel.Stage.Downloaded -> stringResource(MR.strings.action_install)
NewUpdateScreenModel.Stage.Failed -> stringResource(MR.strings.action_retry)
},
onAcceptClick = onAcceptUpdate,
canAccept = stage != NewUpdateScreenModel.Stage.Downloading,
rejectText = stringResource(MR.strings.action_not_now),
onRejectClick = onRejectUpdate,
) {
@@ -47,7 +60,7 @@ fun NewUpdateScreen(
) {
MarkdownRender(
content = changelogInfo,
flavour = GFMFlavourDescriptor(),
flavour = remember { GFMFlavourDescriptor() },
)
TextButton(
@@ -76,9 +89,11 @@ private fun NewUpdateScreenPreview() {
- Hello
- World
""".trimIndent(),
stage = NewUpdateScreenModel.Stage.Available,
downloadProgress = { 0 },
onOpenInBrowser = {},
onRejectUpdate = {},
onAcceptUpdate = {},
onRejectUpdate = {},
)
}
}
@@ -169,8 +169,6 @@ internal class PermissionStep : OnboardingStep {
) {
ListItem(
modifier = modifier,
headlineContent = { Text(text = title) },
supportingContent = { Text(text = subtitle) },
trailingContent = {
OutlinedButton(
enabled = !granted,
@@ -187,7 +185,9 @@ internal class PermissionStep : OnboardingStep {
}
}
},
supportingContent = { Text(text = subtitle) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
content = { Text(text = title) },
)
}
@@ -201,15 +201,15 @@ internal class PermissionStep : OnboardingStep {
) {
ListItem(
modifier = modifier,
headlineContent = { Text(text = title) },
supportingContent = { Text(text = subtitle) },
trailingContent = {
Switch(
checked = granted,
onCheckedChange = onToggleChange,
)
},
supportingContent = { Text(text = subtitle) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
content = { Text(text = title) },
)
}
}
@@ -103,12 +103,13 @@ sealed class Preference {
* A [PreferenceItem] that displays a list of entries as a dialog.
* Multiple entries can be selected at the same time.
*/
data class MultiSelectListPreference(
val preference: PreferenceData<Set<String>>,
val entries: Map<String, String>,
@Suppress("UNCHECKED_CAST")
data class MultiSelectListPreference<T>(
val preference: PreferenceData<Set<T>>,
val entries: Map<T, String>,
override val title: String,
override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: Set<String>, entries: Map<String, String>) -> String? =
val subtitleProvider: @Composable (value: Set<T>, entries: Map<T, String>) -> String? =
{ v, e ->
val combined = remember(v, e) {
v.mapNotNull { e[it] }
@@ -120,8 +121,15 @@ sealed class Preference {
},
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: Set<String>) -> Boolean = { true },
) : PreferenceItem<Set<String>, Boolean>()
override val onValueChanged: suspend (value: Set<T>) -> Boolean = { true },
) : PreferenceItem<Set<T>, Boolean>() {
internal fun internalSet(value: Set<Any?>) = preference.set(value as Set<T>)
internal suspend fun internalOnValueChanged(value: Set<Any?>) = onValueChanged(value as Set<T>)
@Composable
internal fun internalSubtitleProvider(value: Set<Any?>, entries: Map<out Any?, String>) =
subtitleProvider(value as Set<T>, entries as Map<T, String>)
}
/**
* A [PreferenceItem] that shows a EditText in the dialog.
@@ -128,15 +128,18 @@ internal fun PreferenceItem(
onValueChange = { scope.launch { item.onValueChanged(it) } },
)
}
is Preference.PreferenceItem.MultiSelectListPreference -> {
is Preference.PreferenceItem.MultiSelectListPreference<*> -> {
val values by item.preference.collectAsState()
MultiSelectListPreferenceWidget(
preference = item,
values = values,
title = item.title,
subtitle = item.internalSubtitleProvider(values, item.entries),
icon = item.icon,
entries = item.entries,
onValuesChange = { newValues ->
scope.launch {
if (item.onValueChanged(newValues)) {
item.preference.set(newValues.toMutableSet())
if (item.internalOnValueChanged(newValues)) {
item.internalSet(newValues)
}
}
},
@@ -171,7 +174,7 @@ internal fun PreferenceItem(
}
TrackingPreferenceWidget(
tracker = item.tracker,
checked = isLoggedIn,
isLoggedIn = isLoggedIn,
onClick = { if (isLoggedIn) item.logout() else item.login() },
)
}
@@ -19,12 +19,15 @@ import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.LocalDate
import kotlin.time.Clock
object SettingsAppearanceScreen : SearchableSettings {
@@ -99,7 +102,7 @@ object SettingsAppearanceScreen : SearchableSettings {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val now = remember { LocalDate.now() }
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
val dateFormat by uiPreferences.dateFormat.collectAsState()
val formattedNow = remember(dateFormat) {
@@ -115,7 +115,7 @@ object SettingsDownloadScreen : SearchableSettings {
private fun getExcludedCategoriesPreference(
downloadPreferences: DownloadPreferences,
categories: () -> List<Category>,
): Preference.PreferenceItem.MultiSelectListPreference {
): Preference.PreferenceItem.MultiSelectListPreference<String> {
return Preference.PreferenceItem.MultiSelectListPreference(
preference = downloadPreferences.removeExcludeCategories,
entries = categories()
@@ -71,7 +71,6 @@ object SettingsReaderScreen : SearchableSettings {
@Composable
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val fullscreen by readerPreferences.fullscreen.collectAsState()
val verticalNavigatorForLongStrip by readerPreferences.verticalNavigatorForLongStrip.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = listOf(
@@ -108,15 +107,6 @@ object SettingsReaderScreen : SearchableSettings {
preference = readerPreferences.showPageNumber,
title = stringResource(MR.strings.pref_show_page_number),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.verticalNavigatorForLongStrip,
title = stringResource(MR.strings.pref_webtoon_vertical_navigator),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.verticalNavigatorOnLeft,
title = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
enabled = verticalNavigatorForLongStrip,
),
),
)
}
@@ -392,6 +382,11 @@ object SettingsReaderScreen : SearchableSettings {
private fun getNavigationGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val readWithVolumeKeysPref = readerPreferences.readWithVolumeKeys
val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState()
val verticalNavigator by readerPreferences.verticalNavigator.collectAsState()
val verticalNavigatorHeightPref = readerPreferences.verticalNavigatorHeight
val verticalNavigatorHeight by verticalNavigatorHeightPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_reader_navigation),
preferenceItems = listOf(
@@ -404,6 +399,25 @@ object SettingsReaderScreen : SearchableSettings {
title = stringResource(MR.strings.pref_read_with_volume_keys_inverted),
enabled = readWithVolumeKeys,
),
Preference.PreferenceItem.MultiSelectListPreference(
preference = readerPreferences.verticalNavigator,
entries = ReadingMode.entries.filter { it != ReadingMode.DEFAULT }
.associate { it to stringResource(it.stringRes) },
title = stringResource(MR.strings.pref_vertical_navigator),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.verticalNavigatorOnLeft,
title = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
enabled = verticalNavigator.isNotEmpty(),
),
Preference.PreferenceItem.SliderPreference(
value = verticalNavigatorHeight,
valueRange = 65..100,
steps = 6,
title = stringResource(MR.strings.pref_vertical_navigator_height),
onValueChanged = { verticalNavigatorHeightPref.set(it) },
enabled = verticalNavigator.isNotEmpty(),
),
),
)
}
@@ -51,6 +51,8 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBakaApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser
@@ -136,6 +138,11 @@ object SettingsTrackingScreen : SearchableSettings {
Preference.PreferenceGroup(
title = stringResource(MR.strings.services),
preferenceItems = listOf(
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.mangaBaka,
login = { context.openInBrowser(MangaBakaApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.mangaBaka) },
),
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.myAnimeList,
login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) },
@@ -166,6 +173,11 @@ object SettingsTrackingScreen : SearchableSettings {
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.bangumi) },
),
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.hikka,
login = { context.openInBrowser(HikkaApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.hikka) },
),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.tracking_info)),
),
),
@@ -40,6 +40,8 @@ import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.updaterEnabled
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.withIOContext
@@ -59,9 +61,7 @@ import tachiyomi.presentation.core.icons.Reddit
import tachiyomi.presentation.core.icons.X
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
object AboutScreen : Screen() {
@@ -219,7 +219,7 @@ object AboutScreen : Screen() {
val updateChecker = AppUpdateChecker()
withUIContext {
try {
when (val result = withIOContext { updateChecker.checkForUpdate(context, forceCheck = true) }) {
when (val result = withIOContext { updateChecker.checkForUpdate(forceCheck = true) }) {
is GetApplicationRelease.Result.NewUpdate -> {
onAvailableUpdate(result)
}
@@ -273,16 +273,14 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
LocalDateTime.ofInstant(
Instant.parse(BuildConfig.BUILD_TIME),
ZoneId.systemDefault(),
)
Instant.parse(BuildConfig.BUILD_TIME)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat.get(),
),
)
} catch (e: Exception) {
} catch (_: Exception) {
BuildConfig.BUILD_TIME
}
}
@@ -1,80 +0,0 @@
package eu.kanade.presentation.more.settings.screen.about
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.google.android.material.textview.MaterialTextView
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
class OpenSourceLibraryLicenseScreen(
private val name: String,
private val website: String?,
private val license: String,
) : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
AppBar(
title = name,
navigateUp = navigator::pop,
actions = {
if (!website.isNullOrEmpty()) {
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(MR.strings.website),
icon = Icons.Default.Public,
onClick = { uriHandler.openUri(website) },
),
),
)
}
},
scrollBehavior = it,
)
},
) { contentPadding ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(contentPadding)
.padding(16.dp),
) {
HtmlLicenseText(html = license)
}
}
}
@Composable
private fun HtmlLicenseText(html: String) {
AndroidView(
factory = {
MaterialTextView(it)
},
update = {
it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}
}
@@ -8,7 +8,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryDetailMode
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
@@ -36,15 +36,7 @@ class OpenSourceLicensesScreen : Screen() {
modifier = Modifier
.fillMaxSize(),
contentPadding = contentPadding,
onLibraryClick = {
navigator.push(
OpenSourceLibraryLicenseScreen(
name = it.name,
website = it.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
),
)
},
detailMode = LibraryDetailMode.Sheet,
)
}
}
@@ -30,9 +30,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastMap
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.components.SourceIcon
@@ -42,6 +41,7 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import mihon.core.viewmodel.StateViewModel
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.core.common.util.lang.toLong
@@ -67,13 +67,13 @@ class ClearDatabaseScreen : Screen() {
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { ClearDatabaseScreenModel() }
val state by model.state.collectAsState()
val viewModel = viewModel<ClearDatabaseViewModel>()
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
when (val s = state) {
is ClearDatabaseScreenModel.State.Loading -> LoadingScreen()
is ClearDatabaseScreenModel.State.Ready -> {
is ClearDatabaseViewModel.State.Loading -> LoadingScreen()
is ClearDatabaseViewModel.State.Ready -> {
if (s.showConfirmation) {
var keepReadManga by remember { mutableStateOf(true) }
AlertDialog(
@@ -108,14 +108,14 @@ class ClearDatabaseScreen : Screen() {
}
}
},
onDismissRequest = model::hideConfirmation,
onDismissRequest = viewModel::hideConfirmation,
confirmButton = {
TextButton(
onClick = {
scope.launchUI {
model.removeMangaBySourceId(keepReadManga)
model.clearSelection()
model.hideConfirmation()
viewModel.removeMangaBySourceId(keepReadManga)
viewModel.clearSelection()
viewModel.hideConfirmation()
context.toast(MR.strings.clear_database_completed)
}
},
@@ -124,7 +124,7 @@ class ClearDatabaseScreen : Screen() {
}
},
dismissButton = {
TextButton(onClick = model::hideConfirmation) {
TextButton(onClick = viewModel::hideConfirmation) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
@@ -143,12 +143,12 @@ class ClearDatabaseScreen : Screen() {
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = model::selectAll,
onClick = viewModel::selectAll,
),
AppBar.Action(
title = stringResource(MR.strings.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = model::invertSelection,
onClick = viewModel::invertSelection,
),
),
)
@@ -168,14 +168,14 @@ class ClearDatabaseScreen : Screen() {
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.action_delete),
actionEnabled = s.selection.isNotEmpty(),
onClickAction = model::showConfirmation,
onClickAction = viewModel::showConfirmation,
) {
items(s.items) { sourceWithCount ->
ClearDatabaseItem(
source = sourceWithCount.source,
count = sourceWithCount.count,
isSelected = s.selection.contains(sourceWithCount.id),
onClickSelect = { model.toggleSelection(sourceWithCount.source) },
onClickSelect = { viewModel.toggleSelection(sourceWithCount.source) },
)
}
}
@@ -220,12 +220,12 @@ class ClearDatabaseScreen : Screen() {
}
}
private class ClearDatabaseScreenModel : StateScreenModel<ClearDatabaseScreenModel.State>(State.Loading) {
class ClearDatabaseViewModel : StateViewModel<ClearDatabaseViewModel.State>(State.Loading) {
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga = Injekt.get()
private val database: Database = Injekt.get()
init {
screenModelScope.launchIO {
viewModelScope.launchIO {
getSourcesWithNonLibraryManga.subscribe()
.collectLatest { list ->
mutableState.update { old ->
@@ -71,12 +71,6 @@ class AppLanguageScreen : Screen() {
modifier = Modifier.clickable {
currentLanguage = it.langTag
},
headlineContent = { Text(it.displayName) },
supportingContent = {
it.localizedDisplayName?.let {
Text(it)
}
},
trailingContent = {
if (currentLanguage == it.langTag) {
Icon(
@@ -86,6 +80,12 @@ class AppLanguageScreen : Screen() {
)
}
},
supportingContent = {
it.localizedDisplayName?.let {
Text(it)
}
},
content = { Text(it.displayName) },
)
}
}
@@ -5,7 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreConfirmDialog
@@ -26,11 +26,11 @@ class ExtensionStoresScreen(
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val state by screenModel.state.collectAsState()
val viewModel = viewModel<ExtensionStoresViewModel>()
val state by viewModel.state.collectAsState()
LaunchedEffect(url) {
url?.let { screenModel.addFromDeeplink(url) }
url?.let { viewModel.addFromDeeplink(url) }
}
if (state is ExtensionStoreScreenState.Loading) {
@@ -42,12 +42,12 @@ class ExtensionStoresScreen(
ExtensionStoresScreen(
state = successState,
onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
onClickCreate = { viewModel.showDialog(ExtensionStoreDialog.Create()) },
onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
onClickDelete = { viewModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { viewModel.refreshRepos() },
navigateUp = navigator::pop,
)
@@ -55,8 +55,8 @@ class ExtensionStoresScreen(
null -> {}
is ExtensionStoreDialog.Create -> {
ExtensionStoreCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
onDismissRequest = viewModel::dismissDialog,
onCreate = { viewModel.createRepo(it) },
storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
processing = dialog.processing,
errorMessage = dialog.errorMessage,
@@ -64,16 +64,16 @@ class ExtensionStoresScreen(
}
is ExtensionStoreDialog.Delete -> {
ExtensionStoreDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
onDismissRequest = viewModel::dismissDialog,
onDelete = { viewModel.deleteRepo(dialog.store.indexUrl) },
storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
)
}
is ExtensionStoreDialog.Confirm -> {
ExtensionStoreConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) },
onDismissRequest = viewModel::dismissDialog,
onCreate = { viewModel.createRepo(dialog.url) },
storeIndexUrl = dialog.url,
storeAlreadyExists = dialog.alreadyExists,
processing = dialog.processing,
@@ -1,12 +1,12 @@
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 androidx.lifecycle.viewModelScope
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mihon.core.viewmodel.StateViewModel
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
@@ -16,13 +16,13 @@ import tachiyomi.core.common.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresScreenModel(
class ExtensionStoresViewModel(
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(),
) : StateScreenModel<ExtensionStoreScreenState>(ExtensionStoreScreenState.Loading) {
) : StateViewModel<ExtensionStoreScreenState>(ExtensionStoreScreenState.Loading) {
private inline fun updateSuccessState(
func: (ExtensionStoreScreenState.Success) -> ExtensionStoreScreenState.Success,
@@ -36,7 +36,7 @@ class ExtensionStoresScreenModel(
}
init {
screenModelScope.launchIO {
viewModelScope.launchIO {
getExtensionStores.subscribe()
.collectLatest { stores ->
mutableState.update {
@@ -55,7 +55,7 @@ class ExtensionStoresScreenModel(
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(baseUrl: String) {
screenModelScope.launch {
viewModelScope.launch {
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
@@ -97,7 +97,7 @@ class ExtensionStoresScreenModel(
val status = state.value
if (status is ExtensionStoreScreenState.Success) {
screenModelScope.launchIO {
viewModelScope.launchIO {
updateExtensionStores()
}
}
@@ -107,7 +107,7 @@ class ExtensionStoresScreenModel(
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
screenModelScope.launchIO {
viewModelScope.launchIO {
removeExtensionStore(baseUrl)
extensionManager.findAvailableExtensions()
}
@@ -20,10 +20,8 @@ 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(
@@ -94,8 +92,6 @@ fun ExtensionStoreCreateDialog(
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@@ -11,8 +11,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
@@ -24,6 +23,7 @@ import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.update
import mihon.core.viewmodel.StateViewModel
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
@@ -37,8 +37,8 @@ class CreateBackupScreen : Screen() {
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { CreateBackupScreenModel() }
val state by model.state.collectAsState()
val viewModel = viewModel<CreateBackupViewModel>()
val state by viewModel.state.collectAsState()
val chooseBackupDir = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/*"),
@@ -49,7 +49,7 @@ class CreateBackupScreen : Screen() {
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
model.createBackup(context, it)
viewModel.createBackup(context, it)
navigator.pop()
}
}
@@ -87,13 +87,13 @@ class CreateBackupScreen : Screen() {
item {
SectionCard(MR.strings.label_library) {
Options(BackupOptions.libraryOptions, state, model)
Options(BackupOptions.libraryOptions, state, viewModel)
}
}
item {
SectionCard(MR.strings.label_settings) {
Options(BackupOptions.settingsOptions, state, model)
Options(BackupOptions.settingsOptions, state, viewModel)
}
}
}
@@ -103,8 +103,8 @@ class CreateBackupScreen : Screen() {
@Composable
private fun Options(
options: List<BackupOptions.Entry>,
state: CreateBackupScreenModel.State,
model: CreateBackupScreenModel,
state: CreateBackupViewModel.State,
model: CreateBackupViewModel,
) {
options.forEach { option ->
LabeledCheckbox(
@@ -119,7 +119,7 @@ class CreateBackupScreen : Screen() {
}
}
private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel.State>(State()) {
class CreateBackupViewModel : StateViewModel<CreateBackupViewModel.State>(State()) {
fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
mutableState.update {
@@ -1,5 +1,6 @@
package eu.kanade.presentation.more.settings.screen.data
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
@@ -14,14 +15,15 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.core.net.toUri
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
@@ -32,6 +34,7 @@ import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil
import kotlinx.coroutines.flow.update
import mihon.core.viewmodel.StateViewModel
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
@@ -39,6 +42,8 @@ import tachiyomi.presentation.core.components.SectionCard
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class RestoreBackupScreen(
private val uri: String,
@@ -46,10 +51,14 @@ class RestoreBackupScreen(
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { RestoreBackupScreenModel(context, uri) }
val state by model.state.collectAsState()
val viewModel = viewModel<RestoreBackupViewModel>(
factory = RestoreBackupViewModel.Factory,
extras = CreationExtras {
set(RestoreBackupViewModel.URI_KEY, uri)
},
)
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
@@ -65,7 +74,7 @@ class RestoreBackupScreen(
actionLabel = stringResource(MR.strings.action_restore),
actionEnabled = state.canRestore && state.options.canRestore(),
onClickAction = {
model.startRestore()
viewModel.startRestore()
navigator.pop()
},
) {
@@ -83,7 +92,7 @@ class RestoreBackupScreen(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
viewModel.toggle(option.setter, it)
},
)
}
@@ -164,10 +173,23 @@ class RestoreBackupScreen(
}
}
private class RestoreBackupScreenModel(
class RestoreBackupViewModel(
private val context: Context,
private val uri: String,
) : StateScreenModel<RestoreBackupScreenModel.State>(State()) {
) : StateViewModel<RestoreBackupViewModel.State>(State()) {
companion object {
val URI_KEY = CreationExtras.Key<String>()
val Factory = viewModelFactory {
initializer {
RestoreBackupViewModel(
context = Injekt.get<Application>(),
uri = get(URI_KEY)!!,
)
}
}
}
init {
validate(uri.toUri())
@@ -1,5 +1,6 @@
package eu.kanade.presentation.more.settings.screen.debug
import android.app.Application
import android.content.Context
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.PaddingValues
@@ -18,32 +19,34 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.ioCoroutineScope
import eu.kanade.tachiyomi.util.lang.toDateTimestampString
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
class WorkerInfoScreen : Screen() {
@@ -56,10 +59,10 @@ class WorkerInfoScreen : Screen() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { Model(context) }
val enqueued by screenModel.enqueued.collectAsState()
val finished by screenModel.finished.collectAsState()
val running by screenModel.running.collectAsState()
val viewModel = viewModel<WorkerInfoViewModel>(factory = WorkerInfoViewModel.Factory)
val enqueued by viewModel.enqueued.collectAsState()
val finished by viewModel.finished.collectAsState()
val running by viewModel.running.collectAsState()
Scaffold(
topBar = {
@@ -117,7 +120,20 @@ class WorkerInfoScreen : Screen() {
)
}
private class Model(context: Context) : ScreenModel {
class WorkerInfoViewModel(
context: Context,
) : ViewModel() {
companion object {
val Factory = viewModelFactory {
initializer {
WorkerInfoViewModel(
context = Injekt.get<Application>(),
)
}
}
}
private val workManager = context.workManager
val finished = workManager
@@ -125,17 +141,17 @@ class WorkerInfoScreen : Screen() {
WorkQuery.fromStates(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED),
)
.map(::constructString)
.stateIn(ioCoroutineScope, SharingStarted.WhileSubscribed(), "")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val running = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map(::constructString)
.stateIn(ioCoroutineScope, SharingStarted.WhileSubscribed(), "")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val enqueued = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
.map(::constructString)
.stateIn(ioCoroutineScope, SharingStarted.WhileSubscribed(), "")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
private fun constructString(list: List<WorkInfo>) = buildString {
if (list.isEmpty()) {
@@ -149,10 +165,8 @@ class WorkerInfoScreen : Screen() {
}
appendLine("State: ${workInfo.state}")
if (workInfo.state == WorkInfo.State.ENQUEUED) {
val timestamp = LocalDateTime.ofInstant(
Instant.ofEpochMilli(workInfo.nextScheduleTimeMillis),
ZoneId.systemDefault(),
)
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat.get(),
@@ -10,39 +10,42 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.window.DialogProperties
import eu.kanade.presentation.more.settings.Preference
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MultiSelectListPreferenceWidget(
preference: Preference.PreferenceItem.MultiSelectListPreference,
values: Set<String>,
onValuesChange: (Set<String>) -> Unit,
fun <T> MultiSelectListPreferenceWidget(
values: Set<T>,
title: String,
subtitle: String?,
icon: ImageVector?,
entries: Map<out T, String>,
onValuesChange: (Set<T>) -> Unit,
) {
var isDialogShown by remember { mutableStateOf(false) }
TextPreferenceWidget(
title = preference.title,
subtitle = preference.subtitleProvider(values, preference.entries),
icon = preference.icon,
title = title,
subtitle = subtitle,
icon = icon,
onPreferenceClick = { isDialogShown = true },
)
if (isDialogShown) {
val selected = remember {
preference.entries.keys
entries.keys
.filter { values.contains(it) }
.toMutableStateList()
}
AlertDialog(
onDismissRequest = { isDialogShown = false },
title = { Text(text = preference.title) },
title = { Text(text = title) },
text = {
LazyColumn {
preference.entries.forEach { current ->
entries.forEach { current ->
item {
val isSelected = selected.contains(current.key)
LabeledCheckbox(
@@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -15,6 +16,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted
import eu.kanade.presentation.track.components.TrackLogoIcon
@@ -26,7 +29,7 @@ import tachiyomi.presentation.core.i18n.stringResource
fun TrackingPreferenceWidget(
modifier: Modifier = Modifier,
tracker: Tracker,
checked: Boolean,
isLoggedIn: Boolean,
onClick: (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
@@ -39,16 +42,29 @@ fun TrackingPreferenceWidget(
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(tracker)
Text(
text = tracker.name,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
maxLines = 1,
style = MaterialTheme.typography.titleLarge,
fontSize = TitleFontSize,
)
if (checked) {
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = tracker.name,
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1,
style = MaterialTheme.typography.titleLarge,
fontSize = TitleFontSize,
fontWeight = FontWeight.Medium,
)
val displayName = tracker.getDisplayUsername()
if (isLoggedIn && displayName.isNotBlank()) {
Text(
text = displayName,
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
)
}
}
if (isLoggedIn) {
Icon(
imageVector = Icons.Outlined.Done,
modifier = Modifier
@@ -18,7 +18,7 @@ import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.presentation.reader.components.ModeSelectionDialog
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.IconToggleButton
@@ -29,17 +29,17 @@ private val ReaderOrientationsWithoutDefault = ReaderOrientation.entries - Reade
@Composable
fun OrientationSelectDialog(
onDismissRequest: () -> Unit,
screenModel: ReaderSettingsScreenModel,
viewModel: ReaderSettingsViewModel,
onChange: (StringResource) -> Unit,
) {
val manga by screenModel.mangaFlow.collectAsState()
val manga by viewModel.mangaFlow.collectAsState()
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
AdaptiveSheet(onDismissRequest = onDismissRequest) {
DialogContent(
orientation = orientation,
onChangeOrientation = {
screenModel.onChangeOrientation(it)
viewModel.onChangeOrientation(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -19,7 +19,7 @@ import eu.kanade.domain.manga.model.readingMode
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.presentation.reader.components.ModeSelectionDialog
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.SettingsIconGrid
@@ -31,17 +31,17 @@ private val ReadingModesWithoutDefault = ReadingMode.entries - ReadingMode.DEFAU
@Composable
fun ReadingModeSelectDialog(
onDismissRequest: () -> Unit,
screenModel: ReaderSettingsScreenModel,
viewModel: ReaderSettingsViewModel,
onChange: (StringResource) -> Unit,
) {
val manga by screenModel.mangaFlow.collectAsState()
val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
AdaptiveSheet(onDismissRequest = onDismissRequest) {
DialogContent(
readingMode = readingMode,
onChangeReadingMode = {
screenModel.onChangeReadingMode(it)
viewModel.onChangeReadingMode(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -26,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
@@ -55,6 +57,7 @@ fun ReaderAppBars(
onShare: (() -> Unit)?,
chapterNavigatorType: ChapterNavigatorType,
verticalNavigatorHeight: Float,
onNextChapter: () -> Unit,
enabledNext: Boolean,
onPreviousChapter: () -> Unit,
@@ -62,6 +65,7 @@ fun ReaderAppBars(
currentPage: Int,
totalPages: Int,
onPageIndexChange: (Int) -> Unit,
onPageIndexChangeFinished: () -> Unit,
readingMode: ReadingMode,
onClickReadingMode: () -> Unit,
@@ -111,16 +115,23 @@ fun ReaderAppBars(
) {
Row {
Spacer(modifier = Modifier.width(MaterialTheme.padding.small))
ChapterNavigator(
type = chapterNavigatorType,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
enabledPrevious = enabledPrevious,
currentPage = currentPage,
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
)
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.BottomCenter,
) {
ChapterNavigator(
modifier = Modifier.fillMaxHeight(verticalNavigatorHeight),
type = chapterNavigatorType,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
enabledPrevious = enabledPrevious,
currentPage = currentPage,
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
onPageIndexChangeFinished = onPageIndexChangeFinished,
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
@@ -146,6 +157,7 @@ fun ReaderAppBars(
currentPage = currentPage,
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
onPageIndexChangeFinished = onPageIndexChangeFinished,
)
}
ReaderBottomBar(
@@ -24,7 +24,6 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalSlider
import androidx.compose.material3.rememberSliderState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -71,16 +70,21 @@ fun ChapterNavigator(
currentPage: Int,
totalPages: Int,
onPageIndexChange: (Int) -> Unit,
onPageIndexChangeFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptic = LocalHapticFeedback.current
val state = rememberSliderState(
value = currentPage.toFloat(),
steps = totalPages - 2,
valueRange = 1f..totalPages.toFloat(),
)
val state = remember(totalPages) {
SliderState(
value = currentPage.toFloat(),
steps = totalPages - 2,
valueRange = 1f..totalPages.toFloat(),
)
}
state.value = currentPage.toFloat()
state.onValueChange = { onPageIndexChange(it.roundToInt() - 1) }
state.onValueChangeFinished = onPageIndexChangeFinished
val interactionSource = remember { MutableInteractionSource() }
val sliderDragged by interactionSource.collectIsDraggedAsState()
@@ -103,7 +107,7 @@ fun ChapterNavigator(
)
if (type.isHorizontal()) {
ChapterNavigator(
HorizontalChapterNavigator(
isRtl = type == ChapterNavigatorType.HORIZONTAL_RTL,
state = state,
onNextChapter = onNextChapter,
@@ -116,6 +120,7 @@ fun ChapterNavigator(
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
modifier = modifier,
)
} else {
VerticalChapterNavigator(
@@ -130,12 +135,13 @@ fun ChapterNavigator(
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
modifier = modifier,
)
}
}
@Composable
fun ChapterNavigator(
fun HorizontalChapterNavigator(
isRtl: Boolean,
state: SliderState,
onNextChapter: () -> Unit,
@@ -148,13 +154,14 @@ fun ChapterNavigator(
mainAxisPadding: Dp,
backgroundColor: Color,
buttonColor: IconButtonColors,
modifier: Modifier = Modifier,
) {
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
// We explicitly handle direction based on the reader viewer rather than the system direction
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.padding(horizontal = mainAxisPadding),
verticalAlignment = Alignment.CenterVertically,
@@ -232,9 +239,10 @@ fun VerticalChapterNavigator(
mainAxisPadding: Dp,
backgroundColor: Color,
buttonColor: IconButtonColors,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
modifier = modifier
.fillMaxHeight()
.padding(vertical = mainAxisPadding),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -304,6 +312,7 @@ private fun ChapterNavigatorPreview() {
currentPage = currentPage,
totalPages = 10,
onPageIndexChange = { currentPage = (it + 1) },
onPageIndexChangeFinished = {},
)
}
}
@@ -11,7 +11,7 @@ import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.Companion.ColorFilterMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
@@ -21,11 +21,11 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
@Composable
internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel) {
val customBrightness by screenModel.preferences.customBrightness.collectAsState()
internal fun ColumnScope.ColorFilterPage(viewModel: ReaderSettingsViewModel) {
val customBrightness by viewModel.preferences.customBrightness.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_custom_brightness),
pref = screenModel.preferences.customBrightness,
pref = viewModel.preferences.customBrightness,
)
/*
@@ -35,31 +35,31 @@ internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel)
* 0 sets system brightness and hides the overlay.
*/
if (customBrightness) {
val customBrightnessValue by screenModel.preferences.customBrightnessValue.collectAsState()
val customBrightnessValue by viewModel.preferences.customBrightnessValue.collectAsState()
SliderItem(
value = customBrightnessValue,
valueRange = -75..100,
steps = 0,
label = stringResource(MR.strings.pref_custom_brightness),
onChange = { screenModel.preferences.customBrightnessValue.set(it) },
onChange = { viewModel.preferences.customBrightnessValue.set(it) },
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
}
val colorFilter by screenModel.preferences.colorFilter.collectAsState()
val colorFilter by viewModel.preferences.colorFilter.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_custom_color_filter),
pref = screenModel.preferences.colorFilter,
pref = viewModel.preferences.colorFilter,
)
if (colorFilter) {
val colorFilterValue by screenModel.preferences.colorFilterValue.collectAsState()
val colorFilterValue by viewModel.preferences.colorFilterValue.collectAsState()
SliderItem(
value = colorFilterValue.red,
valueRange = 0..255,
steps = 0,
label = stringResource(MR.strings.color_filter_r_value),
onChange = { newRValue ->
screenModel.preferences.colorFilterValue.getAndSet {
viewModel.preferences.colorFilterValue.getAndSet {
getColorValue(it, newRValue, RED_MASK, 16)
}
},
@@ -71,7 +71,7 @@ internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel)
steps = 0,
label = stringResource(MR.strings.color_filter_g_value),
onChange = { newGValue ->
screenModel.preferences.colorFilterValue.getAndSet {
viewModel.preferences.colorFilterValue.getAndSet {
getColorValue(it, newGValue, GREEN_MASK, 8)
}
},
@@ -83,7 +83,7 @@ internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel)
steps = 0,
label = stringResource(MR.strings.color_filter_b_value),
onChange = { newBValue ->
screenModel.preferences.colorFilterValue.getAndSet {
viewModel.preferences.colorFilterValue.getAndSet {
getColorValue(it, newBValue, BLUE_MASK, 0)
}
},
@@ -95,19 +95,19 @@ internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel)
steps = 0,
label = stringResource(MR.strings.color_filter_a_value),
onChange = { newAValue ->
screenModel.preferences.colorFilterValue.getAndSet {
viewModel.preferences.colorFilterValue.getAndSet {
getColorValue(it, newAValue, ALPHA_MASK, 24)
}
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
val colorFilterMode by screenModel.preferences.colorFilterMode.collectAsState()
val colorFilterMode by viewModel.preferences.colorFilterMode.collectAsState()
SettingsChipRow(MR.strings.pref_color_filter_mode) {
ColorFilterMode.mapIndexed { index, it ->
FilterChip(
selected = colorFilterMode == index,
onClick = { screenModel.preferences.colorFilterMode.set(index) },
onClick = { viewModel.preferences.colorFilterMode.set(index) },
label = { Text(stringResource(it.first)) },
)
}
@@ -116,11 +116,11 @@ internal fun ColumnScope.ColorFilterPage(screenModel: ReaderSettingsScreenModel)
CheckboxItem(
label = stringResource(MR.strings.pref_grayscale),
pref = screenModel.preferences.grayscale,
pref = viewModel.preferences.grayscale,
)
CheckboxItem(
label = stringResource(MR.strings.pref_inverted_colors),
pref = screenModel.preferences.invertedColors,
pref = viewModel.preferences.invertedColors,
)
}
@@ -8,7 +8,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
@@ -32,25 +33,25 @@ private val flashColors = listOf(
)
@Composable
internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
val readerTheme by screenModel.preferences.readerTheme.collectAsState()
internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
val readerTheme by viewModel.preferences.readerTheme.collectAsState()
val flashPageState by screenModel.preferences.flashOnPageChange.collectAsState()
val flashPageState by viewModel.preferences.flashOnPageChange.collectAsState()
val flashMillisPref = screenModel.preferences.flashDurationMillis
val flashMillisPref = viewModel.preferences.flashDurationMillis
val flashMillis by flashMillisPref.collectAsState()
val flashIntervalPref = screenModel.preferences.flashPageInterval
val flashIntervalPref = viewModel.preferences.flashPageInterval
val flashInterval by flashIntervalPref.collectAsState()
val flashColorPref = screenModel.preferences.flashColor
val flashColorPref = viewModel.preferences.flashColor
val flashColor by flashColorPref.collectAsState()
SettingsChipRow(MR.strings.pref_reader_theme) {
themes.map { (labelRes, value) ->
FilterChip(
selected = readerTheme == value,
onClick = { screenModel.preferences.readerTheme.set(value) },
onClick = { viewModel.preferences.readerTheme.set(value) },
label = { Text(stringResource(labelRes)) },
)
}
@@ -58,58 +59,82 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
CheckboxItem(
label = stringResource(MR.strings.pref_show_page_number),
pref = screenModel.preferences.showPageNumber,
pref = viewModel.preferences.showPageNumber,
)
val verticalNavigatorForLongStrip by screenModel.preferences.verticalNavigatorForLongStrip.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_vertical_navigator),
pref = screenModel.preferences.verticalNavigatorForLongStrip,
)
val verticalNavigatorModes by viewModel.preferences.verticalNavigator.collectAsState()
SettingsChipRow(MR.strings.pref_vertical_navigator) {
ReadingMode.entries.filter { it != ReadingMode.DEFAULT }.forEach { mode ->
FilterChip(
selected = verticalNavigatorModes.contains(mode),
onClick = {
val newModes = if (verticalNavigatorModes.contains(mode)) {
verticalNavigatorModes - mode
} else {
verticalNavigatorModes + mode
}
viewModel.preferences.verticalNavigator.set(newModes)
},
label = { Text(stringResource(mode.stringRes)) },
)
}
}
if (verticalNavigatorModes.isNotEmpty()) {
val verticalNavigatorHeightPref = viewModel.preferences.verticalNavigatorHeight
val verticalNavigatorHeight by verticalNavigatorHeightPref.collectAsState()
if (verticalNavigatorForLongStrip) {
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
pref = screenModel.preferences.verticalNavigatorOnLeft,
pref = viewModel.preferences.verticalNavigatorOnLeft,
)
SliderItem(
label = stringResource(MR.strings.pref_vertical_navigator_height),
value = verticalNavigatorHeight,
valueRange = 65..100,
steps = 6,
onChange = { verticalNavigatorHeightPref.set(it) },
)
}
CheckboxItem(
label = stringResource(MR.strings.pref_fullscreen),
pref = screenModel.preferences.fullscreen,
pref = viewModel.preferences.fullscreen,
)
val isFullscreen by screenModel.preferences.fullscreen.collectAsState()
val isFullscreen by viewModel.preferences.fullscreen.collectAsState()
if (LocalActivity.current?.hasDisplayCutout() == true && isFullscreen) {
CheckboxItem(
label = stringResource(MR.strings.pref_cutout_short),
pref = screenModel.preferences.drawUnderCutout,
pref = viewModel.preferences.drawUnderCutout,
)
}
CheckboxItem(
label = stringResource(MR.strings.pref_keep_screen_on),
pref = screenModel.preferences.keepScreenOn,
pref = viewModel.preferences.keepScreenOn,
)
CheckboxItem(
label = stringResource(MR.strings.pref_read_with_long_tap),
pref = screenModel.preferences.readWithLongTap,
pref = viewModel.preferences.readWithLongTap,
)
CheckboxItem(
label = stringResource(MR.strings.pref_always_show_chapter_transition),
pref = screenModel.preferences.alwaysShowChapterTransition,
pref = viewModel.preferences.alwaysShowChapterTransition,
)
CheckboxItem(
label = stringResource(MR.strings.pref_page_transitions),
pref = screenModel.preferences.pageTransitions,
pref = viewModel.preferences.pageTransitions,
)
CheckboxItem(
label = stringResource(MR.strings.pref_flash_page),
pref = screenModel.preferences.flashOnPageChange,
pref = viewModel.preferences.flashOnPageChange,
)
if (flashPageState) {
SliderItem(
@@ -14,7 +14,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@@ -23,7 +23,7 @@ fun ReaderSettingsDialog(
onDismissRequest: () -> Unit,
onShowMenus: () -> Unit,
onHideMenus: () -> Unit,
screenModel: ReaderSettingsScreenModel,
viewModel: ReaderSettingsViewModel,
) {
val tabTitles = listOf(
stringResource(MR.strings.pref_category_reading_mode),
@@ -60,9 +60,9 @@ fun ReaderSettingsDialog(
.verticalScroll(rememberScrollState()),
) {
when (page) {
0 -> ReadingModePage(screenModel)
1 -> GeneralPage(screenModel)
2 -> ColorFilterPage(screenModel)
0 -> ReadingModePage(viewModel)
1 -> GeneralPage(viewModel)
2 -> ColorFilterPage(viewModel)
}
}
}
@@ -12,7 +12,7 @@ import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import tachiyomi.i18n.MR
@@ -25,16 +25,16 @@ import tachiyomi.presentation.core.util.collectAsState
import java.text.NumberFormat
@Composable
internal fun ColumnScope.ReadingModePage(screenModel: ReaderSettingsScreenModel) {
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.pref_category_for_this_series)
val manga by screenModel.mangaFlow.collectAsState()
val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) {
ReadingMode.entries.map {
FilterChip(
selected = it == readingMode,
onClick = { screenModel.onChangeReadingMode(it) },
onClick = { viewModel.onChangeReadingMode(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
@@ -45,50 +45,50 @@ internal fun ColumnScope.ReadingModePage(screenModel: ReaderSettingsScreenModel)
ReaderOrientation.entries.map {
FilterChip(
selected = it == orientation,
onClick = { screenModel.onChangeOrientation(it) },
onClick = { viewModel.onChangeOrientation(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
val viewer by screenModel.viewerFlow.collectAsState()
val viewer by viewModel.viewerFlow.collectAsState()
if (viewer is WebtoonViewer) {
WebtoonViewerSettings(screenModel)
WebtoonViewerSettings(viewModel)
} else {
PagerViewerSettings(screenModel)
PagerViewerSettings(viewModel)
}
}
@Composable
private fun ColumnScope.PagerViewerSettings(screenModel: ReaderSettingsScreenModel) {
private fun ColumnScope.PagerViewerSettings(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.pager_viewer)
val navigationModePager by screenModel.preferences.navigationModePager.collectAsState()
val pagerNavInverted by screenModel.preferences.pagerNavInverted.collectAsState()
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState()
TapZonesItems(
selected = navigationModePager,
onSelect = screenModel.preferences.navigationModePager::set,
onSelect = viewModel.preferences.navigationModePager::set,
invertMode = pagerNavInverted,
onSelectInvertMode = screenModel.preferences.pagerNavInverted::set,
onSelectInvertMode = viewModel.preferences.pagerNavInverted::set,
)
val imageScaleType by screenModel.preferences.imageScaleType.collectAsState()
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
SettingsChipRow(MR.strings.pref_image_scale_type) {
ReaderPreferences.ImageScaleType.mapIndexed { index, it ->
FilterChip(
selected = imageScaleType == index + 1,
onClick = { screenModel.preferences.imageScaleType.set(index + 1) },
onClick = { viewModel.preferences.imageScaleType.set(index + 1) },
label = { Text(stringResource(it)) },
)
}
}
val zoomStart by screenModel.preferences.zoomStart.collectAsState()
val zoomStart by viewModel.preferences.zoomStart.collectAsState()
SettingsChipRow(MR.strings.pref_zoom_start) {
ReaderPreferences.ZoomStart.mapIndexed { index, it ->
FilterChip(
selected = zoomStart == index + 1,
onClick = { screenModel.preferences.zoomStart.set(index + 1) },
onClick = { viewModel.preferences.zoomStart.set(index + 1) },
label = { Text(stringResource(it)) },
)
}
@@ -96,111 +96,111 @@ private fun ColumnScope.PagerViewerSettings(screenModel: ReaderSettingsScreenMod
CheckboxItem(
label = stringResource(MR.strings.pref_crop_borders),
pref = screenModel.preferences.cropBorders,
pref = viewModel.preferences.cropBorders,
)
CheckboxItem(
label = stringResource(MR.strings.pref_landscape_zoom),
pref = screenModel.preferences.landscapeZoom,
pref = viewModel.preferences.landscapeZoom,
)
CheckboxItem(
label = stringResource(MR.strings.pref_navigate_pan),
pref = screenModel.preferences.navigateToPan,
pref = viewModel.preferences.navigateToPan,
)
val dualPageSplitPaged by screenModel.preferences.dualPageSplitPaged.collectAsState()
val dualPageSplitPaged by viewModel.preferences.dualPageSplitPaged.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_dual_page_split),
pref = screenModel.preferences.dualPageSplitPaged,
pref = viewModel.preferences.dualPageSplitPaged,
)
if (dualPageSplitPaged) {
CheckboxItem(
label = stringResource(MR.strings.pref_dual_page_invert),
pref = screenModel.preferences.dualPageInvertPaged,
pref = viewModel.preferences.dualPageInvertPaged,
)
}
val dualPageRotateToFit by screenModel.preferences.dualPageRotateToFit.collectAsState()
val dualPageRotateToFit by viewModel.preferences.dualPageRotateToFit.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_page_rotate),
pref = screenModel.preferences.dualPageRotateToFit,
pref = viewModel.preferences.dualPageRotateToFit,
)
if (dualPageRotateToFit) {
CheckboxItem(
label = stringResource(MR.strings.pref_page_rotate_invert),
pref = screenModel.preferences.dualPageRotateToFitInvert,
pref = viewModel.preferences.dualPageRotateToFitInvert,
)
}
}
@Composable
private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenModel) {
private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel) {
val numberFormat = remember { NumberFormat.getPercentInstance() }
HeadingItem(MR.strings.webtoon_viewer)
val navigationModeWebtoon by screenModel.preferences.navigationModeWebtoon.collectAsState()
val webtoonNavInverted by screenModel.preferences.webtoonNavInverted.collectAsState()
val navigationModeWebtoon by viewModel.preferences.navigationModeWebtoon.collectAsState()
val webtoonNavInverted by viewModel.preferences.webtoonNavInverted.collectAsState()
TapZonesItems(
selected = navigationModeWebtoon,
onSelect = screenModel.preferences.navigationModeWebtoon::set,
onSelect = viewModel.preferences.navigationModeWebtoon::set,
invertMode = webtoonNavInverted,
onSelectInvertMode = screenModel.preferences.webtoonNavInverted::set,
onSelectInvertMode = viewModel.preferences.webtoonNavInverted::set,
)
val webtoonSidePadding by screenModel.preferences.webtoonSidePadding.collectAsState()
val webtoonSidePadding by viewModel.preferences.webtoonSidePadding.collectAsState()
SliderItem(
value = webtoonSidePadding,
valueRange = ReaderPreferences.let { it.WEBTOON_PADDING_MIN..it.WEBTOON_PADDING_MAX },
label = stringResource(MR.strings.pref_webtoon_side_padding),
valueString = numberFormat.format(webtoonSidePadding / 100f),
onChange = {
screenModel.preferences.webtoonSidePadding.set(it)
viewModel.preferences.webtoonSidePadding.set(it)
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
CheckboxItem(
label = stringResource(MR.strings.pref_crop_borders),
pref = screenModel.preferences.cropBordersWebtoon,
pref = viewModel.preferences.cropBordersWebtoon,
)
val dualPageSplitWebtoon by screenModel.preferences.dualPageSplitWebtoon.collectAsState()
val dualPageSplitWebtoon by viewModel.preferences.dualPageSplitWebtoon.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_dual_page_split),
pref = screenModel.preferences.dualPageSplitWebtoon,
pref = viewModel.preferences.dualPageSplitWebtoon,
)
if (dualPageSplitWebtoon) {
CheckboxItem(
label = stringResource(MR.strings.pref_dual_page_invert),
pref = screenModel.preferences.dualPageInvertWebtoon,
pref = viewModel.preferences.dualPageInvertWebtoon,
)
}
val dualPageRotateToFitWebtoon by screenModel.preferences.dualPageRotateToFitWebtoon.collectAsState()
val dualPageRotateToFitWebtoon by viewModel.preferences.dualPageRotateToFitWebtoon.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_page_rotate),
pref = screenModel.preferences.dualPageRotateToFitWebtoon,
pref = viewModel.preferences.dualPageRotateToFitWebtoon,
)
if (dualPageRotateToFitWebtoon) {
CheckboxItem(
label = stringResource(MR.strings.pref_page_rotate_invert),
pref = screenModel.preferences.dualPageRotateToFitInvertWebtoon,
pref = viewModel.preferences.dualPageRotateToFitInvertWebtoon,
)
}
CheckboxItem(
label = stringResource(MR.strings.pref_double_tap_zoom),
pref = screenModel.preferences.webtoonDoubleTapZoomEnabled,
pref = viewModel.preferences.webtoonDoubleTapZoomEnabled,
)
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
pref = screenModel.preferences.webtoonDisableZoomOut,
pref = viewModel.preferences.webtoonDisableZoomOut,
)
}
@@ -22,6 +22,7 @@ import eu.kanade.presentation.theme.colorscheme.TachiyomiColorScheme
import eu.kanade.presentation.theme.colorscheme.TakoColorScheme
import eu.kanade.presentation.theme.colorscheme.TealTurqoiseColorScheme
import eu.kanade.presentation.theme.colorscheme.TidalWaveColorScheme
import eu.kanade.presentation.theme.colorscheme.TokyoNightColorScheme
import eu.kanade.presentation.theme.colorscheme.YinYangColorScheme
import eu.kanade.presentation.theme.colorscheme.YotsubaColorScheme
import uy.kohesive.injekt.Injekt
@@ -90,6 +91,7 @@ private fun getThemeColorScheme(
private val colorSchemes: Map<AppTheme, BaseColorScheme> = mapOf(
AppTheme.DEFAULT to TachiyomiColorScheme,
AppTheme.CATPPUCCIN to CatppuccinColorScheme,
AppTheme.TOKYONIGHT to TokyoNightColorScheme,
AppTheme.GREEN_APPLE to GreenAppleColorScheme,
AppTheme.LAVENDER to LavenderColorScheme,
AppTheme.MIDNIGHT_DUSK to MidnightDuskColorScheme,
@@ -0,0 +1,89 @@
package eu.kanade.presentation.theme.colorscheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
/**
* Colors for Tokyo Night Moon/Day
*
* https://github.com/folke/tokyonight.nvim
*/
internal object TokyoNightColorScheme : BaseColorScheme() {
override val darkScheme = darkColorScheme(
primary = Color(0xFF82AAFF),
onPrimary = Color(0xFF1B1D2B),
primaryContainer = Color(0xFF444A73),
onPrimaryContainer = Color(0xFF9AB8FF),
secondary = Color(0xFF86E1FC),
onSecondary = Color(0xFF1B1D2B),
secondaryContainer = Color(0xFF444A73),
onSecondaryContainer = Color(0xFFB2EBFF),
tertiary = Color(0xFFC099FF),
onTertiary = Color(0xFF1B1D2B),
tertiaryContainer = Color(0xFF444A73),
onTertiaryContainer = Color(0xFFCAABFF),
error = Color(0xFFFF757F),
onError = Color(0xFF1B1D2B),
errorContainer = Color(0xFFC53B53),
onErrorContainer = Color(0xFFFF8D94),
background = Color(0xFF222436),
onBackground = Color(0xFFC8D3F5),
surface = Color(0xFF222436),
onSurface = Color(0xFFC8D3F5),
surfaceVariant = Color(0xFF444A73),
onSurfaceVariant = Color(0xFF828BB8),
outline = Color(0xFF828BB8),
outlineVariant = Color(0xFF444A73),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFC8D3F5),
inverseOnSurface = Color(0xFF222436),
inversePrimary = Color(0xFF3D5C9E),
surfaceContainerLowest = Color(0xFF1E202F),
surfaceDim = Color(0xFF222436),
surfaceContainerLow = Color(0xFF292C44),
surfaceContainer = Color(0xFF2D304B),
surfaceContainerHigh = Color(0xFF363B5D),
surfaceContainerHighest = Color(0xFF3F466F),
surfaceBright = Color(0xFF434A77),
)
override val lightScheme = lightColorScheme(
primary = Color(0xFF2E7DE9),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF92A6D5),
onPrimaryContainer = Color(0xFF15386A),
inversePrimary = Color(0xFF7890DD),
secondary = Color(0xFF007197),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFA9C6D3),
onSecondaryContainer = Color(0xFF006A83),
tertiary = Color(0xFF9854F1),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFCFBFEA),
onTertiaryContainer = Color(0xFF7847BD),
background = Color(0xFFE1E2E7),
onBackground = Color(0xFF3760BF),
surface = Color(0xFFE1E2E7),
onSurface = Color(0xFF3760BF),
surfaceVariant = Color(0xFFC4C8DA),
onSurfaceVariant = Color(0xFF6172B0),
surfaceTint = Color(0xFF2E7DE9),
inverseSurface = Color(0xFF3760BF),
inverseOnSurface = Color(0xFFE1E2E7),
outline = Color(0xFF68709A),
outlineVariant = Color(0xFFA8AECB),
error = Color(0xFFF52A65),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFE6B4C7),
onErrorContainer = Color(0xFF6E132D),
surfaceDim = Color(0xFF9195B1),
surfaceBright = Color(0xFFE1E2E7),
surfaceContainerLowest = Color(0xFFE4E5E9),
surfaceContainerLow = Color(0xFFD0D5E3),
surfaceContainer = Color(0xFFC1C9DF),
surfaceContainerHigh = Color(0xFFC4C8DA),
surfaceContainerHighest = Color(0xFFA1A6C5),
)
}
@@ -56,8 +56,10 @@ import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.util.lang.toJavaLocalDate
import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.datetime.toJavaLocalDate
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.format.DateTimeFormatter
@@ -110,11 +112,13 @@ fun TrackInfoDialogHome(
.takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring },
startDate = remember(item.track.startDate) { dateFormat.format(item.track.startDate.toLocalDate()) }
startDate = remember(item.track.startDate) {
dateFormat.format(item.track.startDate.toLocalDate().toJavaLocalDate())
}
.takeIf { supportsReadingDates && item.track.startDate != 0L },
onStartDateClick = { onStartDateEdit(item) } // TODO
.takeIf { supportsReadingDates },
endDate = dateFormat.format(item.track.finishDate.toLocalDate())
endDate = dateFormat.format(item.track.finishDate.toJavaLocalDate())
.takeIf { supportsReadingDates && item.track.finishDate != 0L },
onEndDateClick = { onEndDateEdit(item) }
.takeIf { supportsReadingDates },
@@ -6,11 +6,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.Locale
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaInstant
internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composable () -> Unit> {
private val fullPageWithSecondSelected = @Composable {
@@ -93,7 +94,7 @@ internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composab
it.finished_reading_date = 0L
it.tracking_url = "https://example.com/tracker-example"
it.cover_url = "https://example.com/cover.png"
it.start_date = formatter.format(Date.from(Instant.now().minus((1L..365).random(), ChronoUnit.DAYS)))
it.start_date = formatter.format(Date.from(Clock.System.now().minus((1L..365).random().days).toJavaInstant()))
it.summary = lorem((0..40).random()).joinToString()
it.publishing_status = if (Random.nextBoolean()) "Finished" else ""
it.publishing_type = if (Random.nextBoolean()) "Oneshot" else ""
@@ -14,12 +14,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsScreenModel
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsViewModel
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences
import tachiyomi.i18n.MR
@@ -27,66 +34,71 @@ import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable
fun UpdatesFilterDialog(
onDismissRequest: () -> Unit,
screenModel: UpdatesSettingsScreenModel,
viewModel: UpdatesSettingsViewModel,
) {
TabbedDialog(
onDismissRequest = onDismissRequest,
tabTitles = listOf(
stringResource(MR.strings.action_filter),
stringResource(MR.strings.categories),
),
) {
) { page ->
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
FilterSheet(screenModel = screenModel)
when (page) {
0 -> FilterSheet(viewModel = viewModel)
1 -> CategoryFilterSheet(viewModel = viewModel)
}
}
}
}
@Composable
private fun ColumnScope.FilterSheet(
screenModel: UpdatesSettingsScreenModel,
viewModel: UpdatesSettingsViewModel,
) {
val filterDownloaded by screenModel.updatesPreferences.filterDownloaded.collectAsState()
val filterDownloaded by viewModel.updatesPreferences.filterDownloaded.collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_downloaded),
state = filterDownloaded,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterDownloaded) },
onClick = { viewModel.toggleFilter(UpdatesPreferences::filterDownloaded) },
)
val filterUnread by screenModel.updatesPreferences.filterUnread.collectAsState()
val filterUnread by viewModel.updatesPreferences.filterUnread.collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_unread),
state = filterUnread,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterUnread) },
onClick = { viewModel.toggleFilter(UpdatesPreferences::filterUnread) },
)
val filterStarted by screenModel.updatesPreferences.filterStarted.collectAsState()
val filterStarted by viewModel.updatesPreferences.filterStarted.collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_started),
state = filterStarted,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterStarted) },
onClick = { viewModel.toggleFilter(UpdatesPreferences::filterStarted) },
)
val filterBookmarked by screenModel.updatesPreferences.filterBookmarked.collectAsState()
val filterBookmarked by viewModel.updatesPreferences.filterBookmarked.collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_bookmarked),
state = filterBookmarked,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterBookmarked) },
onClick = { viewModel.toggleFilter(UpdatesPreferences::filterBookmarked) },
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.small))
val filterExcludedScanlators by screenModel.updatesPreferences.filterExcludedScanlators.collectAsState()
val filterExcludedScanlators by viewModel.updatesPreferences.filterExcludedScanlators.collectAsState()
fun toggleScanlatorFilter() = screenModel.updatesPreferences.filterExcludedScanlators.getAndSet { !it }
fun toggleScanlatorFilter() = viewModel.updatesPreferences.filterExcludedScanlators.getAndSet { !it }
Row(
modifier = Modifier
@@ -108,3 +120,55 @@ private fun ColumnScope.FilterSheet(
)
}
}
@Composable
private fun ColumnScope.CategoryFilterSheet(
viewModel: UpdatesSettingsViewModel,
) {
Text(
stringResource(MR.strings.pref_filter_update_categories_details),
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.extraSmall))
val allCategories by viewModel.getCategories.subscribe().collectAsState(initial = emptyList())
if (allCategories.isEmpty()) {
// since it includes the system category, this should only happen when loading is required
LoadingScreen(modifier = Modifier.padding(16.dp))
return
}
val excluded by viewModel.updatesPreferences.filterExcludedCategories.collectAsState()
val included by viewModel.updatesPreferences.filterIncludedCategories.collectAsState()
val selected = remember {
allCategories.map { category ->
when (category.id) {
in included -> TriState.ENABLED_IS
in excluded -> TriState.ENABLED_NOT
else -> TriState.DISABLED
}
}.toMutableStateList()
}
Column {
allCategories.fastForEachIndexed { idx, category ->
val state = selected[idx]
TriStateItem(
label = category.visualName,
state = state,
onClick = {
selected[idx] = state.next()
viewModel.cycleCategory(category)
},
)
}
}
}
@@ -29,9 +29,10 @@ import eu.kanade.presentation.manga.components.ChapterDownloadAction
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel
import eu.kanade.tachiyomi.ui.updates.UpdatesViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh
@@ -40,12 +41,11 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.active
import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds
@Composable
fun UpdateScreen(
state: UpdatesScreenModel.State,
state: UpdatesViewModel.State,
snackbarHostState: SnackbarHostState,
lastUpdated: Long,
onClickCover: (UpdatesItem) -> Unit,
@@ -37,7 +37,6 @@ import eu.kanade.presentation.manga.components.ChapterDownloadAction
import eu.kanade.presentation.manga.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.components.DotSeparatorText
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.relativeTimeSpanString
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.updates.UpdatesItem
@@ -92,14 +91,14 @@ internal fun LazyListScope.updatesUiItems(
when (item) {
is UpdatesUiModel.Header -> {
ListGroupHeader(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
text = relativeDateText(item.date),
)
}
is UpdatesUiModel.Item -> {
val updatesItem = item.item
UpdatesUiItem(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItem(),
update = updatesItem.update,
selected = updatesItem.selected,
readProgress = updatesItem.update.lastPageRead
@@ -1,10 +0,0 @@
package eu.kanade.presentation.util
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.ui.Modifier
// https://issuetracker.google.com/352584409
context(itemScope: LazyItemScope)
fun Modifier.animateItemFastScroll() = with(itemScope) {
this@animateItemFastScroll.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
@@ -8,20 +8,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.ScreenModelStore
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransitionContent
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.plus
import soup.compose.material.motion.animation.materialSharedAxisX
import soup.compose.material.motion.animation.rememberSlideDistance
@@ -39,18 +31,6 @@ abstract class Screen : Screen {
override val key: ScreenKey = uniqueScreenKey
}
/**
* A variant of ScreenModel.coroutineScope except with the IO dispatcher instead of the
* main dispatcher.
*/
val ScreenModel.ioCoroutineScope: CoroutineScope
get() = ScreenModelStore.getOrPutDependency(
screenModel = this,
name = "ScreenModelIoCoroutineScope",
factory = { key -> CoroutineScope(Dispatchers.IO + SupervisorJob()) + CoroutineName(key) },
onDispose = { scope -> scope.cancel() },
)
interface AssistContentScreen {
fun onProvideAssistUrl(): String?
}
@@ -7,7 +7,7 @@ import androidx.compose.runtime.ReadOnlyComposable
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.Instant
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@@ -29,7 +29,7 @@ fun Duration.toDurationString(context: Context, fallback: String): String {
@Composable
@ReadOnlyComposable
fun relativeTimeSpanString(epochMillis: Long): String {
val now = Instant.now().toEpochMilli()
val now = Clock.System.now().toEpochMilliseconds()
return when {
epochMillis <= 0L -> stringResource(MR.strings.relative_time_span_never)
now - epochMillis < 1.minutes.inWholeMilliseconds -> stringResource(
@@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
@@ -72,6 +73,7 @@ fun WebViewScreenContent(
onNavigateUp: () -> Unit,
initialTitle: String?,
url: String,
defaultUserAgentProvider: () -> String,
onShare: (String) -> Unit,
onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit,
@@ -340,9 +342,7 @@ fun WebViewScreenContent(
WebView.setWebContentsDebuggingEnabled(true)
}
headers["user-agent"]?.let {
webView.settings.userAgentString = it
}
webView.setUserAgent(headers["user-agent"] ?: defaultUserAgentProvider())
},
onDispose = { webView ->
val window = windowStack.items.find { it.webView == webView }
+1 -2
View File
@@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Looper
import android.webkit.WebView
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
@@ -231,7 +230,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
override fun getPackageName(): String {
try {
// Override the value passed as X-Requested-With in WebView requests
val stackTrace = Looper.getMainLooper().thread.stackTrace
val stackTrace = Thread.currentThread().stackTrace
val isChromiumCall = stackTrace.any { trace ->
trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") &&
trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>")
@@ -18,7 +18,7 @@ import tachiyomi.core.common.storage.displayablePath
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
class BackupNotifier(private val context: Context) {
@@ -149,10 +149,8 @@ class BackupNotifier(private val context: Context) {
val timeString = context.stringResource(
MR.strings.restore_duration,
TimeUnit.MILLISECONDS.toMinutes(time),
TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds(
TimeUnit.MILLISECONDS.toMinutes(time),
),
time.milliseconds.inWholeMinutes,
time.milliseconds.inWholeSeconds - (time.milliseconds.inWholeMinutes * 60),
)
with(completeNotificationBuilder) {
@@ -33,9 +33,9 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
import kotlin.time.Clock
class BackupCreator(
private val context: Context,
@@ -108,7 +108,7 @@ class BackupCreator(
BackupFileValidator(context).validate(fileUri)
if (isAutoBackup) {
backupPreferences.lastAutoBackupTimestamp.set(Instant.now().toEpochMilli())
backupPreferences.lastAutoBackupTimestamp.set(Clock.System.now().toEpochMilliseconds())
}
return fileUri.toString()
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.core.common.extensions.EMPTY
import mihon.core.common.extensions.JsonObjectEmptyBytes
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.domain.chapter.model.Chapter
@@ -2,9 +2,7 @@ package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.core.common.extensions.EMPTY
import mihon.core.common.extensions.JsonObjectEmptyBytes
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.domain.manga.model.Manga
@@ -13,12 +13,15 @@ import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.Database
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
@@ -59,6 +62,15 @@ class BackupRestorer(
restoreFromFile(uri, options)
// Invalidate download cache to ensure UI reflects any restored downloads
if (options.libraryEntries) {
try {
Injekt.get<DownloadCache>().invalidateCache()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to invalidate download cache after restore" }
}
}
val time = System.currentTimeMillis() - startTime
val logFile = writeErrorLog()
@@ -136,13 +148,30 @@ class BackupRestorer(
mangaRestorer.sortByNew(backupMangas)
.chunked(100)
.forEach { chunk ->
database.transaction {
val restoredAsBatch = try {
database.transaction {
chunk.forEach {
ensureActive()
mangaRestorer.restore(it, backupCategories)
}
}
true
} catch (e: Exception) {
ensureActive()
logcat(LogPriority.WARN, e) { "Batch restore failed, retrying entry by entry" }
false
}
if (restoredAsBatch) {
restoreProgress.addAndFetch(chunk.size)
} else {
chunk.forEach {
ensureActive()
try {
mangaRestorer.restore(it, backupCategories)
} catch (e: Exception) {
ensureActive()
val sourceName = sourceMapping[it.source] ?: it.source.toString()
errors.add(Date() to "${it.title} [$sourceName]: ${e.message}")
}
@@ -150,6 +179,7 @@ class BackupRestorer(
restoreProgress.incrementAndFetch()
}
}
notifier.showRestoreProgress(chunk.last().title, restoreProgress.load(), restoreAmount, isSync)
}
}
@@ -9,9 +9,10 @@ import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.Database
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.data.MemoColumnAdapter.encode
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -24,9 +25,9 @@ import tachiyomi.domain.track.interactor.InsertTrack
import tachiyomi.domain.track.model.Track
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZonedDateTime
import java.util.Date
import kotlin.math.max
import kotlin.time.Clock
class MangaRestorer(
private val database: Database = Injekt.get(),
@@ -39,13 +40,9 @@ class MangaRestorer(
fetchInterval: FetchInterval = Injekt.get(),
) {
private var now = ZonedDateTime.now()
private var currentFetchWindow = fetchInterval.getWindow(now)
init {
now = ZonedDateTime.now()
currentFetchWindow = fetchInterval.getWindow(now)
}
private val timeZone = TimeZone.currentSystemDefault()
private val now = Clock.System.now().toLocalDateTime(timeZone)
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
val urlsBySource = database.mangasQueries
@@ -285,9 +282,9 @@ class MangaRestorer(
restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters)
restoreTracking(manga, tracks)
restoreHistory(history)
restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, currentFetchWindow)
return manga
}
@@ -325,16 +322,16 @@ class MangaRestorer(
}
}
private suspend fun restoreHistory(backupHistory: List<BackupHistory>) {
private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
val toUpdate = backupHistory.mapNotNull { history ->
val dbHistory = database.historyQueries
.getHistoryByChapterUrl(history.url)
.getHistoryByChapterUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull()
val item = history.getHistoryImpl()
if (dbHistory == null) {
val chapter = database.chaptersQueries
.getChapterByUrl(history.url)
.getChapterByUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull()
return@mapNotNull if (chapter == null) {
// Chapter doesn't exist; skip
@@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
@@ -42,7 +43,6 @@ import mihon.core.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNow
import tachiyomi.core.common.util.lang.withIOContext
@@ -362,11 +362,6 @@ class Downloader(
reIndexedPages
}
// Delete all temporary (unfinished) files
tmpDir.listFiles()
?.filter { it.extension == "tmp" }
?.forEach { it.delete() }
download.status = Download.State.DOWNLOADING
// Start downloading images, consider we can have downloaded images already
@@ -441,14 +436,10 @@ class Downloader(
val digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3)
val filename = "%0${digitCount}d".format(Locale.ENGLISH, page.number)
val tmpFile = tmpDir.findFile("$filename.tmp")
// Delete temp file if it exists
tmpFile?.delete()
// Try to find the image file
val imageFile = tmpDir.listFiles()?.firstOrNull {
it.name!!.startsWith("$filename.") || it.name!!.startsWith("${filename}__001")
isDownloadedPageImage(it.name ?: return@firstOrNull false, filename)
}
try {
@@ -488,15 +479,24 @@ class Downloader(
page.status = Page.State.DownloadImage
page.progress = 0
return flow {
val response = source.getImage(page)
val file = tmpDir.createFile("$filename.tmp")!!
val file = tmpDir.findFile("$filename.tmp")
?: tmpDir.createFile("$filename.tmp")!!
try {
response.body.source().saveTo(file.openOutputStream())
val extension = getImageExtension(response, file)
file.renameTo("$filename.$extension")
} catch (e: Exception) {
response.close()
file.delete()
source.getImage(page, file.length()).use {
it.body.source().saveTo(
// If the server supports partial downloads (HTTP 206),
// append to the existing file.
// Otherwise, start from scratch and overwrite the file.
stream = file.openOutputStream(it.code == 206),
)
val extension = getImageExtension(it, file)
file.renameTo("$filename.$extension")
}
} catch (e: HttpException) {
if (e.code == 416) {
file.delete()
}
throw e
}
emit(file)
@@ -521,6 +521,8 @@ class Downloader(
* @param filename the filename of the image.
*/
private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
// Delete temp file if it exists
tmpDir.findFile("$filename.tmp")?.delete()
val tmpFile = tmpDir.createFile("$filename.tmp")!!
cacheFile.inputStream().use { input ->
tmpFile.openOutputStream().use { output ->
@@ -594,6 +596,18 @@ class Downloader(
return downloadedImagesCount == downloadPageCount
}
/**
* Checks if the file name matches a downloaded page image.
*
* @param fileName Name of the file to check
* @param pagePrefix Expected page prefix (e.g., "001")
*/
private fun isDownloadedPageImage(fileName: String, pagePrefix: String): Boolean =
!fileName.endsWith(".tmp") && (
fileName.startsWith("$pagePrefix.") ||
fileName.startsWith("${pagePrefix}__001.")
)
/**
* Archive the chapter pages as a CBZ.
*/
@@ -35,6 +35,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.source.interactor.UpdateMangaFromRemote
@@ -64,14 +66,13 @@ import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.time.Instant
import java.time.ZonedDateTime
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.time.Clock
@OptIn(ExperimentalAtomicApi::class)
class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
@@ -108,7 +109,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
setForegroundSafely()
libraryPreferences.lastUpdatedTimestamp.set(Instant.now().toEpochMilli())
libraryPreferences.lastUpdatedTimestamp.set(Clock.System.now().toEpochMilliseconds())
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
addMangaToQueue(categoryId)
@@ -167,7 +168,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val restrictions = libraryPreferences.autoUpdateMangaRestrictions.get()
val skippedUpdates = mutableListOf<Pair<Manga, String?>>()
val (_, fetchWindowUpperBound) = fetchInterval.getWindow(ZonedDateTime.now())
val timeZone = TimeZone.currentSystemDefault()
val (_, fetchWindowUpperBound) = fetchInterval.getWindow(
Clock.System.now().toLocalDateTime(timeZone).date,
timeZone,
)
mangaToUpdate = listToUpdate
.filter {
@@ -234,7 +239,8 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
val timeZone = TimeZone.currentSystemDefault()
val fetchWindow = fetchInterval.getWindow(Clock.System.now().toLocalDateTime(timeZone).date, timeZone)
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
@@ -9,7 +9,6 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -72,10 +71,6 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_CANCEL_RESTORE -> cancelRestore(context)
// Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
// Start downloading app update
ACTION_START_APP_UPDATE -> startDownloadAppUpdate(context, intent)
// Cancel downloading app update
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
// Open reader activity
ACTION_OPEN_CHAPTER -> {
openChapter(
@@ -178,15 +173,6 @@ class NotificationReceiver : BroadcastReceiver() {
LibraryUpdateJob.stop(context)
}
private fun startDownloadAppUpdate(context: Context, intent: Intent) {
val url = intent.getStringExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL) ?: return
AppUpdateDownloadJob.start(context, url)
}
private fun cancelDownloadAppUpdate(context: Context) {
AppUpdateDownloadJob.stop(context)
}
/**
* Method called when user wants to mark manga chapters as read
*
@@ -241,9 +227,6 @@ class NotificationReceiver : BroadcastReceiver() {
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
private const val ACTION_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE"
private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD"
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER"
@@ -524,45 +507,6 @@ class NotificationReceiver : BroadcastReceiver() {
)
}
/**
* Returns [PendingIntent] that starts the [AppUpdateDownloadJob] to download an app update.
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun downloadAppUpdatePendingBroadcast(
context: Context,
url: String,
title: String? = null,
): PendingIntent {
return Intent(context, NotificationReceiver::class.java).run {
action = ACTION_START_APP_UPDATE
putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL, url)
title?.let { putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_TITLE, it) }
PendingIntent.getBroadcast(
context,
0,
this,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
/**
*
*/
internal fun cancelDownloadAppUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_APP_UPDATE_DOWNLOAD
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/**
* Returns [PendingIntent] that opens the extensions controller.
*
@@ -70,8 +70,6 @@ object Notifications {
private const val GROUP_APK_UPDATES = "group_apk_updates"
const val CHANNEL_APP_UPDATE = "app_apk_update_channel"
const val ID_APP_UPDATER = 1
const val ID_APP_UPDATE_PROMPT = 2
const val ID_APP_UPDATE_ERROR = 3
const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel"
const val ID_UPDATES_TO_EXTS = -401
const val ID_EXTENSION_INSTALLER = -402
@@ -24,7 +24,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.time.Instant
import kotlin.time.Clock
class ImageSaver(
val context: Context,
@@ -85,7 +85,7 @@ class ImageSaver(
MediaStore.MediaColumns.RELATIVE_PATH to relativePath,
MediaStore.MediaColumns.DISPLAY_NAME to if (isMimeTypeSupported) image.name else filename,
MediaStore.MediaColumns.MIME_TYPE to type.mime,
MediaStore.MediaColumns.DATE_MODIFIED to Instant.now().epochSecond,
MediaStore.MediaColumns.DATE_MODIFIED to Clock.System.now().epochSeconds,
)
val picture = findUriOrDefault(relativePath, filename) {
@@ -68,6 +68,10 @@ abstract class BaseTracker(
override fun getUsername() = trackPreferences.trackUsername(this).get()
override fun getDisplayUsername(): String = trackPreferences.trackDisplayUsername(this).get()
override fun saveDisplayUsername(displayName: String) = trackPreferences.trackDisplayUsername(this).set(displayName)
override fun getPassword() = trackPreferences.trackPassword(this).get()
override fun saveCredentials(username: String, password: String) {
@@ -65,6 +65,10 @@ interface Tracker {
fun getPassword(): String
fun getDisplayUsername(): String
fun saveDisplayUsername(displayName: String)
fun saveCredentials(username: String, password: String)
// TODO: move this to an interactor, and update all trackers based on common data
@@ -2,9 +2,11 @@ package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.hikka.Hikka
import eu.kanade.tachiyomi.data.track.kavita.Kavita
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.komga.Komga
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
@@ -17,6 +19,7 @@ class TrackerManager {
const val ANILIST = 2L
const val KITSU = 3L
const val KAVITA = 8L
const val MANGABAKA = 11L
}
val myAnimeList = MyAnimeList(1L)
@@ -28,8 +31,22 @@ class TrackerManager {
val mangaUpdates = MangaUpdates(7L)
val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L)
val hikka = Hikka(10L)
val mangaBaka = MangaBaka(MANGABAKA)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
val trackers = listOf(
myAnimeList,
aniList,
kitsu,
shikimori,
bangumi,
komga,
mangaUpdates,
kavita,
suwayomi,
hikka,
mangaBaka,
)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
@@ -213,9 +213,10 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
try {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
val (username, scoreType) = api.getCurrentUser()
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.accessToken)
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
saveDisplayUsername(currentUser.name)
saveCredentials(currentUser.id.toString(), oauth.accessToken)
} catch (e: Throwable) {
logout()
}
@@ -8,12 +8,16 @@ import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserViewerData
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@@ -24,10 +28,8 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@@ -285,12 +287,13 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
}
suspend fun getCurrentUser(): Pair<Int, String> {
suspend fun getCurrentUser(): ALUserViewerData {
return withIOContext {
val query = """
|query User {
|Viewer {
|id
|name
|mediaListOptions {
|scoreFormat
|}
@@ -310,10 +313,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
)
.awaitSuccess()
.parseAs<ALCurrentUserResult>()
.let {
val viewer = it.data.viewer
Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
}
.data.viewer
}
}
}
@@ -327,11 +327,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
}
val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateValue), ZoneId.systemDefault())
val dateTime = Instant.fromEpochMilliseconds(dateValue).toLocalDateTime(TimeZone.currentSystemDefault())
return buildJsonObject {
put("year", dateTime.year)
put("month", dateTime.monthValue)
put("day", dateTime.dayOfMonth)
put("month", dateTime.month.number)
put("day", dateTime.day)
}
}
@@ -1,8 +1,9 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.serialization.Serializable
import java.time.LocalDate
import java.time.ZoneId
@Serializable
data class ALFuzzyDate(
@@ -11,10 +12,9 @@ data class ALFuzzyDate(
val day: Int?,
) {
fun toEpochMilli(): Long = try {
LocalDate.of(year!!, month!!, day!!)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
LocalDate(year!!, month!!, day!!)
.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
} catch (_: Exception) {
0L
}
@@ -17,6 +17,7 @@ data class ALUserViewer(
@Serializable
data class ALUserViewerData(
val id: Int,
val name: String,
val mediaListOptions: ALUserListOptions,
)
@@ -106,8 +106,9 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
// Users can set a 'username' (not nickname) once which effectively
// replaces the stringified ID in certain queries.
// If no username is set, the API returns the user ID as a strings
val username = api.getUsername()
saveCredentials(username, oauth.accessToken)
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.nickname?.takeIf { it.isNotBlank() } ?: currentUser.username)
saveCredentials(currentUser.username, oauth.accessToken)
} catch (_: Throwable) {
logout()
}
@@ -153,13 +153,12 @@ class BangumiApi(
}
}
suspend fun getUsername(): String {
suspend fun getCurrentUser(): BGMUser {
return withIOContext {
with(json) {
authClient.newCall(GET("$API_URL/v0/me"))
.awaitSuccess()
.parseAs<BGMUser>()
.username
}
}
}
@@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable
// Incomplete DTO with only our needed attributes
data class BGMUser(
val username: String,
val nickname: String?,
)
@@ -0,0 +1,172 @@
package eu.kanade.tachiyomi.data.track.hikka
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker {
companion object {
const val READING = 0L
const val COMPLETED = 1L
const val ON_HOLD = 2L
const val DROPPED = 3L
const val PLAN_TO_READ = 4L
const val REREADING = 5L
private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString)
.toImmutableList()
}
private val json: Json by injectLazy()
private val interceptor by lazy { HikkaInterceptor(this) }
private val api by lazy { HikkaApi(id, client, interceptor) }
override val supportsReadingDates: Boolean = true
override fun getLogo(): Int = R.drawable.brand_hikka
override fun getStatusList(): List<Long> {
return listOf(
READING,
COMPLETED,
ON_HOLD,
DROPPED,
PLAN_TO_READ,
REREADING,
)
}
override fun getStatus(status: Long): StringResource? = when (status) {
READING -> MR.strings.reading
PLAN_TO_READ -> MR.strings.plan_to_read
COMPLETED -> MR.strings.completed
ON_HOLD -> MR.strings.on_hold
DROPPED -> MR.strings.dropped
REREADING -> MR.strings.repeating
else -> null
}
override fun getReadingStatus(): Long = READING
override fun getRereadingStatus(): Long = REREADING
override fun getCompletionStatus(): Long = COMPLETED
override fun getScoreList(): ImmutableList<String> = SCORE_LIST
override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString()
}
override suspend fun update(
track: Track,
didReadChapter: Boolean,
): Track {
if (track.status != COMPLETED) {
if (didReadChapter) {
if (track.last_chapter_read.toLong() == track.total_chapters && track.total_chapters > 0) {
track.status = COMPLETED
track.finished_reading_date = System.currentTimeMillis()
} else if (track.status != REREADING) {
track.status = READING
if (track.last_chapter_read == 1.0) {
track.started_reading_date = System.currentTimeMillis()
}
}
}
}
return api.updateUserManga(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val readContent = api.getRead(track)
val remoteTrack = api.getManga(track)
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
return if (readContent != null) {
track.score = readContent.score.toDouble()
track.last_chapter_read = readContent.chapters.toDouble()
track.started_reading_date = (readContent.startDate ?: 0L) * 1000
track.finished_reading_date = (readContent.endDate ?: 0L) * 1000
update(track)
} else {
track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0
update(track)
}
}
override suspend fun search(query: String): List<TrackSearch> = api.searchManga(query)
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
val readContent = api.getRead(track) ?: throw Exception("Could not find manga")
track.score = readContent.score.toDouble()
track.last_chapter_read = readContent.chapters.toDouble()
track.status = toTrackStatus(readContent.status)
track.started_reading_date = (readContent.startDate ?: 0L) * 1000
track.finished_reading_date = (readContent.endDate ?: 0L) * 1000
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(reference: String) {
try {
val oauth = api.accessToken(reference)
interceptor.setAuth(oauth)
val user = api.getCurrentUser()
saveDisplayUsername(user.username)
saveCredentials(user.reference, oauth.accessToken)
} catch (_: Throwable) {
logout()
}
}
override suspend fun delete(track: DomainTrack) = api.deleteUserManga(track)
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
interceptor.setAuth(null)
}
fun saveOAuth(oAuth: HKOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
}
fun loadOAuth(): HKOAuth? {
return try {
json.decodeFromString<HKOAuth>(trackPreferences.trackToken(this).get())
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,224 @@
package eu.kanade.tachiyomi.data.track.hikka
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.hikka.dto.HKManga
import eu.kanade.tachiyomi.data.track.hikka.dto.HKMangaPagination
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.hikka.dto.HKRead
import eu.kanade.tachiyomi.data.track.hikka.dto.HKUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class HikkaApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: HikkaInterceptor,
) {
suspend fun getCurrentUser(): HKUser {
return withIOContext {
val request = Request.Builder()
.url("${BASE_API_URL}/user/me")
.get()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess()
.parseAs<HKUser>()
}
}
}
suspend fun accessToken(reference: String): HKOAuth {
return withIOContext {
with(json) {
client.newCall(authTokenCreate(reference))
.awaitSuccess()
.parseAs<HKOAuth>()
}
}
}
suspend fun searchManga(query: String): List<TrackSearch> {
return withIOContext {
val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendQueryParameter("page", "1")
.appendQueryParameter("size", "50")
.build()
val payload = buildJsonObject {
put("media_type", buildJsonArray { })
put("status", buildJsonArray { })
put("only_translated", false)
put("magazines", buildJsonArray { })
put("genres", buildJsonArray { })
put(
"score",
buildJsonArray {
add(0)
add(10)
},
)
put("query", query)
put(
"sort",
buildJsonArray {
add("score:desc")
add("scored_by:desc")
},
)
}
with(json) {
authClient.newCall(POST(url.toString(), body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKMangaPagination>()
.list
.map { it.toTrack(trackId) }
}
}
}
suspend fun getRead(track: Track): HKRead? {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
with(json) {
try {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<HKRead>()
} catch (e: HttpException) {
if (e.code == 404) {
null
} else {
throw e
}
}
}
}
}
suspend fun getManga(track: Track): TrackSearch {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon()
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<HKManga>()
.toTrack(trackId)
}
}
}
suspend fun deleteUserManga(track: DomainTrack) {
return withIOContext {
val slug = track.remoteUrl.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
.build()
authClient.newCall(DELETE(url.toString()))
.awaitSuccess()
}
}
suspend fun addUserManga(track: Track): Track {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
.build()
var rereads = getRead(track)?.rereads ?: 0
if (track.status == Hikka.REREADING && rereads == 0) {
rereads = 1
}
val payload = buildJsonObject {
put("note", "")
put("chapters", track.last_chapter_read.toInt())
put("volumes", 0)
put("rereads", rereads)
put("score", track.score.toInt())
put("status", track.toApiStatus())
put("start_date", if (track.started_reading_date > 0L) track.started_reading_date / 1000 else null)
put("end_date", if (track.finished_reading_date > 0L) track.finished_reading_date / 1000 else null)
}
with(json) {
authClient.newCall(PUT(url.toString(), body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKRead>()
.toTrack(trackId)
}
}
}
suspend fun updateUserManga(track: Track): Track = addUserManga(track)
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
companion object {
const val BASE_API_URL = "https://api.hikka.io"
const val BASE_URL = "https://hikka.io"
private const val SCOPE = "readlist,read:user-details"
private const val CLIENT_REFERENCE = "598ef1f5-b9d2-4e66-8b65-06949d5e14fc"
private const val CLIENT_SECRET = "OKwzrNOZxq40psFgfcCUYddnvaeZWDnd34rt7fdcB5GmHoBBQuNTWX" +
"61sZs8KECEWVXtMUDtq8QC4t9WX4DwWWYLXEVlgnlUXGT1fWCb-18c" +
"Zd2m8Co-8HN6JQcjoP-B"
fun authUrl(): Uri = "$BASE_URL/oauth".toUri().buildUpon()
.appendQueryParameter("reference", CLIENT_REFERENCE)
.appendQueryParameter("scope", SCOPE)
.build()
fun refreshTokenRequest(accessToken: String): Request {
val headers = Headers.Builder()
.add("auth", accessToken)
.build()
return GET("$BASE_API_URL/user/me", headers = headers) // Any request with auth
}
fun authTokenCreate(reference: String): Request {
val payload = buildJsonObject {
put("request_reference", reference)
put("client_secret", CLIENT_SECRET)
}
return POST("$BASE_API_URL/auth/token", body = payload.toString().toRequestBody(jsonMime))
}
fun authTokenInfo(accessToken: String): Request {
val headers = Headers.Builder()
.add("auth", accessToken)
.build()
return GET("$BASE_API_URL/auth/token/info", headers = headers)
}
}
}

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