chore: merge upstream changes.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2023-12-28 22:48:32 +11:00
commit 80570a823f
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
132 changed files with 1513 additions and 1181 deletions

View File

@ -2,7 +2,6 @@
|-------|----------|---------|------------|---------| |-------|----------|---------|------------|---------|
| [![CI](https://github.com/tachiyomiorg/tachiyomi/actions/workflows/build_push.yml/badge.svg)](https://github.com/tachiyomiorg/tachiyomi/actions/workflows/build_push.yml) | [![stable release](https://img.shields.io/github/release/tachiyomiorg/tachiyomi.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi/releases) | [![latest preview build](https://img.shields.io/github/v/release/tachiyomiorg/tachiyomi-preview.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi-preview/releases) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/tachiyomi) | | [![CI](https://github.com/tachiyomiorg/tachiyomi/actions/workflows/build_push.yml/badge.svg)](https://github.com/tachiyomiorg/tachiyomi/actions/workflows/build_push.yml) | [![stable release](https://img.shields.io/github/release/tachiyomiorg/tachiyomi.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi/releases) | [![latest preview build](https://img.shields.io/github/v/release/tachiyomiorg/tachiyomi-preview.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi-preview/releases) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/tachiyomi) |
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi # ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
Tachiyomi is a free and open source manga reader for Android 6.0 and above. Tachiyomi is a free and open source manga reader for Android 6.0 and above.

View File

@ -45,7 +45,7 @@
##---------------Begin: proguard configuration for kotlinx.serialization ---------- ##---------------Begin: proguard configuration for kotlinx.serialization ----------
-keepattributes *Annotation*, InnerClasses -keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations -dontnote kotlinx.serialization.** # core serialization annotations
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer # kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
-keepclassmembers class kotlinx.serialization.json.** { -keepclassmembers class kotlinx.serialization.json.** {

View File

@ -8,7 +8,8 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Storage --> <!-- Storage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- For background jobs --> <!-- For background jobs -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -20,10 +21,12 @@
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- To view extension packages in API 30+ --> <!-- To view extension packages in API 30+ -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_APP_SPECIFIC_LOCALES" /> <uses-permission android:name="android.permission.READ_APP_SPECIFIC_LOCALES"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

View File

@ -25,7 +25,7 @@ class RefreshTracks(
suspend fun await(mangaId: Long): List<Pair<Tracker?, Throwable>> { suspend fun await(mangaId: Long): List<Pair<Tracker?, Throwable>> {
return supervisorScope { return supervisorScope {
return@supervisorScope getTracks.await(mangaId) return@supervisorScope getTracks.await(mangaId)
.map { it to trackerManager.get(it.syncId) } .map { it to trackerManager.get(it.trackerId) }
.filter { (_, service) -> service?.isLoggedIn == true } .filter { (_, service) -> service?.isLoggedIn == true }
.map { (track, service) -> .map { (track, service) ->
async { async {

View File

@ -27,7 +27,7 @@ class TrackChapter(
if (tracks.isEmpty()) return@withNonCancellableContext if (tracks.isEmpty()) return@withNonCancellableContext
tracks.mapNotNull { track -> tracks.mapNotNull { track ->
val service = trackerManager.get(track.syncId) val service = trackerManager.get(track.trackerId)
if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) { if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) {
return@mapNotNull null return@mapNotNull null
} }

View File

@ -13,10 +13,10 @@ fun Track.copyPersonalFrom(other: Track): Track {
) )
} }
fun Track.toDbTrack(): DbTrack = DbTrack.create(syncId).also { fun Track.toDbTrack(): DbTrack = DbTrack.create(trackerId).also {
it.id = id it.id = id
it.manga_id = mangaId it.manga_id = mangaId
it.media_id = remoteId it.remote_id = remoteId
it.library_id = libraryId it.library_id = libraryId
it.title = title it.title = title
it.last_chapter_read = lastChapterRead.toFloat() it.last_chapter_read = lastChapterRead.toFloat()
@ -33,8 +33,8 @@ fun DbTrack.toDomainTrack(idRequired: Boolean = true): Track? {
return Track( return Track(
id = trackId, id = trackId,
mangaId = manga_id, mangaId = manga_id,
syncId = sync_id.toLong(), trackerId = tracker_id.toLong(),
remoteId = media_id, remoteId = remote_id,
libraryId = library_id, libraryId = library_id,
title = title, title = title,
lastChapterRead = last_chapter_read.toDouble(), lastChapterRead = last_chapter_read.toDouble(),

View File

@ -9,26 +9,24 @@ class TrackPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
) { ) {
fun trackUsername(sync: Tracker) = preferenceStore.getString(trackUsername(sync.id), "") fun trackUsername(tracker: Tracker) = preferenceStore.getString(
Preference.privateKey("pref_mangasync_username_${tracker.id}"),
"",
)
fun trackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "") fun trackPassword(tracker: Tracker) = preferenceStore.getString(
Preference.privateKey("pref_mangasync_password_${tracker.id}"),
"",
)
fun setCredentials(sync: Tracker, username: String, password: String) { fun setCredentials(tracker: Tracker, username: String, password: String) {
trackUsername(sync).set(username) trackUsername(tracker).set(username)
trackPassword(sync).set(password) trackPassword(tracker).set(password)
} }
fun trackToken(sync: Tracker) = preferenceStore.getString(trackToken(sync.id), "") fun trackToken(tracker: Tracker) = preferenceStore.getString(Preference.privateKey("track_token_${tracker.id}"), "")
fun anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) fun anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
companion object {
fun trackUsername(syncId: Long) = Preference.privateKey("pref_mangasync_username_$syncId")
private fun trackPassword(syncId: Long) = Preference.privateKey("pref_mangasync_password_$syncId")
private fun trackToken(syncId: Long) = Preference.privateKey("track_token_$syncId")
}
} }

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import eu.kanade.core.preference.asToggleableState import eu.kanade.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName import eu.kanade.presentation.category.visualName
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import tachiyomi.core.preference.CheckboxState import tachiyomi.core.preference.CheckboxState
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.seconds
fun CategoryCreateDialog( fun CategoryCreateDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onCreate: (String) -> Unit, onCreate: (String) -> Unit,
categories: List<Category>, categories: ImmutableList<Category>,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@ -98,7 +99,7 @@ fun CategoryCreateDialog(
fun CategoryRenameDialog( fun CategoryRenameDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onRename: (String) -> Unit, onRename: (String) -> Unit,
categories: List<Category>, categories: ImmutableList<Category>,
category: Category, category: Category,
) { ) {
var name by remember { mutableStateOf(category.name) } var name by remember { mutableStateOf(category.name) }

View File

@ -6,6 +6,7 @@ import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@ -16,11 +17,13 @@ import tachiyomi.presentation.core.util.isScrollingUp
fun CategoryFloatingActionButton( fun CategoryFloatingActionButton(
lazyListState: LazyListState, lazyListState: LazyListState,
onCreate: () -> Unit, onCreate: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) }, text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) }, icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate, onClick = onCreate,
expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(), expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(),
modifier = modifier,
) )
} }

View File

@ -3,7 +3,9 @@ package eu.kanade.presentation.components
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.DownloadAction
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@ -13,18 +15,22 @@ fun DownloadDropdownMenu(
expanded: Boolean, expanded: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit, onDownloadClicked: (DownloadAction) -> Unit,
modifier: Modifier = Modifier,
) { ) {
DropdownMenu( val options = persistentListOf(
expanded = expanded,
onDismissRequest = onDismissRequest,
) {
listOfNotNull(
DownloadAction.NEXT_1_CHAPTER to pluralStringResource(MR.plurals.download_amount, 1, 1), DownloadAction.NEXT_1_CHAPTER to pluralStringResource(MR.plurals.download_amount, 1, 1),
DownloadAction.NEXT_5_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 5, 5), DownloadAction.NEXT_5_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 5, 5),
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10), DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25), DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread), DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
).map { (downloadAction, string) -> )
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
options.map { (downloadAction, string) ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(text = string) }, text = { Text(text = string) },
onClick = { onClick = {

View File

@ -1,7 +1,10 @@
package eu.kanade.presentation.components package eu.kanade.presentation.components
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.RadioButtonChecked import androidx.compose.material.icons.outlined.RadioButtonChecked
@ -22,12 +25,17 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu
/**
* DropdownMenu but overlaps anchor and has width constraints to better
* match non-Compose implementation.
*/
@Composable @Composable
fun DropdownMenu( fun DropdownMenu(
expanded: Boolean, expanded: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(8.dp, (-56).dp), offset: DpOffset = DpOffset(8.dp, (-56).dp),
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true), properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
@ -36,6 +44,7 @@ fun DropdownMenu(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = modifier.sizeIn(minWidth = 196.dp, maxWidth = 196.dp), modifier = modifier.sizeIn(minWidth = 196.dp, maxWidth = 196.dp),
offset = offset, offset = offset,
scrollState = scrollState,
properties = properties, properties = properties,
content = content, content = content,
) )
@ -45,6 +54,7 @@ fun DropdownMenu(
fun RadioMenuItem( fun RadioMenuItem(
text: @Composable () -> Unit, text: @Composable () -> Unit,
isChecked: Boolean, isChecked: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
DropdownMenuItem( DropdownMenuItem(
@ -64,6 +74,7 @@ fun RadioMenuItem(
) )
} }
}, },
modifier = modifier,
) )
} }
@ -71,10 +82,12 @@ fun RadioMenuItem(
fun NestedMenuItem( fun NestedMenuItem(
text: @Composable () -> Unit, text: @Composable () -> Unit,
children: @Composable ColumnScope.(() -> Unit) -> Unit, children: @Composable ColumnScope.(() -> Unit) -> Unit,
modifier: Modifier = Modifier,
) { ) {
var nestedExpanded by remember { mutableStateOf(false) } var nestedExpanded by remember { mutableStateOf(false) }
val closeMenu = { nestedExpanded = false } val closeMenu = { nestedExpanded = false }
Box {
DropdownMenuItem( DropdownMenuItem(
text = text, text = text,
onClick = { nestedExpanded = true }, onClick = { nestedExpanded = true },
@ -89,7 +102,9 @@ fun NestedMenuItem(
DropdownMenu( DropdownMenu(
expanded = nestedExpanded, expanded = nestedExpanded,
onDismissRequest = closeMenu, onDismissRequest = closeMenu,
modifier = modifier,
) { ) {
children(closeMenu) children(closeMenu)
} }
} }
}

View File

@ -77,6 +77,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.source.local.isLocal
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@ -718,13 +719,13 @@ fun MangaScreenLargeImpl(
@Composable @Composable
private fun SharedMangaBottomActionMenu( private fun SharedMangaBottomActionMenu(
selected: List<ChapterList.Item>, selected: List<ChapterList.Item>,
modifier: Modifier = Modifier,
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit, onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit, onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit, onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?, onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
onMultiDeleteClicked: (List<Chapter>) -> Unit, onMultiDeleteClicked: (List<Chapter>) -> Unit,
fillFraction: Float, fillFraction: Float,
modifier: Modifier = Modifier,
) { ) {
MangaBottomActionMenu( MangaBottomActionMenu(
visible = selected.isNotEmpty(), visible = selected.isNotEmpty(),
@ -752,7 +753,7 @@ private fun SharedMangaBottomActionMenu(
onDeleteClicked = { onDeleteClicked = {
onMultiDeleteClicked(selected.fastMap { it.chapter }) onMultiDeleteClicked(selected.fastMap { it.chapter })
}.takeIf { }.takeIf {
onDownloadChapter != null && selected.fastAny { it.downloadState == Download.State.DOWNLOADED } selected.fastAny { it.downloadState == Download.State.DOWNLOADED }
}, },
) )
} }
@ -818,7 +819,7 @@ private fun LazyListScope.sharedChapterItems(
read = item.chapter.read, read = item.chapter.read,
bookmark = item.chapter.bookmark, bookmark = item.chapter.bookmark,
selected = item.selected, selected = item.selected,
downloadIndicatorEnabled = !isAnyChapterSelected, downloadIndicatorEnabled = !isAnyChapterSelected && !manga.isLocal(),
downloadStateProvider = { item.downloadState }, downloadStateProvider = { item.downloadState },
downloadProgressProvider = { item.downloadProgress }, downloadProgressProvider = { item.downloadProgress },
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,

View File

@ -49,10 +49,10 @@ enum class ChapterDownloadAction {
@Composable @Composable
fun ChapterDownloadIndicator( fun ChapterDownloadIndicator(
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier,
downloadStateProvider: () -> Download.State, downloadStateProvider: () -> Download.State,
downloadProgressProvider: () -> Int, downloadProgressProvider: () -> Int,
onClick: (ChapterDownloadAction) -> Unit, onClick: (ChapterDownloadAction) -> Unit,
modifier: Modifier = Modifier,
) { ) {
when (val downloadState = downloadStateProvider()) { when (val downloadState = downloadStateProvider()) {
Download.State.NOT_DOWNLOADED -> NotDownloadedIndicator( Download.State.NOT_DOWNLOADED -> NotDownloadedIndicator(
@ -109,10 +109,10 @@ private fun NotDownloadedIndicator(
@Composable @Composable
private fun DownloadingIndicator( private fun DownloadingIndicator(
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier,
downloadState: Download.State, downloadState: Download.State,
downloadProgressProvider: () -> Int, downloadProgressProvider: () -> Int,
onClick: (ChapterDownloadAction) -> Unit, onClick: (ChapterDownloadAction) -> Unit,
modifier: Modifier = Modifier,
) { ) {
var isMenuExpanded by remember { mutableStateOf(false) } var isMenuExpanded by remember { mutableStateOf(false) }
Box( Box(

View File

@ -182,7 +182,10 @@ private fun RowScope.Button(
onClick: () -> Unit, onClick: () -> Unit,
content: (@Composable () -> Unit)? = null, content: (@Composable () -> Unit)? = null,
) { ) {
val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f) val animatedWeight by animateFloatAsState(
targetValue = if (toConfirm) 2f else 1f,
label = "weight",
)
Column( Column(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)

View File

@ -203,19 +203,17 @@ fun MangaChapterListItem(
} }
} }
if (onDownloadClick != null) {
ChapterDownloadIndicator( ChapterDownloadIndicator(
enabled = downloadIndicatorEnabled, enabled = downloadIndicatorEnabled,
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider, downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider, downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadClick, onClick = { onDownloadClick?.invoke(it) },
) )
} }
} }
} }
} }
}
private fun getSwipeAction( private fun getSwipeAction(
action: LibraryPreferences.ChapterSwipeAction, action: LibraryPreferences.ChapterSwipeAction,

View File

@ -20,7 +20,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar

View File

@ -20,6 +20,7 @@ import tachiyomi.presentation.core.i18n.stringResource
internal class GuidesStep( internal class GuidesStep(
private val onRestoreBackup: () -> Unit, private val onRestoreBackup: () -> Unit,
) : OnboardingStep { ) : OnboardingStep {
override val isComplete: Boolean = true override val isComplete: Boolean = true
@Composable @Composable

View File

@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.core.preference.Preference as PreferenceData import tachiyomi.core.preference.Preference as PreferenceData
@ -64,20 +66,20 @@ sealed class Preference {
val pref: PreferenceData<T>, val pref: PreferenceData<T>,
override val title: String, override val title: String,
override val subtitle: String? = "%s", override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: T, entries: Map<T, String>) -> String? = val subtitleProvider: @Composable (value: T, entries: ImmutableMap<T, String>) -> String? =
{ v, e -> subtitle?.format(e[v]) }, { v, e -> subtitle?.format(e[v]) },
override val icon: ImageVector? = null, override val icon: ImageVector? = null,
override val enabled: Boolean = true, override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: T) -> Boolean = { true }, override val onValueChanged: suspend (newValue: T) -> Boolean = { true },
val entries: Map<T, String>, val entries: ImmutableMap<T, String>,
) : PreferenceItem<T>() { ) : PreferenceItem<T>() {
internal fun internalSet(newValue: Any) = pref.set(newValue as T) internal fun internalSet(newValue: Any) = pref.set(newValue as T)
internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T) internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T)
@Composable @Composable
internal fun internalSubtitleProvider(value: Any?, entries: Map<out Any?, String>) = internal fun internalSubtitleProvider(value: Any?, entries: ImmutableMap<out Any?, String>) =
subtitleProvider(value as T, entries as Map<T, String>) subtitleProvider(value as T, entries as ImmutableMap<T, String>)
} }
/** /**
@ -87,13 +89,13 @@ sealed class Preference {
val value: String, val value: String,
override val title: String, override val title: String,
override val subtitle: String? = "%s", override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: String, entries: Map<String, String>) -> String? = val subtitleProvider: @Composable (value: String, entries: ImmutableMap<String, String>) -> String? =
{ v, e -> subtitle?.format(e[v]) }, { v, e -> subtitle?.format(e[v]) },
override val icon: ImageVector? = null, override val icon: ImageVector? = null,
override val enabled: Boolean = true, override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
val entries: Map<String, String>, val entries: ImmutableMap<String, String>,
) : PreferenceItem<String>() ) : PreferenceItem<String>()
/** /**
@ -104,7 +106,10 @@ sealed class Preference {
val pref: PreferenceData<Set<String>>, val pref: PreferenceData<Set<String>>,
override val title: String, override val title: String,
override val subtitle: String? = "%s", override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: Set<String>, entries: Map<String, String>) -> String? = { v, e -> val subtitleProvider: @Composable (
value: Set<String>,
entries: ImmutableMap<String, String>,
) -> String? = { v, e ->
val combined = remember(v) { val combined = remember(v) {
v.map { e[it] } v.map { e[it] }
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
@ -116,7 +121,7 @@ sealed class Preference {
override val enabled: Boolean = true, override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true }, override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true },
val entries: Map<String, String>, val entries: ImmutableMap<String, String>,
) : PreferenceItem<Set<String>>() ) : PreferenceItem<Set<String>>()
/** /**
@ -170,6 +175,6 @@ sealed class Preference {
override val title: String, override val title: String,
override val enabled: Boolean = true, override val enabled: Boolean = true,
val preferenceItems: List<PreferenceItem<out Any>>, val preferenceItems: ImmutableList<PreferenceItem<out Any>>,
) : Preference() ) : Preference()
} }

View File

@ -21,7 +21,6 @@ import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TrackingPreferenceWidget import eu.kanade.presentation.more.settings.widget.TrackingPreferenceWidget
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.presentation.core.components.SliderItem import tachiyomi.presentation.core.components.SliderItem
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -157,8 +156,8 @@ internal fun PreferenceItem(
) )
} }
is Preference.PreferenceItem.TrackerPreference -> { is Preference.PreferenceItem.TrackerPreference -> {
val uName by Injekt.get<PreferenceStore>() val uName by Injekt.get<TrackPreferences>()
.getString(TrackPreferences.trackUsername(item.tracker.id)) .trackUsername(item.tracker)
.collectAsState() .collectAsState()
item.tracker.run { item.tracker.run {
TrackingPreferenceWidget( TrackingPreferenceWidget(

View File

@ -11,7 +11,6 @@ import tachiyomi.presentation.core.i18n.stringResource
/** /**
* Returns a string of categories name for settings subtitle * Returns a string of categories name for settings subtitle
*/ */
@ReadOnlyComposable @ReadOnlyComposable
@Composable @Composable
fun getCategoriesLabel( fun getCategoriesLabel(

View File

@ -51,6 +51,9 @@ import eu.kanade.tachiyomi.util.system.isShizukuInstalled
import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Headers import okhttp3.Headers
@ -149,7 +152,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_background_activity), title = stringResource(MR.strings.label_background_activity),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_disable_battery_optimization), title = stringResource(MR.strings.pref_disable_battery_optimization),
subtitle = stringResource(MR.strings.pref_disable_battery_optimization_summary), subtitle = stringResource(MR.strings.pref_disable_battery_optimization_summary),
@ -188,7 +191,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data), title = stringResource(MR.strings.label_data),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_invalidate_download_cache), title = stringResource(MR.strings.pref_invalidate_download_cache),
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary), subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
@ -218,7 +221,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_network), title = stringResource(MR.strings.label_network),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_clear_cookies), title = stringResource(MR.strings.pref_clear_cookies),
onClick = { onClick = {
@ -249,7 +252,7 @@ object SettingsAdvancedScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = networkPreferences.dohProvider(), pref = networkPreferences.dohProvider(),
title = stringResource(MR.strings.pref_dns_over_https), title = stringResource(MR.strings.pref_dns_over_https),
entries = mapOf( entries = persistentMapOf(
-1 to stringResource(MR.strings.disabled), -1 to stringResource(MR.strings.disabled),
PREF_DOH_CLOUDFLARE to "Cloudflare", PREF_DOH_CLOUDFLARE to "Cloudflare",
PREF_DOH_GOOGLE to "Google", PREF_DOH_GOOGLE to "Google",
@ -302,7 +305,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_library), title = stringResource(MR.strings.label_library),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_refresh_library_covers), title = stringResource(MR.strings.pref_refresh_library_covers),
onClick = { MetadataUpdateJob.startNow(context) }, onClick = { MetadataUpdateJob.startNow(context) },
@ -362,12 +365,13 @@ object SettingsAdvancedScreen : SearchableSettings {
} }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_extensions), title = stringResource(MR.strings.label_extensions),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = extensionInstallerPref, pref = extensionInstallerPref,
title = stringResource(MR.strings.ext_installer_pref), title = stringResource(MR.strings.ext_installer_pref),
entries = extensionInstallerPref.entries entries = extensionInstallerPref.entries
.associateWith { stringResource(it.titleRes) }, .associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
onValueChanged = { onValueChanged = {
if (it == BasePreferences.ExtensionInstaller.SHIZUKU && if (it == BasePreferences.ExtensionInstaller.SHIZUKU &&
!context.isShizukuInstalled !context.isShizukuInstalled

View File

@ -24,6 +24,9 @@ import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -66,7 +69,7 @@ object SettingsAppearanceScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_theme), title = stringResource(MR.strings.pref_category_theme),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.CustomPreference( Preference.PreferenceItem.CustomPreference(
title = stringResource(MR.strings.pref_app_theme), title = stringResource(MR.strings.pref_app_theme),
) { ) {
@ -127,7 +130,7 @@ object SettingsAppearanceScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display), title = stringResource(MR.strings.pref_category_display),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.BasicListPreference( Preference.PreferenceItem.BasicListPreference(
value = currentLanguage, value = currentLanguage,
title = stringResource(MR.strings.pref_app_language), title = stringResource(MR.strings.pref_app_language),
@ -140,7 +143,9 @@ object SettingsAppearanceScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = uiPreferences.tabletUiMode(), pref = uiPreferences.tabletUiMode(),
title = stringResource(MR.strings.pref_tablet_ui_mode), title = stringResource(MR.strings.pref_tablet_ui_mode),
entries = TabletUiMode.entries.associateWith { stringResource(it.titleRes) }, entries = TabletUiMode.entries
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
onValueChanged = { onValueChanged = {
context.toast(MR.strings.requires_app_restart) context.toast(MR.strings.requires_app_restart)
true true
@ -153,7 +158,8 @@ object SettingsAppearanceScreen : SearchableSettings {
.associateWith { .associateWith {
val formattedDate = UiPreferences.dateFormat(it).format(now) val formattedDate = UiPreferences.dateFormat(it).format(now)
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)" "${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
}, }
.toImmutableMap(),
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = uiPreferences.relativeTime(), pref = uiPreferences.relativeTime(),
@ -167,7 +173,7 @@ object SettingsAppearanceScreen : SearchableSettings {
), ),
) )
} }
private fun getLangs(context: Context): Map<String, String> { private fun getLangs(context: Context): ImmutableMap<String, String> {
val langs = mutableListOf<Pair<String, String>>() val langs = mutableListOf<Pair<String, String>>()
val parser = context.resources.getXml(R.xml.locales_config) val parser = context.resources.getXml(R.xml.locales_config)
var eventType = parser.eventType var eventType = parser.eventType
@ -189,7 +195,7 @@ object SettingsAppearanceScreen : SearchableSettings {
langs.sortBy { it.second } langs.sortBy { it.second }
langs.add(0, Pair("", context.stringResource(MR.strings.label_default))) langs.add(0, Pair("", context.stringResource(MR.strings.label_default)))
return langs.toMap() return langs.toMap().toImmutableMap()
} }
} }

View File

@ -8,6 +8,7 @@ import androidx.fragment.app.FragmentActivity
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@ -27,7 +28,7 @@ object SettingsBrowseScreen : SearchableSettings {
return listOf( return listOf(
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources), title = stringResource(MR.strings.label_sources),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = sourcePreferences.hideInLibraryItems(), pref = sourcePreferences.hideInLibraryItems(),
title = stringResource(MR.strings.pref_hide_in_library_items), title = stringResource(MR.strings.pref_hide_in_library_items),
@ -36,7 +37,7 @@ object SettingsBrowseScreen : SearchableSettings {
), ),
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_nsfw_content), title = stringResource(MR.strings.pref_category_nsfw_content),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = sourcePreferences.showNsfwSource(), pref = sourcePreferences.showNsfwSource(),
title = stringResource(MR.strings.pref_show_nsfw_source), title = stringResource(MR.strings.pref_show_nsfw_source),

View File

@ -1,21 +1,17 @@
package eu.kanade.presentation.more.settings.screen package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.text.format.Formatter
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -34,12 +30,12 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.more.settings.screen.data.StorageInfo
import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString import eu.kanade.presentation.util.relativeTimeSpanString
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.sync.SyncManager import eu.kanade.tachiyomi.data.sync.SyncManager
@ -49,6 +45,8 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
@ -67,6 +65,8 @@ import uy.kohesive.injekt.api.get
object SettingsDataScreen : SearchableSettings { object SettingsDataScreen : SearchableSettings {
val restorePreferenceKeyString = MR.strings.label_backup
@ReadOnlyComposable @ReadOnlyComposable
@Composable @Composable
override fun getTitleRes() = MR.strings.label_data_storage override fun getTitleRes() = MR.strings.label_data_storage
@ -79,7 +79,7 @@ object SettingsDataScreen : SearchableSettings {
val syncPreferences = remember { Injekt.get<SyncPreferences>() } val syncPreferences = remember { Injekt.get<SyncPreferences>() }
val syncService by syncPreferences.syncService().collectAsState() val syncService by syncPreferences.syncService().collectAsState()
return listOf( return persistentListOf(
getStorageLocationPref(storagePreferences = storagePreferences), getStorageLocationPref(storagePreferences = storagePreferences),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)),
@ -150,20 +150,48 @@ object SettingsDataScreen : SearchableSettings {
@Composable @Composable
private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup { private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState() val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_backup), title = stringResource(MR.strings.label_backup),
preferenceItems = listOf( preferenceItems = persistentListOf(
// Manual actions // Manual actions
getCreateBackupPref(), Preference.PreferenceItem.CustomPreference(
getRestoreBackupPref(), title = stringResource(restorePreferenceKeyString),
) {
BasePreferenceWidget(
subcomponent = {
MultiChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = PrefsHorizontalPadding),
) {
SegmentedButton(
checked = false,
onCheckedChange = { navigator.push(CreateBackupScreen()) },
shape = SegmentedButtonDefaults.itemShape(0, 2),
) {
Text(stringResource(MR.strings.pref_create_backup))
}
SegmentedButton(
checked = false,
onCheckedChange = { navigator.push(RestoreBackupScreen()) },
shape = SegmentedButtonDefaults.itemShape(1, 2),
) {
Text(stringResource(MR.strings.pref_restore_backup))
}
}
},
)
},
// Automatic backups // Automatic backups
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = backupPreferences.backupInterval(), pref = backupPreferences.backupInterval(),
title = stringResource(MR.strings.pref_backup_interval), title = stringResource(MR.strings.pref_backup_interval),
entries = mapOf( entries = persistentMapOf(
0 to stringResource(MR.strings.off), 0 to stringResource(MR.strings.off),
6 to stringResource(MR.strings.update_6hour), 6 to stringResource(MR.strings.update_6hour),
12 to stringResource(MR.strings.update_12hour), 12 to stringResource(MR.strings.update_12hour),
@ -184,142 +212,10 @@ object SettingsDataScreen : SearchableSettings {
) )
} }
@Composable
private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference {
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_create_backup),
subtitle = stringResource(MR.strings.pref_create_backup_summ),
onClick = { navigator.push(CreateBackupScreen()) },
)
}
@Composable
private fun getRestoreBackupPref(): Preference.PreferenceItem.TextPreference {
val context = LocalContext.current
var error by remember { mutableStateOf<Any?>(null) }
if (error != null) {
val onDismissRequest = { error = null }
when (val err = error) {
is InvalidRestore -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.invalid_backup_file)) },
text = { Text(text = listOfNotNull(err.uri, err.message).joinToString("\n\n")) },
dismissButton = {
TextButton(
onClick = {
context.copyToClipboard(err.message, err.message)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_copy_to_clipboard))
}
},
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
is MissingRestoreComponents -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_restore_backup)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
val msg = buildString {
append(stringResource(MR.strings.backup_restore_content_full))
if (err.sources.isNotEmpty()) {
append("\n\n").append(stringResource(MR.strings.backup_restore_missing_sources))
err.sources.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
if (err.trackers.isNotEmpty()) {
append(
"\n\n",
).append(stringResource(MR.strings.backup_restore_missing_trackers))
err.trackers.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
}
Text(text = msg)
}
},
confirmButton = {
TextButton(
onClick = {
BackupRestoreJob.start(context, err.uri)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_restore))
}
},
)
}
else -> error = null // Unknown
}
}
val chooseBackup = rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
return Intent.createChooser(intent, context.stringResource(MR.strings.file_select_backup))
}
},
) {
if (it == null) {
context.toast(MR.strings.file_null_uri_error)
return@rememberLauncherForActivityResult
}
val results = try {
BackupFileValidator().validate(context, it)
} catch (e: Exception) {
error = InvalidRestore(it, e.message.toString())
return@rememberLauncherForActivityResult
}
if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) {
BackupRestoreJob.start(context, it)
return@rememberLauncherForActivityResult
}
error = MissingRestoreComponents(it, results.missingSources, results.missingTrackers)
}
return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_restore_backup),
subtitle = stringResource(MR.strings.pref_restore_backup_summ),
onClick = {
if (!BackupRestoreJob.isRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning, Toast.LENGTH_LONG)
}
// no need to catch because it's wrapped with a chooser
chooseBackup.launch("*/*")
} else {
context.toast(MR.strings.restore_in_progress)
}
},
)
}
@Composable @Composable
private fun getDataGroup(): Preference.PreferenceGroup { private fun getDataGroup(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() } val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
val chapterCache = remember { Injekt.get<ChapterCache>() } val chapterCache = remember { Injekt.get<ChapterCache>() }
@ -327,9 +223,19 @@ object SettingsDataScreen : SearchableSettings {
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize } val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data), title = stringResource(MR.strings.pref_storage_usage),
preferenceItems = listOf( preferenceItems = persistentListOf(
getStorageInfoPref(cacheReadableSize), Preference.PreferenceItem.CustomPreference(
title = stringResource(MR.strings.pref_storage_usage),
) {
BasePreferenceWidget(
subcomponent = {
StorageInfo(
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
)
},
)
},
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_clear_chapter_cache), title = stringResource(MR.strings.pref_clear_chapter_cache),
@ -357,43 +263,16 @@ object SettingsDataScreen : SearchableSettings {
) )
} }
@Composable
fun getStorageInfoPref(
chapterCacheReadableSize: String,
): Preference.PreferenceItem.CustomPreference {
val context = LocalContext.current
val available = remember {
Formatter.formatFileSize(context, DiskUtil.getAvailableStorageSpace(Environment.getDataDirectory()))
}
val total = remember {
Formatter.formatFileSize(context, DiskUtil.getTotalStorageSpace(Environment.getDataDirectory()))
}
return Preference.PreferenceItem.CustomPreference(
title = stringResource(MR.strings.pref_storage_usage),
) {
BasePreferenceWidget(
title = stringResource(MR.strings.pref_storage_usage),
subcomponent = {
// TODO: downloads, SD cards, bar representation?, i18n
Box(modifier = Modifier.padding(horizontal = PrefsHorizontalPadding)) {
Text(text = "Available: $available / $total (chapter cache: $chapterCacheReadableSize)")
}
},
)
}
}
@Composable @Composable
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> { private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
return listOf( return listOf(
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_service_category), title = stringResource(MR.strings.pref_sync_service_category),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = syncPreferences.syncService(), pref = syncPreferences.syncService(),
title = stringResource(MR.strings.pref_sync_service), title = stringResource(MR.strings.pref_sync_service),
entries = mapOf( entries = persistentMapOf(
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off), SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi), SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive), SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive),
@ -404,7 +283,7 @@ object SettingsDataScreen : SearchableSettings {
), ),
) + getSyncServicePreferences(syncPreferences, syncService) ) + getSyncServicePreferences(syncPreferences, syncService)
} }
}
@Composable @Composable
private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> { private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
@ -558,7 +437,7 @@ private fun getSyncNowPref(): Preference.PreferenceGroup {
} }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title), title = stringResource(MR.strings.pref_sync_now_group_title),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_now), title = stringResource(MR.strings.pref_sync_now),
subtitle = stringResource(MR.strings.pref_sync_now_subtitle), subtitle = stringResource(MR.strings.pref_sync_now_subtitle),
@ -578,11 +457,11 @@ private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_automatic_category), title = stringResource(MR.strings.pref_sync_automatic_category),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = syncIntervalPref, pref = syncIntervalPref,
title = stringResource(MR.strings.pref_sync_interval), title = stringResource(MR.strings.pref_sync_interval),
entries = mapOf( entries = persistentMapOf(
0 to stringResource(MR.strings.off), 0 to stringResource(MR.strings.off),
30 to stringResource(MR.strings.update_30min), 30 to stringResource(MR.strings.update_30min),
60 to stringResource(MR.strings.update_1hour), 60 to stringResource(MR.strings.update_1hour),
@ -627,13 +506,4 @@ private fun SyncConfirmationDialog(
) )
} }
private data class MissingRestoreComponents( }
val uri: Uri,
val sources: List<String>,
val trackers: List<String>,
)
private data class InvalidRestore(
val uri: Uri? = null,
val message: String,
)

