chore: fix conflict.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2023-12-29 01:12:23 +11:00
commit 8ba133ae7b
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
133 changed files with 1572 additions and 1218 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) |
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
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 ----------
-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
-keepclassmembers class kotlinx.serialization.json.** {

View File

@ -8,7 +8,8 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 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 -->
<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.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- 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.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" />

View File

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

View File

@ -27,7 +27,7 @@ class TrackChapter(
if (tracks.isEmpty()) return@withNonCancellableContext
tracks.mapNotNull { track ->
val service = trackerManager.get(track.syncId)
val service = trackerManager.get(track.trackerId)
if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) {
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.manga_id = mangaId
it.media_id = remoteId
it.remote_id = remoteId
it.library_id = libraryId
it.title = title
it.last_chapter_read = lastChapterRead.toFloat()
@ -33,8 +33,8 @@ fun DbTrack.toDomainTrack(idRequired: Boolean = true): Track? {
return Track(
id = trackId,
mangaId = manga_id,
syncId = sync_id.toLong(),
remoteId = media_id,
trackerId = tracker_id.toLong(),
remoteId = remote_id,
libraryId = library_id,
title = title,
lastChapterRead = last_chapter_read.toDouble(),

View File

@ -9,26 +9,24 @@ class TrackPreferences(
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) {
trackUsername(sync).set(username)
trackPassword(sync).set(password)
fun setCredentials(tracker: Tracker, username: String, password: String) {
trackUsername(tracker).set(username)
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 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 eu.kanade.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import tachiyomi.core.preference.CheckboxState
import tachiyomi.domain.category.model.Category
@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.seconds
fun CategoryCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
categories: List<Category>,
categories: ImmutableList<Category>,
) {
var name by remember { mutableStateOf("") }
@ -98,7 +99,7 @@ fun CategoryCreateDialog(
fun CategoryRenameDialog(
onDismissRequest: () -> Unit,
onRename: (String) -> Unit,
categories: List<Category>,
categories: ImmutableList<Category>,
category: Category,
) {
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource
@ -16,11 +17,13 @@ import tachiyomi.presentation.core.util.isScrollingUp
fun CategoryFloatingActionButton(
lazyListState: LazyListState,
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate,
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.manga.DownloadAction
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
@ -13,18 +15,22 @@ fun DownloadDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit,
modifier: Modifier = Modifier,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
) {
listOfNotNull(
val options = persistentListOf(
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_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
).map { (downloadAction, string) ->
)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
options.map { (downloadAction, string) ->
DropdownMenuItem(
text = { Text(text = string) },
onClick = {

View File

@ -1,7 +1,10 @@
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.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.RadioButtonChecked
@ -22,12 +25,17 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu
/**
* DropdownMenu but overlaps anchor and has width constraints to better
* match non-Compose implementation.
*/
@Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(8.dp, (-56).dp),
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit,
) {
@ -36,6 +44,7 @@ fun DropdownMenu(
onDismissRequest = onDismissRequest,
modifier = modifier.sizeIn(minWidth = 196.dp, maxWidth = 196.dp),
offset = offset,
scrollState = scrollState,
properties = properties,
content = content,
)
@ -45,6 +54,7 @@ fun DropdownMenu(
fun RadioMenuItem(
text: @Composable () -> Unit,
isChecked: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
DropdownMenuItem(
@ -64,6 +74,7 @@ fun RadioMenuItem(
)
}
},
modifier = modifier,
)
}
@ -71,10 +82,12 @@ fun RadioMenuItem(
fun NestedMenuItem(
text: @Composable () -> Unit,
children: @Composable ColumnScope.(() -> Unit) -> Unit,
modifier: Modifier = Modifier,
) {
var nestedExpanded by remember { mutableStateOf(false) }
val closeMenu = { nestedExpanded = false }
Box {
DropdownMenuItem(
text = text,
onClick = { nestedExpanded = true },
@ -89,7 +102,9 @@ fun NestedMenuItem(
DropdownMenu(
expanded = nestedExpanded,
onDismissRequest = closeMenu,
modifier = modifier,
) {
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.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.source.local.isLocal
import java.text.DateFormat
import java.util.Date
@ -718,13 +719,13 @@ fun MangaScreenLargeImpl(
@Composable
private fun SharedMangaBottomActionMenu(
selected: List<ChapterList.Item>,
modifier: Modifier = Modifier,
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<Chapter>, markAsRead: Boolean) -> Unit,
onMarkPreviousAsReadClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
onMultiDeleteClicked: (List<Chapter>) -> Unit,
fillFraction: Float,
modifier: Modifier = Modifier,
) {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
@ -752,7 +753,7 @@ private fun SharedMangaBottomActionMenu(
onDeleteClicked = {
onMultiDeleteClicked(selected.fastMap { it.chapter })
}.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,
bookmark = item.chapter.bookmark,
selected = item.selected,
downloadIndicatorEnabled = !isAnyChapterSelected,
downloadIndicatorEnabled = !isAnyChapterSelected && !manga.isLocal(),
downloadStateProvider = { item.downloadState },
downloadProgressProvider = { item.downloadProgress },
chapterSwipeStartAction = chapterSwipeStartAction,

View File

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

View File

@ -182,7 +182,10 @@ private fun RowScope.Button(
onClick: () -> Unit,
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(
modifier = Modifier
.size(48.dp)

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import eu.kanade.tachiyomi.data.track.Tracker
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.core.preference.Preference as PreferenceData
@ -64,20 +66,20 @@ sealed class Preference {
val pref: PreferenceData<T>,
override val title: String,
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]) },
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: T) -> Boolean = { true },
val entries: Map<T, String>,
val entries: ImmutableMap<T, String>,
) : PreferenceItem<T>() {
internal fun internalSet(newValue: Any) = pref.set(newValue as T)
internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T)
@Composable
internal fun internalSubtitleProvider(value: Any?, entries: Map<out Any?, String>) =
subtitleProvider(value as T, entries as Map<T, String>)
internal fun internalSubtitleProvider(value: Any?, entries: ImmutableMap<out Any?, String>) =
subtitleProvider(value as T, entries as ImmutableMap<T, String>)
}
/**
@ -87,13 +89,13 @@ sealed class Preference {
val value: String,
override val title: String,
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]) },
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
val entries: Map<String, String>,
val entries: ImmutableMap<String, String>,
) : PreferenceItem<String>()
/**
@ -104,7 +106,10 @@ sealed class Preference {
val pref: PreferenceData<Set<String>>,
override val title: String,
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) {
v.map { e[it] }
.takeIf { it.isNotEmpty() }
@ -116,7 +121,7 @@ sealed class Preference {
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true },
val entries: Map<String, String>,
val entries: ImmutableMap<String, String>,
) : PreferenceItem<Set<String>>()
/**
@ -170,6 +175,6 @@ sealed class Preference {
override val title: String,
override val enabled: Boolean = true,
val preferenceItems: List<PreferenceItem<out Any>>,
val preferenceItems: ImmutableList<PreferenceItem<out Any>>,
) : 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.TrackingPreferenceWidget
import kotlinx.coroutines.launch
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.presentation.core.components.SliderItem
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
@ -157,8 +156,8 @@ internal fun PreferenceItem(
)
}
is Preference.PreferenceItem.TrackerPreference -> {
val uName by Injekt.get<PreferenceStore>()
.getString(TrackPreferences.trackUsername(item.tracker.id))
val uName by Injekt.get<TrackPreferences>()
.trackUsername(item.tracker)
.collectAsState()
item.tracker.run {
TrackingPreferenceWidget(

View File

@ -11,7 +11,6 @@ import tachiyomi.presentation.core.i18n.stringResource
/**
* Returns a string of categories name for settings subtitle
*/
@ReadOnlyComposable
@Composable
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.setDefaultSettings
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 logcat.LogPriority
import okhttp3.Headers
@ -149,7 +152,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_background_activity),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_disable_battery_optimization),
subtitle = stringResource(MR.strings.pref_disable_battery_optimization_summary),
@ -188,7 +191,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_invalidate_download_cache),
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
@ -218,7 +221,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_network),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_clear_cookies),
onClick = {
@ -249,7 +252,7 @@ object SettingsAdvancedScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference(
pref = networkPreferences.dohProvider(),
title = stringResource(MR.strings.pref_dns_over_https),
entries = mapOf(
entries = persistentMapOf(
-1 to stringResource(MR.strings.disabled),
PREF_DOH_CLOUDFLARE to "Cloudflare",
PREF_DOH_GOOGLE to "Google",
@ -302,7 +305,7 @@ object SettingsAdvancedScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_library),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_refresh_library_covers),
onClick = { MetadataUpdateJob.startNow(context) },
@ -362,12 +365,13 @@ object SettingsAdvancedScreen : SearchableSettings {
}
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_extensions),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = extensionInstallerPref,
title = stringResource(MR.strings.ext_installer_pref),
entries = extensionInstallerPref.entries
.associateWith { stringResource(it.titleRes) },
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
onValueChanged = {
if (it == BasePreferences.ExtensionInstaller.SHIZUKU &&
!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.util.system.LocaleHelper
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 tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
@ -66,7 +69,7 @@ object SettingsAppearanceScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_theme),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.CustomPreference(
title = stringResource(MR.strings.pref_app_theme),
) {
@ -127,7 +130,7 @@ object SettingsAppearanceScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.BasicListPreference(
value = currentLanguage,
title = stringResource(MR.strings.pref_app_language),
@ -140,7 +143,9 @@ object SettingsAppearanceScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference(
pref = uiPreferences.tabletUiMode(),
title = stringResource(MR.strings.pref_tablet_ui_mode),
entries = TabletUiMode.entries.associateWith { stringResource(it.titleRes) },
entries = TabletUiMode.entries
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
onValueChanged = {
context.toast(MR.strings.requires_app_restart)
true
@ -153,7 +158,8 @@ object SettingsAppearanceScreen : SearchableSettings {
.associateWith {
val formattedDate = UiPreferences.dateFormat(it).format(now)
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
},
}
.toImmutableMap(),
),
Preference.PreferenceItem.SwitchPreference(
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 parser = context.resources.getXml(R.xml.locales_config)
var eventType = parser.eventType
@ -189,7 +195,7 @@ object SettingsAppearanceScreen : SearchableSettings {
langs.sortBy { it.second }
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.presentation.more.settings.Preference
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@ -27,7 +28,7 @@ object SettingsBrowseScreen : SearchableSettings {
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = sourcePreferences.hideInLibraryItems(),
title = stringResource(MR.strings.pref_hide_in_library_items),
@ -36,7 +37,7 @@ object SettingsBrowseScreen : SearchableSettings {
),
Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_nsfw_content),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = sourcePreferences.showNsfwSource(),
title = stringResource(MR.strings.pref_show_nsfw_source),

View File

@ -1,21 +1,17 @@
package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
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.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@ -35,21 +31,20 @@ import com.hippo.unifile.UniFile
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.SyncOptionsScreen
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.PrefsHorizontalPadding
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.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.sync.SyncManager
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.i18n.stringResource
@ -68,6 +63,8 @@ import uy.kohesive.injekt.api.get
object SettingsDataScreen : SearchableSettings {
val restorePreferenceKeyString = MR.strings.label_backup
@ReadOnlyComposable
@Composable
override fun getTitleRes() = MR.strings.label_data_storage
@ -80,7 +77,7 @@ object SettingsDataScreen : SearchableSettings {
val syncPreferences = remember { Injekt.get<SyncPreferences>() }
val syncService by syncPreferences.syncService().collectAsState()
return listOf(
return persistentListOf(
getStorageLocationPref(storagePreferences = storagePreferences),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)),
@ -151,20 +148,48 @@ object SettingsDataScreen : SearchableSettings {
@Composable
private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_backup),
preferenceItems = listOf(
preferenceItems = persistentListOf(
// Manual actions
getCreateBackupPref(),
getRestoreBackupPref(),
Preference.PreferenceItem.CustomPreference(
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
Preference.PreferenceItem.ListPreference(
pref = backupPreferences.backupInterval(),
title = stringResource(MR.strings.pref_backup_interval),
entries = mapOf(
entries = persistentMapOf(
0 to stringResource(MR.strings.off),
6 to stringResource(MR.strings.update_6hour),
12 to stringResource(MR.strings.update_12hour),
@ -185,142 +210,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
private fun getDataGroup(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
val chapterCache = remember { Injekt.get<ChapterCache>() }
@ -328,9 +221,19 @@ object SettingsDataScreen : SearchableSettings {
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data),
preferenceItems = listOf(
getStorageInfoPref(cacheReadableSize),
title = stringResource(MR.strings.pref_storage_usage),
preferenceItems = persistentListOf(
Preference.PreferenceItem.CustomPreference(
title = stringResource(MR.strings.pref_storage_usage),
) {
BasePreferenceWidget(
subcomponent = {
StorageInfo(
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
)
},
)
},
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_clear_chapter_cache),
@ -358,43 +261,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
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_service_category),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncPreferences.syncService(),
title = stringResource(MR.strings.pref_sync_service),
entries = mapOf(
entries = persistentMapOf(
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive),
@ -405,10 +281,9 @@ object SettingsDataScreen : SearchableSettings {
),
) + getSyncServicePreferences(syncPreferences, syncService)
}
}
@Composable
private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
@Composable
private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
val syncServiceType = SyncManager.SyncService.fromInt(syncService)
val basePreferences = getBasePreferences(syncServiceType, syncPreferences)
@ -418,27 +293,27 @@ private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncServ
} else {
basePreferences
}
}
}
@Composable
private fun getBasePreferences(
@Composable
private fun getBasePreferences(
syncServiceType: SyncManager.SyncService,
syncPreferences: SyncPreferences,
): List<Preference> {
): List<Preference> {
return when (syncServiceType) {
SyncManager.SyncService.NONE -> emptyList()
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
}
}
}
@Composable
private fun getAdditionalPreferences(syncPreferences: SyncPreferences): List<Preference> {
@Composable
private fun getAdditionalPreferences(syncPreferences: SyncPreferences): List<Preference> {
return listOf(getSyncNowPref(), getAutomaticSyncGroup(syncPreferences))
}
}
@Composable
private fun getGoogleDrivePreferences(): List<Preference> {
@Composable
private fun getGoogleDrivePreferences(): List<Preference> {
val context = LocalContext.current
val googleDriveSync = Injekt.get<GoogleDriveService>()
return listOf(
@ -451,10 +326,10 @@ private fun getGoogleDrivePreferences(): List<Preference> {
),
getGoogleDrivePurge(),
)
}
}
@Composable
private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference {
@Composable
private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val googleDriveSync = remember { GoogleDriveSyncService(context) }
@ -487,13 +362,13 @@ private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference {
title = stringResource(MR.strings.pref_google_drive_purge_sync_data),
onClick = { showPurgeDialog = true },
)
}
}
@Composable
private fun PurgeConfirmationDialog(
@Composable
private fun PurgeConfirmationDialog(
onConfirm: () -> Unit,
onDismissRequest: () -> Unit,
) {
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) },
@ -509,10 +384,10 @@ private fun PurgeConfirmationDialog(
}
},
)
}
}
@Composable
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
@Composable
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
val scope = rememberCoroutineScope()
return listOf(
Preference.PreferenceItem.EditTextPreference(
@ -535,10 +410,10 @@ private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Prefe
pref = syncPreferences.syncAPIKey(),
),
)
}
}
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope()
var showDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
@ -559,7 +434,7 @@ private fun getSyncNowPref(): Preference.PreferenceGroup {
}
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title),
preferenceItems = listOf(
preferenceItems = persistentListOf(
getSyncOptionsPref(),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_now),
@ -572,7 +447,7 @@ private fun getSyncNowPref(): Preference.PreferenceGroup {
)
}
@Composable
@Composable
private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference {
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceItem.TextPreference(
@ -583,18 +458,18 @@ private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference {
}
@Composable
private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup {
private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup {
val context = LocalContext.current
val syncIntervalPref = syncPreferences.syncInterval()
val lastSync by syncPreferences.lastSyncTimestamp().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_automatic_category),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncIntervalPref,
title = stringResource(MR.strings.pref_sync_interval),
entries = mapOf(
entries = persistentMapOf(
0 to stringResource(MR.strings.off),
30 to stringResource(MR.strings.update_30min),
60 to stringResource(MR.strings.update_1hour),
@ -615,13 +490,13 @@ private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.
),
),
)
}
}
@Composable
private fun SyncConfirmationDialog(
@Composable
private fun SyncConfirmationDialog(
onConfirm: () -> Unit,
onDismissRequest: () -> Unit,
) {
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_sync_confirmation_title)) },
@ -637,15 +512,5 @@ 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.more.settings.Preference
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 tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
@ -68,7 +71,7 @@ object SettingsDownloadScreen : SearchableSettings {
): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_delete_chapters),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = downloadPreferences.removeAfterMarkedAsRead(),
title = stringResource(MR.strings.pref_remove_after_marked_as_read),
@ -76,7 +79,7 @@ object SettingsDownloadScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference(
pref = downloadPreferences.removeAfterReadSlots(),
title = stringResource(MR.strings.pref_remove_after_read),
entries = mapOf(
entries = persistentMapOf(
-1 to stringResource(MR.strings.disabled),
0 to stringResource(MR.strings.last_read_chapter),
1 to stringResource(MR.strings.second_to_last),
@ -105,7 +108,9 @@ object SettingsDownloadScreen : SearchableSettings {
return Preference.PreferenceItem.MultiSelectListPreference(
pref = downloadPreferences.removeExcludeCategories(),
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(
title = stringResource(MR.strings.pref_category_auto_download),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = downloadNewChaptersPref,
title = stringResource(MR.strings.pref_download_new),
@ -167,17 +172,19 @@ object SettingsDownloadScreen : SearchableSettings {
): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.download_ahead),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = downloadPreferences.autoDownloadWhileReading(),
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) {
stringResource(MR.strings.disabled)
} else {
pluralStringResource(MR.plurals.next_unread_chapters, count = it, it)
}
},
}
.toImmutableMap(),
),
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.tachiyomi.data.library.LibraryUpdateJob
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.runBlocking
import tachiyomi.domain.category.interactor.GetCategories
@ -65,7 +68,6 @@ object SettingsLibraryScreen : SearchableSettings {
allCategories: List<Category>,
libraryPreferences: LibraryPreferences,
): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val userCategoriesCount = allCategories.filterNot(Category::isSystemCategory).size
@ -76,11 +78,11 @@ object SettingsLibraryScreen : SearchableSettings {
val ids = listOf(libraryPreferences.defaultCategory().defaultValue()) +
allCategories.fastMap { it.id.toInt() }
val labels = listOf(stringResource(MR.strings.default_category_summary)) +
allCategories.fastMap { it.visualName(context) }
allCategories.fastMap { it.visualName }
return Preference.PreferenceGroup(
title = stringResource(MR.strings.categories),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.action_edit_categories),
subtitle = pluralStringResource(
@ -94,7 +96,7 @@ object SettingsLibraryScreen : SearchableSettings {
pref = libraryPreferences.defaultCategory(),
title = stringResource(MR.strings.default_category),
subtitle = selectedCategory?.visualName ?: stringResource(MR.strings.default_category_summary),
entries = ids.zip(labels).toMap(),
entries = ids.zip(labels).toMap().toImmutableMap(),
),
Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.categorizedDisplaySettings(),
@ -147,11 +149,11 @@ object SettingsLibraryScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_library_update),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = autoUpdateIntervalPref,
title = stringResource(MR.strings.pref_library_update_interval),
entries = mapOf(
entries = persistentMapOf(
0 to stringResource(MR.strings.update_never),
12 to stringResource(MR.strings.update_12hour),
24 to stringResource(MR.strings.update_24hour),
@ -169,7 +171,7 @@ object SettingsLibraryScreen : SearchableSettings {
enabled = autoUpdateInterval > 0,
title = stringResource(MR.strings.pref_library_update_restriction),
subtitle = stringResource(MR.strings.restrictions),
entries = mapOf(
entries = persistentMapOf(
DEVICE_ONLY_ON_WIFI to stringResource(MR.strings.connected_to_wifi),
DEVICE_NETWORK_NOT_METERED to stringResource(MR.strings.network_not_metered),
DEVICE_CHARGING to stringResource(MR.strings.charging),
@ -197,7 +199,7 @@ object SettingsLibraryScreen : SearchableSettings {
Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryPreferences.autoUpdateMangaRestrictions(),
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_NON_READ to stringResource(MR.strings.pref_update_only_started),
MANGA_NON_COMPLETED to stringResource(MR.strings.pref_update_only_non_completed),
@ -218,11 +220,11 @@ object SettingsLibraryScreen : SearchableSettings {
): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_chapter_swipe),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = libraryPreferences.swipeToStartAction(),
title = stringResource(MR.strings.pref_chapter_swipe_start),
entries = mapOf(
entries = persistentMapOf(
LibraryPreferences.ChapterSwipeAction.Disabled to
stringResource(MR.strings.disabled),
LibraryPreferences.ChapterSwipeAction.ToggleBookmark to
@ -236,7 +238,7 @@ object SettingsLibraryScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference(
pref = libraryPreferences.swipeToEndAction(),
title = stringResource(MR.strings.pref_chapter_swipe_end),
entries = mapOf(
entries = persistentMapOf(
LibraryPreferences.ChapterSwipeAction.Disabled to
stringResource(MR.strings.disabled),
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.ReaderPreferences
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.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
@ -31,12 +34,13 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPref.defaultReadingMode(),
title = stringResource(MR.strings.pref_viewer_type),
entries = ReadingMode.entries.drop(1)
.associate { it.flagValue to stringResource(it.stringRes) },
.associate { it.flagValue to stringResource(it.stringRes) }
.toImmutableMap(),
),
Preference.PreferenceItem.ListPreference(
pref = readerPref.doubleTapAnimSpeed(),
title = stringResource(MR.strings.pref_double_tap_anim_speed),
entries = mapOf(
entries = persistentMapOf(
1 to stringResource(MR.strings.double_tap_anim_speed_0),
500 to stringResource(MR.strings.double_tap_anim_speed_normal),
250 to stringResource(MR.strings.double_tap_anim_speed_fast),
@ -82,17 +86,18 @@ object SettingsReaderScreen : SearchableSettings {
val fullscreen by fullscreenPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.defaultOrientationType(),
title = stringResource(MR.strings.pref_rotation_type),
entries = ReaderOrientation.entries.drop(1)
.associate { it.flagValue to stringResource(it.stringRes) },
.associate { it.flagValue to stringResource(it.stringRes) }
.toImmutableMap(),
),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.readerTheme(),
title = stringResource(MR.strings.pref_reader_theme),
entries = mapOf(
entries = persistentMapOf(
1 to stringResource(MR.strings.black_background),
2 to stringResource(MR.strings.gray_background),
0 to stringResource(MR.strings.white_background),
@ -126,7 +131,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getReadingGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reading),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.skipRead(),
title = stringResource(MR.strings.pref_skip_read_chapters),
@ -161,23 +166,26 @@ object SettingsReaderScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pager_viewer),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = navModePref,
title = stringResource(MR.strings.pref_viewer_nav),
entries = ReaderPreferences.TapZones
.mapIndexed { index, it -> index to stringResource(it) }
.toMap(),
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.pagerNavInverted(),
title = stringResource(MR.strings.pref_read_with_tapping_inverted),
entries = listOf(
entries = persistentListOf(
ReaderPreferences.TappingInvertMode.NONE,
ReaderPreferences.TappingInvertMode.HORIZONTAL,
ReaderPreferences.TappingInvertMode.VERTICAL,
ReaderPreferences.TappingInvertMode.BOTH,
).associateWith { stringResource(it.titleRes) },
)
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
enabled = navMode != 5,
),
Preference.PreferenceItem.ListPreference(
@ -185,14 +193,16 @@ object SettingsReaderScreen : SearchableSettings {
title = stringResource(MR.strings.pref_image_scale_type),
entries = ReaderPreferences.ImageScaleType
.mapIndexed { index, it -> index + 1 to stringResource(it) }
.toMap(),
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.zoomStart(),
title = stringResource(MR.strings.pref_zoom_start),
entries = ReaderPreferences.ZoomStart
.mapIndexed { index, it -> index + 1 to stringResource(it) }
.toMap(),
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.cropBorders(),
@ -255,23 +265,26 @@ object SettingsReaderScreen : SearchableSettings {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.webtoon_viewer),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = navModePref,
title = stringResource(MR.strings.pref_viewer_nav),
entries = ReaderPreferences.TapZones
.mapIndexed { index, it -> index to stringResource(it) }
.toMap(),
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.webtoonNavInverted(),
title = stringResource(MR.strings.pref_read_with_tapping_inverted),
entries = listOf(
entries = persistentListOf(
ReaderPreferences.TappingInvertMode.NONE,
ReaderPreferences.TappingInvertMode.HORIZONTAL,
ReaderPreferences.TappingInvertMode.VERTICAL,
ReaderPreferences.TappingInvertMode.BOTH,
).associateWith { stringResource(it.titleRes) },
)
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
enabled = navMode != 5,
),
Preference.PreferenceItem.SliderPreference(
@ -288,7 +301,7 @@ object SettingsReaderScreen : SearchableSettings {
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.readerHideThreshold(),
title = stringResource(MR.strings.pref_hide_threshold),
entries = mapOf(
entries = persistentMapOf(
ReaderPreferences.ReaderHideThreshold.HIGHEST to stringResource(MR.strings.pref_highest),
ReaderPreferences.ReaderHideThreshold.HIGH to stringResource(MR.strings.pref_high),
ReaderPreferences.ReaderHideThreshold.LOW to stringResource(MR.strings.pref_low),
@ -341,7 +354,7 @@ object SettingsReaderScreen : SearchableSettings {
val readWithVolumeKeys by readWithVolumeKeysPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_reader_navigation),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = readWithVolumeKeysPref,
title = stringResource(MR.strings.pref_read_with_volume_keys),
@ -359,7 +372,7 @@ object SettingsReaderScreen : SearchableSettings {
private fun getActionsGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_reader_actions),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.readWithLongTap(),
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.util.system.AuthenticatorUtil.authenticate
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.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
@ -55,7 +57,8 @@ object SettingsSecurityScreen : SearchableSettings {
0 -> stringResource(MR.strings.lock_always)
else -> pluralStringResource(MR.plurals.lock_after_mins, count = it, it)
}
},
}
.toImmutableMap(),
onValueChanged = {
(context as FragmentActivity).authenticate(
title = context.stringResource(MR.strings.lock_when_idle),
@ -70,14 +73,15 @@ object SettingsSecurityScreen : SearchableSettings {
pref = securityPreferences.secureScreen(),
title = stringResource(MR.strings.secure_screen),
entries = SecurityPreferences.SecureScreenMode.entries
.associateWith { stringResource(it.titleRes) },
.associateWith { stringResource(it.titleRes) }
.toImmutableMap(),
),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.secure_screen_summary)),
)
}
}
private val LockAfterValues = listOf(
private val LockAfterValues = persistentListOf(
0, // Always
1,
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.util.system.openInBrowser
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.withUIContext
import tachiyomi.domain.source.service.SourceManager
@ -125,7 +127,7 @@ object SettingsTrackingScreen : SearchableSettings {
),
Preference.PreferenceGroup(
title = stringResource(MR.strings.services),
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.myAnimeList.name,
tracker = trackerManager.myAnimeList,
@ -167,7 +169,8 @@ object SettingsTrackingScreen : SearchableSettings {
),
Preference.PreferenceGroup(
title = stringResource(MR.strings.enhanced_services),
preferenceItems = enhancedTrackers.first
preferenceItems = (
enhancedTrackers.first
.map { service ->
Preference.PreferenceItem.TrackerPreference(
title = service.name,
@ -175,7 +178,8 @@ object SettingsTrackingScreen : SearchableSettings {
login = { (service as EnhancedTracker).loginNoop() },
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.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.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.create.BackupCreateFlags
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.toast
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.plus
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
@ -83,16 +84,21 @@ class CreateBackupScreen : Screen() {
.fillMaxSize(),
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = MaterialTheme.padding.medium),
modifier = Modifier.weight(1f),
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
}
}
item {
LabeledCheckbox(
label = stringResource(MR.strings.manga),
checked = true,
onCheckedChange = {},
enabled = false,
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
}
BackupChoices.forEach { (k, v) ->
@ -103,6 +109,7 @@ class CreateBackupScreen : Screen() {
onCheckedChange = {
model.toggleFlag(k)
},
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
}
}
@ -116,11 +123,8 @@ class CreateBackupScreen : Screen() {
.fillMaxWidth(),
onClick = {
if (!BackupCreateJob.isManualJobRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning, Toast.LENGTH_LONG)
}
try {
chooseBackupDir.launch(Backup.getFilename())
chooseBackupDir.launch(BackupCreator.getFilename())
} catch (e: ActivityNotFoundException) {
context.toast(MR.strings.file_picker_error)
}
@ -158,11 +162,16 @@ private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel
@Immutable
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_CHAPTER to MR.strings.chapters,
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.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.guava.await
import tachiyomi.i18n.MR
@ -47,7 +49,7 @@ class DebugInfoScreen : Screen() {
private fun getAppInfoGroup(): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = "App info",
preferenceItems = listOf(
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = "Version",
subtitle = AboutScreen.getVersionName(false),
@ -96,8 +98,8 @@ class DebugInfoScreen : Screen() {
}
private fun getDeviceInfoGroup(): Preference.PreferenceGroup {
val items = buildList {
add(
val items = persistentListOf<Preference.PreferenceItem<out Any>>().mutate {
it.add(
Preference.PreferenceItem.TextPreference(
title = "Model",
subtitle = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})",
@ -105,14 +107,14 @@ class DebugInfoScreen : Screen() {
)
if (DeviceUtil.oneUiVersion != null) {
add(
it.add(
Preference.PreferenceItem.TextPreference(
title = "OneUI version",
subtitle = "${DeviceUtil.oneUiVersion}",
),
)
} else if (DeviceUtil.miuiMajorVersion != null) {
add(
it.add(
Preference.PreferenceItem.TextPreference(
title = "MIUI version",
subtitle = "${DeviceUtil.miuiMajorVersion}",
@ -127,7 +129,7 @@ class DebugInfoScreen : Screen() {
} else {
Build.VERSION.RELEASE
}
add(
it.add(
Preference.PreferenceItem.TextPreference(
title = "Android version",
subtitle = "$androidVersion (${Build.DISPLAY})",

View File

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

View File

@ -140,7 +140,7 @@ private fun TrackerStats(
val meanScoreStr = remember(data.trackedTitleCount, data.meanScore) {
if (data.trackedTitleCount > 0 && !data.meanScore.isNaN()) {
// All other numbers are localized in English
String.format(Locale.ENGLISH, "%.2f ★", data.meanScore)
"%.2f ★".format(Locale.ENGLISH, data.meanScore)
} else {
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.sp
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.ui.reader.model.ChapterTransition
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.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
@ -51,8 +51,8 @@ fun ChapterTransition(
currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean,
) {
val currChapter = transition.from.chapter
val goingToChapter = transition.to?.chapter
val currChapter = transition.from.chapter.toDomainChapter()
val goingToChapter = transition.to?.chapter?.toDomainChapter()
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
when (transition) {
@ -65,7 +65,7 @@ fun ChapterTransition(
bottomChapter = currChapter,
bottomChapterDownloaded = currChapterDownloaded,
fallbackLabel = stringResource(MR.strings.transition_no_previous),
chapterGap = calculateChapterGap(currChapter.toDomainChapter(), goingToChapter?.toDomainChapter()),
chapterGap = calculateChapterGap(currChapter, goingToChapter),
)
}
is ChapterTransition.Next -> {
@ -77,7 +77,7 @@ fun ChapterTransition(
bottomChapter = goingToChapter,
bottomChapterDownloaded = goingToChapterDownloaded,
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,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
inlineContent = mapOf(
inlineContent = persistentMapOf(
DownloadedIconContentId to InlineTextContent(
Placeholder(
width = 22.sp,
@ -275,24 +275,23 @@ private val CardColor: CardColors
private val VerticalSpacerSize = 24.dp
private const val DownloadedIconContentId = "downloaded"
private fun previewChapter(name: String, scanlator: String, chapterNumber: Float) = ChapterImpl().apply {
this.name = name
this.scanlator = scanlator
this.chapter_number = chapterNumber
this.id = 0
this.manga_id = 0
this.url = ""
}
private fun previewChapter(name: String, scanlator: String, chapterNumber: Double) = Chapter.create().copy(
id = 0L,
mangaId = 0L,
url = "",
name = name,
scanlator = scanlator,
chapterNumber = chapterNumber,
)
private val FakeChapter = previewChapter(
name = "Vol.1, Ch.1 - Fake Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 1f,
chapterNumber = 1.0,
)
private val FakeGapChapter = previewChapter(
name = "Vol.5, Ch.44 - Fake Gap Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 44f,
chapterNumber = 44.0,
)
private val FakeChapterLongTitle = previewChapter(
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 " +
"and the Line Between Author and Character is Forever Blurred.",
scanlator = "Long Long Funny Scanlator Sniper Group Name Reborn",
chapterNumber = 1f,
chapterNumber = 1.0,
)
@PreviewLightDark

View File

@ -30,7 +30,7 @@ fun DisplayRefreshHost(
val currentDisplayRefresh = hostState.currentDisplayRefresh
LaunchedEffect(currentDisplayRefresh) {
if (currentDisplayRefresh) {
delay(200)
delay(1500)
hostState.currentDisplayRefresh = false
}
}
@ -39,7 +39,7 @@ fun DisplayRefreshHost(
Canvas(
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.unit.dp
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.track.components.TrackLogoIcon
@ -101,7 +100,7 @@ fun TrackInfoDialogHome(
}
},
onChaptersClick = { onChapterClick(item) },
score = item.tracker.displayScore(item.track.toDbTrack())
score = item.tracker.displayScore(item.track)
.takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring },

View File

@ -13,7 +13,7 @@ internal class TrackInfoDialogHomePreviewProvider :
private val aTrack = Track(
id = 1L,
mangaId = 2L,
syncId = 3L,
trackerId = 3L,
remoteId = 4L,
libraryId = null,
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 eu.kanade.presentation.theme.TachiyomiTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
@ -233,7 +234,7 @@ private fun TrackStatusSelectorPreviews() {
TrackStatusSelector(
selection = 1,
onSelectionChange = {},
selections = mapOf(
selections = persistentMapOf(
// Anilist values
1 to MR.strings.reading,
2 to MR.strings.plan_to_read,

View File

@ -62,8 +62,8 @@ internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composab
private fun randTrackSearch() = TrackSearch().let {
it.id = Random.nextLong()
it.manga_id = Random.nextLong()
it.sync_id = Random.nextInt()
it.media_id = Random.nextLong()
it.tracker_id = Random.nextInt()
it.remote_id = Random.nextLong()
it.library_id = Random.nextLong()
it.title = lorem((1..10).random()).joinToString()
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.net.Uri
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.util.BackupUtil
import tachiyomi.core.i18n.stringResource
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
@ -11,6 +10,8 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class BackupFileValidator(
private val context: Context,
private val sourceManager: SourceManager = Injekt.get(),
private val trackerManager: TrackerManager = Injekt.get(),
) {
@ -21,9 +22,9 @@ class BackupFileValidator(
* @throws Exception if manga cannot be found.
* @return List of missing sources or missing trackers.
*/
fun validate(context: Context, uri: Uri): Results {
fun validate(uri: Uri): Results {
val backup = try {
BackupUtil.decodeBackup(context, uri)
BackupDecoder(context).decode(uri)
} catch (e: Exception) {
throw IllegalStateException(e)
}

View File

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

View File

@ -3,85 +3,56 @@ package eu.kanade.tachiyomi.data.backup.create
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig
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_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_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.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.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.BackupSource
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 logcat.LogPriority
import okio.buffer
import okio.gzip
import okio.sink
import tachiyomi.core.i18n.stringResource
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.util.system.logcat
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
class BackupCreator(
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()
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),
)
suspend fun backup(uri: Uri, flags: Int): String {
var file: UniFile? = null
try {
file = (
@ -90,14 +61,14 @@ class BackupCreator(
val dir = UniFile.fromUri(context, uri)
// Delete older backups
dir?.listFiles { _, filename -> Backup.filenameRegex.matches(filename) }
dir?.listFiles { _, filename -> FILENAME_REGEX.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(MAX_AUTO_BACKUPS - 1)
.forEach { it.delete() }
// Create new file to place backup
dir?.createFile(Backup.getFilename())
dir?.createFile(getFilename())
} else {
UniFile.fromUri(context, uri)
}
@ -108,19 +79,36 @@ class BackupCreator(
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)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
file.openOutputStream().also {
file.openOutputStream()
.also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}.sink().gzip().buffer().use { it.write(byteArray) }
}
.sink().gzip().buffer().use {
it.write(byteArray)
}
val fileUri = file.uri
// 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()
} catch (e: Exception) {
@ -130,135 +118,39 @@ class BackupCreator(
}
}
fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map(BackupSource::copyFrom)
.toList()
}
/**
* 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()
}
if (options and BACKUP_CATEGORY != BACKUP_CATEGORY) return emptyList()
return categoriesBackupCreator.backupCategories()
}
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupManga(it, flags)
}
return mangaBackupCreator.backupMangas(mangas, flags)
}
/**
* Convert a manga to Json
*
* @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 backupSources(mangas: List<Manga>): List<BackupSource> {
return sourcesBackupCreator.backupSources(mangas)
}
fun backupAppPreferences(flags: Int): List<BackupPreference> {
if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList()
return preferenceStore.getAll().toBackupPreferences()
return preferenceBackupCreator.backupAppPreferences()
}
fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> {
if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList()
return sourceManager.getCatalogueSources()
.filterIsInstance<ConfigurableSource>()
.map {
BackupSourcePreferences(
it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(),
)
}
return preferenceBackupCreator.backupSourcePreferences()
}
@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
}
companion object {
private const val MAX_AUTO_BACKUPS: Int = 4
private val FILENAME_REGEX = """${BuildConfig.APPLICATION_ID}_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}.tachibk""".toRegex()
fun getFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.ENGLISH).format(Date())
return "${BuildConfig.APPLICATION_ID}_$date.tachibk"
}
}
}
private val MAX_AUTO_BACKUPS: Int = 4

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
import eu.kanade.tachiyomi.BuildConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import kotlinx.serialization.protobuf.ProtoNumber
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Serializer(forClass = Backup::class)
object BackupSerializer
@Serializable
data class Backup(
@ -15,14 +15,4 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = 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
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.manga.model.Manga
@ -60,28 +59,4 @@ data class BackupManga(
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
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupSource(
@ProtoNumber(1) var name: String = "",
@ProtoNumber(2) var sourceId: Long,
)
@Serializable
data class BrokenBackupSource(
@ProtoNumber(0) var name: String = "",
@ -11,18 +16,3 @@ data class BrokenBackupSource(
) {
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
data class BackupTracking(
// in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long,
@ -34,7 +33,7 @@ data class BackupTracking(
return Track(
id = -1,
mangaId = -1,
syncId = this@BackupTracking.syncId.toLong(),
trackerId = this@BackupTracking.syncId.toLong(),
remoteId = if (this@BackupTracking.mediaIdInt != 0) {
this@BackupTracking.mediaIdInt.toLong()
} else {

View File

@ -30,13 +30,19 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result {
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)
setForegroundSafely()
return try {
BackupRestorer(context, notifier, isSync).restore(uri)
BackupRestorer(context, notifier, isSync).restore(uri, options)
Result.success()
} catch (e: Exception) {
if (e is CancellationException) {
@ -69,10 +75,16 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
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(
LOCATION_URI_KEY to uri.toString(),
SYNC_KEY to sync,
OPTIONS_KEY to options.toBooleanArray(),
)
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG)
@ -91,3 +103,4 @@ private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String
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.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
@ -39,10 +42,10 @@ class BackupRestorer(
*/
private var sourceMapping: Map<Long, String> = emptyMap()
suspend fun restore(uri: Uri) {
suspend fun restore(uri: Uri, options: RestoreOptions) {
val startTime = System.currentTimeMillis()
restoreFromFile(uri)
restoreFromFile(uri, options)
val time = System.currentTimeMillis() - startTime
@ -57,20 +60,36 @@ class BackupRestorer(
)
}
private suspend fun restoreFromFile(uri: Uri) {
val backup = BackupUtil.decodeBackup(context, uri)
restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
val backup = BackupDecoder(context).decode(uri)
// Store source mapping for error messages
val backupMaps = backup.backupSources + backup.backupBrokenSources.map { it.toBackupSource() }
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 {
if (options.library) {
restoreCategories(backup.backupCategories)
}
if (options.appSettings) {
restoreAppPreferences(backup.backupPreferences)
}
if (options.sourceSettings) {
restoreSourcePreferences(backup.backupSourcePreferences)
}
if (options.library) {
restoreManga(backup.backupManga, backup.backupCategories)
}
// 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 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.tachiyomi.data.backup.models.BackupCategory
@ -329,7 +329,7 @@ class MangaRestorer(
readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
.takeIf { it > 0L }
?.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>) {
val dbTrackBySyncId = getTracks.await(manga.id).associateBy { it.syncId }
val dbTrackByTrackerId = getTracks.await(manga.id).associateBy { it.trackerId }
val (existingTracks, newTracks) = backupTracks
.mapNotNull {
val track = it.getTrackImpl()
val dbTrack = dbTrackBySyncId[track.syncId]
val dbTrack = dbTrackByTrackerId[track.trackerId]
?: // New track
return@mapNotNull track.copy(
id = 0, // Let DB assign new ID
@ -381,7 +381,7 @@ class MangaRestorer(
existingTracks.forEach { track ->
manga_syncQueries.update(
track.mangaId,
track.syncId,
track.trackerId,
track.remoteId,
track.libraryId,
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 eu.kanade.tachiyomi.data.backup.create.BackupCreateJob

View File

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

View File

@ -8,9 +8,9 @@ interface Track : Serializable {
var manga_id: Long
var sync_id: Int
var tracker_id: Int
var media_id: Long
var remote_id: Long
var library_id: Long?
@ -40,7 +40,7 @@ interface Track : Serializable {
companion object {
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 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

View File

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

View File

@ -59,6 +59,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream
import java.io.File
import java.util.Locale
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@ -422,7 +423,7 @@ class Downloader(
}
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")
// Delete temp file if it exists
@ -537,7 +538,7 @@ class Downloader(
if (!downloadPreferences.splitTallImages().get()) return
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) }
?: error(context.stringResource(MR.strings.download_notifier_split_page_not_found, page.number))
@ -579,11 +580,7 @@ class Downloader(
else -> true
}
}
if (downloadedImagesCount != downloadPageCount) {
return false
}
return true
return downloadedImagesCount == downloadPageCount
}
/**

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()}]" }
.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.
*
@ -246,7 +225,7 @@ class LibraryUpdateNotifier(private val context: Context) {
// Mark chapters as read action
addAction(
R.drawable.ic_glasses_24dp,
R.drawable.ic_done_24dp,
context.stringResource(MR.strings.action_mark_as_read),
NotificationReceiver.markAsReadPendingBroadcast(
context,
@ -385,4 +364,3 @@ class LibraryUpdateNotifier(private val context: Context) {
private const val NOTIF_MAX_CHAPTERS = 5
private const val NOTIF_TITLE_MAX_LEN = 45
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 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"
@ -270,7 +269,6 @@ class NotificationReceiver : BroadcastReceiver() {
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_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
@ -375,7 +373,7 @@ class NotificationReceiver : BroadcastReceiver() {
it.id == notificationId
}?.groupKey
if (groupId != null && groupId != 0 && groupKey != null && groupKey.isNotEmpty()) {
if (groupId != null && groupId != 0 && !groupKey.isNullOrEmpty()) {
val notifications = context.notificationManager.activeNotifications.filter {
it.groupKey == groupKey
}

View File

@ -30,8 +30,6 @@ object Notifications {
const val ID_LIBRARY_SIZE_WARNING = -103
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
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.
@ -86,6 +84,7 @@ object Notifications {
"updates_ext_channel",
"downloader_cache_renewal",
"crash_logs_channel",
"library_skipped_channel",
)
/**
@ -132,11 +131,6 @@ object Notifications {
setGroup(GROUP_LIBRARY)
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) {
setName(context.stringResource(MR.strings.channel_new_chapters))
},

View File

@ -9,7 +9,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.data.sync.service.SyncData
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
@ -21,6 +22,7 @@ import tachiyomi.data.Chapters
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.manga.MangaMapper.mapManga
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.sync.SyncPreferences
@ -48,7 +50,7 @@ class SyncManager(
private val getFavorites: GetFavorites = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
) {
private val backupCreator: BackupCreator = BackupCreator(context)
private val backupCreator: BackupCreator = BackupCreator(context, false)
private val notifier: SyncNotifier = SyncNotifier(context)
private val mangaRestorer: MangaRestorer = MangaRestorer()
@ -72,12 +74,11 @@ class SyncManager(
suspend fun syncData() {
val databaseManga = getAllMangaFromDB()
val backup = Backup(
backupCreator.backupMangas(databaseManga, BackupCreateFlags.AutomaticDefaults),
backupCreator.backupCategories(BackupCreateFlags.AutomaticDefaults),
emptyList(),
backupCreator.prepExtensionInfoForSync(databaseManga),
backupCreator.backupAppPreferences(BackupCreateFlags.AutomaticDefaults),
backupCreator.backupSourcePreferences(BackupCreateFlags.AutomaticDefaults),
backupManga = backupCreator.backupMangas(databaseManga, BackupCreateFlags.AutomaticDefaults),
backupCategories = backupCreator.backupCategories(BackupCreateFlags.AutomaticDefaults),
backupSources = backupCreator.backupSources(databaseManga),
backupPreferences = backupCreator.backupAppPreferences(BackupCreateFlags.AutomaticDefaults),
backupSourcePreferences = backupCreator.backupSourcePreferences(BackupCreateFlags.AutomaticDefaults),
)
// Create the SyncData object
@ -116,6 +117,8 @@ class SyncManager(
backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences,
)
// It's local sync no need to restore data. (just update remote data)
@ -128,7 +131,16 @@ class SyncManager(
val backupUri = writeSyncDataToCache(context, newSyncData)
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
if (backupUri != null) {
BackupRestoreJob.start(context, backupUri, sync = true)
BackupRestoreJob.start(
context,
backupUri,
sync = true,
options = RestoreOptions(
appSettings = true,
sourceSettings = true,
library = true,
),
)
} else {
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
}
@ -158,58 +170,73 @@ class SyncManager(
}
/**
* Compares two Manga objects (one from the local database and one from the backup) to check if they are different.
* @param localManga the Manga object from the local database.
* @param remoteManga the BackupManga object from the backup.
* @return true if the Manga objects are different, otherwise false.
* Determines if there are differences between the local manga details and categories and their corresponding
* remote backup versions. This is used to identify changes or updates that might have occurred.
*
* @param localManga The manga object from the local database of type [Manga].
* @param backupManga The manga object from the remote backup of type [BackupManga].
*
* @return Boolean indicating whether there are differences. Returns true if any of the following is true:
* - The local manga details differ from the remote backup manga details.
* - The chapters of the local manga differ from the chapters of the remote backup manga.
* - The categories of the local manga differ from the categories of the remote backup manga.
*
* This function uses a combination of direct property comparisons and delegated comparison functions
* to assess differences across manga details, chapters, and categories. It relies on proper conversion
* of remote manga and chapters to the local format for accurate comparison.
*/
private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean {
private suspend fun isMangaDifferent(localManga: Manga, backupManga: BackupManga): Boolean {
val remoteManga = backupManga.getMangaImpl()
val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() }
val localCategories = getCategories.await(localManga.id).map { it.order }
return localManga.source != remoteManga.source ||
localManga.url != remoteManga.url ||
localManga.title != remoteManga.title ||
localManga.artist != remoteManga.artist ||
localManga.author != remoteManga.author ||
localManga.description != remoteManga.description ||
localManga.genre != remoteManga.genre ||
localManga.status.toInt() != remoteManga.status ||
localManga.thumbnailUrl != remoteManga.thumbnailUrl ||
localManga.dateAdded != remoteManga.dateAdded ||
localManga.chapterFlags.toInt() != remoteManga.chapterFlags ||
localManga.favorite != remoteManga.favorite ||
localManga.viewerFlags.toInt() != remoteManga.viewer_flags ||
localManga.updateStrategy != remoteManga.updateStrategy ||
areChaptersDifferent(localChapters, remoteManga.chapters) ||
localCategories != remoteManga.categories
return localManga != remoteManga || areChaptersDifferent(localChapters, backupManga.chapters) || localCategories != backupManga.categories
}
/**
* Compares two lists of chapters (one from the local database and one from the backup) to check if they are different.
* @param localChapters the list of chapters from the local database.
* @param remoteChapters the list of BackupChapter objects from the backup.
* @return true if the lists of chapters are different, otherwise false.
* Checks if there are any differences between a list of local chapters and a list of backup chapters.
* This function is used to determine if updates or changes have occurred between the two sets of chapters.
*
* @param localChapters The list of chapters from the local source, of type [Chapters].
* @param remoteChapters The list of chapters from the remote backup source, of type [BackupChapter].
*
* @return Boolean indicating whether there are differences. Returns true if any of the following is true:
* - The count of local and remote chapters differs.
* - Any corresponding chapters (matched by URL) have differing attributes including name, scanlator,
* read status, bookmark status, last page read, chapter number, source order, fetch date, upload date,
* or last modified date.
*
* Each chapter is compared based on a set of fields that define its content and state. If any of these fields
* differ between the local chapter and its corresponding remote chapter, it is considered a difference.
*
*/
private fun areChaptersDifferent(localChapters: List<Chapters>, remoteChapters: List<BackupChapter>): Boolean {
// Early return if the sizes are different
if (localChapters.size != remoteChapters.size) {
return true
}
// Convert all remote chapters to Chapter instances
val convertedRemoteChapters = remoteChapters.map { it.toChapterImpl() }
// Create a map for the local chapters for efficient comparison
val localChapterMap = localChapters.associateBy { it.url }
return remoteChapters.any { remoteChapter ->
localChapterMap[remoteChapter.url]?.let { localChapter ->
// Check for any differences
return convertedRemoteChapters.any { remoteChapter ->
val localChapter = localChapterMap[remoteChapter.url]
localChapter == null || // No corresponding local chapter
localChapter.url != remoteChapter.url ||
localChapter.name != remoteChapter.name ||
localChapter.scanlator != remoteChapter.scanlator ||
localChapter.read != remoteChapter.read ||
localChapter.bookmark != remoteChapter.bookmark ||
localChapter.last_page_read != remoteChapter.lastPageRead ||
localChapter.chapter_number != remoteChapter.chapterNumber ||
localChapter.source_order != remoteChapter.sourceOrder ||
localChapter.date_fetch != remoteChapter.dateFetch ||
localChapter.date_upload != remoteChapter.dateUpload ||
localChapter.chapter_number.toFloat() != remoteChapter.chapterNumber ||
localChapter.source_order != remoteChapter.sourceOrder
} ?: true
localChapter.last_modified_at != remoteChapter.lastModifiedAt
}
}

View File

@ -1,11 +1,11 @@
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.
*/
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 kotlinx.collections.immutable.ImmutableList
import okhttp3.OkHttpClient
import tachiyomi.domain.track.model.Track as DomainTrack
interface Tracker {
@ -39,11 +40,11 @@ interface Tracker {
fun getScoreList(): ImmutableList<String>
// 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 displayScore(track: Track): String
fun displayScore(track: DomainTrack): String
suspend fun update(track: Track, didReadChapter: Boolean = false): Track

View File

@ -33,6 +33,4 @@ class TrackerManager {
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
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 dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
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
return when (scorePreference.get()) {
POINT_5 -> when (score) {
0f -> "0 ★"
0.0 -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
score == 0f -> "0"
score == 0.0 -> "0"
score <= 35 -> "😦"
score <= 60 -> "😐"
else -> "😊"
@ -167,13 +168,13 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
return api.updateLibManga(track)
}
override suspend fun delete(track: Track): Track {
if (track.library_id == null || track.library_id!! == 0L) {
val libManga = api.findLibManga(track, getUsername().toInt()) ?: return track
track.library_id = libManga.library_id
override suspend fun delete(track: DomainTrack) {
if (track.libraryId == null || track.libraryId == 0L) {
val libManga = api.findLibManga(track.toDbTrack(), getUsername().toInt()) ?: return
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 {

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
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 displayScore(track: Track): String {
override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString()
}

View File

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

View File

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

View File

@ -62,12 +62,3 @@ fun Track.toBangumiStatus() = when (status) {
Bangumi.PLAN_TO_READ -> "wish"
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 displayScore(track: Track): String = ""
override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) {

View File

@ -14,6 +14,7 @@ import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
import tachiyomi.domain.track.model.Track as DomainTrack
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
}
override fun displayScore(track: Track): String {
override fun displayScore(track: DomainTrack): String {
val df = DecimalFormat("0.#")
return df.format(track.score)
}
@ -92,15 +93,15 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
return api.updateLibManga(track)
}
override suspend fun delete(track: Track): Track {
return api.removeLibManga(track)
override suspend fun delete(track: DomainTrack) {
api.removeLibManga(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUserId())
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) {
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 java.net.URLEncoder
import java.nio.charset.StandardCharsets
import tachiyomi.domain.track.model.Track as DomainTrack
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
@ -54,7 +55,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
putJsonObject("media") {
putJsonObject("data") {
put("id", track.media_id)
put("id", track.remote_id)
put("type", "manga")
}
}
@ -77,7 +78,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.awaitSuccess()
.parseAs<JsonObject>()
.let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
track.remote_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
track
}
}
@ -89,7 +90,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.media_id)
put("id", track.remote_id)
putJsonObject("attributes") {
put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read.toInt())
@ -103,7 +104,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
with(json) {
authClient.newCall(
Request.Builder()
.url("${baseUrl}library-entries/${track.media_id}")
.url("${baseUrl}library-entries/${track.remote_id}")
.headers(
headersOf(
"Content-Type",
@ -124,11 +125,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
}
suspend fun removeLibManga(track: Track): Track {
return withIOContext {
authClient.newCall(
suspend fun removeLibManga(track: DomainTrack) {
withIOContext {
authClient
.newCall(
DELETE(
"${baseUrl}library-entries/${track.media_id}",
"${baseUrl}library-entries/${track.remoteId}",
headers = headersOf(
"Content-Type",
"application/vnd.api+json",
@ -136,7 +138,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
),
)
.awaitSuccess()
track
}
}
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? {
return withIOContext {
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")
.build()
with(json) {
@ -210,7 +211,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
suspend fun getLibManga(track: Track): Track {
return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.media_id}")
.encodedQuery("filter[id]=${track.remote_id}")
.appendQueryParameter("include", "manga")
.build()
with(json) {

View File

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

View File

@ -37,12 +37,12 @@ class KitsuSearchManga(obj: JsonObject) {
@CallSuper
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = this@KitsuSearchManga.id
remote_id = this@KitsuSearchManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = original ?: ""
summary = synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(media_id)
tracking_url = KitsuApi.mangaUrl(remote_id)
score = rating ?: -1f
publishing_status = if (endDate == null) {
"Publishing"
@ -70,12 +70,12 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = libraryId
remote_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = original
summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
tracking_url = KitsuApi.mangaUrl(remote_id)
publishing_status = this@KitsuLibManga.status
publishing_type = type
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 displayScore(track: Track): String = ""
override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
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.toImmutableList
import tachiyomi.i18n.MR
import tachiyomi.domain.track.model.Track as DomainTrack
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 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 {
if (track.status != COMPLETE_LIST && didReadChapter) {
@ -70,9 +71,8 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
return track
}
override suspend fun delete(track: Track): Track {
override suspend fun delete(track: DomainTrack) {
api.deleteSeriesFromList(track)
return 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 tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class MangaUpdatesApi(
interceptor: MangaUpdatesInterceptor,
@ -48,7 +49,7 @@ class MangaUpdatesApi(
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
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()
.parseAs<ListItem>()
}
@ -63,7 +64,7 @@ class MangaUpdatesApi(
val body = buildJsonArray {
addJsonObject {
putJsonObject("series") {
put("id", track.media_id)
put("id", track.remote_id)
}
put("list_id", status)
}
@ -87,7 +88,7 @@ class MangaUpdatesApi(
val body = buildJsonArray {
addJsonObject {
putJsonObject("series") {
put("id", track.media_id)
put("id", track.remote_id)
}
put("list_id", track.status)
putJsonObject("status") {
@ -106,9 +107,9 @@ class MangaUpdatesApi(
updateSeriesRating(track)
}
suspend fun deleteSeriesFromList(track: Track) {
suspend fun deleteSeriesFromList(track: DomainTrack) {
val body = buildJsonArray {
add(track.media_id)
add(track.remoteId)
}
authClient.newCall(
POST(
@ -122,7 +123,7 @@ class MangaUpdatesApi(
private suspend fun getSeriesRating(track: Track): Rating? {
return try {
with(json) {
authClient.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating"))
authClient.newCall(GET("$baseUrl/v1/series/${track.remote_id}/rating"))
.awaitSuccess()
.parseAs<Rating>()
}
@ -138,7 +139,7 @@ class MangaUpdatesApi(
}
authClient.newCall(
PUT(
url = "$baseUrl/v1/series/${track.media_id}/rating",
url = "$baseUrl/v1/series/${track.remote_id}/rating",
body = body.toString().toRequestBody(contentType),
),
)
@ -146,7 +147,7 @@ class MangaUpdatesApi(
} else {
authClient.newCall(
DELETE(
url = "$baseUrl/v1/series/${track.media_id}/rating",
url = "$baseUrl/v1/series/${track.remote_id}/rating",
),
)
.awaitSuccess()

View File

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

View File

@ -8,9 +8,9 @@ class TrackSearch : Track {
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
@ -47,22 +47,22 @@ class TrackSearch : Track {
other as TrackSearch
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
if (media_id != other.media_id) return false
if (tracker_id != other.tracker_id) return false
if (remote_id != other.remote_id) return false
return true
}
override fun hashCode(): Int {
var result = manga_id.hashCode()
result = 31 * result + sync_id
result = 31 * result + media_id.hashCode()
result = 31 * result + tracker_id
result = 31 * result + remote_id.hashCode()
return result
}
companion object {
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 tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
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 displayScore(track: Track): String {
override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString()
}
@ -91,15 +92,15 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
return api.updateItem(track)
}
override suspend fun delete(track: Track): Track {
return api.deleteItem(track)
override suspend fun delete(track: DomainTrack) {
api.deleteItem(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findListItem(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING

View File

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

View File

@ -13,6 +13,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
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 displayScore(track: Track): String {
override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString()
}
@ -59,8 +60,8 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
return api.updateLibManga(track, getUsername())
}
override suspend fun delete(track: Track): Track {
return api.deleteLibManga(track)
override suspend fun delete(track: DomainTrack) {
api.deleteLibManga(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 tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class ShikimoriApi(
private val trackId: Long,
@ -44,7 +45,7 @@ class ShikimoriApi(
val payload = buildJsonObject {
putJsonObject("user_rate") {
put("user_id", userId)
put("target_id", track.media_id)
put("target_id", track.remote_id)
put("target_type", "Manga")
put("chapters", track.last_chapter_read.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 deleteLibManga(track: Track): Track {
return withIOContext {
authClient.newCall(
DELETE(
"$apiUrl/v2/user_rates/${track.library_id}",
),
).awaitSuccess()
track
suspend fun deleteLibManga(track: DomainTrack) {
withIOContext {
authClient
.newCall(DELETE("$apiUrl/v2/user_rates/${track.libraryId}"))
.awaitSuccess()
}
}
@ -102,7 +100,7 @@ class ShikimoriApi(
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long
remote_id = obj["id"]!!.jsonPrimitive.long
title = obj["name"]!!.jsonPrimitive.content
total_chapters = obj["chapters"]!!.jsonPrimitive.int
cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content
@ -118,7 +116,7 @@ class ShikimoriApi(
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(trackId).apply {
title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.long
remote_id = obj["id"]!!.jsonPrimitive.long
total_chapters = mangas["chapters"]!!.jsonPrimitive.int
library_id = obj["id"]!!.jsonPrimitive.long
last_chapter_read = obj["chapters"]!!.jsonPrimitive.float
@ -131,7 +129,7 @@ class ShikimoriApi(
suspend fun findLibManga(track: Track, userId: String): Track? {
return withIOContext {
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
.appendPath(track.media_id.toString())
.appendPath(track.remote_id.toString())
.build()
val mangas = with(json) {
authClient.newCall(GET(urlMangas.toString()))
@ -141,7 +139,7 @@ class ShikimoriApi(
val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
.appendQueryParameter("user_id", userId)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_id", track.remote_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
with(json) {

View File

@ -5,7 +5,7 @@ import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class ShikimoriInterceptor(val shikimori: Shikimori) : Interceptor {
class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
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 displayScore(track: Track): String = ""
override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) {

View File

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

View File

@ -141,6 +141,11 @@ internal object ExtensionLoader {
?.asSequence()
?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
?.mapNotNull {
// Just in case, since Android 14+ requires them to be read-only
if (it.canWrite()) {
it.setReadOnly()
}
val path = it.absolutePath
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
?.apply { applicationInfo.fixBasePaths(path) }
@ -277,7 +282,12 @@ internal object ExtensionLoader {
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 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)!!
.split(";")

View File

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

View File

@ -983,7 +983,7 @@ class MangaScreenModel(
.map { tracks ->
loggedInTrackers
// 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
.filter { (it.tracker as? EnhancedTracker)?.accept(source!!) ?: true }
}

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