mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-17 14:12:25 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d3e39779 | ||
|
|
3d3bd49d1c | ||
|
|
4a66b8b5df | ||
|
|
52f827c6ff | ||
|
|
aa434285b2 | ||
|
|
91e967128d | ||
|
|
abef1cb8c5 | ||
|
|
1d8e5d7eb1 | ||
|
|
75f506836c | ||
|
|
44780e74b5 | ||
|
|
8407439b8a | ||
|
|
ab58a9952e | ||
|
|
0ff654205f | ||
|
|
1d8789d800 | ||
|
|
a313c747f3 | ||
|
|
66b390c62a | ||
|
|
26abad0be0 | ||
|
|
68f293be45 | ||
|
|
41f4579c74 | ||
|
|
0787678c12 | ||
|
|
d0c79399b9 | ||
|
|
17aed02e56 | ||
|
|
88a9d44bb7 | ||
|
|
aa46039e6c | ||
|
|
3b078331a0 | ||
|
|
b8e5f22c01 | ||
|
|
c3bf7a78c0 | ||
|
|
94b3b5eaa2 | ||
|
|
27284a40ab | ||
|
|
76cc04c8f4 | ||
|
|
77580cd55f | ||
|
|
db095c87aa | ||
|
|
f3c6789dfb | ||
|
|
0772f72021 | ||
|
|
1623c188df | ||
|
|
dfed97edc8 | ||
|
|
f6632940fa | ||
|
|
ca2e8cf4de | ||
|
|
000e2d091b | ||
|
|
40f782e10c | ||
|
|
f306e65b25 | ||
|
|
d0f373df22 | ||
|
|
3fd76e4f93 | ||
|
|
d8c3440d37 | ||
|
|
a82ccea6f5 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+6
@@ -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.**
|
||||
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+24
-10
@@ -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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+12
@@ -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)),
|
||||
),
|
||||
),
|
||||
|
||||
-80
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -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(
|
||||
|
||||
+27
-11
@@ -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
|
||||
}
|
||||
+20
@@ -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 |
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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>()
|
||||
}
|
||||
|
||||
+17
-1
@@ -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
@@ -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
Reference in New Issue
Block a user