View File

@ -12,6 +12,9 @@ import androidx.compose.ui.util.fastMap
import eu.kanade.presentation.category.visualName import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.widget.TriStateListDialog import eu.kanade.presentation.more.settings.widget.TriStateListDialog
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@ -68,7 +71,7 @@ object SettingsDownloadScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_delete_chapters), title = stringResource(MR.strings.pref_category_delete_chapters),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = downloadPreferences.removeAfterMarkedAsRead(), pref = downloadPreferences.removeAfterMarkedAsRead(),
title = stringResource(MR.strings.pref_remove_after_marked_as_read), title = stringResource(MR.strings.pref_remove_after_marked_as_read),
@ -76,7 +79,7 @@ object SettingsDownloadScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = downloadPreferences.removeAfterReadSlots(), pref = downloadPreferences.removeAfterReadSlots(),
title = stringResource(MR.strings.pref_remove_after_read), title = stringResource(MR.strings.pref_remove_after_read),
entries = mapOf( entries = persistentMapOf(
-1 to stringResource(MR.strings.disabled), -1 to stringResource(MR.strings.disabled),
0 to stringResource(MR.strings.last_read_chapter), 0 to stringResource(MR.strings.last_read_chapter),
1 to stringResource(MR.strings.second_to_last), 1 to stringResource(MR.strings.second_to_last),
@ -105,7 +108,9 @@ object SettingsDownloadScreen : SearchableSettings {
return Preference.PreferenceItem.MultiSelectListPreference( return Preference.PreferenceItem.MultiSelectListPreference(
pref = downloadPreferences.removeExcludeCategories(), pref = downloadPreferences.removeExcludeCategories(),
title = stringResource(MR.strings.pref_remove_exclude_categories), title = stringResource(MR.strings.pref_remove_exclude_categories),
entries = categories().associate { it.id.toString() to it.visualName }, entries = categories()
.associate { it.id.toString() to it.visualName }
.toImmutableMap(),
) )
} }
@ -142,7 +147,7 @@ object SettingsDownloadScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_auto_download), title = stringResource(MR.strings.pref_category_auto_download),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = downloadNewChaptersPref, pref = downloadNewChaptersPref,
title = stringResource(MR.strings.pref_download_new), title = stringResource(MR.strings.pref_download_new),
@ -167,17 +172,19 @@ object SettingsDownloadScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.download_ahead), title = stringResource(MR.strings.download_ahead),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = downloadPreferences.autoDownloadWhileReading(), pref = downloadPreferences.autoDownloadWhileReading(),
title = stringResource(MR.strings.auto_download_while_reading), title = stringResource(MR.strings.auto_download_while_reading),
entries = listOf(0, 2, 3, 5, 10).associateWith { entries = listOf(0, 2, 3, 5, 10)
.associateWith {
if (it == 0) { if (it == 0) {
stringResource(MR.strings.disabled) stringResource(MR.strings.disabled)
} else { } else {
pluralStringResource(MR.plurals.next_unread_chapters, count = it, it) pluralStringResource(MR.plurals.next_unread_chapters, count = it, it)
} }
}, }
.toImmutableMap(),
), ),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.download_ahead_info)), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.download_ahead_info)),
), ),

