Compare commits

..
45 Commits
Author SHA1 Message Date
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
120 changed files with 3119 additions and 2617 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.1](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.1"
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.1](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
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
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@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
@@ -83,7 +83,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
+31 -1
View File
@@ -11,6 +11,35 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff.
## [Unreleased]
### 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))
## [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
@@ -550,7 +579,8 @@ 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.1...main
[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: [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.
+4 -4
View File
@@ -32,8 +32,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 24
versionName = "0.20.0"
versionCode = 26
versionName = "0.20.1"
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
+6
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.**
+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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -129,6 +129,7 @@ class SyncChaptersWithSource(
chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder,
memo = chapter.memo,
)
if (chapter.dateUpload != 0L) {
@@ -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(
@@ -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() },
)
}
@@ -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)),
),
),
@@ -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,
)
}
}
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -71,12 +72,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 +81,12 @@ class AppLanguageScreen : Screen() {
)
}
},
supportingContent = {
it.localizedDisplayName?.let {
Text(it)
}
},
content = { Text(it.displayName) },
)
}
}
@@ -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
@@ -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,
@@ -111,16 +114,22 @@ 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,
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
@@ -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,14 +70,17 @@ fun ChapterNavigator(
currentPage: Int,
totalPages: Int,
onPageIndexChange: (Int) -> 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) }
@@ -103,7 +105,7 @@ fun ChapterNavigator(
)
if (type.isHorizontal()) {
ChapterNavigator(
HorizontalChapterNavigator(
isRtl = type == ChapterNavigatorType.HORIZONTAL_RTL,
state = state,
onNextChapter = onNextChapter,
@@ -116,6 +118,7 @@ fun ChapterNavigator(
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
modifier = modifier,
)
} else {
VerticalChapterNavigator(
@@ -130,12 +133,13 @@ fun ChapterNavigator(
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
modifier = modifier,
)
}
}
@Composable
fun ChapterNavigator(
fun HorizontalChapterNavigator(
isRtl: Boolean,
state: SliderState,
onNextChapter: () -> Unit,
@@ -148,13 +152,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 +237,10 @@ fun VerticalChapterNavigator(
mainAxisPadding: Dp,
backgroundColor: Color,
buttonColor: IconButtonColors,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
modifier = modifier
.fillMaxHeight()
.padding(vertical = mainAxisPadding),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -9,6 +9,7 @@ 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.ReadingMode
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
@@ -61,17 +62,41 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.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 screenModel.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
}
screenModel.preferences.verticalNavigator.set(newModes)
},
label = { Text(stringResource(mode.stringRes)) },
)
}
}
if (verticalNavigatorModes.isNotEmpty()) {
val verticalNavigatorHeightPref = screenModel.preferences.verticalNavigatorHeight
val verticalNavigatorHeight by verticalNavigatorHeightPref.collectAsState()
if (verticalNavigatorForLongStrip) {
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
pref = screenModel.preferences.verticalNavigatorOnLeft,
)
SliderItem(
label = stringResource(MR.strings.pref_vertical_navigator_height),
value = verticalNavigatorHeight,
valueRange = 65..100,
steps = 6,
onChange = { verticalNavigatorHeightPref.set(it) },
)
}
CheckboxItem(
@@ -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()
@@ -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).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.
*/
@@ -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,6 +8,7 @@ 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
@@ -285,12 +286,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 +312,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
}
}
}
@@ -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)
}
}
}
@@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.data.track.hikka
import eu.kanade.tachiyomi.data.track.hikka.dto.HKAuthTokenInfo
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
private val json: Json by injectLazy()
private var oauth: HKOAuth? = hikka.loadOAuth()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Hikka: You are not authorized")
if (currAuth.isExpired()) {
val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken))
if (!refreshTokenResponse.isSuccessful) {
refreshTokenResponse.close()
hikka.logout()
throw Exception("Hikka: The token is expired")
} else {
refreshTokenResponse.close()
}
val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken))
if (!authTokenInfoResponse.isSuccessful) {
authTokenInfoResponse.close()
throw Exception("Hikka: Auth token info failed")
}
val authTokenInfo = json.decodeFromString<HKAuthTokenInfo>(authTokenInfoResponse.body.string())
setAuth(HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created))
}
val authRequest = originalRequest.newBuilder()
.addHeader("auth", currAuth.accessToken)
.addHeader("accept", "application/json")
.build()
return chain.proceed(authRequest)
}
fun setAuth(oauth: HKOAuth?) {
this.oauth = oauth
hikka.saveOAuth(oauth)
}
}
@@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.track.hikka
import eu.kanade.tachiyomi.data.database.models.Track
import java.util.UUID
fun Track.toApiStatus() = when (status) {
Hikka.READING -> "reading"
Hikka.COMPLETED -> "completed"
Hikka.ON_HOLD -> "on_hold"
Hikka.DROPPED -> "dropped"
Hikka.PLAN_TO_READ -> "planned"
Hikka.REREADING -> "reading"
else -> throw NotImplementedError("Hikka: Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
"reading" -> Hikka.READING
"completed" -> Hikka.COMPLETED
"on_hold" -> Hikka.ON_HOLD
"dropped" -> Hikka.DROPPED
"planned" -> Hikka.PLAN_TO_READ
else -> throw NotImplementedError("Hikka: Unknown status: $status")
}
fun stringToNumber(input: String): Long {
val uuid = UUID.nameUUIDFromBytes(input.toByteArray())
return uuid.mostSignificantBits and Long.MAX_VALUE
}
@@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKAuthTokenInfo(
val reference: String,
val created: Long,
val client: HKClient,
val scope: List<String>,
val expiration: Long,
val used: Long,
)
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKClient(
val reference: String,
val name: String,
val description: String,
val verified: Boolean,
val user: HKUser,
val created: Long,
val updated: Long,
)
@@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.hikka.toTrackStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class HKManga(
@SerialName("data_type")
val dataType: String,
@SerialName("title_original")
val titleOriginal: String,
@SerialName("media_type")
val mediaType: String?,
@SerialName("title_ua")
val titleUa: String? = null,
@SerialName("title_en")
val titleEn: String? = null,
val chapters: Int? = null,
val volumes: Int? = null,
@SerialName("translated_ua")
val translatedUa: Boolean,
val status: String,
val image: String,
val year: Int? = null,
@SerialName("scored_by")
val scoredBy: Int,
val score: Double,
val slug: String,
@SerialName("start_date")
val startDate: Long? = null,
val read: List<HKRead>? = emptyList(),
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = stringToNumber(this@HKManga.slug)
title = this@HKManga.titleUa ?: this@HKManga.titleEn ?: this@HKManga.titleOriginal
total_chapters = this@HKManga.chapters?.toLong() ?: 0
cover_url = this@HKManga.image
score = this@HKManga.score
tracking_url = "${HikkaApi.BASE_URL}/manga/${this@HKManga.slug}"
publishing_status = this@HKManga.status
publishing_type = this@HKManga.mediaType?.replace("_", " ").orEmpty()
startDate?.takeIf { it != 0L }?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
start_date = try {
outputDf.format(it * 1000)
} catch (_: Exception) {
""
}
}
val userProgress = read?.firstOrNull()
if (userProgress != null) {
status = toTrackStatus(userProgress.status)
last_chapter_read = userProgress.chapters.toDouble()
score = userProgress.score.toDouble()
started_reading_date = (userProgress.startDate ?: 0L) * 1000
finished_reading_date = (userProgress.endDate ?: 0L) * 1000
}
}
}
}
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKMangaPagination(
val pagination: HKPagination,
val list: List<HKManga>,
)
@@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKOAuth(
@SerialName("secret")
val accessToken: String,
val expiration: Long,
val created: Long,
) {
fun isExpired(): Boolean {
val currentTime = System.currentTimeMillis() / 1000
val buffer = 5 * 60 // safety margin
return currentTime >= (expiration - buffer)
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKPagination(
val total: Int,
val pages: Int,
val page: Int,
)
@@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.hikka.toTrackStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKRead(
val reference: String,
val note: String?,
val updated: Long,
val created: Long,
val status: String,
val chapters: Int,
val volumes: Int,
val rereads: Int,
val score: Int,
@SerialName("start_date")
val startDate: Long? = null,
@SerialName("end_date")
val endDate: Long? = null,
val content: HKManga? = null,
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
val mangaContent = this@HKRead.content
if (mangaContent != null) {
title = mangaContent.titleUa ?: mangaContent.titleEn ?: mangaContent.titleOriginal
remote_id = stringToNumber(mangaContent.slug)
library_id = stringToNumber(mangaContent.slug)
total_chapters = mangaContent.chapters?.toLong() ?: 0
tracking_url = "${HikkaApi.BASE_URL}/manga/${mangaContent.slug}"
}
last_chapter_read = this@HKRead.chapters.toDouble()
score = this@HKRead.score.toDouble()
status = toTrackStatus(this@HKRead.status)
started_reading_date = startDate?.let { it * 1000 } ?: 0L
finished_reading_date = endDate?.let { it * 1000 } ?: 0L
}
}
}
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKUser(
val reference: String,
val username: String,
)
@@ -127,8 +127,9 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
override suspend fun login(username: String, password: String) {
val token = api.login(username, password)
interceptor.newAuth(token)
val userId = api.getCurrentUser()
saveCredentials(username, userId)
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.attributes.name)
saveCredentials(username, currentUser.id)
}
override fun logout() {
@@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
@@ -227,7 +228,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
}
suspend fun getCurrentUser(): String {
suspend fun getCurrentUser(): KitsuUser {
return withIOContext {
val url = "${BASE_URL}users".toUri().buildUpon()
.encodedQuery("filter[self]=true")
@@ -237,7 +238,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.awaitSuccess()
.parseAs<KitsuCurrentUserResult>()
.data[0]
.id
}
}
}
@@ -10,4 +10,10 @@ data class KitsuCurrentUserResult(
@Serializable
data class KitsuUser(
val id: String,
val attributes: KitsuUserAttributes,
)
@Serializable
data class KitsuUserAttributes(
val name: String,
)
@@ -0,0 +1,196 @@
package eu.kanade.tachiyomi.data.track.mangabaka
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.mangabaka.dto.MangaBakaOAuth
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 MangaBaka(id: Long) : BaseTracker(id, "MangaBaka"), DeletableTracker {
private val json: Json by injectLazy()
private val interceptor by lazy { MangaBakaInterceptor(this) }
private val api by lazy { MangaBakaApi(id, client, interceptor) }
override val supportsReadingDates: Boolean = true
override val supportsPrivateTracking: Boolean = true
private val scorePreference = trackPreferences.mangabakaScoreType
override fun getLogo(): Int = R.drawable.brand_mangabaka
override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, PAUSED, DROPPED, PLAN_TO_READ, REREADING, CONSIDERING)
}
override fun getStatus(status: Long): StringResource? = when (status) {
CONSIDERING -> MR.strings.considering
COMPLETED -> MR.strings.completed
DROPPED -> MR.strings.dropped
PAUSED -> MR.strings.paused
PLAN_TO_READ -> MR.strings.plan_to_read
READING -> MR.strings.reading
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> {
return when (scorePreference.get()) {
// 1, 2, ..., 99, 100
STEP_1 -> IntRange(0, 100).map(Int::toString).toImmutableList()
// 5, 10, ..., 95, 100
STEP_5 -> IntRange(0, 100).step(5).map(Int::toString).toImmutableList()
// 10, 20, ..., 90, 100
STEP_10 -> IntRange(0, 100).step(10).map(Int::toString).toImmutableList()
// 20, 40, ..., 80, 100
STEP_20 -> IntRange(0, 100).step(20).map(Int::toString).toImmutableList()
// 25, 50, 75, 100
STEP_25 -> IntRange(0, 100).step(25).map(Int::toString).toImmutableList()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: DomainTrack): String = track.score.toInt().toString()
override suspend fun update(
track: Track,
didReadChapter: Boolean,
): Track {
if (track.status != COMPLETED && didReadChapter) {
if (track.total_chapters > 0 && track.last_chapter_read.toLong() == track.total_chapters) {
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.updateLibManga(track)
}
override suspend fun bind(
track: Track,
hasReadChapters: Boolean,
): Track {
val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.title = remoteTrack.title
track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
update(track)
} else {
// Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0
api.addLibManga(track)
}
}
override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) {
query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id ->
return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList()
}
}
return api.search(query)
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.findLibManga(track) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
track.title = remoteTrack.title
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String) {
try {
val oauth = api.getAccessToken(code)
interceptor.setAuth(oauth)
val currentUser = api.getCurrentUser()
val scoreType = when (currentUser.ratingSteps) {
1 -> STEP_1
5 -> STEP_5
10 -> STEP_10
20 -> STEP_20
25 -> STEP_25
else -> throw Exception("Unknown score step size ${currentUser.ratingSteps}")
}
scorePreference.set(scoreType)
saveDisplayUsername(currentUser.nickname ?: currentUser.preferredUsername ?: currentUser.id)
saveCredentials("user", oauth.accessToken)
} catch (_: Exception) {
logout()
}
}
fun saveToken(oauth: MangaBakaOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
fun restoreToken(): MangaBakaOAuth? {
return try {
json.decodeFromString(trackPreferences.trackToken(this).get())
} catch (_: Exception) {
null
}
}
fun verifyOAuthState(state: String): Boolean = api.verifyOAuthState(state)
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
interceptor.setAuth(null)
}
override suspend fun delete(track: DomainTrack) {
api.deleteLibManga(track)
}
companion object {
const val READING = 1L
const val COMPLETED = 2L
const val PAUSED = 3L
const val DROPPED = 4L
const val PLAN_TO_READ = 5L
const val REREADING = 6L
const val CONSIDERING = 7L
const val STEP_1 = "STEP_1"
const val STEP_5 = "STEP_5"
const val STEP_10 = "STEP_10"
const val STEP_20 = "STEP_20"
const val STEP_25 = "STEP_25"
private const val SEARCH_ID_PREFIX = "id:"
}
}
@@ -0,0 +1,327 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItem
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItemResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaListResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaSearchResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfile
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfileResponse
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.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom
import java.util.Base64
import java.util.Locale
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
class MangaBakaApi(
private val trackId: Long,
baseClient: OkHttpClient,
interceptor: MangaBakaInterceptor,
) {
private val json: Json by injectLazy()
private val client = baseClient.newBuilder().addInterceptor {
it.request().newBuilder()
.header(
"User-Agent",
buildString {
append("${MR.strings.app_name}/v${BuildConfig.VERSION_NAME} ")
append("(${BuildConfig.APPLICATION_ID} ${BuildConfig.COMMIT_SHA}) ")
append("(Android) (https://github.com/mihonapp/mihon)")
},
)
.build()
.let(it::proceed)
}.build()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject {
put("is_private", track.private)
put("state", track.toApiStatus())
if (track.last_chapter_read > 0.0) {
put("progress_chapter", track.last_chapter_read)
}
if (track.score > 0) {
put("rating", track.score.toInt().coerceIn(0, 100))
}
if (track.started_reading_date > 0) {
put("start_date", track.started_reading_date.toLocalDate().toString())
}
if (track.finished_reading_date > 0) {
put("finish_date", track.finished_reading_date.toLocalDate().toString())
}
}
.toString()
.toRequestBody()
authClient
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess()
// only returns 201 with the body { "status": 201, "data": true }, so no library ID for us
track
}
}
suspend fun deleteLibManga(track: DomainTrack) {
withIOContext {
val url = "$LIBRARY_API_URL/${track.remoteId}"
authClient
.newCall(DELETE(url))
.awaitSuccess()
}
}
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
with(json) {
try {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val userData = authClient.newCall(GET(url))
.awaitSuccess()
.parseAs<MangaBakaListResult>()
.data
val additionalData = authClient.newCall(GET("$API_BASE_URL/v1/series/${track.remote_id}"))
.awaitSuccess()
.parseAs<MangaBakaItemResult>()
.data
Track.create(TrackerManager.MANGABAKA).apply {
remote_id = track.remote_id
title = additionalData.chooseBestTitle()
status = userData.getStatus()
score = userData.rating?.toDouble() ?: 0.0
started_reading_date = userData.startDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
finished_reading_date =
userData.finishDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
last_chapter_read = userData.progressChapter ?: 0.0
private = userData.isPrivate
}
} catch (e: HttpException) {
if (e.code == 404) {
null
} else {
throw e
}
}
}
}
}
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject {
put("state", track.toApiStatus())
put("is_private", track.private)
if (track.last_chapter_read > 0.0) {
put("progress_chapter", track.last_chapter_read)
} else {
put("progress_chapter", null)
}
if (track.score > 0) {
put("rating", track.score.toInt().coerceIn(0, 100))
} else {
put("rating", null)
}
if (track.started_reading_date > 0) {
put("start_date", track.started_reading_date.toLocalDate().toString())
} else {
put("start_date", null)
}
if (track.finished_reading_date > 0) {
put("finish_date", track.finished_reading_date.toLocalDate().toString())
} else {
put("finish_date", null)
}
}
.toString()
.toRequestBody()
authClient
.newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess()
track
}
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon()
.appendQueryParameter("q", search)
.appendQueryParameter("type_not", "novel")
.build()
with(json) {
client.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MangaBakaSearchResult>()
.data
.map { parseSearchItem(it) }
}
}
}
private fun parseSearchItem(item: MangaBakaItem): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = item.id
title = item.chooseBestTitle()
summary = item.description?.trim().orEmpty()
score = item.rating?.toBigDecimal()?.setScale(2, RoundingMode.HALF_UP)?.toDouble() ?: -1.0
cover_url = item.cover.x250.x1.orEmpty()
tracking_url = "$BASE_URL/${item.id}"
start_date = item.published.startDate.orEmpty()
publishing_status = item.status
publishing_type = item.type.replaceFirstChar { c ->
if (c.isLowerCase()) c.titlecase(Locale.getDefault()) else c.toString()
}
authors = item.authors.orEmpty()
artists = item.artists.orEmpty()
}
}
suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext {
val url = "$API_BASE_URL/v1/series".toUri().buildUpon()
.appendPath(id.toString())
.build()
with(json) {
try {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MangaBakaItemResult>()
.data
.let { parseSearchItem(it) }
} catch (e: HttpException) {
if (e.code == 404) {
return@with null
}
throw e
}
}
}
}
suspend fun getCurrentUser(): MangaBakaUserProfile {
return withIOContext {
with(json) {
authClient.newCall(GET("$API_BASE_URL/v1/my/profile"))
.awaitSuccess()
.parseAs<MangaBakaUserProfileResponse>()
.data
}
}
}
suspend fun getAccessToken(code: String): MangaBakaOAuth {
return withIOContext {
val formBody = FormBody.Builder()
.add("client_id", CLIENT_ID)
.add("code", code)
.add("code_verifier", codeVerifier)
.add("code_challenge_method", "S256")
.add("grant_type", "authorization_code")
.add("redirect_uri", REDIRECT_URI)
.add("scope", SCOPES)
.build()
with(json) {
client.newCall(POST("${OAUTH_URL}/token", body = formBody))
.awaitSuccess().parseAs()
}
}
}
fun verifyOAuthState(state: String): Boolean = state == oauthStateParam
companion object {
private const val CLIENT_ID = "zEZYMHXLWsLsafgbvJHXqzGvqQNOdkpo"
private const val BASE_URL = "https://mangabaka.org"
private const val API_BASE_URL = "https://api.mangabaka.org"
private const val LIBRARY_API_URL = "$API_BASE_URL/v1/my/library"
private const val OAUTH_URL = "$BASE_URL/auth/oauth2"
private const val SCOPES = "library.read library.write offline_access openid"
private const val REDIRECT_URI = "mihon://mangabaka-auth"
private const val APP_JSON = "application/json"
private var codeVerifier: String = ""
private var oauthStateParam: String = ""
fun authUrl(): Uri = "$OAUTH_URL/authorize".toUri().buildUpon() //
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("code_challenge", getPkceS256ChallengeCode())
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", SCOPES)
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("state", getOAuthStateParam())
.build()
fun refreshTokenRequest(token: String) = POST(
"$OAUTH_URL/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("refresh_token", token)
.add("redirect_uri", REDIRECT_URI)
.build(),
)
private fun getOAuthStateParam(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
oauthStateParam = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes)
return oauthStateParam
}
private fun getPkceS256ChallengeCode(): String {
// MangaBaka requires an actually conformant PKCE process, unlike MAL
// 1. create verifier
// 2. create challenge from verifier (S256 hash -> base64 URL encode)
// 3. send challenge to /authorize
// 4. send verifier for access tokens to /token
val codes = PkceUtil.generateS256Codes()
codeVerifier = codes.codeVerifier
return codes.codeChallenge
}
}
}
@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor {
private val json: Json by injectLazy()
private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka")
if (currentAuth.isExpired()) {
val response = chain.proceed(MangaBakaApi.refreshTokenRequest(currentAuth.refreshToken))
if (response.isSuccessful) {
currentAuth = json.decodeFromString(response.body.string())
setAuth(currentAuth)
} else {
response.close()
}
}
return originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${currentAuth.accessToken}")
.build()
.let(chain::proceed)
}
fun setAuth(oauth: MangaBakaOAuth?) {
this.oauth = oauth
mangaBaka.saveToken(oauth)
}
}
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toApiStatus() = when (status) {
MangaBaka.CONSIDERING -> "considering"
MangaBaka.COMPLETED -> "completed"
MangaBaka.DROPPED -> "dropped"
MangaBaka.PAUSED -> "paused"
MangaBaka.PLAN_TO_READ -> "plan_to_read"
MangaBaka.READING -> "reading"
MangaBaka.REREADING -> "rereading"
else -> throw NotImplementedError("Unknown status: $status")
}
@@ -0,0 +1,76 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
private val TITLE_PRIORITIES = listOf("en", "ja-Latn", "ja", "ko-Latn", "ko", "zh-Latn", "zh")
@Serializable
data class MangaBakaItemResult(
val data: MangaBakaItem,
)
@Serializable
data class MangaBakaSearchResult(
val data: List<MangaBakaItem>,
)
@Serializable
data class MangaBakaItem(
val id: Long,
val cover: MangaBakaCover,
val authors: List<String>?,
val artists: List<String>?,
val description: String?,
val published: MangaBakaPublishData,
val status: String,
val type: String,
val rating: Double?,
val titles: List<MangaBakaItemTitle>?,
) {
fun chooseBestTitle(): String {
// based on https://mangabaka.org/pages/announcements/15-titles-v2#finding-the-title-you-want
// extended with zh-Latn and zh
val bestTitlePerLanguage = TITLE_PRIORITIES.associateWith { lang ->
titles?.filter { it.language == lang }
?.minByOrNull {
when {
it.isPrimary -> 0
"official" in it.traits -> 1
"native" in it.traits -> 2
else -> 3
}
}
}
return TITLE_PRIORITIES
.firstNotNullOfOrNull { bestTitlePerLanguage[it]?.title }
?: titles?.firstOrNull()?.title
?: "ID: $id - Could not find name! (report on the MangaBaka Discord)"
}
}
@Serializable
data class MangaBakaCover(
val x250: MangaBakaScaledCover,
)
@Serializable
data class MangaBakaScaledCover(
val x1: String?,
)
@Serializable
data class MangaBakaPublishData(
@SerialName("start_date")
val startDate: String?,
)
@Serializable
data class MangaBakaItemTitle(
val language: String,
val traits: List<String>,
val title: String,
@SerialName("is_primary")
val isPrimary: Boolean,
)
@@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MangaBakaListResult(
val data: MangaBakaListEntry,
)
@Serializable
data class MangaBakaListEntry(
val state: String,
@SerialName("start_date")
val startDate: String?,
@SerialName("finish_date")
val finishDate: String?,
@SerialName("is_private")
val isPrivate: Boolean,
@SerialName("progress_chapter")
val progressChapter: Double?,
val rating: Long?,
) {
fun getStatus(): Long = when (state) {
"considering" -> MangaBaka.CONSIDERING
"completed" -> MangaBaka.COMPLETED
"dropped" -> MangaBaka.DROPPED
"paused" -> MangaBaka.PAUSED
"plan_to_read" -> MangaBaka.PLAN_TO_READ
"reading" -> MangaBaka.READING
"rereading" -> MangaBaka.REREADING
else -> throw NotImplementedError("Unknown status: $state")
}
}
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@Serializable
data class MangaBakaOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("expires_at")
val expiresAt: Long,
@SerialName("token_type")
val tokenType: String,
val scope: String,
) {
fun isExpired(): Boolean = Clock.System.now().plus(1.minutes).epochSeconds > expiresAt
}
@@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MangaBakaUserProfileResponse(
val data: MangaBakaUserProfile,
)
@Serializable
data class MangaBakaUserProfile(
// incomplete DTO since this is the only part we need
val id: String,
@SerialName("rating_steps")
val ratingSteps: Int,
val nickname: String?,
@SerialName("preferred_username")
val preferredUsername: String?,
)
@@ -107,8 +107,10 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
override suspend fun login(username: String, password: String) {
val authenticated = api.authenticate(username, password)
saveCredentials(authenticated.uid.toString(), authenticated.sessionToken)
interceptor.newAuth(authenticated.sessionToken)
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.username)
saveCredentials(authenticated.uid.toString(), authenticated.sessionToken)
}
fun restoreSession(): String? {
@@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUContext
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUCurrentUser
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MULoginResponse
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating
@@ -190,6 +191,14 @@ class MangaUpdatesApi(
}
}
suspend fun getCurrentUser(): MUCurrentUser {
return with(json) {
authClient.newCall(GET("$BASE_URL/v1/account/profile"))
.awaitSuccess()
.parseAs<MUCurrentUser>()
}
}
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"
@@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class MUCurrentUser(
val username: String,
)
@@ -137,6 +137,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
val oauth = api.getAccessToken(authCode)
interceptor.setAuth(oauth)
val username = api.getCurrentUser()
saveDisplayUsername(username)
saveCredentials(username, oauth.accessToken)
} catch (e: Throwable) {
logout()
@@ -61,7 +61,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUsername())
val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
@@ -85,7 +85,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
}
override suspend fun refresh(track: Track): Track {
api.findLibManga(track, getUsername())?.let { remoteTrack ->
api.findLibManga(track, isRefresh = true)?.let { remoteTrack ->
track.library_id = remoteTrack.library_id
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
@@ -122,7 +122,8 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.accessToken)
saveDisplayUsername(user.nickname)
saveCredentials(user.id, oauth.accessToken)
} catch (e: Throwable) {
logout()
}
@@ -5,12 +5,12 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMAddMangaResponse
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMManga
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMSearchResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListEntry
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserResult
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
@@ -77,58 +77,123 @@ class ShikimoriApi(
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val url = "$API_URL/mangas".toUri().buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
val query = $$"""
|query($query: String) {
|mangas(search: $query, limit: 20, kind:"!light_novel,!novel") {
|id
|name
|chapters
|kind
|poster {
|mainUrl
|}
|score
|url
|status
|airedOn {
|date
|}
|description
|personRoles {
|person {
|name
|}
|rolesEn
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
}
with(json) {
authClient.newCall(GET(url.toString()))
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<List<SMManga>>()
.parseAs<SMSearchResult>()
.data.mangas
.map { it.toTrack(trackId) }
}
}
}
suspend fun findLibManga(track: Track, userId: String): Track? {
suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? {
return withIOContext {
val urlMangas = "$API_URL/mangas".toUri().buildUpon()
.appendPath(track.remote_id.toString())
.build()
val manga = with(json) {
authClient.newCall(GET(urlMangas.toString()))
.awaitSuccess()
.parseAs<SMManga>()
}
val query = $$"""
|query($id: String) {
|mangas(ids: $id, limit: 1) {
|id
|url
|name
|chapters
|userRate {
|id
|chapters
|status
|score
|}
|}
|}
""".trimMargin()
val url = "$API_URL/v2/user_rates".toUri().buildUpon()
.appendQueryParameter("user_id", userId)
.appendQueryParameter("target_id", track.remote_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("id", track.remote_id.toString())
}
}
with(json) {
authClient.newCall(GET(url.toString()))
val listResult = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<List<SMUserListEntry>>()
.let { entries ->
if (entries.size > 1) {
throw Exception("Too many manga in response")
}
entries
.map { it.toTrack(trackId, manga) }
.firstOrNull()
}
.parseAs<SMUserListResult>()
.data.mangas
.firstOrNull()
// Shikimori has no user list query that allows query by ID, so we go via the "mangas" query & include
// userRate data which will be null if the title is not in the user's list.
// If it was removed on Shikimori and is still linked in the app, notify user via returning null here
// which throws an exception at the Shikimori.refresh call
if (isRefresh && listResult?.userRate == null) return@with null
listResult?.toTrack(trackId)
}
}
}
suspend fun getCurrentUser(): Int {
suspend fun getCurrentUser(): SMUser {
return with(json) {
authClient.newCall(GET("$API_URL/users/whoami"))
val query = """
|{
|currentUser {
|id
|nickname
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<SMUser>()
.id
.parseAs<SMUserResult>()
.data.currentUser
}
}
@@ -154,8 +219,9 @@ class ShikimoriApi(
)
companion object {
const val BASE_URL = "https://shikimori.one"
private const val BASE_URL = "https://shikimori.io"
private const val API_URL = "$BASE_URL/api"
private const val GRAPHQL_API_URL = "$BASE_URL/api/graphql"
private const val OAUTH_URL = "$BASE_URL/oauth/token"
private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
@@ -1,40 +1,73 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SMSearchResult(
val data: SMMangaResults,
)
@Serializable
data class SMMangaResults(
val mangas: List<SMManga>,
)
@Serializable
data class SMManga(
val id: Long,
val name: String,
val chapters: Long,
val image: SUMangaCover,
val score: Double,
val score: Double?,
val url: String,
val status: String,
val kind: String,
@SerialName("aired_on")
val airedOn: String?,
val status: String?,
val poster: SMPoster?,
val airedOn: SMAiredDate?,
val description: String?,
val kind: String?,
val personRoles: List<SMPersonRole>?,
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = this@SMManga.id
title = name
total_chapters = chapters
cover_url = ShikimoriApi.BASE_URL + image.preview
summary = ""
score = this@SMManga.score
tracking_url = ShikimoriApi.BASE_URL + url
publishing_status = this@SMManga.status
publishing_type = kind
start_date = airedOn ?: ""
cover_url = poster?.mainUrl.orEmpty()
summary = description.orEmpty()
score = this@SMManga.score?.takeIf { it > 0.0 } ?: -1.0
tracking_url = url
publishing_status = this@SMManga.status.orEmpty()
publishing_type = kind?.replace("one_shot", "oneshot").orEmpty()
start_date = airedOn?.date.orEmpty()
personRoles?.forEach { personRole ->
personRole.roles.forEach { role ->
if ("Story" in role) authors += personRole.person.name
if ("Art" in role) artists += personRole.person.name
}
}
}
}
}
@Serializable
data class SUMangaCover(
val preview: String,
data class SMPoster(
val mainUrl: String,
)
@Serializable
data class SMAiredDate(
val date: String?,
)
@Serializable
data class SMPersonRole(
val person: SMPerson,
@SerialName("rolesEn")
val roles: List<String>,
)
@Serializable
data class SMPerson(
val name: String,
)
@@ -3,6 +3,17 @@ package eu.kanade.tachiyomi.data.track.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMUserResult(
val data: SMCurrentUser,
)
@Serializable
data class SMCurrentUser(
val currentUser: SMUser,
)
@Serializable
data class SMUser(
val id: Int,
val id: String,
val nickname: String,
)
@@ -1,27 +1,52 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SMUserListEntry(
val id: Long,
val chapters: Double,
val score: Int,
val status: String,
data class SMUserListResult(
val data: SMUserListEntries,
)
@Serializable
data class SMUserListEntries(
val mangas: List<SMUserListManga>,
)
@Serializable
data class SMUserListManga(
val id: String,
val url: String,
val name: String,
@SerialName("chapters")
val totalChapters: Long, // the title's total chapters
val userRate: SMUserRate?,
) {
fun toTrack(trackId: Long, manga: SMManga): Track {
fun toTrack(trackId: Long): Track {
return Track.create(trackId).apply {
title = manga.name
remote_id = this@SMUserListEntry.id
total_chapters = manga.chapters
library_id = this@SMUserListEntry.id
last_chapter_read = this@SMUserListEntry.chapters
score = this@SMUserListEntry.score.toDouble()
status = toTrackStatus(this@SMUserListEntry.status)
tracking_url = ShikimoriApi.BASE_URL + manga.url
title = name
total_chapters = totalChapters
tracking_url = url
if (userRate != null) {
// null if not in user's list, must not throw here because it'd break adding titles
// throws in the findLibManga method of ShikimoriApi if null and shouldn't be
remote_id = userRate.rateId.toLong()
library_id = userRate.rateId.toLong()
last_chapter_read = userRate.chapters.toDouble()
score = userRate.score
status = toTrackStatus(userRate.status)
}
}
}
}
@Serializable
data class SMUserRate(
@SerialName("id")
val rateId: String, // ID of the list entry (NOT the title)
val chapters: Long, // the user's chapter progress
val status: String,
val score: Double,
)
@@ -24,7 +24,9 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.DefaultXmlSerializationPolicy
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlConfig
import tachiyomi.core.common.storage.AndroidStorageFolderProvider
import tachiyomi.data.Chapters
import tachiyomi.data.Database
@@ -92,15 +94,15 @@ class AppModule(val app: Application) : InjektModule {
explicitNulls = false
}
}
addSingletonFactory {
XML {
defaultPolicy {
addSingletonFactory<XML> {
XML.v1 {
policy {
ignoreUnknownChildren()
autoPolymorphic = true
}
autoPolymorphic = true
xmlDeclMode = XmlDeclMode.Charset
indent = 2
xmlVersion = XmlVersion.XML10
setIndent(2)
}
}
addSingletonFactory<ProtoBuf> {
@@ -54,8 +54,7 @@ internal object ExtensionLoader {
private const val METADATA_EXTENSION_LIB = "tachiyomix.extensionLib"
private const val METADATA_CONTENT_WARNING = "tachiyomix.contentWarning"
const val LIB_VERSION_MIN = 1.4
const val LIB_VERSION_MAX = 1.6
private val SUPPORTED_LIB_VERSIONS = listOf(1.4, 1.6)
@Suppress("DEPRECATION")
private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
@@ -244,22 +243,17 @@ internal object ExtensionLoader {
}
// Validate lib version
val libVersion = appInfo.metaData.getDouble(METADATA_EXTENSION_LIB).takeUnless { it == 0.0 }
val libVersion = appInfo.metaData.getFloat(METADATA_EXTENSION_LIB)
.takeUnless { it == 0.0f }
?.toString()
?.toDouble()
?: versionName.substringBeforeLast('.').toDoubleOrNull()
if (libVersion == null || (libVersion != LIB_VERSION_MIN && libVersion != LIB_VERSION_MAX)) {
if (libVersion == null || libVersion !in SUPPORTED_LIB_VERSIONS) {
logcat(LogPriority.WARN) {
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN and $LIB_VERSION_MAX is allowed"
"Lib version is $libVersion, while only version(s) ${SUPPORTED_LIB_VERSIONS.joinToString()} are supported"
}
return LoadResult.Error
}
// if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
// logcat(LogPriority.WARN) {
// "Lib version is $libVersion, while only versions " +
// "$LIB_VERSION_MIN or $LIB_VERSION_MAX is allowed"
// }
// return LoadResult.Error
// }
val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) {
@@ -461,8 +461,12 @@ class ReaderActivity : BaseActivity() {
val isPagerType = ReadingMode.isPagerType(viewModel.getMangaReadingMode())
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
val verticalNavigatorForLongStrip by readerPreferences.verticalNavigatorForLongStrip.collectAsState()
val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState()
val verticalNavigator = verticalNavigatorModes.contains(
ReadingMode.fromPreference(viewModel.getMangaReadingMode()),
)
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState()
ReaderAppBars(
visible = state.menuVisible,
@@ -477,7 +481,7 @@ class ReaderActivity : BaseActivity() {
onOpenInBrowser = ::openChapterInBrowser.takeIf { isHttpSource },
onShare = ::shareChapter.takeIf { isHttpSource },
chapterNavigatorType = if (isPagerType || !verticalNavigatorForLongStrip) {
chapterNavigatorType = if (!verticalNavigator) {
if (state.viewer is R2LPagerViewer) {
ChapterNavigatorType.HORIZONTAL_RTL
} else {
@@ -490,6 +494,7 @@ class ReaderActivity : BaseActivity() {
ChapterNavigatorType.VERTICAL_RIGHT
}
},
verticalNavigatorHeight = verticalNavigatorHeight / 100f,
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
@@ -6,6 +6,7 @@ import dev.icerock.moko.resources.StringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.i18n.MR
class ReaderPreferences(
@@ -28,16 +29,21 @@ class ReaderPreferences(
val showPageNumber: Preference<Boolean> = preferenceStore.getBoolean("pref_show_page_number_key", true)
val verticalNavigatorForLongStrip: Preference<Boolean> = preferenceStore.getBoolean(
"pref_webtoon_vertical_navigator",
true,
val verticalNavigator: Preference<Set<ReadingMode>> = preferenceStore.getEnumSet(
"pref_vertical_navigator",
emptySet(),
)
val verticalNavigatorOnLeft: Preference<Boolean> = preferenceStore.getBoolean(
"pref_webtoon_vertical_navigator_on_left",
"pref_vertical_navigator_on_left",
false,
)
val verticalNavigatorHeight: Preference<Int> = preferenceStore.getInt(
"pref_vertical_navigator_height",
65,
)
val showReadingMode: Preference<Boolean> = preferenceStore.getBoolean("pref_show_reading_mode", true)
val fullscreen: Preference<Boolean> = preferenceStore.getBoolean("fullscreen", true)
@@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
class TrackLoginActivity : BaseOAuthLoginActivity() {
@@ -24,8 +26,10 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
when (uri.host) {
"anilist-auth" -> handleAniList(data["access_token"])
"bangumi-auth" -> handleBangumi(data["code"])
"mangabaka-auth" -> handleMangaBaka(data["code"], data["state"])
"myanimelist-auth" -> handleMyAnimeList(data["code"])
"shikimori-auth" -> handleShikimori(data["code"])
"hikka-auth" -> handleHikka(data["reference"])
}
returnToSettings()
}
@@ -47,6 +51,22 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
}
}
private suspend fun handleMangaBaka(code: String?, state: String?) {
if (state == null) {
logcat(LogPriority.WARN) { "Did not receive state parameter from MangaBaka OAuth" }
return
}
if (code != null) {
if (!trackerManager.mangaBaka.verifyOAuthState(state)) {
logcat(LogPriority.WARN) { "Received wrong OAuth state back from MangaBaka" }
return
}
trackerManager.mangaBaka.login(code)
} else {
trackerManager.mangaBaka.logout()
}
}
private suspend fun handleMyAnimeList(code: String?) {
if (code != null) {
trackerManager.myAnimeList.login(code)
@@ -62,4 +82,12 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
trackerManager.shikimori.logout()
}
}
private suspend fun handleHikka(reference: String?) {
if (reference != null) {
trackerManager.hikka.login(reference)
} else {
trackerManager.hikka.logout()
}
}
}
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.util
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
@@ -12,4 +13,17 @@ object PkceUtil {
.withoutPadding()
.encodeToString(codeVerifier)
}
fun generateS256Codes(): PkceCodes {
val codeVerifier = generateCodeVerifier()
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
val codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
return PkceCodes(codeVerifier = codeVerifier, codeChallenge = codeChallenge)
}
}
data class PkceCodes(
val codeVerifier: String,
val codeChallenge: String,
)
@@ -81,6 +81,10 @@ data class DummyTracker(
override fun getUsername(): String = "username"
override fun getDisplayUsername(): String = "UserName"
override fun saveDisplayUsername(displayName: String): Unit = Unit
override fun getPassword(): String = "passw0rd"
override fun saveCredentials(username: String, password: String) = Unit
@@ -2,7 +2,10 @@ package mihon.core.migration
import uy.kohesive.injekt.Injekt
class MigrationContext(val dryrun: Boolean) {
class MigrationContext(
val dryrun: Boolean,
val previousVersion: Int,
) {
inline fun <reified T> get(): T? {
return Injekt.getInstanceOrNull(T::class.java)
@@ -19,7 +19,7 @@ object Migrator {
dryrun: Boolean = false,
onMigrationComplete: () -> Unit,
) {
val migrationContext = MigrationContext(dryrun)
val migrationContext = MigrationContext(dryrun, old)
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
val strategy = migrationStrategyFactory.create(old, new)
@@ -9,4 +9,5 @@ val migrations: List<Migration>
TrustExtensionRepositoryMigration(),
CategoryPreferencesCleanupMigration(),
InstallationIdMigration(),
VerticalNavigatorMigration(),
)
@@ -0,0 +1,33 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
class VerticalNavigatorMigration : Migration {
override val version: Float = 25f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val preferenceStore = migrationContext.get<PreferenceStore>() ?: return@withIOContext false
val readerPreferences = migrationContext.get<ReaderPreferences>() ?: return@withIOContext false
if (migrationContext.previousVersion == 24) {
val oldVerticalNavigator = preferenceStore.getBoolean("pref_webtoon_vertical_navigator", true)
if (oldVerticalNavigator.get()) {
readerPreferences.verticalNavigator.set(setOf(ReadingMode.WEBTOON, ReadingMode.CONTINUOUS_VERTICAL))
}
if (oldVerticalNavigator.isSet()) oldVerticalNavigator.delete()
}
val oldVerticalNavigatorOnLeft = preferenceStore.getBoolean("pref_webtoon_vertical_navigator_on_left", false)
if (oldVerticalNavigatorOnLeft.isSet()) {
readerPreferences.verticalNavigatorOnLeft.set(oldVerticalNavigatorOnLeft.get())
oldVerticalNavigatorOnLeft.delete()
}
return@withIOContext true
}
}
@@ -268,27 +268,7 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = source.source)
Text(
text = source.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (showLanguage) {
Pill(
text = LocaleHelper.getShortDisplayName(source.shortLanguage, uppercase = true),
style = MaterialTheme.typography.bodySmall,
)
}
}
},
modifier = Modifier.clickable(onClick = onClick),
trailingContent = if (dragEnabled) {
{
Icon(
@@ -302,11 +282,28 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
} else {
null
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
modifier = Modifier.clickable(onClick = onClick),
)
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = source.source)
Text(
text = source.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (showLanguage) {
Pill(
text = LocaleHelper.getShortDisplayName(source.shortLanguage, uppercase = true),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
private class ScreenModel(
@@ -201,16 +201,16 @@ private fun MigrationSheetSwitchItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(text = title) },
supportingContent = subtitle?.let { { Text(text = subtitle) } },
modifier = Modifier.clickable(onClick = onClick),
trailingContent = {
Switch(
checked = checked,
onCheckedChange = null,
)
},
supportingContent = subtitle?.let { { Text(text = subtitle) } },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick),
content = { Text(text = title) },
)
}
@@ -231,13 +231,12 @@ private fun MigrationSheetWarningItem(
tint = MaterialTheme.colorScheme.active,
)
},
headlineContent = {
Text(
text = text,
color = MaterialTheme.colorScheme.error,
modifier = Modifier,
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
) {
Text(
text = text,
color = MaterialTheme.colorScheme.error,
modifier = Modifier,
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+29
View File
@@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="192dp"
android:height="192dp"
android:viewportWidth="192"
android:viewportHeight="192">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h192v192H0z"
android:strokeLineJoin="round"
android:fillType="evenOdd"/>
<path
android:pathData="m53.06,32.02 l-21.8,21.77a8.59,8.59 0,0 0,0.03 12.19l24.2,23.94a8.59,8.59 0,0 1,0.12 12.1l-24.46,25.13a8.59,8.59 0,0 0,0.18 12.17l22.08,21.38a8.59,8.59 0,0 0,12.02 -0.06l24.36,-24.09a8.6,8.6 0,0 1,12.12 0.03l23.98,23.94a8.59,8.59 0,0 0,12.11 0.03l22.03,-21.77c3.41,-3.38 3.4,-8.89 -0.03,-12.25l-24.26,-23.76c-3.44,-3.37 -3.44,-8.91 -0,-12.28l24.96,-24.47a8.59,8.59 0,0 0,0.03 -12.25l-22.64,-22.39a8.59,8.59 0,0 0,-12.2 0.12l-23.98,24.64a8.59,8.59 0,0 1,-12.2 0.12L65.18,31.99a8.6,8.6 0,0 0,-12.12 0.03"
android:strokeLineJoin="round"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startX="37.31"
android:startY="40.85"
android:endX="166.82"
android:endY="133.86"
android:type="linear">
<item android:offset="0" android:color="#FFD691F7"/>
<item android:offset="0.29" android:color="#FFD691F7"/>
<item android:offset="1" android:color="#FF226CBB"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -30,7 +30,7 @@ class MigratorTest {
@BeforeEach
fun initialize() {
migrationContext = MigrationContext(false)
migrationContext = MigrationContext(false, 0)
migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job())))
migrationCompletedListener = spyk<MigrationCompletedListener>(block = {})
migrationStrategyFactory = spyk(MigrationStrategyFactory(migrationJobFactory, migrationCompletedListener))
@@ -118,13 +118,26 @@ suspend fun Call.awaitSuccess(): Response {
return response
}
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
fun OkHttpClient.newCachelessCallWithProgress(
request: Request,
listener: ProgressListener,
existingSize: Long = 0L,
): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
val request = chain.request()
.newBuilder()
.apply {
if (existingSize > 0 && request.header("Range") == null) {
header("Range", "bytes=$existingSize-")
}
}
.build()
val originalResponse = chain.proceed(request)
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body, listener))
.body(ProgressResponseBody(originalResponse.body, listener, existingSize))
.build()
}
.build()
@@ -12,6 +12,7 @@ import java.io.IOException
class ProgressResponseBody(
private val responseBody: ResponseBody,
private val progressListener: ProgressListener,
private val existingSize: Long, // bytes already downloaded
) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
@@ -32,7 +33,7 @@ class ProgressResponseBody(
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
var totalBytesRead = existingSize
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
@@ -212,4 +212,25 @@ sealed class AndroidPreference<T>(
putInt(key, serializer(value))
}
}
class ObjectSetAsStringSet<T>(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Set<T>,
private val serializer: (T) -> String,
private val deserializer: (String) -> T?,
) : AndroidPreference<Set<T>>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Set<T>): Set<T> {
return try {
preferences.getStringSet(key, null)?.mapNotNull(deserializer)?.toSet() ?: defaultValue
} catch (_: Exception) {
defaultValue
}
}
override fun write(key: String, value: Set<T>): Editor.() -> Unit = {
putStringSet(key, value.map(serializer).toSet())
}
}
}
@@ -11,6 +11,7 @@ import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive
import tachiyomi.core.common.preference.AndroidPreference.LongPrimitive
import tachiyomi.core.common.preference.AndroidPreference.ObjectAsInt
import tachiyomi.core.common.preference.AndroidPreference.ObjectAsString
import tachiyomi.core.common.preference.AndroidPreference.ObjectSetAsStringSet
import tachiyomi.core.common.preference.AndroidPreference.StringPrimitive
import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive
@@ -77,6 +78,22 @@ class AndroidPreferenceStore(
)
}
override fun <T> getObjectSetFromStringSet(
key: String,
defaultValue: Set<T>,
serializer: (T) -> String,
deserializer: (String) -> T?,
): Preference<Set<T>> {
return ObjectSetAsStringSet(
preferences = sharedPreferences,
keyFlow = keyFlow,
key = key,
defaultValue = defaultValue,
serializer = serializer,
deserializer = deserializer,
)
}
override fun getAll(): Map<String, *> {
return sharedPreferences.all ?: emptyMap<String, Any>()
}
@@ -48,7 +48,11 @@ class InMemoryPreferenceStore(
}
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
TODO("Not yet implemented")
val default = InMemoryPreference(key, null, defaultValue)
@Suppress("UNCHECKED_CAST")
val data: Set<String>? = preferences[key]?.get() as? Set<String>
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
}
@Suppress("UNCHECKED_CAST")
@@ -75,6 +79,18 @@ class InMemoryPreferenceStore(
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
}
@Suppress("UNCHECKED_CAST")
override fun <T> getObjectSetFromStringSet(
key: String,
defaultValue: Set<T>,
serializer: (T) -> String,
deserializer: (String) -> T?,
): Preference<Set<T>> {
val default = InMemoryPreference(key, null, defaultValue)
val data: Set<T>? = preferences[key]?.get() as? Set<T>
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
}
override fun getAll(): Map<String, *> {
return preferences
}
@@ -28,6 +28,13 @@ interface PreferenceStore {
deserializer: (Int) -> T,
): Preference<T>
fun <T> getObjectSetFromStringSet(
key: String,
defaultValue: Set<T>,
serializer: (T) -> String,
deserializer: (String) -> T?,
): Preference<Set<T>>
fun getAll(): Map<String, *>
}
@@ -60,3 +67,21 @@ inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
},
)
}
inline fun <reified T : Enum<T>> PreferenceStore.getEnumSet(
key: String,
defaultValue: Set<T>,
): Preference<Set<T>> {
return getObjectSetFromStringSet(
key = key,
defaultValue = defaultValue,
serializer = { it.name },
deserializer = {
try {
enumValueOf<T>(it)
} catch (_: IllegalArgumentException) {
null
}
},
)
}
@@ -1,14 +1,20 @@
package tachiyomi.domain.manga.model
import android.annotation.SuppressLint
import androidx.compose.runtime.Immutable
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import mihon.core.common.extensions.EMPTY
import tachiyomi.core.common.preference.TriState
import java.io.Serializable
import java.io.ObjectStreamException
import java.time.Instant
import java.io.Serializable as JavaSerializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable
@Immutable
data class Manga(
val id: Long,
@@ -36,7 +42,7 @@ data class Manga(
val version: Long,
val notes: String,
val memo: JsonObject,
) : Serializable {
) : JavaSerializable {
val expectedNextUpdate: Instant?
get() = nextUpdate
@@ -134,4 +140,17 @@ data class Manga(
memo = JsonObject.EMPTY,
)
}
@Throws(ObjectStreamException::class)
private fun writeReplace(): Any {
return JavaToKotlinXSerializable(Json.encodeToString<Manga>(this))
}
class JavaToKotlinXSerializable(private val data: String) : JavaSerializable {
@Throws(ObjectStreamException::class)
private fun readResolve(): Any {
return Json.decodeFromString<Manga>(data)
}
}
}
@@ -5,13 +5,11 @@ import org.gradle.api.Project
interface BuildConfig {
val includeTelemetry: Boolean
val enableUpdater: Boolean
val enableCodeShrink: Boolean
val includeDependencyInfo: Boolean
}
val Project.Config: BuildConfig get() = object : BuildConfig {
override val includeTelemetry: Boolean = project.hasProperty("include-telemetry")
override val enableUpdater: Boolean = project.hasProperty("enable-updater")
override val enableCodeShrink: Boolean = !project.hasProperty("disable-code-shrink")
override val includeDependencyInfo: Boolean = project.hasProperty("include-dependency-info")
}
+14 -14
View File
@@ -1,14 +1,14 @@
[versions]
aboutLibraries = "14.2.1"
aboutLibraries = "15.0.3"
android-desugar = "2.1.5"
android-gradle = "9.2.1"
androidx-activity-compose = "1.13.0"
androidx-annotation = "1.10.0"
androidx-appCompat = "1.7.1"
androidx-baselineProfile = "1.5.0-alpha06"
androidx-baselineProfile = "1.5.0-alpha07"
androidx-benchmark = "1.4.1"
androidx-biometric = "1.2.0-alpha05"
androidx-compose-bom = "2026.06.00"
androidx-compose-bom = "2026.06.01"
androidx-constraintLayout = "2.2.1"
androidx-core = "1.19.0"
androidx-coreSplashScreen = "1.2.0"
@@ -19,19 +19,19 @@ androidx-paging = "3.5.0"
androidx-preference = "1.2.1"
androidx-profileInstaller = "1.4.1"
androidx-recyclerView = "1.4.0"
androidx-sqlite = "2.6.2"
androidx-sqlite = "2.7.0"
androidx-test-espresso = "3.7.0"
androidx-test-junit = "1.3.0"
androidx-test-uiautomator = "2.3.0"
androidx-test-uiautomator = "2.4.0"
androidx-viewPager = "1.1.0"
androidx-work = "2.11.2"
archive = "1.1.6"
coil = "3.5.0"
composeGrid = "1.2.2"
composeMaterialMotion = "2.0.1"
composeRichEditor = "1.0.0-rc14"
composeRichEditor = "1.0.0-rc13"
composeWebview = "0.33.6"
conscrypt = "2.5.3"
conscrypt = "2.6.0"
directionalViewPager = "1.0.0"
diskLruCache = "2.0.2"
firebase-bom = "34.15.0"
@@ -41,8 +41,8 @@ google-services = "4.5.0"
image-decoder = "e03b81e18a"
injekt = "91edab2317"
jsoup = "1.22.2"
junit = "6.1.0"
kotest-assertions = "6.2.1"
junit = "6.1.1"
kotest-assertions = "6.2.2"
kotlin-gradle = "2.4.0"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.11.0"
@@ -61,16 +61,16 @@ quickJs = "547f5b1597"
reorderable = "3.1.0"
rxJava = "1.3.8"
shizuku = "13.1.5"
spotless = "8.7.0"
spotless = "8.8.0"
sqldelight = "2.3.2"
sqldelight-androidx-driver = "0637a964a1"
stringSimilarity = "0.1.0"
subsamplingScaleImageView = "66e0db195d"
swipe = "1.3.0"
tapmoc = "0.4.2"
unifile = "e0def6b3dc"
voyager = "1.1.0-beta03"
xmlutil = "0.91.3"
unifile = "08f224c8f9"
voyager = "2.2.21-1.10.3"
xmlutil = "1.0.1"
[libraries]
aboutLibraries-compose = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
@@ -176,7 +176,7 @@ stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin",
subsamplingScaleImageView = { module = "com.github.tachiyomiorg:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" }
swipe = { module = "me.saket.swipe:swipe", version.ref = "swipe" }
tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" }
unifile = { module = "com.github.tachiyomiorg:unifile", version.ref = "unifile" }
unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenModel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>
@@ -400,7 +400,8 @@
<string name="pref_flash_style_white_black">White and Black</string>
<string name="pref_double_tap_anim_speed">Double tap animation speed</string>
<string name="pref_show_page_number">Show page number</string>
<string name="pref_webtoon_vertical_navigator">Use vertical navigator for long strip reading</string>
<string name="pref_vertical_navigator">Use vertical chapter navigator in</string>
<string name="pref_vertical_navigator_height">Vertical navigator height</string>
<string name="pref_webtoon_vertical_navigator_on_left">Place vertical navigator on the left side</string>
<string name="pref_show_reading_mode">Show reading mode</string>
<string name="pref_show_reading_mode_summary">Briefly show current mode when reader is opened</string>
@@ -786,6 +787,7 @@
<string name="on_hold">On hold</string>
<string name="paused">Paused</string>
<string name="plan_to_read">Plan to read</string>
<string name="considering">Considering</string>
<string name="repeating">Rereading</string>
<string name="reading_list">Reading List</string>
<string name="wish_list">Wish List</string>
@@ -904,7 +904,6 @@
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Chcete přidat níže uvedený obchod s doplňky?</string>
<string name="extensionStoresScreen.deleteStore.title">Odstranit obchod doplňků</string>
<string name="extensionStoresScreen.deleteStore.body">Chcete odstranit obchod s doplňky „%1$s“ (%2$s)?</string>
<string name="pref_webtoon_vertical_navigator">Pro čtení dlouhých úseků použijte vertikální navigační lištu</string>
<string name="pref_webtoon_vertical_navigator_on_left">Umístěte svislý navigační panel na levou stranu</string>
<string name="supportUsScreen.perks">Patronům se otevírá přístup k exkluzivním kanálům a další výhody budou následovat.</string>
<string name="supportUsScreen.donationPlatform.patreon">Patreon (upřednostňováno)</string>
@@ -918,4 +917,7 @@
<string name="donationCampaign.paragraph3">Ať už to dopadne jakkoli, o Mihon se budu i nadále starat já spolu s dobrovolníky a zůstane open source.</string>
<string name="donationCampaign.contactPlatform">Discord</string>
<string name="donationCampaign.dismiss">Zavřít</string>
<string name="considering">S ohledem na</string>
<string name="pref_vertical_navigator">Použijte svislý navigační panel kapitol v</string>
<string name="pref_vertical_navigator_height">Vertikální výška navigátoru</string>
</resources>
@@ -916,6 +916,5 @@
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Möchtest du dieses Erweiterungsdepot hinzufügen?</string>
<string name="extensionStoresScreen.deleteStore.title">Erweiterungsdepot entfernen</string>
<string name="extensionStoresScreen.deleteStore.body">Möchtest du das Erweiterungsdepot \"%1$s\" (%2$s) entfernen?</string>
<string name="pref_webtoon_vertical_navigator">Vertikalen Navigatior für das Lesen langer Streifen verwenden</string>
<string name="pref_webtoon_vertical_navigator_on_left">Vertikalen Navigator auf der linken Seite platzieren</string>
</resources>
@@ -904,7 +904,6 @@
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Θέλετε να προσθέσετε το παρακάτω κατάστημα επεκτάσεων;</string>
<string name="extensionStoresScreen.deleteStore.title">Κατάργηση καταστήματος επεκτάσεων</string>
<string name="extensionStoresScreen.deleteStore.body">Θέλετε να καταργήσετε το κατάστημα επεκτάσεων \"%1$s\" (%2$s);</string>
<string name="pref_webtoon_vertical_navigator">Χρησιμοποιήστε τον κάθετο πλοηγό για ανάγνωση μεγάλων λωρίδων</string>
<string name="pref_webtoon_vertical_navigator_on_left">Τοποθέτηση του κάθετου πλοηγού στην αριστερή πλευρά</string>
<string name="supportUsScreen.perks">Οι Patrons έχουν πρόσβαση σε αποκλειστικά κανάλια, ενώ θα ακολουθήσουν και άλλα προνόμια.</string>
<string name="supportUsScreen.donationPlatform.patreon">Patreon (προτιμάται)</string>

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