mirror of
https://github.com/mihonapp/mihon.git
synced 2025-10-20 01:59:43 +02:00
Merge branch 'sync-part-final' into feat/add-sync-triggers-experimental
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "eu.kanade.tachiyomi"
|
||||
|
||||
versionCode = 115
|
||||
versionName = "0.15.0"
|
||||
versionCode = 117
|
||||
versionName = "0.15.1"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
|
@@ -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 -->
|
||||
@@ -21,17 +22,20 @@
|
||||
<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" />
|
||||
|
||||
<!-- Remove permission from Firebase dependency -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||
<uses-permission
|
||||
android:name="com.google.android.gms.permission.AD_ID"
|
||||
tools:node="remove" />
|
||||
|
||||
<application
|
||||
@@ -52,13 +56,53 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.Tachiyomi.SplashScreen"
|
||||
android:exported="true">
|
||||
android:theme="@style/Theme.Tachiyomi.SplashScreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link to add repos -->
|
||||
<intent-filter android:label="@string/action_add_repo">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="tachiyomi" />
|
||||
<data android:host="add-repo" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open backup files -->
|
||||
<intent-filter android:label="@string/pref_restore_backup">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="file" />
|
||||
<data android:scheme="content" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="*/*" />
|
||||
<!--
|
||||
Work around Android's ugly primitive PatternMatcher
|
||||
implementation that can't cope with finding a . early in
|
||||
the path unless it's explicitly matched.
|
||||
|
||||
See https://stackoverflow.com/a/31028507
|
||||
-->
|
||||
<data android:pathPattern=".*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\..*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.tachibk" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.tachibk" />
|
||||
</intent-filter>
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
@@ -66,16 +110,16 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:process=":error_handler"
|
||||
android:name=".crash.CrashActivity"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:process=":error_handler" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.deeplink.DeepLinkActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:exported="true"
|
||||
android:label="@string/action_search"
|
||||
android:exported="true">
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
|
||||
@@ -99,20 +143,21 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="false">
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||
android:resource="@xml/s_pen_actions"/>
|
||||
<meta-data
|
||||
android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||
android:resource="@xml/s_pen_actions" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.security.UnlockActivity"
|
||||
android:theme="@style/Theme.Tachiyomi"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Tachiyomi" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.webview.WebViewActivity"
|
||||
@@ -121,25 +166,25 @@
|
||||
|
||||
<activity
|
||||
android:name=".extension.util.ExtensionInstallActivity"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.setting.track.TrackLoginActivity"
|
||||
android:label="@string/track_activity_name"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:label="@string/track_activity_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:host="anilist-auth"/>
|
||||
<data android:host="bangumi-auth"/>
|
||||
<data android:host="myanimelist-auth"/>
|
||||
<data android:host="shikimori-auth"/>
|
||||
<data android:scheme="tachiyomi" />
|
||||
|
||||
<data android:scheme="tachiyomi"/>
|
||||
<data android:host="anilist-auth" />
|
||||
<data android:host="bangumi-auth" />
|
||||
<data android:host="myanimelist-auth" />
|
||||
<data android:host="shikimori-auth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
@@ -193,9 +238,9 @@
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:multiprocess="false"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<meta-data
|
||||
|
@@ -21,6 +21,7 @@ import eu.kanade.domain.source.interactor.SetMigrateSorting
|
||||
import eu.kanade.domain.source.interactor.ToggleLanguage
|
||||
import eu.kanade.domain.source.interactor.ToggleSource
|
||||
import eu.kanade.domain.source.interactor.ToggleSourcePin
|
||||
import eu.kanade.domain.source.interactor.TrustExtension
|
||||
import eu.kanade.domain.track.interactor.AddTracks
|
||||
import eu.kanade.domain.track.interactor.RefreshTracks
|
||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||
@@ -170,6 +171,7 @@ class DomainModule : InjektModule {
|
||||
addFactory { ToggleLanguage(get()) }
|
||||
addFactory { ToggleSource(get()) }
|
||||
addFactory { ToggleSourcePin(get()) }
|
||||
addFactory { TrustExtension(get()) }
|
||||
|
||||
addFactory { CreateSourceRepo(get()) }
|
||||
addFactory { DeleteSourceRepo(get()) }
|
||||
|
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.domain.source.interactor
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
|
||||
class TrustExtension(
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean {
|
||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:$signatureHash"
|
||||
return key in preferences.trustedExtensions().get()
|
||||
}
|
||||
|
||||
fun trust(pkgName: String, versionCode: Long, signatureHash: String) {
|
||||
preferences.trustedExtensions().getAndSet { exts ->
|
||||
// Remove previously trusted versions
|
||||
val removed = exts.filter { it.startsWith("$pkgName:") }.toMutableSet()
|
||||
|
||||
removed.also {
|
||||
it += "$pkgName:$versionCode:$signatureHash"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -38,11 +38,14 @@ class SourcePreferences(
|
||||
SetMigrateSorting.Direction.ASCENDING,
|
||||
)
|
||||
|
||||
fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false)
|
||||
|
||||
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
|
||||
|
||||
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
|
||||
|
||||
fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet())
|
||||
|
||||
fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false)
|
||||
fun trustedExtensions() = preferenceStore.getStringSet(
|
||||
Preference.appStateKey("trusted_extensions"),
|
||||
emptySet(),
|
||||
)
|
||||
}
|
||||
|
@@ -133,13 +133,13 @@ private fun ExtensionContent(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
|
||||
val installGranted = rememberRequestPackageInstallsPermissionState()
|
||||
val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)
|
||||
|
||||
FastScrollLazyColumn(
|
||||
contentPadding = contentPadding + topSmallPaddingValues,
|
||||
) {
|
||||
if (!installGranted && state.installer?.requiresSystemPermission == true) {
|
||||
item {
|
||||
item(key = "extension-permissions-warning") {
|
||||
WarningBanner(
|
||||
textRes = MR.strings.ext_permission_install_apps_warning,
|
||||
modifier = Modifier.clickable {
|
||||
|
@@ -16,16 +16,22 @@ import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
|
||||
class ExtensionReposScreen : Screen() {
|
||||
class ExtensionReposScreen(
|
||||
private val url: String? = null,
|
||||
) : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
|
||||
|
||||
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(url) {
|
||||
url?.let { screenModel.createRepo(it) }
|
||||
}
|
||||
|
||||
if (state is RepoScreenState.Loading) {
|
||||
LoadingScreen()
|
||||
return
|
||||
|
@@ -14,11 +14,11 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
@Composable
|
||||
fun rememberRequestPackageInstallsPermissionState(): Boolean {
|
||||
fun rememberRequestPackageInstallsPermissionState(initialValue: Boolean = false): Boolean {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var installGranted by remember { mutableStateOf(false) }
|
||||
var installGranted by remember { mutableStateOf(initialValue) }
|
||||
|
||||
DisposableEffect(lifecycleOwner.lifecycle) {
|
||||
val observer = object : DefaultLifecycleObserver {
|
||||
|
@@ -374,13 +374,6 @@ object Migrations {
|
||||
uiPreferences.relativeTime().set(false)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 107) {
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
|
||||
newKey = { Preference.privateKey(it) },
|
||||
)
|
||||
}
|
||||
if (oldVersion < 113) {
|
||||
val prefsToReplace = listOf(
|
||||
"pref_download_only",
|
||||
@@ -407,7 +400,19 @@ object Migrations {
|
||||
}
|
||||
if (oldVersion < 114) {
|
||||
sourcePreferences.extensionRepos().getAndSet {
|
||||
it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet()
|
||||
it.map { repo -> "https://raw.githubusercontent.com/$repo/repo" }.toSet()
|
||||
}
|
||||
}
|
||||
if (oldVersion < 116) {
|
||||
replacePreferences(
|
||||
preferenceStore = preferenceStore,
|
||||
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
|
||||
newKey = { Preference.privateKey(it) },
|
||||
)
|
||||
}
|
||||
if (oldVersion < 117) {
|
||||
prefs.edit {
|
||||
remove(Preference.appStateKey("trusted_signatures"))
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
@@ -31,6 +31,7 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
/**
|
||||
* A manager to handle synchronization tasks in the app, such as updating
|
||||
@@ -122,8 +123,10 @@ class SyncManager(
|
||||
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
|
||||
updateNonFavorites(nonFavorites)
|
||||
|
||||
val mangas = processFavoriteManga(filteredFavorites)
|
||||
|
||||
val newSyncData = backup.copy(
|
||||
backupManga = filteredFavorites,
|
||||
backupManga = mangas,
|
||||
backupCategories = remoteBackup.backupCategories,
|
||||
backupSources = remoteBackup.backupSources,
|
||||
backupPreferences = remoteBackup.backupPreferences,
|
||||
@@ -131,7 +134,7 @@ class SyncManager(
|
||||
)
|
||||
|
||||
// It's local sync no need to restore data. (just update remote data)
|
||||
if (filteredFavorites.isEmpty()) {
|
||||
if (mangas.isEmpty()) {
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
return
|
||||
@@ -150,6 +153,9 @@ class SyncManager(
|
||||
library = true,
|
||||
),
|
||||
)
|
||||
|
||||
// update the sync timestamp
|
||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||
} else {
|
||||
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
|
||||
}
|
||||
@@ -185,10 +191,6 @@ class SyncManager(
|
||||
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 ||
|
||||
@@ -217,15 +219,10 @@ class SyncManager(
|
||||
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 != remoteChapter.chapterNumber
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,26 +232,90 @@ class SyncManager(
|
||||
* @return a Pair of lists, where the first list contains different favorite manga and the second list contains non-favorite manga.
|
||||
*/
|
||||
private suspend fun filterFavoritesAndNonFavorites(backup: Backup): Pair<List<BackupManga>, List<BackupManga>> {
|
||||
val databaseMangaFavorites = getFavorites.await()
|
||||
val localMangaMap = databaseMangaFavorites.associateBy { it.url }
|
||||
val favorites = mutableListOf<BackupManga>()
|
||||
val nonFavorites = mutableListOf<BackupManga>()
|
||||
val elapsedTimeMillis = measureTimeMillis {
|
||||
val databaseMangaFavorites = getFavorites.await()
|
||||
val localMangaMap = databaseMangaFavorites.associateBy { it.url }
|
||||
|
||||
backup.backupManga.forEach { remoteManga ->
|
||||
if (remoteManga.favorite) {
|
||||
localMangaMap[remoteManga.url]?.let { localManga ->
|
||||
if (isMangaDifferent(localManga, remoteManga)) {
|
||||
favorites.add(remoteManga)
|
||||
logcat(LogPriority.DEBUG) { "Starting to filter favorites and non-favorites from backup data." }
|
||||
|
||||
backup.backupManga.forEach { remoteManga ->
|
||||
val localManga = localMangaMap[remoteManga.url]
|
||||
when {
|
||||
// Checks if the manga is in favorites and needs updating or adding
|
||||
remoteManga.favorite -> {
|
||||
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
|
||||
logcat(LogPriority.DEBUG) { "Adding to favorites: ${remoteManga.title}" }
|
||||
favorites.add(remoteManga)
|
||||
} else {
|
||||
logcat(LogPriority.DEBUG) { "Already up-to-date favorite: ${remoteManga.title}" }
|
||||
}
|
||||
}
|
||||
} ?: favorites.add(remoteManga)
|
||||
} else {
|
||||
nonFavorites.add(remoteManga)
|
||||
// Handle non-favorites
|
||||
!remoteManga.favorite -> {
|
||||
logcat(LogPriority.DEBUG) { "Adding to non-favorites: ${remoteManga.title}" }
|
||||
nonFavorites.add(remoteManga)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val minutes = elapsedTimeMillis / 60000
|
||||
val seconds = (elapsedTimeMillis % 60000) / 1000
|
||||
logcat(LogPriority.DEBUG) {
|
||||
"Filtering completed in ${minutes}m ${seconds}s. Favorites found: ${favorites.size}, " +
|
||||
"Non-favorites found: ${nonFavorites.size}"
|
||||
}
|
||||
|
||||
return Pair(favorites, nonFavorites)
|
||||
}
|
||||
|
||||
private fun processFavoriteManga(backupManga: List<BackupManga>): List<BackupManga> {
|
||||
val mangas = mutableListOf<BackupManga>()
|
||||
val lastSyncTimeStamp = syncPreferences.lastSyncTimestamp().get()
|
||||
|
||||
val elapsedTimeMillis = measureTimeMillis {
|
||||
logcat(LogPriority.DEBUG) { "Starting to process BackupMangas." }
|
||||
backupManga.forEach { manga ->
|
||||
val mangaLastUpdatedStatus = manga.lastModifiedAt * 1000L > lastSyncTimeStamp
|
||||
val chaptersUpdatedStatus = chaptersUpdatedAfterSync(manga, lastSyncTimeStamp)
|
||||
|
||||
if (mangaLastUpdatedStatus || chaptersUpdatedStatus) {
|
||||
mangas.add(manga)
|
||||
logcat(LogPriority.DEBUG) {
|
||||
"Added ${manga.title} to the process list. Manga Last Updated: $mangaLastUpdatedStatus, " +
|
||||
"Chapters Updated: $chaptersUpdatedStatus."
|
||||
}
|
||||
} else {
|
||||
logcat(LogPriority.DEBUG) {
|
||||
"Skipped ${manga.title} as it has not been updated since the last sync " +
|
||||
"(Last Modified: ${manga.lastModifiedAt * 1000L}, Last Sync: $lastSyncTimeStamp)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val minutes = elapsedTimeMillis / 60000
|
||||
val seconds = (elapsedTimeMillis % 60000) / 1000
|
||||
logcat(LogPriority.DEBUG) { "Processing completed in ${minutes}m ${seconds}s. Total Processed: ${mangas.size}" }
|
||||
|
||||
return mangas
|
||||
}
|
||||
|
||||
private fun chaptersUpdatedAfterSync(manga: BackupManga, lastSyncTimeStamp: Long): Boolean {
|
||||
return manga.chapters.any { chapter ->
|
||||
val updated = chapter.lastModifiedAt * 1000L > lastSyncTimeStamp
|
||||
if (updated) {
|
||||
logcat(LogPriority.DEBUG) {
|
||||
"Chapter ${chapter.name} of ${manga.title} updated after last sync " +
|
||||
"(Chapter Last Modified: ${chapter.lastModifiedAt}, Last Sync: $lastSyncTimeStamp)."
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non-favorite manga in the local database with their favorite status from the backup.
|
||||
* @param nonFavorites the list of non-favorite BackupManga objects from the backup.
|
||||
|
@@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import logcat.logcat
|
||||
import tachiyomi.domain.sync.SyncPreferences
|
||||
import java.time.Instant
|
||||
|
||||
@@ -92,37 +94,99 @@ abstract class SyncService(
|
||||
localMangaList: List<BackupManga>?,
|
||||
remoteMangaList: List<BackupManga>?,
|
||||
): List<BackupManga> {
|
||||
val logTag = "MergeMangaLists"
|
||||
|
||||
// Convert null lists to empty to simplify logic
|
||||
val localMangaListSafe = localMangaList.orEmpty()
|
||||
val remoteMangaListSafe = remoteMangaList.orEmpty()
|
||||
|
||||
// Associate both local and remote manga by their unique keys (source and url)
|
||||
val localMangaMap = localMangaListSafe.associateBy { Pair(it.source, it.url) }
|
||||
val remoteMangaMap = remoteMangaListSafe.associateBy { Pair(it.source, it.url) }
|
||||
logcat(logTag, LogPriority.DEBUG) {
|
||||
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
|
||||
}
|
||||
|
||||
// Define a function to create a composite key from manga
|
||||
fun mangaCompositeKey(manga: BackupManga): String {
|
||||
return "${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
|
||||
}
|
||||
|
||||
// Create maps using composite keys
|
||||
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
|
||||
}
|
||||
|
||||
// Prepare to merge both sets of manga
|
||||
return (localMangaMap.keys + remoteMangaMap.keys).mapNotNull { key ->
|
||||
val local = localMangaMap[key]
|
||||
val remote = remoteMangaMap[key]
|
||||
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
|
||||
val local = localMangaMap[compositeKey]
|
||||
val remote = remoteMangaMap[compositeKey]
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Processing key: $compositeKey. Local favorite: ${local?.favorite}, " +
|
||||
"Remote favorite: ${remote?.favorite}"
|
||||
}
|
||||
|
||||
when {
|
||||
local != null && remote == null -> local
|
||||
local == null && remote != null -> remote
|
||||
local != null && remote == null -> {
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Taking local manga: ${local.title} as it is not present remotely. " +
|
||||
"Favorite status: ${local.favorite}"
|
||||
}
|
||||
local
|
||||
}
|
||||
local == null && remote != null -> {
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Taking remote manga: ${remote.title} as it is not present locally. " +
|
||||
"Favorite status: ${remote.favorite}"
|
||||
}
|
||||
remote
|
||||
}
|
||||
local != null && remote != null -> {
|
||||
// Compare last modified times and merge chapters
|
||||
val localTime = Instant.ofEpochMilli(local.lastModifiedAt)
|
||||
val remoteTime = Instant.ofEpochMilli(remote.lastModifiedAt)
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Inspecting timestamps for ${local.title}. Local lastModifiedAt: ${local.lastModifiedAt}, " +
|
||||
"Remote lastModifiedAt: ${remote.lastModifiedAt}"
|
||||
}
|
||||
// Convert seconds to milliseconds for accurate time comparison
|
||||
val localTime = Instant.ofEpochMilli(local.lastModifiedAt * 1000L)
|
||||
val remoteTime = Instant.ofEpochMilli(remote.lastModifiedAt * 1000L)
|
||||
val mergedChapters = mergeChapters(local.chapters, remote.chapters)
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Merging manga: ${local.title}. Local time: $localTime, Remote time: $remoteTime, " +
|
||||
"Local favorite: ${local.favorite}, Remote favorite: ${remote.favorite}"
|
||||
}
|
||||
|
||||
if (localTime >= remoteTime) {
|
||||
logcat(
|
||||
LogPriority.DEBUG,
|
||||
logTag,
|
||||
) { "Keeping local version of ${local.title} with merged chapters." }
|
||||
local.copy(chapters = mergedChapters)
|
||||
} else {
|
||||
logcat(
|
||||
LogPriority.DEBUG,
|
||||
logTag,
|
||||
) { "Keeping remote version of ${remote.title} with merged chapters." }
|
||||
remote.copy(chapters = mergedChapters)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
else -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "No manga found for key: $compositeKey. Skipping." }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counting favorites and non-favorites
|
||||
val (favorites, nonFavorites) = mergedList.partition { it.favorite }
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, " +
|
||||
"Non-Favorites: ${nonFavorites.size}"
|
||||
}
|
||||
|
||||
return mergedList
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,27 +210,64 @@ abstract class SyncService(
|
||||
localChapters: List<BackupChapter>,
|
||||
remoteChapters: List<BackupChapter>,
|
||||
): List<BackupChapter> {
|
||||
// Associate chapters by URL for both local and remote
|
||||
val localChapterMap = localChapters.associateBy { it.url }
|
||||
val remoteChapterMap = remoteChapters.associateBy { it.url }
|
||||
val logTag = "MergeChapters"
|
||||
|
||||
// Define a function to create a composite key from a chapter
|
||||
fun chapterCompositeKey(chapter: BackupChapter): String {
|
||||
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
||||
}
|
||||
|
||||
// Create maps using composite keys
|
||||
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
|
||||
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}"
|
||||
}
|
||||
|
||||
// Merge both chapter maps
|
||||
return (localChapterMap.keys + remoteChapterMap.keys).mapNotNull { url ->
|
||||
// Determine the most recent chapter by comparing lastModifiedAt, considering null as Instant.MIN
|
||||
val localChapter = localChapterMap[url]
|
||||
val remoteChapter = remoteChapterMap[url]
|
||||
val mergedChapters = (localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey ->
|
||||
val localChapter = localChapterMap[compositeKey]
|
||||
val remoteChapter = remoteChapterMap[compositeKey]
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, " +
|
||||
"Remote chapter: ${remoteChapter != null}"
|
||||
}
|
||||
|
||||
when {
|
||||
localChapter != null && remoteChapter == null -> localChapter
|
||||
localChapter == null && remoteChapter != null -> remoteChapter
|
||||
localChapter != null && remoteChapter != null -> {
|
||||
val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN
|
||||
val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN
|
||||
if (localInstant >= remoteInstant) localChapter else remoteChapter
|
||||
localChapter != null && remoteChapter == null -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
|
||||
localChapter
|
||||
}
|
||||
localChapter == null && remoteChapter != null -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
|
||||
remoteChapter
|
||||
}
|
||||
localChapter != null && remoteChapter != null -> {
|
||||
val localInstant = Instant.ofEpochMilli(localChapter.lastModifiedAt * 1000L)
|
||||
val remoteInstant = Instant.ofEpochMilli(remoteChapter.lastModifiedAt * 1000L)
|
||||
|
||||
val chosenChapter = if (localInstant >= remoteInstant) localChapter else remoteChapter
|
||||
logcat(LogPriority.DEBUG, logTag) {
|
||||
"Merging chapter: ${chosenChapter.name}. Chosen from: ${if (localInstant >= remoteInstant) {
|
||||
"Local"
|
||||
} else {
|
||||
"Remote"
|
||||
}}."
|
||||
}
|
||||
chosenChapter
|
||||
}
|
||||
else -> {
|
||||
logcat(LogPriority.DEBUG, logTag) { "No chapter found for composite key: $compositeKey. Skipping." }
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
logcat(LogPriority.DEBUG, logTag) { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" }
|
||||
|
||||
return mergedChapters
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import eu.kanade.domain.source.interactor.TrustExtension
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.api.ExtensionApi
|
||||
import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier
|
||||
@@ -18,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.plusAssign
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
@@ -34,13 +34,11 @@ import java.util.Locale
|
||||
* To avoid malicious distribution, every extension must be signed and it will only be loaded if its
|
||||
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
|
||||
* loaded.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param preferences The application preferences.
|
||||
*/
|
||||
class ExtensionManager(
|
||||
private val context: Context,
|
||||
private val preferences: SourcePreferences = Injekt.get(),
|
||||
private val trustExtension: TrustExtension = Injekt.get(),
|
||||
) {
|
||||
|
||||
var isInitialized = false
|
||||
@@ -249,18 +247,19 @@ class ExtensionManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given signature to the list of trusted signatures. It also loads in background the
|
||||
* extensions that match this signature.
|
||||
* Adds the given extension to the list of trusted extensions. It also loads in background the
|
||||
* now trusted extensions.
|
||||
*
|
||||
* @param signature The signature to whitelist.
|
||||
* @param extension the extension to trust
|
||||
*/
|
||||
fun trustSignature(signature: String) {
|
||||
val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet()
|
||||
if (signature !in untrustedSignatures) return
|
||||
fun trust(extension: Extension.Untrusted) {
|
||||
val untrustedPkgNames = _untrustedExtensionsFlow.value.map { it.pkgName }.toSet()
|
||||
if (extension.pkgName !in untrustedPkgNames) return
|
||||
|
||||
preferences.trustedSignatures() += signature
|
||||
trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash)
|
||||
|
||||
val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature }
|
||||
val nowTrustedExtensions = _untrustedExtensionsFlow.value
|
||||
.filter { it.pkgName == extension.pkgName && it.versionCode == extension.versionCode }
|
||||
_untrustedExtensionsFlow.value -= nowTrustedExtensions
|
||||
|
||||
launchNow {
|
||||
|
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.domain.source.interactor.TrustExtension
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
@@ -15,7 +16,6 @@ import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
||||
import eu.kanade.tachiyomi.util.system.isDevFlavor
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -41,6 +41,7 @@ import java.io.File
|
||||
internal object ExtensionLoader {
|
||||
|
||||
private val preferences: SourcePreferences by injectLazy()
|
||||
private val trustExtension: TrustExtension by injectLazy()
|
||||
private val loadNsfwSource by lazy {
|
||||
preferences.showNsfwSource().get()
|
||||
}
|
||||
@@ -49,8 +50,6 @@ internal object ExtensionLoader {
|
||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||
private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||
private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme"
|
||||
private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog"
|
||||
const val LIB_VERSION_MIN = 1.4
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
@@ -119,12 +118,6 @@ internal object ExtensionLoader {
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<LoadResult> {
|
||||
// Always make users trust unknown extensions on cold starts in non-dev builds
|
||||
// due to inherent security risks
|
||||
if (!isDevFlavor) {
|
||||
preferences.trustedSignatures().delete()
|
||||
}
|
||||
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -262,7 +255,7 @@ internal object ExtensionLoader {
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return LoadResult.Error
|
||||
} else if (!hasTrustedSignature(signatures)) {
|
||||
} else if (!isTrusted(pkgInfo, signatures)) {
|
||||
val extension = Extension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
@@ -281,9 +274,6 @@ internal object ExtensionLoader {
|
||||
return LoadResult.Error
|
||||
}
|
||||
|
||||
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
|
||||
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
|
||||
|
||||
val classLoader = try {
|
||||
PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||
} catch (e: Exception) {
|
||||
@@ -393,13 +383,12 @@ internal object ExtensionLoader {
|
||||
?.toList()
|
||||
}
|
||||
|
||||
private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
||||
private fun isTrusted(pkgInfo: PackageInfo, signatures: List<String>): Boolean {
|
||||
if (officialSignature in signatures) {
|
||||
return true
|
||||
}
|
||||
|
||||
val trustedSignatures = preferences.trustedSignatures().get()
|
||||
return trustedSignatures.any { signatures.contains(it) }
|
||||
return trustExtension.isTrusted(pkgInfo, signatures.last())
|
||||
}
|
||||
|
||||
private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
||||
|
@@ -195,8 +195,8 @@ class ExtensionsScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun trustSignature(signatureHash: String) {
|
||||
extensionManager.trustSignature(signatureHash)
|
||||
fun trustExtension(extension: Extension.Untrusted) {
|
||||
extensionManager.trust(extension)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
@@ -61,7 +61,7 @@ fun extensionsTab(
|
||||
},
|
||||
onInstallExtension = extensionsScreenModel::installExtension,
|
||||
onOpenExtension = { navigator.push(ExtensionDetailsScreen(it.pkgName)) },
|
||||
onTrustExtension = { extensionsScreenModel.trustSignature(it.signatureHash) },
|
||||
onTrustExtension = { extensionsScreenModel.trustExtension(it) },
|
||||
onUninstallExtension = { extensionsScreenModel.uninstallExtension(it) },
|
||||
onUpdateExtension = extensionsScreenModel::updateExtension,
|
||||
onRefresh = extensionsScreenModel::findAvailableExtensions,
|
||||
|
@@ -56,6 +56,8 @@ import eu.kanade.presentation.components.AppStateBanners
|
||||
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
|
||||
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
|
||||
import eu.kanade.presentation.components.IndexingBannerBackgroundColor
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
||||
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
||||
import eu.kanade.presentation.util.AssistContentScreen
|
||||
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
@@ -444,6 +446,21 @@ class MainActivity : BaseActivity() {
|
||||
}
|
||||
null
|
||||
}
|
||||
Intent.ACTION_VIEW -> {
|
||||
// Handling opening of backup files
|
||||
if (intent.data.toString().endsWith(".tachibk")) {
|
||||
navigator.popUntilRoot()
|
||||
navigator.push(RestoreBackupScreen(intent.data.toString()))
|
||||
}
|
||||
// Deep link to add extension repo
|
||||
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") {
|
||||
intent.data?.getQueryParameter("url")?.let { repoUrl ->
|
||||
navigator.popUntilRoot()
|
||||
navigator.push(ExtensionReposScreen(repoUrl))
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user