View File

@ -20,6 +20,9 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.widget.TriStateListDialog import eu.kanade.presentation.more.settings.widget.TriStateListDialog
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
@ -65,7 +68,6 @@ object SettingsLibraryScreen : SearchableSettings {
allCategories: List<Category>, allCategories: List<Category>,
libraryPreferences: LibraryPreferences, libraryPreferences: LibraryPreferences,
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userCategoriesCount = allCategories.filterNot(Category::isSystemCategory).size val userCategoriesCount = allCategories.filterNot(Category::isSystemCategory).size
@ -76,11 +78,11 @@ object SettingsLibraryScreen : SearchableSettings {
val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) + val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) +
allCategories.fastMap { it.id.toInt() } allCategories.fastMap { it.id.toInt() }
val labels = listOf(stringResource(MR.strings.default_category_summary)) + val labels = listOf(stringResource(MR.strings.default_category_summary)) +
allCategories.fastMap { it.visualName(context) } allCategories.fastMap { it.visualName }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.categories), title = stringResource(MR.strings.categories),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.action_edit_categories), title = stringResource(MR.strings.action_edit_categories),
subtitle = pluralStringResource( subtitle = pluralStringResource(
@ -94,7 +96,7 @@ object SettingsLibraryScreen : SearchableSettings {
pref = libraryPreferences.defaultCategory(), pref = libraryPreferences.defaultCategory(),
title = stringResource(MR.strings.default_category), title = stringResource(MR.strings.default_category),
subtitle = selectedCategory?.visualName ?: stringResource(MR.strings.default_category_summary), subtitle = selectedCategory?.visualName ?: stringResource(MR.strings.default_category_summary),
entries = ids.zip(labels).toMap(), entries = ids.zip(labels).toMap().toImmutableMap(),
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.categorizedDisplaySettings(), pref = libraryPreferences.categorizedDisplaySettings(),
@ -147,11 +149,11 @@ object SettingsLibraryScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_library_update), title = stringResource(MR.strings.pref_category_library_update),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = autoUpdateIntervalPref, pref = autoUpdateIntervalPref,
title = stringResource(MR.strings.pref_library_update_interval), title = stringResource(MR.strings.pref_library_update_interval),
entries = mapOf( entries = persistentMapOf(
0 to stringResource(MR.strings.update_never), 0 to stringResource(MR.strings.update_never),
12 to stringResource(MR.strings.update_12hour), 12 to stringResource(MR.strings.update_12hour),
24 to stringResource(MR.strings.update_24hour), 24 to stringResource(MR.strings.update_24hour),
@ -169,7 +171,7 @@ object SettingsLibraryScreen : SearchableSettings {
enabled = autoUpdateInterval > 0, enabled = autoUpdateInterval > 0,
title = stringResource(MR.strings.pref_library_update_restriction), title = stringResource(MR.strings.pref_library_update_restriction),
subtitle = stringResource(MR.strings.restrictions), subtitle = stringResource(MR.strings.restrictions),
entries = mapOf( entries = persistentMapOf(
DEVICE_ONLY_ON_WIFI to stringResource(MR.strings.connected_to_wifi), DEVICE_ONLY_ON_WIFI to stringResource(MR.strings.connected_to_wifi),
DEVICE_NETWORK_NOT_METERED to stringResource(MR.strings.network_not_metered), DEVICE_NETWORK_NOT_METERED to stringResource(MR.strings.network_not_metered),
DEVICE_CHARGING to stringResource(MR.strings.charging), DEVICE_CHARGING to stringResource(MR.strings.charging),
@ -197,7 +199,7 @@ object SettingsLibraryScreen : SearchableSettings {
Preference.PreferenceItem.MultiSelectListPreference( Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryPreferences.autoUpdateMangaRestrictions(), pref = libraryPreferences.autoUpdateMangaRestrictions(),
title = stringResource(MR.strings.pref_library_update_manga_restriction), title = stringResource(MR.strings.pref_library_update_manga_restriction),
entries = mapOf( entries = persistentMapOf(
MANGA_HAS_UNREAD to stringResource(MR.strings.pref_update_only_completely_read), MANGA_HAS_UNREAD to stringResource(MR.strings.pref_update_only_completely_read),
MANGA_NON_READ to stringResource(MR.strings.pref_update_only_started), MANGA_NON_READ to stringResource(MR.strings.pref_update_only_started),
MANGA_NON_COMPLETED to stringResource(MR.strings.pref_update_only_non_completed), MANGA_NON_COMPLETED to stringResource(MR.strings.pref_update_only_non_completed),
@ -218,11 +220,11 @@ object SettingsLibraryScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_chapter_swipe), title = stringResource(MR.strings.pref_chapter_swipe),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = libraryPreferences.swipeToStartAction(), pref = libraryPreferences.swipeToStartAction(),
title = stringResource(MR.strings.pref_chapter_swipe_start), title = stringResource(MR.strings.pref_chapter_swipe_start),
entries = mapOf( entries = persistentMapOf(
LibraryPreferences.ChapterSwipeAction.Disabled to LibraryPreferences.ChapterSwipeAction.Disabled to
stringResource(MR.strings.disabled), stringResource(MR.strings.disabled),
LibraryPreferences.ChapterSwipeAction.ToggleBookmark to LibraryPreferences.ChapterSwipeAction.ToggleBookmark to
@ -236,7 +238,7 @@ object SettingsLibraryScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = libraryPreferences.swipeToEndAction(), pref = libraryPreferences.swipeToEndAction(),
title = stringResource(MR.strings.pref_chapter_swipe_end), title = stringResource(MR.strings.pref_chapter_swipe_end),
entries = mapOf( entries = persistentMapOf(
LibraryPreferences.ChapterSwipeAction.Disabled to LibraryPreferences.ChapterSwipeAction.Disabled to
stringResource(MR.strings.disabled), stringResource(MR.strings.disabled),
LibraryPreferences.ChapterSwipeAction.ToggleBookmark to LibraryPreferences.ChapterSwipeAction.ToggleBookmark to

View File

@ -10,6 +10,9 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
@ -31,12 +34,13 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPref.defaultReadingMode(), pref = readerPref.defaultReadingMode(),
title = stringResource(MR.strings.pref_viewer_type), title = stringResource(MR.strings.pref_viewer_type),
entries = ReadingMode.entries.drop(1) entries = ReadingMode.entries.drop(1)
.associate { it.flagValue to stringResource(it.stringRes) }, .associate { it.flagValue to stringResource(it.stringRes) }
.toImmutableMap(),
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPref.doubleTapAnimSpeed(), pref = readerPref.doubleTapAnimSpeed(),
title = stringResource(MR.strings.pref_double_tap_anim_speed), title = stringResource(MR.strings.pref_double_tap_anim_speed),
entries = mapOf( entries = persistentMapOf(
1 to stringResource(MR.strings.double_tap_anim_speed_0), 1 to stringResource(MR.strings.double_tap_anim_speed_0),
500 to stringResource(MR.strings.double_tap_anim_speed_normal), 500 to stringResource(MR.strings.double_tap_anim_speed_normal),
250 to stringResource(MR.strings.double_tap_anim_speed_fast), 250 to stringResource(MR.strings.double_tap_anim_speed_fast),
@ -82,17 +86,18 @@ object SettingsReaderScreen : SearchableSettings {
val fullscreen by fullscreenPref.collectAsState() val fullscreen by fullscreenPref.collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display), title = stringResource(MR.strings.pref_category_display),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.defaultOrientationType(), pref = readerPreferences.defaultOrientationType(),
title = stringResource(MR.strings.pref_rotation_type), title = stringResource(MR.strings.pref_rotation_type),
entries = ReaderOrientation.entries.drop(1) entries = ReaderOrientation.entries.drop(1)
.associate { it.flagValue to stringResource(it.stringRes) }, .associate { it.flagValue to stringResource(it.stringRes) }
.toImmutableMap(),
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.readerTheme(), pref = readerPreferences.readerTheme(),
title = stringResource(MR.strings.pref_reader_theme), title = stringResource(MR.strings.pref_reader_theme),
entries = mapOf( entries = persistentMapOf(
1 to stringResource(MR.strings.black_background), 1 to stringResource(MR.strings.black_background),
2 to stringResource(MR.strings.gray_background), 2 to stringResource(MR.strings.gray_background),
0 to stringResource(MR.strings.white_background), 0 to stringResource(MR.strings.white_background),
@ -126,7 +131,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getReadingGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { private fun getReadingGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reading), title = stringResource(MR.strings.pref_category_reading),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.skipRead(), pref = readerPreferences.skipRead(),
title = stringResource(MR.strings.pref_skip_read_chapters), title = stringResource(MR.strings.pref_skip_read_chapters),
@ -161,23 +166,26 @@ object SettingsReaderScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pager_viewer), title = stringResource(MR.strings.pager_viewer),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = navModePref, pref = navModePref,
title = stringResource(MR.strings.pref_viewer_nav), title = stringResource(MR.strings.pref_viewer_nav),
entries = ReaderPreferences.TapZones entries = ReaderPreferences.TapZones
.mapIndexed { index, it -> index to stringResource(it) } .mapIndexed { index, it -> index to stringResource(it) }
.toMap(), .toMap()
.toImmutableMap(),
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.pagerNavInverted(), pref = readerPreferences.pagerNavInverted(),
title = stringResource(MR.strings.pref_read_with_tapping_inverted), title = stringResource(MR.strings.pref_read_with_tapping_inverted),
entries = listOf( entries = persistentListOf(
ReaderPreferences.TappingInvertMode.NONE, ReaderPreferences.TappingInvertMode.NONE,
ReaderPreferences.TappingInvertMode.HORIZONTAL, ReaderPreferences.TappingInvertMode.HORIZONTAL,
ReaderPreferences.TappingInvertMode.VERTICAL, ReaderPreferences.TappingInvertMode.VERTICAL,
ReaderPreferences.TappingInvertMode.BOTH, ReaderPreferences.TappingInvertMode.BOTH,
).associateWith { stringResource(it.titleRes) }, )
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
enabled = navMode != 5, enabled = navMode != 5,
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
@ -185,14 +193,16 @@ object SettingsReaderScreen : SearchableSettings {
title = stringResource(MR.strings.pref_image_scale_type), title = stringResource(MR.strings.pref_image_scale_type),
entries = ReaderPreferences.ImageScaleType entries = ReaderPreferences.ImageScaleType
.mapIndexed { index, it -> index + 1 to stringResource(it) } .mapIndexed { index, it -> index + 1 to stringResource(it) }
.toMap(), .toMap()
.toImmutableMap(),
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.zoomStart(), pref = readerPreferences.zoomStart(),
title = stringResource(MR.strings.pref_zoom_start), title = stringResource(MR.strings.pref_zoom_start),
entries = ReaderPreferences.ZoomStart entries = ReaderPreferences.ZoomStart
.mapIndexed { index, it -> index + 1 to stringResource(it) } .mapIndexed { index, it -> index + 1 to stringResource(it) }
.toMap(), .toMap()
.toImmutableMap(),
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.cropBorders(), pref = readerPreferences.cropBorders(),
@ -255,23 +265,26 @@ object SettingsReaderScreen : SearchableSettings {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.webtoon_viewer), title = stringResource(MR.strings.webtoon_viewer),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = navModePref, pref = navModePref,
title = stringResource(MR.strings.pref_viewer_nav), title = stringResource(MR.strings.pref_viewer_nav),
entries = ReaderPreferences.TapZones entries = ReaderPreferences.TapZones
.mapIndexed { index, it -> index to stringResource(it) } .mapIndexed { index, it -> index to stringResource(it) }
.toMap(), .toMap()
.toImmutableMap(),
), ),
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.webtoonNavInverted(), pref = readerPreferences.webtoonNavInverted(),
title = stringResource(MR.strings.pref_read_with_tapping_inverted), title = stringResource(MR.strings.pref_read_with_tapping_inverted),
entries = listOf( entries = persistentListOf(
ReaderPreferences.TappingInvertMode.NONE, ReaderPreferences.TappingInvertMode.NONE,
ReaderPreferences.TappingInvertMode.HORIZONTAL, ReaderPreferences.TappingInvertMode.HORIZONTAL,
ReaderPreferences.TappingInvertMode.VERTICAL, ReaderPreferences.TappingInvertMode.VERTICAL,
ReaderPreferences.TappingInvertMode.BOTH, ReaderPreferences.TappingInvertMode.BOTH,
).associateWith { stringResource(it.titleRes) }, )
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
enabled = navMode != 5, enabled = navMode != 5,
), ),
Preference.PreferenceItem.SliderPreference( Preference.PreferenceItem.SliderPreference(
@ -288,7 +301,7 @@ object SettingsReaderScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPreferences.readerHideThreshold(), pref = readerPreferences.readerHideThreshold(),
title = stringResource(MR.strings.pref_hide_threshold), title = stringResource(MR.strings.pref_hide_threshold),
entries = mapOf( entries = persistentMapOf(
ReaderPreferences.ReaderHideThreshold.HIGHEST to stringResource(MR.strings.pref_highest), ReaderPreferences.ReaderHideThreshold.HIGHEST to stringResource(MR.strings.pref_highest),
ReaderPreferences.ReaderHideThreshold.HIGH to stringResource(MR.strings.pref_high), ReaderPreferences.ReaderHideThreshold.HIGH to stringResource(MR.strings.pref_high),
ReaderPreferences.ReaderHideThreshold.LOW to stringResource(MR.strings.pref_low), ReaderPreferences.ReaderHideThreshold.LOW to stringResource(MR.strings.pref_low),
@ -341,7 +354,7 @@ object SettingsReaderScreen : SearchableSettings {
val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState() val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_reader_navigation), title = stringResource(MR.strings.pref_reader_navigation),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = readWithVolumeKeysPref, pref = readWithVolumeKeysPref,
title = stringResource(MR.strings.pref_read_with_volume_keys), title = stringResource(MR.strings.pref_read_with_volume_keys),
@ -359,7 +372,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getActionsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { private fun getActionsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_reader_actions), title = stringResource(MR.strings.pref_reader_actions),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.readWithLongTap(), pref = readerPreferences.readWithLongTap(),
title = stringResource(MR.strings.pref_read_with_long_tap), title = stringResource(MR.strings.pref_read_with_long_tap),

View File

@ -10,6 +10,8 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
@ -55,7 +57,8 @@ object SettingsSecurityScreen : SearchableSettings {
0 -> stringResource(MR.strings.lock_always) 0 -> stringResource(MR.strings.lock_always)
else -> pluralStringResource(MR.plurals.lock_after_mins, count = it, it) else -> pluralStringResource(MR.plurals.lock_after_mins, count = it, it)
} }
}, }
.toImmutableMap(),
onValueChanged = { onValueChanged = {
(context as FragmentActivity).authenticate( (context as FragmentActivity).authenticate(
title = context.stringResource(MR.strings.lock_when_idle), title = context.stringResource(MR.strings.lock_when_idle),
@ -70,14 +73,15 @@ object SettingsSecurityScreen : SearchableSettings {
pref = securityPreferences.secureScreen(), pref = securityPreferences.secureScreen(),
title = stringResource(MR.strings.secure_screen), title = stringResource(MR.strings.secure_screen),
entries = SecurityPreferences.SecureScreenMode.entries entries = SecurityPreferences.SecureScreenMode.entries
.associateWith { stringResource(it.titleRes) }, .associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
), ),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.secure_screen_summary)), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.secure_screen_summary)),
) )
} }
} }
private val LockAfterValues = listOf( private val LockAfterValues = persistentListOf(
0, // Always 0, // Always
1, 1,
2, 2,

View File

@ -51,6 +51,8 @@ import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withUIContext import tachiyomi.core.util.lang.withUIContext
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@ -125,7 +127,7 @@ object SettingsTrackingScreen : SearchableSettings {
), ),
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.services), title = stringResource(MR.strings.services),
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TrackerPreference( Preference.PreferenceItem.TrackerPreference(
title = trackerManager.myAnimeList.name, title = trackerManager.myAnimeList.name,
tracker = trackerManager.myAnimeList, tracker = trackerManager.myAnimeList,
@ -167,7 +169,8 @@ object SettingsTrackingScreen : SearchableSettings {
), ),
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.enhanced_services), title = stringResource(MR.strings.enhanced_services),
preferenceItems = enhancedTrackers.first preferenceItems = (
enhancedTrackers.first
.map { service -> .map { service ->
Preference.PreferenceItem.TrackerPreference( Preference.PreferenceItem.TrackerPreference(
title = service.name, title = service.name,
@ -175,7 +178,8 @@ object SettingsTrackingScreen : SearchableSettings {
login = { (service as EnhancedTracker).loginNoop() }, login = { (service as EnhancedTracker).loginNoop() },
logout = service::logout, logout = service::logout,
) )
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo)), } + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo))
).toImmutableList(),
), ),
) )
} }

