Merge branch 'sync-part-final' into feat/add-sync-triggers-experimental

This commit is contained in:
KaiserBh
2024-01-08 07:01:19 +11:00
34 changed files with 622 additions and 243 deletions

View File

@@ -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()}\"")

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 -->
@@ -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

View File

@@ -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()) }

View File

@@ -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"
}
}
}
}

View File

@@ -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(),
)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}
/**

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -195,8 +195,8 @@ class ExtensionsScreenModel(
}
}
fun trustSignature(signatureHash: String) {
extensionManager.trustSignature(signatureHash)
fun trustExtension(extension: Extension.Untrusted) {
extensionManager.trust(extension)
}
@Immutable

View File

@@ -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,

View File

@@ -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
}