diff --git a/core/common/src/main/java/com/looker/core/common/cache/Cache.kt b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt index 2e955c3e3..68a168f3c 100644 --- a/core/common/src/main/java/com/looker/core/common/cache/Cache.kt +++ b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt @@ -24,6 +24,7 @@ object Cache { private const val RELEASE_DIR = "releases" private const val PARTIAL_DIR = "partial" private const val IMAGES_DIR = "images" + private const val INDEX_DIR = "index" private const val TEMP_DIR = "temporary" private fun ensureCacheDir(context: Context, name: String): File { @@ -52,6 +53,10 @@ object Cache { return ensureCacheDir(context, IMAGES_DIR) } + fun getIndexFile(context: Context, indexName: String): File { + return File(ensureCacheDir(context, INDEX_DIR), indexName) + } + fun getPartialReleaseFile(context: Context, cacheFileName: String): File { return File(ensureCacheDir(context, PARTIAL_DIR), cacheFileName) } @@ -108,9 +113,10 @@ object Cache { cleanup( context, Pair(IMAGES_DIR, Duration.INFINITE), + Pair(INDEX_DIR, Duration.INFINITE), Pair(PARTIAL_DIR, 24.hours), Pair(RELEASE_DIR, 24.hours), - Pair(TEMP_DIR, 1.hours) + Pair(TEMP_DIR, 1.hours), ) } } diff --git a/core/data/src/main/java/com/looker/core/data/repository/OfflineFirstRepoRepository.kt b/core/data/src/main/java/com/looker/core/data/repository/OfflineFirstRepoRepository.kt index b7ce1862a..0e66971dc 100644 --- a/core/data/src/main/java/com/looker/core/data/repository/OfflineFirstRepoRepository.kt +++ b/core/data/src/main/java/com/looker/core/data/repository/OfflineFirstRepoRepository.kt @@ -1,5 +1,6 @@ package com.looker.core.data.repository +import android.content.Context import com.looker.core.common.extension.exceptCancellation import com.looker.core.data.fdroid.toEntity import com.looker.core.database.dao.AppDao @@ -13,6 +14,7 @@ import com.looker.core.domain.RepoRepository import com.looker.core.domain.model.Repo import com.looker.network.Downloader import com.looker.sync.fdroid.v2.EntrySyncable +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope @@ -26,6 +28,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject class OfflineFirstRepoRepository @Inject constructor( + @ApplicationContext context: Context, private val appDao: AppDao, private val repoDao: RepoDao, private val settingsRepository: SettingsRepository, @@ -62,7 +65,7 @@ class OfflineFirstRepoRepository @Inject constructor( } } - private val syncable = EntrySyncable(downloader, dispatcher) + private val syncable = EntrySyncable(context, downloader, dispatcher) override suspend fun sync(repo: Repo): Boolean = coroutineScope { try { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 795c76c67..e5bc6145d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ # Taken from NIA sample app by Google [versions] -androidDesugarJdkLibs = "2.0.4" -androidGradlePlugin = "8.5.2" +androidDesugarJdkLibs = "2.1.1" +androidGradlePlugin = "8.6.0" androidMaterial = "1.12.0" androidxActivity = "1.9.1" androidxAppCompat = "1.7.0" @@ -26,11 +26,11 @@ hilt = "2.52" hiltExt = "1.2.0" junit4 = "4.13.2" jackson = "2.17.2" -kotlin = "2.0.10" +kotlin = "2.0.20" kotlinxCoroutines = "1.9.0-RC.2" kotlinxDatetime = "0.6.0" kotlinxSerializationJson = "1.7.1" -ksp = "2.0.10-1.0.24" +ksp = "2.0.20-1.0.24" ktlint = "12.1.1" ktor = "2.3.12" libsu = "6.0.0" diff --git a/sync/fdroid/build.gradle.kts b/sync/fdroid/build.gradle.kts index 2b644aff2..08e2a1a12 100644 --- a/sync/fdroid/build.gradle.kts +++ b/sync/fdroid/build.gradle.kts @@ -1,11 +1,16 @@ plugins { - alias(libs.plugins.looker.jvm.library) + alias(libs.plugins.looker.android.library) alias(libs.plugins.looker.serialization) alias(libs.plugins.looker.lint) } +android { + namespace = "com.looker.sync.fdroid" +} + dependencies { modules( + Modules.coreCommon, Modules.coreDomain, Modules.coreNetwork, ) @@ -18,9 +23,9 @@ dependencies { testRuntimeOnly(libs.junit.platform) } -tasks.test { +/*tasks.test { useJUnitPlatform() testLogging { events("passed", "skipped", "failed") } -} +}*/ diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/common/IndexDownloader.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/common/IndexDownloader.kt index 4e1816a8d..2f833cbe4 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/common/IndexDownloader.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/common/IndexDownloader.kt @@ -1,5 +1,7 @@ package com.looker.sync.fdroid.common +import android.content.Context +import com.looker.core.common.cache.Cache import com.looker.core.domain.model.Repo import com.looker.network.Downloader import kotlinx.coroutines.Dispatchers @@ -8,11 +10,13 @@ import java.io.File import java.util.Date suspend fun Downloader.downloadIndex( + context: Context, repo: Repo, fileName: String, url: String, + diff: Boolean = false, ): File = withContext(Dispatchers.IO) { - val tempFile = File.createTempFile(repo.name, fileName) + val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName") downloadToFile( url = url, target = tempFile, @@ -23,10 +27,14 @@ suspend fun Downloader.downloadIndex( repo.authentication.password ) } - if (repo.versionInfo.timestamp > 0L) { + if (repo.versionInfo.timestamp > 0L && !diff) { ifModifiedSince(Date(repo.versionInfo.timestamp)) } } ) tempFile } + +const val INDEX_V1_NAME = "index-v1.jar" +const val ENTRY_V2_NAME = "entry.jar" +const val INDEX_V2_NAME = "index-v2.json" diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v1/V1Syncable.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v1/V1Syncable.kt index 9607696a3..bd1e44298 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v1/V1Syncable.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v1/V1Syncable.kt @@ -1,10 +1,12 @@ package com.looker.sync.fdroid.v1 +import android.content.Context import com.looker.core.domain.model.Fingerprint import com.looker.core.domain.model.Repo import com.looker.network.Downloader import com.looker.sync.fdroid.Parser import com.looker.sync.fdroid.Syncable +import com.looker.sync.fdroid.common.INDEX_V1_NAME import com.looker.sync.fdroid.common.IndexJarValidator import com.looker.sync.fdroid.common.JsonParser import com.looker.sync.fdroid.common.downloadIndex @@ -15,6 +17,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext class V1Syncable( + private val context: Context, private val downloader: Downloader, private val dispatcher: CoroutineDispatcher, ) : Syncable { @@ -28,9 +31,10 @@ class V1Syncable( override suspend fun sync(repo: Repo): Pair = withContext(dispatcher) { val jar = downloader.downloadIndex( + context = context, repo = repo, - url = repo.address.removeSuffix("/") + "/index-v1.jar", - fileName = "index-v1.jar", + url = repo.address.removeSuffix("/") + "/$INDEX_V1_NAME", + fileName = INDEX_V1_NAME, ) val (fingerprint, indexV1) = parser.parse(jar, repo) jar.delete() diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/DiffParser.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/DiffParser.kt index 5cc2e96d5..b874c5565 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/DiffParser.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/DiffParser.kt @@ -3,11 +3,26 @@ package com.looker.sync.fdroid.v2 import com.looker.core.domain.model.Fingerprint import com.looker.core.domain.model.Repo import com.looker.sync.fdroid.Parser -import com.looker.sync.fdroid.v2.model.IndexV2 +import com.looker.sync.fdroid.v2.model.IndexV2Diff +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream import java.io.File -class DiffParser: Parser { - override suspend fun parse(file: File, repo: Repo): Pair { - TODO("Not yet implemented") - } +class DiffParser( + private val dispatcher: CoroutineDispatcher, + private val json: Json, +) : Parser { + @OptIn(ExperimentalSerializationApi::class) + override suspend fun parse(file: File, repo: Repo): Pair = + withContext(dispatcher) { + val indexV2 = file.inputStream().use { + json.decodeFromStream(IndexV2Diff.serializer(), it) + } + requireNotNull(repo.fingerprint) { + "Fingerprint should not be null when parsing diff" + } to indexV2 + } } diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/EntrySyncable.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/EntrySyncable.kt index d269f9878..0177fdd8c 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/EntrySyncable.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/EntrySyncable.kt @@ -1,20 +1,29 @@ package com.looker.sync.fdroid.v2 +import android.content.Context +import com.looker.core.common.cache.Cache import com.looker.core.domain.model.Fingerprint import com.looker.core.domain.model.Repo import com.looker.network.Downloader import com.looker.sync.fdroid.Parser import com.looker.sync.fdroid.Syncable +import com.looker.sync.fdroid.common.ENTRY_V2_NAME +import com.looker.sync.fdroid.common.INDEX_V2_NAME import com.looker.sync.fdroid.common.IndexJarValidator import com.looker.sync.fdroid.common.JsonParser import com.looker.sync.fdroid.common.downloadIndex import com.looker.sync.fdroid.v2.model.Entry import com.looker.sync.fdroid.v2.model.IndexV2 +import com.looker.sync.fdroid.v2.model.IndexV2Diff import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToStream class EntrySyncable( + private val context: Context, private val downloader: Downloader, private val dispatcher: CoroutineDispatcher, ) : Syncable { @@ -30,23 +39,52 @@ class EntrySyncable( json = JsonParser.parser, ) + private val diffParser: Parser = DiffParser( + dispatcher = dispatcher, + json = JsonParser.parser, + ) + + @OptIn(ExperimentalSerializationApi::class) override suspend fun sync(repo: Repo): Pair = withContext(Dispatchers.IO) { + // example https://apt.izzysoft.de/fdroid/repo/entry.json val jar = downloader.downloadIndex( + context = context, repo = repo, - url = repo.address.removeSuffix("/") + "/entry.jar", - fileName = "entry.jar" + url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME", + fileName = ENTRY_V2_NAME ) val (fingerprint, entry) = parser.parse(jar, repo) + jar.delete() val index = entry.getDiff(repo.versionInfo.timestamp) ?: return@withContext fingerprint to null val indexPath = repo.address.removeSuffix("/") + index.name - val indexFile = downloader.downloadIndex( - repo = repo, - url = indexPath, - fileName = "index-v2.json" - ) - val (_, indexV2) = indexParser.parse(indexFile, repo) + val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME") + val indexV2 = if (index != entry.index && indexFile.exists()) { + // example https://apt.izzysoft.de/fdroid/repo/diff/1725372028000.json + val diffFile = downloader.downloadIndex( + context = context, + repo = repo, + url = indexPath, + fileName = "diff_${repo.versionInfo.timestamp}.json", + diff = true, + ) + diffParser.parse(diffFile, repo).second.let { + diffFile.delete() + it.patchInto(indexParser.parse(indexFile, repo).second) { index -> + Json.encodeToStream(index, indexFile.outputStream()) + } + } + } else { + // example https://apt.izzysoft.de/fdroid/repo/index-v2.json + val newIndexFile = downloader.downloadIndex( + context = context, + repo = repo, + url = indexPath, + fileName = INDEX_V2_NAME, + ) + indexParser.parse(newIndexFile, repo).second + } fingerprint to indexV2 } } diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/IndexV2.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/IndexV2.kt index 80f432b71..4118fa4a9 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/IndexV2.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/IndexV2.kt @@ -7,3 +7,28 @@ data class IndexV2( val repo: RepoV2, val packages: Map ) + +@Serializable +data class IndexV2Diff( + val repo: RepoV2Diff, + val packages: Map +) { + fun patchInto(index: IndexV2, saveIndex: (IndexV2) -> Unit): IndexV2 { + val packagesToRemove = packages.filter { it.value == null }.keys + val packagesToAdd = packages + .mapNotNull { (key, value) -> + value?.let { value -> + if (index.packages.keys.contains(key)) + index.packages[key]?.let { value.patchInto(it) } + else value.toPackage() + }?.let { key to it } + } + + val newIndex = index.copy( + repo = repo.patchInto(index.repo), + packages = index.packages.minus(packagesToRemove).plus(packagesToAdd), + ) + saveIndex(newIndex) + return newIndex + } +} diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/Localization.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/Localization.kt index 1cf627eb0..2b9b0b7f0 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/Localization.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/Localization.kt @@ -1,6 +1,7 @@ package com.looker.sync.fdroid.v2.model typealias LocalizedString = Map +typealias NullableLocalizedString = Map typealias LocalizedIcon = Map typealias LocalizedList = Map> typealias LocalizedFiles = Map> diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/PackageV2.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/PackageV2.kt index 871812437..03bc3205d 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/PackageV2.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/PackageV2.kt @@ -8,6 +8,107 @@ data class PackageV2( val versions: Map, ) +@Serializable +data class PackageV2Diff( + val metadata: MetadataV2Diff?, + val versions: Map? = null, +) { + fun toPackage(): PackageV2 = PackageV2( + metadata = MetadataV2( + added = metadata?.added ?: 0L, + lastUpdated = metadata?.lastUpdated ?: 0L, + name = metadata?.name + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), + summary = metadata?.summary + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), + description = metadata?.description + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), + icon = metadata?.icon, + authorEmail = metadata?.authorEmail, + authorName = metadata?.authorName, + authorPhone = metadata?.authorPhone, + authorWebsite = metadata?.authorWebsite, + bitcoin = metadata?.bitcoin, + categories = metadata?.categories ?: emptyList(), + changelog = metadata?.changelog, + donate = metadata?.donate ?: emptyList(), + featureGraphic = metadata?.featureGraphic, + flattrID = metadata?.flattrID, + issueTracker = metadata?.issueTracker, + liberapay = metadata?.liberapay, + license = metadata?.license, + litecoin = metadata?.litecoin, + openCollective = metadata?.openCollective, + preferredSigner = metadata?.preferredSigner, + promoGraphic = metadata?.promoGraphic, + sourceCode = metadata?.sourceCode, + screenshots = metadata?.screenshots, + tvBanner = metadata?.tvBanner, + translation = metadata?.translation, + video = metadata?.video, + webSite = metadata?.webSite, + ), + versions = versions + ?.mapNotNull { (key, value) -> value?.let { key to it.toVersion() } } + ?.toMap() ?: emptyMap() + ) + + fun patchInto(pack: PackageV2): PackageV2 { + val versionsToRemove = versions?.filterValues { it == null }?.keys ?: emptySet() + val versionsToAdd = versions + ?.mapNotNull { (key, value) -> + value?.let { value -> + if (pack.versions.keys.contains(key)) + pack.versions[key]?.let { value.patchInto(it) } + else value.toVersion() + }?.let { key to it } + } ?: emptyList() + + return pack.copy( + metadata = pack.metadata.copy( + added = pack.metadata.added, + lastUpdated = metadata?.lastUpdated ?: pack.metadata.lastUpdated, + name = metadata?.name + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap() + ?: pack.metadata.name, + summary = metadata?.summary + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap() + ?: pack.metadata.summary, + description = metadata?.description + ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap() + ?: pack.metadata.description, + icon = metadata?.icon ?: pack.metadata.icon, + authorEmail = metadata?.authorEmail ?: pack.metadata.authorEmail, + authorName = metadata?.authorName ?: pack.metadata.authorName, + authorPhone = metadata?.authorPhone ?: pack.metadata.authorPhone, + authorWebsite = metadata?.authorWebsite ?: pack.metadata.authorWebsite, + bitcoin = metadata?.bitcoin ?: pack.metadata.bitcoin, + categories = metadata?.categories ?: pack.metadata.categories, + changelog = metadata?.changelog ?: pack.metadata.changelog, + donate = metadata?.donate?.takeIf { it.isNotEmpty() } ?: pack.metadata.donate, + featureGraphic = metadata?.featureGraphic ?: pack.metadata.featureGraphic, + flattrID = metadata?.flattrID ?: pack.metadata.flattrID, + issueTracker = metadata?.issueTracker ?: pack.metadata.issueTracker, + liberapay = metadata?.liberapay ?: pack.metadata.liberapay, + license = metadata?.license ?: pack.metadata.license, + litecoin = metadata?.litecoin ?: pack.metadata.litecoin, + openCollective = metadata?.openCollective ?: pack.metadata.openCollective, + preferredSigner = metadata?.preferredSigner ?: pack.metadata.preferredSigner, + promoGraphic = metadata?.promoGraphic ?: pack.metadata.promoGraphic, + sourceCode = metadata?.sourceCode ?: pack.metadata.sourceCode, + screenshots = metadata?.screenshots ?: pack.metadata.screenshots, + tvBanner = metadata?.tvBanner ?: pack.metadata.tvBanner, + translation = metadata?.translation ?: pack.metadata.translation, + video = metadata?.video ?: pack.metadata.video, + webSite = metadata?.webSite ?: pack.metadata.webSite, + ), + versions = pack.versions + .minus(versionsToRemove) + .plus(versionsToAdd), + ) + } +} + @Serializable data class MetadataV2( val name: LocalizedString? = null, @@ -41,6 +142,39 @@ data class MetadataV2( val webSite: String? = null, ) +@Serializable +data class MetadataV2Diff( + val name: NullableLocalizedString? = null, + val summary: NullableLocalizedString? = null, + val description: NullableLocalizedString? = null, + val icon: LocalizedIcon? = null, + val added: Long? = null, + val lastUpdated: Long? = null, + val authorEmail: String? = null, + val authorName: String? = null, + val authorPhone: String? = null, + val authorWebsite: String? = null, + val bitcoin: String? = null, + val categories: List = emptyList(), + val changelog: String? = null, + val donate: List = emptyList(), + val featureGraphic: LocalizedIcon? = null, + val flattrID: String? = null, + val issueTracker: String? = null, + val liberapay: String? = null, + val license: String? = null, + val litecoin: String? = null, + val openCollective: String? = null, + val preferredSigner: String? = null, + val promoGraphic: LocalizedIcon? = null, + val sourceCode: String? = null, + val screenshots: ScreenshotsV2? = null, + val tvBanner: LocalizedIcon? = null, + val translation: String? = null, + val video: LocalizedString? = null, + val webSite: String? = null, +) + @Serializable data class VersionV2( val added: Long, @@ -52,6 +186,42 @@ data class VersionV2( val antiFeatures: Map = emptyMap(), ) +@Serializable +data class VersionV2Diff( + val added: Long? = null, + val file: FileV2? = null, + val src: FileV2? = null, + val signer: SignerV2? = null, + val whatsNew: LocalizedString? = null, + val manifest: ManifestV2? = null, + val antiFeatures: Map? = null, +) { + fun toVersion() = VersionV2( + added = added ?: 0, + file = file ?: FileV2(""), + src = src ?: FileV2(""), + signer = signer ?: SignerV2(emptyList()), + whatsNew = whatsNew ?: emptyMap(), + manifest = manifest ?: ManifestV2( + versionName = "", + versionCode = 0, + ), + antiFeatures = antiFeatures ?: emptyMap(), + ) + + fun patchInto(version: VersionV2): VersionV2 { + return version.copy( + added = added ?: version.added, + file = file ?: version.file, + src = src ?: version.src, + signer = signer ?: version.signer, + whatsNew = whatsNew ?: version.whatsNew, + manifest = manifest ?: version.manifest, + antiFeatures = antiFeatures ?: version.antiFeatures, + ) + } +} + @Serializable data class ManifestV2( val versionName: String, diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/RepoV2.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/RepoV2.kt index 09bfc287a..9bdd1907a 100644 --- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/RepoV2.kt +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/v2/model/RepoV2.kt @@ -14,6 +14,55 @@ data class RepoV2( val timestamp: Long, ) +@Serializable +data class RepoV2Diff( + val address: String? = null, + val icon: LocalizedIcon? = null, + val name: LocalizedString? = null, + val description: LocalizedString? = null, + val antiFeatures: Map? = null, + val categories: Map? = null, + val mirrors: List? = null, + val timestamp: Long, +) { + fun patchInto(repo: RepoV2): RepoV2 { + val (antiFeaturesToRemove, antiFeaturesToAdd) = (antiFeatures?.entries + ?.partition { it.value == null } + ?: Pair(emptyList(), emptyList())) + .let { + Pair( + it.first.map { entry -> entry.key }.toSet(), + it.second.mapNotNull { (key, value) -> value?.let { key to value } } + ) + } + + val (categoriesToRemove, categoriesToAdd) = (categories?.entries + ?.partition { it.value == null } + ?: Pair(emptyList(), emptyList())) + .let { + Pair( + it.first.map { entry -> entry.key }.toSet(), + it.second.mapNotNull { (key, value) -> value?.let { key to value } } + ) + } + + return repo.copy( + timestamp = timestamp, + address = address ?: repo.address, + icon = icon ?: repo.icon, + name = name ?: repo.name, + description = description ?: repo.description, + mirrors = mirrors ?: repo.mirrors, + antiFeatures = repo.antiFeatures + .minus(antiFeaturesToRemove) + .plus(antiFeaturesToAdd), + categories = repo.categories + .minus(categoriesToRemove) + .plus(categoriesToAdd), + ) + } +} + @Serializable data class MirrorV2( val url: String,