View File

@ -4,7 +4,6 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,16 +27,18 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.create.BackupCreator
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.plus import kotlinx.collections.immutable.plus
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
@ -83,16 +84,21 @@ class CreateBackupScreen : Screen() {
.fillMaxSize(), .fillMaxSize(),
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f)
.padding(horizontal = MaterialTheme.padding.medium),
) { ) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
}
}
item { item {
LabeledCheckbox( LabeledCheckbox(
label = stringResource(MR.strings.manga), label = stringResource(MR.strings.manga),
checked = true, checked = true,
onCheckedChange = {}, onCheckedChange = {},
enabled = false, enabled = false,
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
) )
} }
BackupChoices.forEach { (k, v) -> BackupChoices.forEach { (k, v) ->
@ -103,6 +109,7 @@ class CreateBackupScreen : Screen() {
onCheckedChange = { onCheckedChange = {
model.toggleFlag(k) model.toggleFlag(k)
}, },
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
) )
} }
} }
@ -116,11 +123,8 @@ class CreateBackupScreen : Screen() {
.fillMaxWidth(), .fillMaxWidth(),
onClick = { onClick = {
if (!BackupCreateJob.isManualJobRunning(context)) { if (!BackupCreateJob.isManualJobRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning, Toast.LENGTH_LONG)
}
try { try {
chooseBackupDir.launch(Backup.getFilename()) chooseBackupDir.launch(BackupCreator.getFilename())
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
context.toast(MR.strings.file_picker_error) context.toast(MR.strings.file_picker_error)
} }
@ -158,11 +162,16 @@ private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel
@Immutable @Immutable
data class State( data class State(
val flags: PersistentSet<Int> = BackupChoices.keys.toPersistentSet(), val flags: PersistentSet<Int> = persistentSetOf(
BackupCreateFlags.BACKUP_CATEGORY,
BackupCreateFlags.BACKUP_CHAPTER,
BackupCreateFlags.BACKUP_TRACK,
BackupCreateFlags.BACKUP_HISTORY,
),
) )
} }
private val BackupChoices = mapOf( private val BackupChoices = persistentMapOf(
BackupCreateFlags.BACKUP_CATEGORY to MR.strings.categories, BackupCreateFlags.BACKUP_CATEGORY to MR.strings.categories,
BackupCreateFlags.BACKUP_CHAPTER to MR.strings.chapters, BackupCreateFlags.BACKUP_CHAPTER to MR.strings.chapters,
BackupCreateFlags.BACKUP_TRACK to MR.strings.track, BackupCreateFlags.BACKUP_TRACK to MR.strings.track,

View File

@ -0,0 +1,242 @@
package eu.kanade.presentation.more.settings.screen.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.update
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
class RestoreBackupScreen : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { RestoreBackupScreenModel() }
val state by model.state.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_restore_backup),
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
if (state.error != null) {
val onDismissRequest = model::clearError
when (val err = state.error) {
is InvalidRestore -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.invalid_backup_file)) },
text = { Text(text = listOfNotNull(err.uri, err.message).joinToString("\n\n")) },
dismissButton = {
TextButton(
onClick = {
context.copyToClipboard(err.message, err.message)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_copy_to_clipboard))
}
},
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
is MissingRestoreComponents -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_restore_backup)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
val msg = buildString {
append(stringResource(MR.strings.backup_restore_content_full))
if (err.sources.isNotEmpty()) {
append(
"\n\n",
).append(stringResource(MR.strings.backup_restore_missing_sources))
err.sources.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
if (err.trackers.isNotEmpty()) {
append(
"\n\n",
).append(stringResource(MR.strings.backup_restore_missing_trackers))
err.trackers.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
}
Text(text = msg)
}
},
confirmButton = {
TextButton(
onClick = {
BackupRestoreJob.start(
context = context,
uri = err.uri,
options = state.options,
)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_restore))
}
},
)
}
else -> onDismissRequest() // Unknown
}
}
val chooseBackup = rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
return Intent.createChooser(intent, context.stringResource(MR.strings.file_select_backup))
}
},
) {
if (it == null) {
context.toast(MR.strings.file_null_uri_error)
return@rememberLauncherForActivityResult
}
val results = try {
BackupFileValidator(context).validate(it)
} catch (e: Exception) {
model.setError(InvalidRestore(it, e.message.toString()))
return@rememberLauncherForActivityResult
}
if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) {
BackupRestoreJob.start(
context = context,
uri = it,
options = state.options,
)
return@rememberLauncherForActivityResult
}
model.setError(MissingRestoreComponents(it, results.missingSources, results.missingTrackers))
}
LazyColumn(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
}
}
item {
Button(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium)
.fillMaxWidth(),
onClick = {
if (!BackupRestoreJob.isRunning(context)) {
// no need to catch because it's wrapped with a chooser
chooseBackup.launch("*/*")
} else {
context.toast(MR.strings.restore_in_progress)
}
},
) {
Text(stringResource(MR.strings.pref_restore_backup))
}
}
// TODO: show validation errors inline
// TODO: show options for what to restore
}
}
}
}
private class RestoreBackupScreenModel : StateScreenModel<RestoreBackupScreenModel.State>(State()) {
fun setError(error: Any) {
mutableState.update {
it.copy(error = error)
}
}
fun clearError() {
mutableState.update {
it.copy(error = null)
}
}
@Immutable
data class State(
val error: Any? = null,
// TODO: allow user-selectable restore options
val options: RestoreOptions = RestoreOptions(),
)
}
private data class MissingRestoreComponents(
val uri: Uri,
val sources: List<String>,
val trackers: List<String>,
)
private data class InvalidRestore(
val uri: Uri? = null,
val message: String,
)

View File

@ -0,0 +1,74 @@
package eu.kanade.presentation.more.settings.screen.data
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.secondaryItemAlpha
import java.io.File
@Composable
fun StorageInfo(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val storages = remember { DiskUtil.getExternalStorages(context) }
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
storages.forEach {
StorageInfo(it)
}
}
}
@Composable
private fun StorageInfo(
file: File,
) {
val context = LocalContext.current
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = file.absolutePath,
style = MaterialTheme.typography.header,
)
LinearProgressIndicator(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.height(12.dp),
progress = { (1 - (available / total.toFloat())) },
)
Text(
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
modifier = Modifier.secondaryItemAlpha(),
style = MaterialTheme.typography.bodySmall,
)
}
}

View File

@ -15,6 +15,8 @@ import eu.kanade.presentation.more.settings.screen.about.AboutScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -47,7 +49,7 @@ class DebugInfoScreen : Screen() {
private fun getAppInfoGroup(): Preference.PreferenceGroup { private fun getAppInfoGroup(): Preference.PreferenceGroup {
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = "App info", title = "App info",
preferenceItems = listOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Version", title = "Version",
subtitle = AboutScreen.getVersionName(false), subtitle = AboutScreen.getVersionName(false),
@ -96,8 +98,8 @@ class DebugInfoScreen : Screen() {
} }
private fun getDeviceInfoGroup(): Preference.PreferenceGroup { private fun getDeviceInfoGroup(): Preference.PreferenceGroup {
val items = buildList { val items = persistentListOf<Preference.PreferenceItem<out Any>>().mutate {
add( it.add(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Model", title = "Model",
subtitle = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})", subtitle = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})",
@ -105,14 +107,14 @@ class DebugInfoScreen : Screen() {
) )
if (DeviceUtil.oneUiVersion != null) { if (DeviceUtil.oneUiVersion != null) {
add( it.add(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "OneUI version", title = "OneUI version",
subtitle = "${DeviceUtil.oneUiVersion}", subtitle = "${DeviceUtil.oneUiVersion}",
), ),
) )
} else if (DeviceUtil.miuiMajorVersion != null) { } else if (DeviceUtil.miuiMajorVersion != null) {
add( it.add(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "MIUI version", title = "MIUI version",
subtitle = "${DeviceUtil.miuiMajorVersion}", subtitle = "${DeviceUtil.miuiMajorVersion}",
@ -127,7 +129,7 @@ class DebugInfoScreen : Screen() {
} else { } else {
Build.VERSION.RELEASE Build.VERSION.RELEASE
} }
add( it.add(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Android version", title = "Android version",
subtitle = "$androidVersion (${Build.DISPLAY})", subtitle = "$androidVersion (${Build.DISPLAY})",

View File

@ -114,6 +114,7 @@ internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = comp
} else { } else {
tween(200) tween(200)
}, },
label = "highlight",
) )
Modifier.background(color = highlight) Modifier.background(color = highlight)
} }

View File

@ -140,7 +140,7 @@ private fun TrackerStats(
val meanScoreStr = remember(data.trackedTitleCount, data.meanScore) { val meanScoreStr = remember(data.trackedTitleCount, data.meanScore) {
if (data.trackedTitleCount > 0 && !data.meanScore.isNaN()) { if (data.trackedTitleCount > 0 && !data.meanScore.isNaN()) {
// All other numbers are localized in English // All other numbers are localized in English
String.format(Locale.ENGLISH, "%.2f ★", data.meanScore) "%.2f ★".format(Locale.ENGLISH, data.meanScore)
} else { } else {
notApplicable notApplicable
} }

View File

@ -34,11 +34,11 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.toDomainChapter import eu.kanade.tachiyomi.data.database.models.toDomainChapter
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import kotlinx.collections.immutable.persistentMapOf
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.service.calculateChapterGap import tachiyomi.domain.chapter.service.calculateChapterGap
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
@ -51,8 +51,8 @@ fun ChapterTransition(
currChapterDownloaded: Boolean, currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean, goingToChapterDownloaded: Boolean,
) { ) {
val currChapter = transition.from.chapter val currChapter = transition.from.chapter.toDomainChapter()
val goingToChapter = transition.to?.chapter val goingToChapter = transition.to?.chapter?.toDomainChapter()
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
when (transition) { when (transition) {
@ -65,7 +65,7 @@ fun ChapterTransition(
bottomChapter = currChapter, bottomChapter = currChapter,
bottomChapterDownloaded = currChapterDownloaded, bottomChapterDownloaded = currChapterDownloaded,
fallbackLabel = stringResource(MR.strings.transition_no_previous), fallbackLabel = stringResource(MR.strings.transition_no_previous),
chapterGap = calculateChapterGap(currChapter.toDomainChapter(), goingToChapter?.toDomainChapter()), chapterGap = calculateChapterGap(currChapter, goingToChapter),
) )
} }
is ChapterTransition.Next -> { is ChapterTransition.Next -> {
@ -77,7 +77,7 @@ fun ChapterTransition(
bottomChapter = goingToChapter, bottomChapter = goingToChapter,
bottomChapterDownloaded = goingToChapterDownloaded, bottomChapterDownloaded = goingToChapterDownloaded,
fallbackLabel = stringResource(MR.strings.transition_no_next), fallbackLabel = stringResource(MR.strings.transition_no_next),
chapterGap = calculateChapterGap(goingToChapter?.toDomainChapter(), currChapter.toDomainChapter()), chapterGap = calculateChapterGap(goingToChapter, currChapter),
) )
} }
} }
@ -235,7 +235,7 @@ private fun ChapterText(
maxLines = 5, maxLines = 5,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
inlineContent = mapOf( inlineContent = persistentMapOf(
DownloadedIconContentId to InlineTextContent( DownloadedIconContentId to InlineTextContent(
Placeholder( Placeholder(
width = 22.sp, width = 22.sp,
@ -275,24 +275,23 @@ private val CardColor: CardColors
private val VerticalSpacerSize = 24.dp private val VerticalSpacerSize = 24.dp
private const val DownloadedIconContentId = "downloaded" private const val DownloadedIconContentId = "downloaded"
private fun previewChapter(name: String, scanlator: String, chapterNumber: Float) = ChapterImpl().apply { private fun previewChapter(name: String, scanlator: String, chapterNumber: Double) = Chapter.create().copy(
this.name = name id = 0L,
this.scanlator = scanlator mangaId = 0L,
this.chapter_number = chapterNumber url = "",
name = name,
this.id = 0 scanlator = scanlator,
this.manga_id = 0 chapterNumber = chapterNumber,
this.url = "" )
}
private val FakeChapter = previewChapter( private val FakeChapter = previewChapter(
name = "Vol.1, Ch.1 - Fake Chapter Title", name = "Vol.1, Ch.1 - Fake Chapter Title",
scanlator = "Scanlator Name", scanlator = "Scanlator Name",
chapterNumber = 1f, chapterNumber = 1.0,
) )
private val FakeGapChapter = previewChapter( private val FakeGapChapter = previewChapter(
name = "Vol.5, Ch.44 - Fake Gap Chapter Title", name = "Vol.5, Ch.44 - Fake Gap Chapter Title",
scanlator = "Scanlator Name", scanlator = "Scanlator Name",
chapterNumber = 44f, chapterNumber = 44.0,
) )
private val FakeChapterLongTitle = previewChapter( private val FakeChapterLongTitle = previewChapter(
name = "Vol.1, Ch.0 - The Mundane Musings of a Metafictional Manga: A Chapter About a Chapter, Featuring" + name = "Vol.1, Ch.0 - The Mundane Musings of a Metafictional Manga: A Chapter About a Chapter, Featuring" +
@ -301,7 +300,7 @@ private val FakeChapterLongTitle = previewChapter(
"Fictional Realities and Reality-Bending Fiction, Where the Fourth Wall is Always in Danger of Being Broken " + "Fictional Realities and Reality-Bending Fiction, Where the Fourth Wall is Always in Danger of Being Broken " +
"and the Line Between Author and Character is Forever Blurred.", "and the Line Between Author and Character is Forever Blurred.",
scanlator = "Long Long Funny Scanlator Sniper Group Name Reborn", scanlator = "Long Long Funny Scanlator Sniper Group Name Reborn",
chapterNumber = 1f, chapterNumber = 1.0,
) )
@PreviewLightDark @PreviewLightDark

View File

@ -30,7 +30,7 @@ fun DisplayRefreshHost(
val currentDisplayRefresh = hostState.currentDisplayRefresh val currentDisplayRefresh = hostState.currentDisplayRefresh
LaunchedEffect(currentDisplayRefresh) { LaunchedEffect(currentDisplayRefresh) {
if (currentDisplayRefresh) { if (currentDisplayRefresh) {
delay(200) delay(1500)
hostState.currentDisplayRefresh = false hostState.currentDisplayRefresh = false
} }
} }
@ -39,7 +39,7 @@ fun DisplayRefreshHost(
Canvas( Canvas(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
drawRect(Color.White) drawRect(Color.Black)
} }
} }
} }

View File

@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.track.components.TrackLogoIcon import eu.kanade.presentation.track.components.TrackLogoIcon
@ -101,7 +100,7 @@ fun TrackInfoDialogHome(
} }
}, },
onChaptersClick = { onChapterClick(item) }, onChaptersClick = { onChapterClick(item) },
score = item.tracker.displayScore(item.track.toDbTrack()) score = item.tracker.displayScore(item.track)
.takeIf { supportsScoring && item.track.score != 0.0 }, .takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) } onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring }, .takeIf { supportsScoring },

View File

@ -13,7 +13,7 @@ internal class TrackInfoDialogHomePreviewProvider :
private val aTrack = Track( private val aTrack = Track(
id = 1L, id = 1L,
mangaId = 2L, mangaId = 2L,
syncId = 3L, trackerId = 3L,
remoteId = 4L, remoteId = 4L,
libraryId = null, libraryId = null,
title = "Manage Name On Tracker Site", title = "Manage Name On Tracker Site",

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
@ -233,7 +234,7 @@ private fun TrackStatusSelectorPreviews() {
TrackStatusSelector( TrackStatusSelector(
selection = 1, selection = 1,
onSelectionChange = {}, onSelectionChange = {},
selections = mapOf( selections = persistentMapOf(
// Anilist values // Anilist values
1 to MR.strings.reading, 1 to MR.strings.reading,
2 to MR.strings.plan_to_read, 2 to MR.strings.plan_to_read,

View File

@ -62,8 +62,8 @@ internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composab
private fun randTrackSearch() = TrackSearch().let { private fun randTrackSearch() = TrackSearch().let {
it.id = Random.nextLong() it.id = Random.nextLong()
it.manga_id = Random.nextLong() it.manga_id = Random.nextLong()
it.sync_id = Random.nextInt() it.tracker_id = Random.nextInt()
it.media_id = Random.nextLong() it.remote_id = Random.nextLong()
it.library_id = Random.nextLong() it.library_id = Random.nextLong()
it.title = lorem((1..10).random()).joinToString() it.title = lorem((1..10).random()).joinToString()
it.last_chapter_read = (0..100).random().toFloat() it.last_chapter_read = (0..100).random().toFloat()

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import kotlinx.serialization.protobuf.ProtoBuf
import okio.buffer
import okio.gzip
import okio.source
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class BackupDecoder(
private val context: Context,
private val parser: ProtoBuf = Injekt.get(),
) {
/**
* Decode a potentially-gzipped backup.
*/
fun decode(uri: Uri): Backup {
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer()
val peeked = source.peek().apply {
require(2)
}
val id1id2 = peeked.readShort()
val backupString = if (id1id2.toInt() == 0x1f8b) { // 0x1f8b is gzip magic bytes
source.gzip().buffer()
} else {
source
}.use { it.readByteArray() }
parser.decodeFromByteArray(BackupSerializer, backupString)
}
}
}

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.util.BackupUtil
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -11,6 +10,8 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class BackupFileValidator( class BackupFileValidator(
private val context: Context,
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val trackerManager: TrackerManager = Injekt.get(), private val trackerManager: TrackerManager = Injekt.get(),
) { ) {
@ -21,9 +22,9 @@ class BackupFileValidator(
* @throws Exception if manga cannot be found. * @throws Exception if manga cannot be found.
* @return List of missing sources or missing trackers. * @return List of missing sources or missing trackers.
*/ */
fun validate(context: Context, uri: Uri): Results { fun validate(uri: Uri): Results {
val backup = try { val backup = try {
BackupUtil.decodeBackup(context, uri) BackupDecoder(context).decode(uri)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException(e) throw IllegalStateException(e)
} }

View File

@ -29,7 +29,6 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) : class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) :
@ -49,13 +48,10 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
setForegroundSafely() setForegroundSafely()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults) val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults)
val backupPreferences = Injekt.get<BackupPreferences>()
return try { return try {
val location = BackupCreator(context).createBackup(uri, flags, isAutoBackup) val location = BackupCreator(context, isAutoBackup).backup(uri, flags)
if (isAutoBackup) { if (!isAutoBackup) {
backupPreferences.lastAutoBackupTimestamp().set(Instant.now().toEpochMilli())
} else {
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
} }
Result.success() Result.success()

View File

@ -3,85 +3,56 @@ package eu.kanade.tachiyomi.data.backup.create
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_APP_PREFS import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_APP_PREFS
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CATEGORY import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_SOURCE_PREFS import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_SOURCE_PREFS
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.BackupSource import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.preferenceKey
import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority import logcat.LogPriority
import okio.buffer import okio.buffer
import okio.gzip import okio.gzip
import okio.sink import okio.sink
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.data.DatabaseHandler import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
class BackupCreator( class BackupCreator(
private val context: Context, private val context: Context,
private val isAutoBackup: Boolean,
private val parser: ProtoBuf = Injekt.get(),
private val getFavorites: GetFavorites = Injekt.get(),
private val backupPreferences: BackupPreferences = Injekt.get(),
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
) { ) {
private val handler: DatabaseHandler = Injekt.get() suspend fun backup(uri: Uri, flags: Int): String {
private val sourceManager: SourceManager = Injekt.get()
private val getCategories: GetCategories = Injekt.get()
private val getFavorites: GetFavorites = Injekt.get()
private val getHistory: GetHistory = Injekt.get()
private val preferenceStore: PreferenceStore = Injekt.get()
internal val parser = ProtoBuf
/**
* Create backup file.
*
* @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job
*/
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
val databaseManga = getFavorites.await()
val backup = Backup(
backupMangas(databaseManga, flags),
backupCategories(flags),
emptyList(),
prepExtensionInfoForSync(databaseManga),
backupAppPreferences(flags),
backupSourcePreferences(flags),
)
var file: UniFile? = null var file: UniFile? = null
try { try {
file = ( file = (
@ -90,14 +61,14 @@ class BackupCreator(
val dir = UniFile.fromUri(context, uri) val dir = UniFile.fromUri(context, uri)
// Delete older backups // Delete older backups
dir?.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } dir?.listFiles { _, filename -> FILENAME_REGEX.matches(filename) }
.orEmpty() .orEmpty()
.sortedByDescending { it.name } .sortedByDescending { it.name }
.drop(MAX_AUTO_BACKUPS - 1) .drop(MAX_AUTO_BACKUPS - 1)
.forEach { it.delete() } .forEach { it.delete() }
// Create new file to place backup // Create new file to place backup
dir?.createFile(Backup.getFilename()) dir?.createFile(getFilename())
} else { } else {
UniFile.fromUri(context, uri) UniFile.fromUri(context, uri)
} }
@ -108,19 +79,36 @@ class BackupCreator(
throw IllegalStateException("Failed to get handle on a backup file") throw IllegalStateException("Failed to get handle on a backup file")
} }
val databaseManga = getFavorites.await()
val backup = Backup(
backupManga = backupMangas(databaseManga, flags),
backupCategories = backupCategories(flags),
backupSources = backupSources(databaseManga),
backupPreferences = backupAppPreferences(flags),
backupSourcePreferences = backupSourcePreferences(flags),
)
val byteArray = parser.encodeToByteArray(BackupSerializer, backup) val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
if (byteArray.isEmpty()) { if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error)) throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
} }
file.openOutputStream().also { file.openOutputStream()
.also {
// Force overwrite old file // Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0) (it as? FileOutputStream)?.channel?.truncate(0)
}.sink().gzip().buffer().use { it.write(byteArray) } }
.sink().gzip().buffer().use {
it.write(byteArray)
}
val fileUri = file.uri val fileUri = file.uri
// Make sure it's a valid backup file // Make sure it's a valid backup file
BackupFileValidator().validate(context, fileUri) BackupFileValidator(context).validate(fileUri)
if (isAutoBackup) {
backupPreferences.lastAutoBackupTimestamp().set(Instant.now().toEpochMilli())
}
return fileUri.toString() return fileUri.toString()
} catch (e: Exception) { } catch (e: Exception) {
@ -130,135 +118,39 @@ class BackupCreator(
} }
} }
fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> { private suspend fun backupCategories(options: Int): List<BackupCategory> {
return mangas if (options and BACKUP_CATEGORY != BACKUP_CATEGORY) return emptyList()
.asSequence()
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map(BackupSource::copyFrom)
.toList()
}
/** return categoriesBackupCreator.backupCategories()
* Backup the categories of library
*
* @return list of [BackupCategory] to be backed up
*/
suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BACKUP_CATEGORY == BACKUP_CATEGORY) {
getCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else {
emptyList()
}
} }
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map { return mangaBackupCreator.backupMangas(mangas, flags)
backupManga(it, flags)
}
} }
/** private fun backupSources(mangas: List<Manga>): List<BackupSource> {
* Convert a manga to Json return sourcesBackupCreator.backupSources(mangas)
*
* @param manga manga that gets converted
* @param options options for the backup
* @return [BackupManga] containing manga in a serializable form
*/
suspend fun backupManga(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = BackupManga.copyFrom(manga)
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER == BACKUP_CHAPTER) {
// Backup all the chapters
handler.awaitList {
chaptersQueries.getChaptersByMangaId(
mangaId = manga.id,
applyScanlatorFilter = 0, // false
mapper = backupChapterMapper,
)
}
.takeUnless(List<BackupChapter>::isEmpty)
?.let { mangaObject.chapters = it }
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK == BACKUP_TRACK) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY == BACKUP_HISTORY) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
} }
fun backupAppPreferences(flags: Int): List<BackupPreference> { fun backupAppPreferences(flags: Int): List<BackupPreference> {
if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList() if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList()
return preferenceStore.getAll().toBackupPreferences() return preferenceBackupCreator.backupAppPreferences()
} }
fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> { fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> {
if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList() if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList()
return sourceManager.getCatalogueSources() return preferenceBackupCreator.backupSourcePreferences()
.filterIsInstance<ConfigurableSource>()
.map {
BackupSourcePreferences(
it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(),
)
}
} }
@Suppress("UNCHECKED_CAST") companion object {
private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> { private const val MAX_AUTO_BACKUPS: Int = 4
return this.filterKeys { private val FILENAME_REGEX = """${BuildConfig.APPLICATION_ID}_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}.tachibk""".toRegex()
!Preference.isPrivate(it) && !Preference.isAppState(it)
}
.mapNotNull { (key, value) ->
when (value) {
is Int -> BackupPreference(key, IntPreferenceValue(value))
is Long -> BackupPreference(key, LongPreferenceValue(value))
is Float -> BackupPreference(key, FloatPreferenceValue(value))
is String -> BackupPreference(key, StringPreferenceValue(value))
is Boolean -> BackupPreference(key, BooleanPreferenceValue(value))
is Set<*> -> (value as? Set<String>)?.let {
BackupPreference(key, StringSetPreferenceValue(it))
}
else -> null
}
}
}
}
private val MAX_AUTO_BACKUPS: Int = 4 fun getFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.ENGLISH).format(Date())
return "${BuildConfig.APPLICATION_ID}_$date.tachibk"
}
}
}

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class CategoriesBackupCreator(
private val getCategories: GetCategories = Injekt.get(),
) {
suspend fun backupCategories(): List<BackupCategory> {
return getCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
}
}

View File

@ -0,0 +1,101 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaBackupCreator(
private val handler: DatabaseHandler = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val getHistory: GetHistory = Injekt.get(),
) {
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupManga(it, flags)
}
}
private suspend fun backupManga(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = manga.toBackupManga()
// Check if user wants chapter information in backup
if (options and BackupCreateFlags.BACKUP_CHAPTER == BackupCreateFlags.BACKUP_CHAPTER) {
// Backup all the chapters
handler.awaitList {
chaptersQueries.getChaptersByMangaId(
mangaId = manga.id,
applyScanlatorFilter = 0, // false
mapper = backupChapterMapper,
)
}
.takeUnless(List<BackupChapter>::isEmpty)
?.let { mangaObject.chapters = it }
}
// Check if user wants category information in backup
if (options and BackupCreateFlags.BACKUP_CATEGORY == BackupCreateFlags.BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BackupCreateFlags.BACKUP_TRACK == BackupCreateFlags.BACKUP_TRACK) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BackupCreateFlags.BACKUP_HISTORY == BackupCreateFlags.BACKUP_HISTORY) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
}
}
private fun Manga.toBackupManga() =
BackupManga(
url = this.url,
title = this.title,
artist = this.artist,
author = this.author,
description = this.description,
genre = this.genre.orEmpty(),
status = this.status.toInt(),
thumbnailUrl = this.thumbnailUrl,
favorite = this.favorite,
source = this.source,
dateAdded = this.dateAdded,
viewer = (this.viewerFlags.toInt() and ReadingMode.MASK),
viewer_flags = this.viewerFlags.toInt(),
chapterFlags = this.chapterFlags.toInt(),
updateStrategy = this.updateStrategy,
lastModifiedAt = this.lastModifiedAt,
favoriteModifiedAt = this.favoriteModifiedAt,
)

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.preferenceKey
import eu.kanade.tachiyomi.source.sourcePreferences
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class PreferenceBackupCreator(
private val sourceManager: SourceManager = Injekt.get(),
private val preferenceStore: PreferenceStore = Injekt.get(),
) {
fun backupAppPreferences(): List<BackupPreference> {
return preferenceStore.getAll().toBackupPreferences()
}
fun backupSourcePreferences(): List<BackupSourcePreferences> {
return sourceManager.getCatalogueSources()
.filterIsInstance<ConfigurableSource>()
.map {
BackupSourcePreferences(
it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(),
)
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> {
return this.filterKeys {
!Preference.isPrivate(it) && !Preference.isAppState(it)
}
.mapNotNull { (key, value) ->
when (value) {
is Int -> BackupPreference(key, IntPreferenceValue(value))
is Long -> BackupPreference(key, LongPreferenceValue(value))
is Float -> BackupPreference(key, FloatPreferenceValue(value))
is String -> BackupPreference(key, StringPreferenceValue(value))
is Boolean -> BackupPreference(key, BooleanPreferenceValue(value))
is Set<*> -> (value as? Set<String>)?.let {
BackupPreference(key, StringSetPreferenceValue(it))
}
else -> null
}
}
}
}

View File

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SourcesBackupCreator(
private val sourceManager: SourceManager = Injekt.get(),
) {
fun backupSources(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map { it.toBackupSource() }
.toList()
}
}
private fun Source.toBackupSource() =
BackupSource(
name = this.name,
sourceId = this.id,
)

View File

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.BuildConfig
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
import java.text.SimpleDateFormat
import java.util.Date @Serializer(forClass = Backup::class)
import java.util.Locale object BackupSerializer
@Serializable @Serializable
data class Backup( data class Backup(
@ -15,14 +15,4 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(), @ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(), @ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
) { )
companion object {
val filenameRegex = """${BuildConfig.APPLICATION_ID}_\d+-\d+-\d+_\d+-\d+.tachibk""".toRegex()
fun getFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "${BuildConfig.APPLICATION_ID}_$date.tachibk"
}
}
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -60,28 +59,4 @@ data class BackupManga(
favoriteModifiedAt = this@BackupManga.favoriteModifiedAt, favoriteModifiedAt = this@BackupManga.favoriteModifiedAt,
) )
} }
companion object {
fun copyFrom(manga: Manga): BackupManga {
return BackupManga(
url = manga.url,
title = manga.title,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre.orEmpty(),
status = manga.status.toInt(),
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
source = manga.source,
dateAdded = manga.dateAdded,
viewer = (manga.viewerFlags.toInt() and ReadingMode.MASK),
viewer_flags = manga.viewerFlags.toInt(),
chapterFlags = manga.chapterFlags.toInt(),
updateStrategy = manga.updateStrategy,
lastModifiedAt = manga.lastModifiedAt,
favoriteModifiedAt = manga.favoriteModifiedAt,
)
}
}
} }

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializer
@Serializer(forClass = Backup::class)
object BackupSerializer

View File

@ -1,9 +1,14 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupSource(
@ProtoNumber(1) var name: String = "",
@ProtoNumber(2) var sourceId: Long,
)
@Serializable @Serializable
data class BrokenBackupSource( data class BrokenBackupSource(
@ProtoNumber(0) var name: String = "", @ProtoNumber(0) var name: String = "",
@ -11,18 +16,3 @@ data class BrokenBackupSource(
) { ) {
fun toBackupSource() = BackupSource(name, sourceId) fun toBackupSource() = BackupSource(name, sourceId)
} }
@Serializable
data class BackupSource(
@ProtoNumber(1) var name: String = "",
@ProtoNumber(2) var sourceId: Long,
) {
companion object {
fun copyFrom(source: Source): BackupSource {
return BackupSource(
name = source.name,
sourceId = source.id,
)
}
}
}

View File

@ -7,7 +7,6 @@ import tachiyomi.domain.track.model.Track
@Serializable @Serializable
data class BackupTracking( data class BackupTracking(
// in 1.x some of these values have different types or names // in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int, @ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x // LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long, @ProtoNumber(2) var libraryId: Long,
@ -34,7 +33,7 @@ data class BackupTracking(
return Track( return Track(
id = -1, id = -1,
mangaId = -1, mangaId = -1,
syncId = this@BackupTracking.syncId.toLong(), trackerId = this@BackupTracking.syncId.toLong(),
remoteId = if (this@BackupTracking.mediaIdInt != 0) { remoteId = if (this@BackupTracking.mediaIdInt != 0) {
this@BackupTracking.mediaIdInt.toLong() this@BackupTracking.mediaIdInt.toLong()
} else { } else {

View File

@ -30,13 +30,19 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure() val options = inputData.getBooleanArray(OPTIONS_KEY)
?.let { RestoreOptions.fromBooleanArray(it) }
if (uri == null || options == null) {
return Result.failure()
}
val isSync = inputData.getBoolean(SYNC_KEY, false) val isSync = inputData.getBoolean(SYNC_KEY, false)
setForegroundSafely() setForegroundSafely()
return try { return try {
BackupRestorer(context, notifier, isSync).restore(uri) BackupRestorer(context, notifier, isSync).restore(uri, options)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) {
@ -69,10 +75,16 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
return context.workManager.isRunning(TAG) return context.workManager.isRunning(TAG)
} }
fun start(context: Context, uri: Uri, sync: Boolean = false) { fun start(
context: Context,
uri: Uri,
options: RestoreOptions,
sync: Boolean = false,
) {
val inputData = workDataOf( val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
SYNC_KEY to sync, SYNC_KEY to sync,
OPTIONS_KEY to options.toBooleanArray(),
) )
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG) .addTag(TAG)
@ -91,3 +103,4 @@ private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String private const val LOCATION_URI_KEY = "location_uri" // String
private const val SYNC_KEY = "sync" // Boolean private const val SYNC_KEY = "sync" // Boolean
private const val OPTIONS_KEY = "options" // BooleanArray

View File

@ -2,12 +2,15 @@ package eu.kanade.tachiyomi.data.backup.restore
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.util.BackupUtil import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -39,10 +42,10 @@ class BackupRestorer(
*/ */
private var sourceMapping: Map<Long, String> = emptyMap() private var sourceMapping: Map<Long, String> = emptyMap()
suspend fun restore(uri: Uri) { suspend fun restore(uri: Uri, options: RestoreOptions) {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
restoreFromFile(uri) restoreFromFile(uri, options)
val time = System.currentTimeMillis() - startTime val time = System.currentTimeMillis() - startTime
@ -57,20 +60,36 @@ class BackupRestorer(
) )
} }
private suspend fun restoreFromFile(uri: Uri) { private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
val backup = BackupUtil.decodeBackup(context, uri) val backup = BackupDecoder(context).decode(uri)
restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs
// Store source mapping for error messages // Store source mapping for error messages
val backupMaps = backup.backupSources + backup.backupBrokenSources.map { it.toBackupSource() } val backupMaps = backup.backupSources + backup.backupBrokenSources.map { it.toBackupSource() }
sourceMapping = backupMaps.associate { it.sourceId to it.name } sourceMapping = backupMaps.associate { it.sourceId to it.name }
if (options.library) {
restoreAmount += backup.backupManga.size + 1 // +1 for categories
}
if (options.appSettings) {
restoreAmount += 1
}
if (options.sourceSettings) {
restoreAmount += 1
}
coroutineScope { coroutineScope {
if (options.library) {
restoreCategories(backup.backupCategories) restoreCategories(backup.backupCategories)
}
if (options.appSettings) {
restoreAppPreferences(backup.backupPreferences) restoreAppPreferences(backup.backupPreferences)
}
if (options.sourceSettings) {
restoreSourcePreferences(backup.backupSourcePreferences) restoreSourcePreferences(backup.backupSourcePreferences)
}
if (options.library) {
restoreManga(backup.backupManga, backup.backupCategories) restoreManga(backup.backupManga, backup.backupCategories)
}
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
} }

View File

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.backup.restore
data class RestoreOptions(
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val library: Boolean = true,
) {
fun toBooleanArray() = booleanArrayOf(appSettings, sourceSettings, library)
companion object {
fun fromBooleanArray(booleanArray: BooleanArray) = RestoreOptions(
appSettings = booleanArray[0],
sourceSettings = booleanArray[1],
library = booleanArray[2],
)
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.restore package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import tachiyomi.data.DatabaseHandler import tachiyomi.data.DatabaseHandler

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.restore package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
@ -329,7 +329,7 @@ class MangaRestorer(
readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L) readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
.takeIf { it > 0L } .takeIf { it > 0L }
?.let { Date(it) }, ?.let { Date(it) },
readDuration = max(item.readDuration, dbHistory.time_read), readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
) )
} }
@ -347,12 +347,12 @@ class MangaRestorer(
} }
private suspend fun restoreTracking(manga: Manga, backupTracks: List<BackupTracking>) { private suspend fun restoreTracking(manga: Manga, backupTracks: List<BackupTracking>) {
val dbTrackBySyncId = getTracks.await(manga.id).associateBy { it.syncId } val dbTrackByTrackerId = getTracks.await(manga.id).associateBy { it.trackerId }
val (existingTracks, newTracks) = backupTracks val (existingTracks, newTracks) = backupTracks
.mapNotNull { .mapNotNull {
val track = it.getTrackImpl() val track = it.getTrackImpl()
val dbTrack = dbTrackBySyncId[track.syncId] val dbTrack = dbTrackByTrackerId[track.trackerId]
?: // New track ?: // New track
return@mapNotNull track.copy( return@mapNotNull track.copy(
id = 0, // Let DB assign new ID id = 0, // Let DB assign new ID
@ -381,7 +381,7 @@ class MangaRestorer(
existingTracks.forEach { track -> existingTracks.forEach { track ->
manga_syncQueries.update( manga_syncQueries.update(
track.mangaId, track.mangaId,
track.syncId, track.trackerId,
track.remoteId, track.remoteId,
track.libraryId, track.libraryId,
track.title, track.title,

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.restore package eu.kanade.tachiyomi.data.backup.restore.restorers
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob

View File

@ -14,7 +14,6 @@ import okio.buffer
import okio.sink import okio.sink
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -26,9 +25,10 @@ import java.io.IOException
* *
* @param context the application context. * @param context the application context.
*/ */
class ChapterCache(private val context: Context) { class ChapterCache(
private val context: Context,
private val json: Json by injectLazy() private val json: Json,
) {
/** Cache class used for cache management. */ /** Cache class used for cache management. */
private val diskCache = DiskLruCache.open( private val diskCache = DiskLruCache.open(

View File

@ -8,9 +8,9 @@ interface Track : Serializable {
var manga_id: Long var manga_id: Long
var sync_id: Int var tracker_id: Int
var media_id: Long var remote_id: Long
var library_id: Long? var library_id: Long?
@ -40,7 +40,7 @@ interface Track : Serializable {
companion object { companion object {
fun create(serviceId: Long): Track = TrackImpl().apply { fun create(serviceId: Long): Track = TrackImpl().apply {
sync_id = serviceId.toInt() tracker_id = serviceId.toInt()
} }
} }
} }

View File

@ -6,9 +6,9 @@ class TrackImpl : Track {
override var manga_id: Long = 0 override var manga_id: Long = 0
override var sync_id: Int = 0 override var tracker_id: Int = 0
override var media_id: Long = 0 override var remote_id: Long = 0
override var library_id: Long? = null override var library_id: Long? = null

View File

@ -25,7 +25,7 @@ class DownloadProvider(
private val storageManager: StorageManager = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
) { ) {
val downloadsDir: UniFile? private val downloadsDir: UniFile?
get() = storageManager.getDownloadsDirectory() get() = storageManager.getDownloadsDirectory()
/** /**

View File

@ -59,6 +59,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.util.Locale
import java.util.zip.CRC32 import java.util.zip.CRC32
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@ -422,7 +423,7 @@ class Downloader(
} }
val digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3) val digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3)
val filename = String.format("%0${digitCount}d", page.number) val filename = "%0${digitCount}d".format(Locale.ENGLISH, page.number)
val tmpFile = tmpDir.findFile("$filename.tmp") val tmpFile = tmpDir.findFile("$filename.tmp")
// Delete temp file if it exists // Delete temp file if it exists
@ -537,7 +538,7 @@ class Downloader(
if (!downloadPreferences.splitTallImages().get()) return if (!downloadPreferences.splitTallImages().get()) return
try { try {
val filenamePrefix = String.format("%03d", page.number) val filenamePrefix = "%03d".format(Locale.ENGLISH, page.number)
val imageFile = tmpDir.listFiles()?.firstOrNull { it.name.orEmpty().startsWith(filenamePrefix) } val imageFile = tmpDir.listFiles()?.firstOrNull { it.name.orEmpty().startsWith(filenamePrefix) }
?: error(context.stringResource(MR.strings.download_notifier_split_page_not_found, page.number)) ?: error(context.stringResource(MR.strings.download_notifier_split_page_not_found, page.number))
@ -579,11 +580,7 @@ class Downloader(
else -> true else -> true
} }
} }
if (downloadedImagesCount != downloadPageCount) { return downloadedImagesCount == downloadPageCount
return false
}
return true
} }
/** /**

View File

@ -235,7 +235,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
.map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" } .map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" }
.joinToString() .joinToString()
} }
notifier.showUpdateSkippedNotification(skippedUpdates.size)
} }
} }

View File

@ -139,27 +139,6 @@ class LibraryUpdateNotifier(private val context: Context) {
} }
} }
/**
* Shows notification containing update entries that were skipped.
*
* @param skipped Number of entries that were skipped during the update.
*/
fun showUpdateSkippedNotification(skipped: Int) {
if (skipped == 0) {
return
}
context.notify(
Notifications.ID_LIBRARY_SKIPPED,
Notifications.CHANNEL_LIBRARY_SKIPPED,
) {
setContentTitle(context.stringResource(MR.strings.notification_update_skipped, skipped))
setContentText(context.stringResource(MR.strings.learn_more))
setSmallIcon(R.drawable.ic_tachi)
setContentIntent(NotificationHandler.openUrl(context, HELP_SKIPPED_URL))
}
}
/** /**
* Shows the notification containing the result of the update done by the service. * Shows the notification containing the result of the update done by the service.
* *
@ -246,7 +225,7 @@ class LibraryUpdateNotifier(private val context: Context) {
// Mark chapters as read action // Mark chapters as read action
addAction( addAction(
R.drawable.ic_glasses_24dp, R.drawable.ic_done_24dp,
context.stringResource(MR.strings.action_mark_as_read), context.stringResource(MR.strings.action_mark_as_read),
NotificationReceiver.markAsReadPendingBroadcast( NotificationReceiver.markAsReadPendingBroadcast(
context, context,
@ -385,4 +364,3 @@ class LibraryUpdateNotifier(private val context: Context) {
private const val NOTIF_MAX_CHAPTERS = 5 private const val NOTIF_MAX_CHAPTERS = 5
private const val NOTIF_TITLE_MAX_LEN = 45 private const val NOTIF_TITLE_MAX_LEN = 45
private const val NOTIF_ICON_SIZE = 192 private const val NOTIF_ICON_SIZE = 192
private const val HELP_SKIPPED_URL = "https://tachiyomi.org/docs/faq/library#why-is-global-update-skipping-entries"

View File

@ -247,7 +247,6 @@ class NotificationReceiver : BroadcastReceiver() {
private const val NAME = "NotificationReceiver" private const val NAME = "NotificationReceiver"
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE" private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP" private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP"
@ -270,7 +269,6 @@ class NotificationReceiver : BroadcastReceiver() {
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION" private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
private const val EXTRA_URI = "$ID.$NAME.URI" private const val EXTRA_URI = "$ID.$NAME.URI"
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID" private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
@ -375,7 +373,7 @@ class NotificationReceiver : BroadcastReceiver() {
it.id == notificationId it.id == notificationId
}?.groupKey }?.groupKey
if (groupId != null && groupId != 0 && groupKey != null && groupKey.isNotEmpty()) { if (groupId != null && groupId != 0 && !groupKey.isNullOrEmpty()) {
val notifications = context.notificationManager.activeNotifications.filter { val notifications = context.notificationManager.activeNotifications.filter {
it.groupKey == groupKey it.groupKey == groupKey
} }

View File

@ -30,8 +30,6 @@ object Notifications {
const val ID_LIBRARY_SIZE_WARNING = -103 const val ID_LIBRARY_SIZE_WARNING = -103
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel" const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
const val ID_LIBRARY_ERROR = -102 const val ID_LIBRARY_ERROR = -102
const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel"
const val ID_LIBRARY_SKIPPED = -104
/** /**
* Notification channel and ids used by the downloader. * Notification channel and ids used by the downloader.
@ -86,6 +84,7 @@ object Notifications {
"updates_ext_channel", "updates_ext_channel",
"downloader_cache_renewal", "downloader_cache_renewal",
"crash_logs_channel", "crash_logs_channel",
"library_skipped_channel",
) )
/** /**
@ -132,11 +131,6 @@ object Notifications {
setGroup(GROUP_LIBRARY) setGroup(GROUP_LIBRARY)
setShowBadge(false) setShowBadge(false)
}, },
buildNotificationChannel(CHANNEL_LIBRARY_SKIPPED, IMPORTANCE_LOW) {
setName(context.stringResource(MR.strings.channel_skipped))
setGroup(GROUP_LIBRARY)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_NEW_CHAPTERS, IMPORTANCE_DEFAULT) { buildNotificationChannel(CHANNEL_NEW_CHAPTERS, IMPORTANCE_DEFAULT) {
setName(context.stringResource(MR.strings.channel_new_chapters)) setName(context.stringResource(MR.strings.channel_new_chapters))
}, },

View File

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.database.models.Track import tachiyomi.domain.track.model.Track
/** /**
* Tracker that support deleting am entry from a user's list. * Tracker that support deleting am entry from a user's list.
*/ */
interface DeletableTracker { interface DeletableTracker {
suspend fun delete(track: Track): Track suspend fun delete(track: Track)
} }

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import tachiyomi.domain.track.model.Track as DomainTrack
interface Tracker { interface Tracker {
@ -39,11 +40,11 @@ interface Tracker {
fun getScoreList(): ImmutableList<String> fun getScoreList(): ImmutableList<String>
// TODO: Store all scores as 10 point in the future maybe? // TODO: Store all scores as 10 point in the future maybe?
fun get10PointScore(track: tachiyomi.domain.track.model.Track): Double fun get10PointScore(track: DomainTrack): Double
fun indexToScore(index: Int): Float fun indexToScore(index: Int): Float
fun displayScore(track: Track): String fun displayScore(track: DomainTrack): String
suspend fun update(track: Track, didReadChapter: Boolean = false): Track suspend fun update(track: Track, didReadChapter: Boolean = false): Track

View File

@ -33,6 +33,4 @@ class TrackerManager {
fun loggedInTrackers() = trackers.filter { it.isLoggedIn } fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
fun get(id: Long) = trackers.find { it.id == id } fun get(id: Long) = trackers.find { it.id == id }
fun hasLoggedIn() = trackers.any { it.isLoggedIn }
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.graphics.Color import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker import eu.kanade.tachiyomi.data.track.BaseTracker
@ -120,16 +121,16 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
} }
} }
override fun displayScore(track: Track): String { override fun displayScore(track: DomainTrack): String {
val score = track.score val score = track.score
return when (scorePreference.get()) { return when (scorePreference.get()) {
POINT_5 -> when (score) { POINT_5 -> when (score) {
0f -> "0 ★" 0.0 -> "0 ★"
else -> "${((score + 10) / 20).toInt()}" else -> "${((score + 10) / 20).toInt()}"
} }
POINT_3 -> when { POINT_3 -> when {
score == 0f -> "0" score == 0.0 -> "0"
score <= 35 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
@ -167,13 +168,13 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override suspend fun delete(track: Track): Track { override suspend fun delete(track: DomainTrack) {
if (track.library_id == null || track.library_id!! == 0L) { if (track.libraryId == null || track.libraryId == 0L) {
val libManga = api.findLibManga(track, getUsername().toInt()) ?: return track val libManga = api.findLibManga(track.toDbTrack(), getUsername().toInt()) ?: return
track.library_id = libManga.library_id return api.deleteLibManga(track.copy(id = libManga.library_id!!))
} }
return api.deleteLibManga(track) api.deleteLibManga(track)
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {

View File

@ -31,6 +31,7 @@ import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import tachiyomi.domain.track.model.Track as DomainTrack
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@ -55,7 +56,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
val payload = buildJsonObject { val payload = buildJsonObject {
put("query", query) put("query", query)
putJsonObject("variables") { putJsonObject("variables") {
put("mangaId", track.media_id) put("mangaId", track.remote_id)
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus()) put("status", track.toAnilistStatus())
} }
@ -113,8 +114,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
} }
} }
suspend fun deleteLibManga(track: Track): Track { suspend fun deleteLibManga(track: DomainTrack) {
return withIOContext { withIOContext {
val query = """ val query = """
|mutation DeleteManga(${'$'}listId: Int) { |mutation DeleteManga(${'$'}listId: Int) {
|DeleteMediaListEntry(id: ${'$'}listId) { |DeleteMediaListEntry(id: ${'$'}listId) {
@ -126,12 +127,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
val payload = buildJsonObject { val payload = buildJsonObject {
put("query", query) put("query", query)
putJsonObject("variables") { putJsonObject("variables") {
put("listId", track.library_id) put("listId", track.libraryId)
} }
} }
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime))) authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess() .awaitSuccess()
track
} }
} }
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
@ -235,7 +235,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
put("query", query) put("query", query)
putJsonObject("variables") { putJsonObject("variables") {
put("id", userid) put("id", userid)
put("manga_id", track.media_id) put("manga_id", track.remote_id)
} }
} }
with(json) { with(json) {
@ -258,8 +258,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
} }
} }
suspend fun getLibManga(track: Track, userid: Int): Track { suspend fun getLibManga(track: Track, userId: Int): Track {
return findLibManga(track, userid) ?: throw Exception("Could not find manga") return findLibManga(track, userId) ?: throw Exception("Could not find manga")
} }
fun createOAuth(token: String): OAuth { fun createOAuth(token: String): OAuth {

View File

@ -9,9 +9,10 @@ import kotlinx.serialization.Serializable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import tachiyomi.domain.track.model.Track as DomainTrack
data class ALManga( data class ALManga(
val media_id: Long, val remote_id: Long,
val title_user_pref: String, val title_user_pref: String,
val image_url_lge: String, val image_url_lge: String,
val description: String?, val description: String?,
@ -23,13 +24,13 @@ data class ALManga(
) { ) {
fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply { fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply {
media_id = this@ALManga.media_id remote_id = this@ALManga.remote_id
title = title_user_pref title = title_user_pref
total_chapters = this@ALManga.total_chapters total_chapters = this@ALManga.total_chapters
cover_url = image_url_lge cover_url = image_url_lge
summary = description?.htmlDecode() ?: "" summary = description?.htmlDecode() ?: ""
score = average_score.toFloat() score = average_score.toFloat()
tracking_url = AnilistApi.mangaUrl(media_id) tracking_url = AnilistApi.mangaUrl(remote_id)
publishing_status = this@ALManga.publishing_status publishing_status = this@ALManga.publishing_status
publishing_type = format publishing_type = format
if (start_date_fuzzy != 0L) { if (start_date_fuzzy != 0L) {
@ -54,7 +55,7 @@ data class ALUserManga(
) { ) {
fun toTrack() = Track.create(TrackerManager.ANILIST).apply { fun toTrack() = Track.create(TrackerManager.ANILIST).apply {
media_id = manga.media_id remote_id = manga.remote_id
title = manga.title_user_pref title = manga.title_user_pref
status = toTrackStatus() status = toTrackStatus()
score = score_raw.toFloat() score = score_raw.toFloat()
@ -98,14 +99,14 @@ fun Track.toAnilistStatus() = when (status) {
private val preferences: TrackPreferences by injectLazy() private val preferences: TrackPreferences by injectLazy()
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().get()) { fun DomainTrack.toAnilistScore(): String = when (preferences.anilistScoreType().get()) {
// 10 point // 10 point
"POINT_10" -> (score.toInt() / 10).toString() "POINT_10" -> (score.toInt() / 10).toString()
// 100 point // 100 point
"POINT_100" -> score.toInt().toString() "POINT_100" -> score.toInt().toString()
// 5 stars // 5 stars
"POINT_5" -> when { "POINT_5" -> when {
score == 0f -> "0" score == 0.0 -> "0"
score < 30 -> "1" score < 30 -> "1"
score < 50 -> "2" score < 50 -> "2"
score < 70 -> "3" score < 70 -> "3"
@ -114,7 +115,7 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().get())
} }
// Smiley // Smiley
"POINT_3" -> when { "POINT_3" -> when {
score == 0f -> "0" score == 0.0 -> "0"
score <= 35 -> ":(" score <= 35 -> ":("
score <= 60 -> ":|" score <= 60 -> ":|"
else -> ":)" else -> ":)"

View File

@ -12,6 +12,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
@ -23,7 +24,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
override fun getScoreList(): ImmutableList<String> = SCORE_LIST override fun getScoreList(): ImmutableList<String> = SCORE_LIST
override fun displayScore(track: Track): String { override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString() return track.score.toInt().toString()
} }

View File

@ -42,7 +42,7 @@ class BangumiApi(
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toBangumiStatus())
.build() .build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = body)) authClient.newCall(POST("$apiUrl/collection/${track.remote_id}/update", body = body))
.awaitSuccess() .awaitSuccess()
track track
} }
@ -55,7 +55,7 @@ class BangumiApi(
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toBangumiStatus())
.build() .build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = sbody)) authClient.newCall(POST("$apiUrl/collection/${track.remote_id}/update", body = sbody))
.awaitSuccess() .awaitSuccess()
// chapter update // chapter update
@ -64,7 +64,7 @@ class BangumiApi(
.build() .build()
authClient.newCall( authClient.newCall(
POST( POST(
"$apiUrl/subject/${track.media_id}/update/watched_eps", "$apiUrl/subject/${track.remote_id}/update/watched_eps",
body = body, body = body,
), ),
).awaitSuccess() ).awaitSuccess()
@ -111,7 +111,7 @@ class BangumiApi(
} }
val rating = obj["rating"]?.jsonObject?.get("score")?.jsonPrimitive?.floatOrNull ?: -1f val rating = obj["rating"]?.jsonObject?.get("score")?.jsonPrimitive?.floatOrNull ?: -1f
return TrackSearch.create(trackId).apply { return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long remote_id = obj["id"]!!.jsonPrimitive.long
title = obj["name_cn"]!!.jsonPrimitive.content title = obj["name_cn"]!!.jsonPrimitive.content
cover_url = coverUrl cover_url = coverUrl
summary = obj["name"]!!.jsonPrimitive.content summary = obj["name"]!!.jsonPrimitive.content
@ -124,7 +124,7 @@ class BangumiApi(
suspend fun findLibManga(track: Track): Track? { suspend fun findLibManga(track: Track): Track? {
return withIOContext { return withIOContext {
with(json) { with(json) {
authClient.newCall(GET("$apiUrl/subject/${track.media_id}")) authClient.newCall(GET("$apiUrl/subject/${track.remote_id}"))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<JsonObject>()
.let { jsonToSearch(it) } .let { jsonToSearch(it) }
@ -134,7 +134,7 @@ class BangumiApi(
suspend fun statusLibManga(track: Track): Track? { suspend fun statusLibManga(track: Track): Track? {
return withIOContext { return withIOContext {
val urlUserRead = "$apiUrl/collection/${track.media_id}" val urlUserRead = "$apiUrl/collection/${track.remote_id}"
val requestUserRead = Request.Builder() val requestUserRead = Request.Builder()
.url(urlUserRead) .url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)

View File

@ -6,7 +6,7 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class BangumiInterceptor(val bangumi: Bangumi) : Interceptor { class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
private val json: Json by injectLazy() private val json: Json by injectLazy()

View File

@ -62,12 +62,3 @@ fun Track.toBangumiStatus() = when (status) {
Bangumi.PLAN_TO_READ -> "wish" Bangumi.PLAN_TO_READ -> "wish"
else -> throw NotImplementedError("Unknown status: $status") else -> throw NotImplementedError("Unknown status: $status")
} }
fun toTrackStatus(status: String) = when (status) {
"do" -> Bangumi.READING
"collect" -> Bangumi.COMPLETED
"on_hold" -> Bangumi.ON_HOLD
"dropped" -> Bangumi.DROPPED
"wish" -> Bangumi.PLAN_TO_READ
else -> throw NotImplementedError("Unknown status: $status")
}

View File

@ -55,7 +55,7 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
override fun getScoreList(): ImmutableList<String> = persistentListOf() override fun getScoreList(): ImmutableList<String> = persistentListOf()
override fun displayScore(track: Track): String = "" override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track { override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) { if (track.status != COMPLETED) {

View File

@ -14,6 +14,7 @@ import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
import tachiyomi.domain.track.model.Track as DomainTrack
class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
@ -65,7 +66,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
return if (index > 0) (index + 1) / 2f else 0f return if (index > 0) (index + 1) / 2f else 0f
} }
override fun displayScore(track: Track): String { override fun displayScore(track: DomainTrack): String {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
return df.format(track.score) return df.format(track.score)
} }
@ -92,15 +93,15 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override suspend fun delete(track: Track): Track { override suspend fun delete(track: DomainTrack) {
return api.removeLibManga(track) api.removeLibManga(track)
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
track.status = if (hasReadChapters) READING else track.status track.status = if (hasReadChapters) READING else track.status

View File

@ -29,6 +29,7 @@ import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import tachiyomi.domain.track.model.Track as DomainTrack
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
@ -54,7 +55,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
putJsonObject("media") { putJsonObject("media") {
putJsonObject("data") { putJsonObject("data") {
put("id", track.media_id) put("id", track.remote_id)
put("type", "manga") put("type", "manga")
} }
} }
@ -77,7 +78,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<JsonObject>()
.let { .let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track.remote_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
track track
} }
} }
@ -89,7 +90,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
val data = buildJsonObject { val data = buildJsonObject {
putJsonObject("data") { putJsonObject("data") {
put("type", "libraryEntries") put("type", "libraryEntries")
put("id", track.media_id) put("id", track.remote_id)
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toKitsuStatus()) put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
@ -103,7 +104,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
with(json) { with(json) {
authClient.newCall( authClient.newCall(
Request.Builder() Request.Builder()
.url("${baseUrl}library-entries/${track.media_id}") .url("${baseUrl}library-entries/${track.remote_id}")
.headers( .headers(
headersOf( headersOf(
"Content-Type", "Content-Type",
@ -124,11 +125,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
} }
suspend fun removeLibManga(track: Track): Track { suspend fun removeLibManga(track: DomainTrack) {
return withIOContext { withIOContext {
authClient.newCall( authClient
.newCall(
DELETE( DELETE(
"${baseUrl}library-entries/${track.media_id}", "${baseUrl}library-entries/${track.remoteId}",
headers = headersOf( headers = headersOf(
"Content-Type", "Content-Type",
"application/vnd.api+json", "application/vnd.api+json",
@ -136,7 +138,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
), ),
) )
.awaitSuccess() .awaitSuccess()
track
} }
} }
suspend fun search(query: String): List<TrackSearch> { suspend fun search(query: String): List<TrackSearch> {
@ -187,7 +188,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
suspend fun findLibManga(track: Track, userId: String): Track? { suspend fun findLibManga(track: Track, userId: String): Track? {
return withIOContext { return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon() val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId") .encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId")
.appendQueryParameter("include", "manga") .appendQueryParameter("include", "manga")
.build() .build()
with(json) { with(json) {
@ -210,7 +211,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
suspend fun getLibManga(track: Track): Track { suspend fun getLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon() val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.media_id}") .encodedQuery("filter[id]=${track.remote_id}")
.appendQueryParameter("include", "manga") .appendQueryParameter("include", "manga")
.build() .build()
with(json) { with(json) {

View File

@ -5,7 +5,7 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class KitsuInterceptor(val kitsu: Kitsu) : Interceptor { class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
private val json: Json by injectLazy() private val json: Json by injectLazy()

View File

@ -37,12 +37,12 @@ class KitsuSearchManga(obj: JsonObject) {
@CallSuper @CallSuper
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = this@KitsuSearchManga.id remote_id = this@KitsuSearchManga.id
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
cover_url = original ?: "" cover_url = original ?: ""
summary = synopsis ?: "" summary = synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(media_id) tracking_url = KitsuApi.mangaUrl(remote_id)
score = rating ?: -1f score = rating ?: -1f
publishing_status = if (endDate == null) { publishing_status = if (endDate == null) {
"Publishing" "Publishing"
@ -70,12 +70,12 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = libraryId remote_id = libraryId
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
cover_url = original cover_url = original
summary = synopsis summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id) tracking_url = KitsuApi.mangaUrl(remote_id)
publishing_status = this@KitsuLibManga.status publishing_status = this@KitsuLibManga.status
publishing_type = type publishing_type = type
start_date = startDate start_date = startDate

View File

@ -52,7 +52,7 @@ class Komga(id: Long) : BaseTracker(id, "Komga"), EnhancedTracker {
override fun getScoreList(): ImmutableList<String> = persistentListOf() override fun getScoreList(): ImmutableList<String> = persistentListOf()
override fun displayScore(track: Track): String = "" override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track { override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) { if (track.status != COMPLETED) {

View File

@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.domain.track.model.Track as DomainTrack
class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker { class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker {
@ -60,7 +61,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
override fun indexToScore(index: Int): Float = SCORE_LIST[index].toFloat() override fun indexToScore(index: Int): Float = SCORE_LIST[index].toFloat()
override fun displayScore(track: Track): String = track.score.toString() override fun displayScore(track: DomainTrack): String = track.score.toString()
override suspend fun update(track: Track, didReadChapter: Boolean): Track { override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETE_LIST && didReadChapter) { if (track.status != COMPLETE_LIST && didReadChapter) {
@ -70,9 +71,8 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
return track return track
} }
override suspend fun delete(track: Track): Track { override suspend fun delete(track: DomainTrack) {
api.deleteSeriesFromList(track) api.deleteSeriesFromList(track)
return track
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {

View File

@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class MangaUpdatesApi( class MangaUpdatesApi(
interceptor: MangaUpdatesInterceptor, interceptor: MangaUpdatesInterceptor,
@ -48,7 +49,7 @@ class MangaUpdatesApi(
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> { suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
val listItem = with(json) { val listItem = with(json) {
authClient.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}")) authClient.newCall(GET("$baseUrl/v1/lists/series/${track.remote_id}"))
.awaitSuccess() .awaitSuccess()
.parseAs<ListItem>() .parseAs<ListItem>()
} }
@ -63,7 +64,7 @@ class MangaUpdatesApi(
val body = buildJsonArray { val body = buildJsonArray {
addJsonObject { addJsonObject {
putJsonObject("series") { putJsonObject("series") {
put("id", track.media_id) put("id", track.remote_id)
} }
put("list_id", status) put("list_id", status)
} }
@ -87,7 +88,7 @@ class MangaUpdatesApi(
val body = buildJsonArray { val body = buildJsonArray {
addJsonObject { addJsonObject {
putJsonObject("series") { putJsonObject("series") {
put("id", track.media_id) put("id", track.remote_id)
} }
put("list_id", track.status) put("list_id", track.status)
putJsonObject("status") { putJsonObject("status") {
@ -106,9 +107,9 @@ class MangaUpdatesApi(
updateSeriesRating(track) updateSeriesRating(track)
} }
suspend fun deleteSeriesFromList(track: Track) { suspend fun deleteSeriesFromList(track: DomainTrack) {
val body = buildJsonArray { val body = buildJsonArray {
add(track.media_id) add(track.remoteId)
} }
authClient.newCall( authClient.newCall(
POST( POST(
@ -122,7 +123,7 @@ class MangaUpdatesApi(
private suspend fun getSeriesRating(track: Track): Rating? { private suspend fun getSeriesRating(track: Track): Rating? {
return try { return try {
with(json) { with(json) {
authClient.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating")) authClient.newCall(GET("$baseUrl/v1/series/${track.remote_id}/rating"))
.awaitSuccess() .awaitSuccess()
.parseAs<Rating>() .parseAs<Rating>()
} }
@ -138,7 +139,7 @@ class MangaUpdatesApi(
} }
authClient.newCall( authClient.newCall(
PUT( PUT(
url = "$baseUrl/v1/series/${track.media_id}/rating", url = "$baseUrl/v1/series/${track.remote_id}/rating",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(contentType),
), ),
) )
@ -146,7 +147,7 @@ class MangaUpdatesApi(
} else { } else {
authClient.newCall( authClient.newCall(
DELETE( DELETE(
url = "$baseUrl/v1/series/${track.media_id}/rating", url = "$baseUrl/v1/series/${track.remote_id}/rating",
), ),
) )
.awaitSuccess() .awaitSuccess()

View File

@ -25,7 +25,7 @@ data class Record(
fun Record.toTrackSearch(id: Long): TrackSearch { fun Record.toTrackSearch(id: Long): TrackSearch {
return TrackSearch.create(id).apply { return TrackSearch.create(id).apply {
media_id = this@toTrackSearch.seriesId ?: 0L remote_id = this@toTrackSearch.seriesId ?: 0L
title = this@toTrackSearch.title?.htmlDecode() ?: "" title = this@toTrackSearch.title?.htmlDecode() ?: ""
total_chapters = 0 total_chapters = 0
cover_url = this@toTrackSearch.image?.url?.original ?: "" cover_url = this@toTrackSearch.image?.url?.original ?: ""

View File

@ -8,9 +8,9 @@ class TrackSearch : Track {
override var manga_id: Long = 0 override var manga_id: Long = 0
override var sync_id: Int = 0 override var tracker_id: Int = 0
override var media_id: Long = 0 override var remote_id: Long = 0
override var library_id: Long? = null override var library_id: Long? = null
@ -47,22 +47,22 @@ class TrackSearch : Track {
other as TrackSearch other as TrackSearch
if (manga_id != other.manga_id) return false if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false if (tracker_id != other.tracker_id) return false
if (media_id != other.media_id) return false if (remote_id != other.remote_id) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = manga_id.hashCode() var result = manga_id.hashCode()
result = 31 * result + sync_id result = 31 * result + tracker_id
result = 31 * result + media_id.hashCode() result = 31 * result + remote_id.hashCode()
return result return result
} }
companion object { companion object {
fun create(serviceId: Long): TrackSearch = TrackSearch().apply { fun create(serviceId: Long): TrackSearch = TrackSearch().apply {
sync_id = serviceId.toInt() tracker_id = serviceId.toInt()
} }
} }
} }

View File

@ -13,6 +13,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker { class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
@ -65,7 +66,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
override fun getScoreList(): ImmutableList<String> = SCORE_LIST override fun getScoreList(): ImmutableList<String> = SCORE_LIST
override fun displayScore(track: Track): String { override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
@ -91,15 +92,15 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
return api.updateItem(track) return api.updateItem(track)
} }
override suspend fun delete(track: Track): Track { override suspend fun delete(track: DomainTrack) {
return api.deleteItem(track) api.deleteItem(track)
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findListItem(track) val remoteTrack = api.findListItem(track)
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
val isRereading = track.status == REREADING val isRereading = track.status == REREADING

View File

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
@ -31,6 +32,7 @@ import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import tachiyomi.domain.track.model.Track as DomainTrack
class MyAnimeListApi( class MyAnimeListApi(
private val trackId: Long, private val trackId: Long,
@ -114,7 +116,7 @@ class MyAnimeListApi(
.let { .let {
val obj = it.jsonObject val obj = it.jsonObject
TrackSearch.create(trackId).apply { TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long remote_id = obj["id"]!!.jsonPrimitive.long
title = obj["title"]!!.jsonPrimitive.content title = obj["title"]!!.jsonPrimitive.content
summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
@ -122,7 +124,7 @@ class MyAnimeListApi(
cover_url = cover_url =
obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content
?: "" ?: ""
tracking_url = "https://myanimelist.net/manga/$media_id" tracking_url = "https://myanimelist.net/manga/$remote_id"
publishing_status = publishing_status =
obj["status"]!!.jsonPrimitive.content.replace("_", " ") obj["status"]!!.jsonPrimitive.content.replace("_", " ")
publishing_type = publishing_type =
@ -154,7 +156,7 @@ class MyAnimeListApi(
} }
val request = Request.Builder() val request = Request.Builder()
.url(mangaUrl(track.media_id).toString()) .url(mangaUrl(track.remote_id).toString())
.put(formBodyBuilder.build()) .put(formBodyBuilder.build())
.build() .build()
with(json) { with(json) {
@ -166,24 +168,18 @@ class MyAnimeListApi(
} }
} }
suspend fun deleteItem(track: Track): Track { suspend fun deleteItem(track: DomainTrack) {
return withIOContext { withIOContext {
val request = Request.Builder() authClient
.url(mangaUrl(track.media_id).toString()) .newCall(DELETE(mangaUrl(track.remoteId).toString()))
.delete()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess() .awaitSuccess()
track
}
} }
} }
suspend fun findListItem(track: Track): Track? { suspend fun findListItem(track: Track): Track? {
return withIOContext { return withIOContext {
val uri = "$baseApiUrl/manga".toUri().buildUpon() val uri = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(track.media_id.toString()) .appendPath(track.remote_id.toString())
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
.build() .build()
with(json) { with(json) {

View File

@ -13,6 +13,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
@ -37,7 +38,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
override fun getScoreList(): ImmutableList<String> = SCORE_LIST override fun getScoreList(): ImmutableList<String> = SCORE_LIST
override fun displayScore(track: Track): String { override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
@ -59,8 +60,8 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
return api.updateLibManga(track, getUsername()) return api.updateLibManga(track, getUsername())
} }
override suspend fun delete(track: Track): Track { override suspend fun delete(track: DomainTrack) {
return api.deleteLibManga(track) api.deleteLibManga(track)
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {

View File

@ -27,6 +27,7 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class ShikimoriApi( class ShikimoriApi(
private val trackId: Long, private val trackId: Long,
@ -44,7 +45,7 @@ class ShikimoriApi(
val payload = buildJsonObject { val payload = buildJsonObject {
putJsonObject("user_rate") { putJsonObject("user_rate") {
put("user_id", userId) put("user_id", userId)
put("target_id", track.media_id) put("target_id", track.remote_id)
put("target_type", "Manga") put("target_type", "Manga")
put("chapters", track.last_chapter_read.toInt()) put("chapters", track.last_chapter_read.toInt())
put("score", track.score.toInt()) put("score", track.score.toInt())
@ -69,14 +70,11 @@ class ShikimoriApi(
suspend fun updateLibManga(track: Track, userId: String): Track = addLibManga(track, userId) suspend fun updateLibManga(track: Track, userId: String): Track = addLibManga(track, userId)
suspend fun deleteLibManga(track: Track): Track { suspend fun deleteLibManga(track: DomainTrack) {
return withIOContext { withIOContext {
authClient.newCall( authClient
DELETE( .newCall(DELETE("$apiUrl/v2/user_rates/${track.libraryId}"))
"$apiUrl/v2/user_rates/${track.library_id}", .awaitSuccess()
),
).awaitSuccess()
track
} }
} }
@ -102,7 +100,7 @@ class ShikimoriApi(
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(trackId).apply { return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long remote_id = obj["id"]!!.jsonPrimitive.long
title = obj["name"]!!.jsonPrimitive.content title = obj["name"]!!.jsonPrimitive.content
total_chapters = obj["chapters"]!!.jsonPrimitive.int total_chapters = obj["chapters"]!!.jsonPrimitive.int
cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content
@ -118,7 +116,7 @@ class ShikimoriApi(
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track { private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(trackId).apply { return Track.create(trackId).apply {
title = mangas["name"]!!.jsonPrimitive.content title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.long remote_id = obj["id"]!!.jsonPrimitive.long
total_chapters = mangas["chapters"]!!.jsonPrimitive.int total_chapters = mangas["chapters"]!!.jsonPrimitive.int
library_id = obj["id"]!!.jsonPrimitive.long library_id = obj["id"]!!.jsonPrimitive.long
last_chapter_read = obj["chapters"]!!.jsonPrimitive.float last_chapter_read = obj["chapters"]!!.jsonPrimitive.float
@ -131,7 +129,7 @@ class ShikimoriApi(
suspend fun findLibManga(track: Track, userId: String): Track? { suspend fun findLibManga(track: Track, userId: String): Track? {
return withIOContext { return withIOContext {
val urlMangas = "$apiUrl/mangas".toUri().buildUpon() val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
.appendPath(track.media_id.toString()) .appendPath(track.remote_id.toString())
.build() .build()
val mangas = with(json) { val mangas = with(json) {
authClient.newCall(GET(urlMangas.toString())) authClient.newCall(GET(urlMangas.toString()))
@ -141,7 +139,7 @@ class ShikimoriApi(
val url = "$apiUrl/v2/user_rates".toUri().buildUpon() val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
.appendQueryParameter("user_id", userId) .appendQueryParameter("user_id", userId)
.appendQueryParameter("target_id", track.media_id.toString()) .appendQueryParameter("target_id", track.remote_id.toString())
.appendQueryParameter("target_type", "Manga") .appendQueryParameter("target_type", "Manga")
.build() .build()
with(json) { with(json) {

View File

@ -5,7 +5,7 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriInterceptor(val shikimori: Shikimori) : Interceptor { class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
private val json: Json by injectLazy() private val json: Json by injectLazy()

View File

@ -45,7 +45,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
override fun getScoreList(): ImmutableList<String> = persistentListOf() override fun getScoreList(): ImmutableList<String> = persistentListOf()
override fun displayScore(track: Track): String = "" override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track { override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) { if (track.status != COMPLETED) {

View File

@ -23,6 +23,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.source.AndroidSourceManager
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
@ -107,8 +108,11 @@ class AppModule(val app: Application) : InjektModule {
xmlVersion = XmlVersion.XML10 xmlVersion = XmlVersion.XML10
} }
} }
addSingletonFactory<ProtoBuf> {
ProtoBuf
}
addSingletonFactory { ChapterCache(app) } addSingletonFactory { ChapterCache(app, get()) }
addSingletonFactory { CoverCache(app) } addSingletonFactory { CoverCache(app) }
addSingletonFactory { NetworkHelper(app, get()) } addSingletonFactory { NetworkHelper(app, get()) }

View File

@ -141,6 +141,11 @@ internal object ExtensionLoader {
?.asSequence() ?.asSequence()
?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION } ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
?.mapNotNull { ?.mapNotNull {
// Just in case, since Android 14+ requires them to be read-only
if (it.canWrite()) {
it.setReadOnly()
}
val path = it.absolutePath val path = it.absolutePath
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
?.apply { applicationInfo.fixBasePaths(path) } ?.apply { applicationInfo.fixBasePaths(path) }
@ -277,7 +282,12 @@ internal object ExtensionLoader {
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1 val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1 val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val classLoader = try {
PathClassLoader(appInfo.sourceDir, null, context.classLoader)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($pkgName)" }
return LoadResult.Error
}
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
.split(";") .split(";")

View File

@ -217,7 +217,7 @@ class LibraryScreenModel(
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
val mangaTracks = trackMap val mangaTracks = trackMap
.mapValues { entry -> entry.value.map { it.syncId } }[item.libraryManga.id] .mapValues { entry -> entry.value.map { it.trackerId } }[item.libraryManga.id]
.orEmpty() .orEmpty()
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks } val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
@ -257,7 +257,7 @@ class LibraryScreenModel(
entry.value.isEmpty() -> null entry.value.isEmpty() -> null
else -> else ->
entry.value entry.value
.mapNotNull { trackerMap[it.syncId]?.get10PointScore(it) } .mapNotNull { trackerMap[it.trackerId]?.get10PointScore(it) }
.average() .average()
} }
} }

View File

@ -983,7 +983,7 @@ class MangaScreenModel(
.map { tracks -> .map { tracks ->
loggedInTrackers loggedInTrackers
// Map to TrackItem // Map to TrackItem
.map { service -> TrackItem(tracks.find { it.syncId == service.id }, service) } .map { service -> TrackItem(tracks.find { it.trackerId == service.id }, service) }
// Show only if the service supports this manga's source // Show only if the service supports this manga's source
.filter { (it.tracker as? EnhancedTracker)?.accept(source!!) ?: true } .filter { (it.tracker as? EnhancedTracker)?.accept(source!!) ?: true }
} }

View File

@ -244,7 +244,7 @@ data class TrackInfoDialogHomeScreen(
val source = Injekt.get<SourceManager>().getOrStub(sourceId) val source = Injekt.get<SourceManager>().getOrStub(sourceId)
return loggedInTrackers return loggedInTrackers
// Map to TrackItem // Map to TrackItem
.map { service -> TrackItem(find { it.syncId == service.id }, service) } .map { service -> TrackItem(find { it.trackerId == service.id }, service) }
// Show only if the service supports this manga's source // Show only if the service supports this manga's source
.filter { (it.tracker as? EnhancedTracker)?.accept(source) ?: true } .filter { (it.tracker as? EnhancedTracker)?.accept(source) ?: true }
} }
@ -399,7 +399,7 @@ private data class TrackScoreSelectorScreen(
private class Model( private class Model(
private val track: Track, private val track: Track,
private val tracker: Tracker, private val tracker: Tracker,
) : StateScreenModel<Model.State>(State(tracker.displayScore(track.toDbTrack()))) { ) : StateScreenModel<Model.State>(State(tracker.displayScore(track))) {
fun getSelections(): ImmutableList<String> { fun getSelections(): ImmutableList<String> {
return tracker.getScoreList() return tracker.getScoreList()
@ -816,7 +816,7 @@ private data class TrackerRemoveScreen(
fun deleteMangaFromService() { fun deleteMangaFromService() {
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
(tracker as DeletableTracker).delete(track.toDbTrack()) (tracker as DeletableTracker).delete(track)
} }
} }

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