This commit is contained in:
Felitendo
2025-05-20 15:22:58 +02:00
parent 8a6d5d19db
commit e65e82c85b
465 changed files with 0 additions and 37626 deletions

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,20 +0,0 @@
plugins {
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,
)
implementation(libs.kotlinx.coroutines.core)
androidTestImplementation(libs.bundles.test.android)
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"timestamp": 1725903808000, "version": 20002, "index": {"name": "/index-v2.json", "sha256": "5c5d5b6495efd95c0e7b849df4f1411b6337272cdee2b28defc4eb0f1c4bae42", "size": 7134576, "numPackages": 1201}, "diffs": {"1725491992000": {"name": "/diff/1725491992000.json", "sha256": "58b2633fd72a8b517a69354f15ee88d028c55c92b4122158f0dd63cca82ff37b", "size": 220333, "numPackages": 55}, "1725492213000": {"name": "/diff/1725492213000.json", "sha256": "7aa22b070d9f6f77fd069cab7fdbb38aa9f734d01982d9b9fadb3560807367ff", "size": 218833, "numPackages": 55}, "1725558218000": {"name": "/diff/1725558218000.json", "sha256": "cfae51610c44ec8dd73f390f7af46f71ebf4b2233151ec2f40f8c403775d815b", "size": 208567, "numPackages": 54}, "1725581280000": {"name": "/diff/1725581280000.json", "sha256": "9c5e39cc363e2a98c35fab6ec389f6b1a272fb92298c8f7f8eb158ba5ad3f7b1", "size": 207136, "numPackages": 48}, "1725645028000": {"name": "/diff/1725645028000.json", "sha256": "996c8e982e21dbdbcf25424ea4d3f32a7b67f7eea249db2392ea31a2bef033f6", "size": 98678, "numPackages": 47}, "1725731263000": {"name": "/diff/1725731263000.json", "sha256": "5dd06ef6da469b3881933b076ca0d989372477300b1f43070ae6b041763539da", "size": 80050, "numPackages": 37}, "1725746579000": {"name": "/diff/1725746579000.json", "sha256": "8bb1c009b828a3cecaa8180c08ac7a81b51c2d8b036566ec08fb7159dc61127a", "size": 58098, "numPackages": 31}, "1725807608000": {"name": "/diff/1725807608000.json", "sha256": "95ffb733c6e1e839f18c90e1cc704e554b0ae0eb26b52f0cf6437ee7a91ec96e", "size": 53358, "numPackages": 30}, "1725817837000": {"name": "/diff/1725817837000.json", "sha256": "21fed03d9e1b89cc2c4753084bcf49b681b4e4219375d9fdb5db1dd48a9a2fd3", "size": 16366, "numPackages": 28}, "1725885326000": {"name": "/diff/1725885326000.json", "sha256": "f2f22d1a56262276e07c68d5d71a149a50ddfa8c47b82bb3b63e0077f58121b6", "size": 13324, "numPackages": 9}}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,64 +0,0 @@
package com.looker.sync.fdroid
import com.looker.network.Downloader
import com.looker.network.NetworkResponse
import com.looker.network.ProgressListener
import com.looker.network.header.HeadersBuilder
import com.looker.network.validation.FileValidator
import com.looker.sync.fdroid.common.assets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.net.Proxy
val FakeDownloader = object : Downloader {
override fun setProxy(proxy: Proxy) {
TODO("Not yet implemented")
}
override suspend fun headCall(
url: String,
headers: HeadersBuilder.() -> Unit
): NetworkResponse {
TODO("Not yet implemented")
}
override suspend fun downloadToFile(
url: String,
target: File,
validator: FileValidator?,
headers: HeadersBuilder.() -> Unit,
block: ProgressListener?
): NetworkResponse {
return if (url.endsWith("fail")) NetworkResponse.Error.Unknown(Exception("You asked for it"))
else {
val index = when {
url.endsWith("index-v1.jar") -> assets("izzy_index_v1.jar")
url.endsWith("index-v2.json") -> assets("izzy_index_v2.json")
url.endsWith("entry.jar") -> assets("izzy_entry.jar")
url.endsWith("/diff/1725731263000.json") -> assets("izzy_diff.json")
// Just in case we try these in future
url.endsWith("index-v1.json") -> assets("izzy_index_v1.json")
url.endsWith("entry.json") -> assets("izzy_entry.json")
else -> error("Unknown URL: $url")
}
index.writeTo(target)
NetworkResponse.Success(200, null, null)
}
}
suspend infix fun InputStream.writeTo(file: File) = withContext(Dispatchers.IO) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead = read(buffer)
file.outputStream().use { output ->
while (bytesRead != -1) {
ensureActive()
output.write(buffer, 0, bytesRead)
bytesRead = read(buffer)
}
output.flush()
}
}
}

View File

@@ -1,107 +0,0 @@
package com.looker.sync.fdroid
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.looker.core.domain.model.Repo
import com.looker.sync.fdroid.common.Izzy
import com.looker.sync.fdroid.common.JsonParser
import com.looker.sync.fdroid.common.assets
import com.looker.sync.fdroid.common.memory
import com.looker.sync.fdroid.v2.EntrySyncable
import com.looker.sync.fdroid.v2.model.Entry
import com.looker.sync.fdroid.v2.model.IndexV2
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.decodeFromStream
import org.junit.Before
import org.junit.runner.RunWith
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class EntrySyncableTest {
private lateinit var dispatcher: CoroutineDispatcher
private lateinit var context: Context
private lateinit var syncable: Syncable<Entry>
private lateinit var repo: Repo
private lateinit var newIndex: IndexV2
/**
* In this particular test 1 package is removed and 36 packages are updated
*/
@OptIn(ExperimentalSerializationApi::class)
@Before
fun before() {
context = InstrumentationRegistry.getInstrumentation().context
dispatcher = StandardTestDispatcher()
syncable = EntrySyncable(context, FakeDownloader, dispatcher)
newIndex = JsonParser.parser.decodeFromStream<IndexV2>(assets("izzy_index_v2_updated.json"))
repo = Izzy
}
// Not very trustworthy
@Test
fun benchmark_sync_full() = runTest(dispatcher) {
memory("Full Benchmark") {
syncable.sync(repo)
}
memory("Diff Benchmark") {
syncable.sync(repo)
}
}
@Test
fun check_if_patch_applies() = runTest(dispatcher) {
// Downloads old index file as the index file does not exist
val (fingerprint1, index1) = syncable.sync(repo)
assert(index1 != null)
// Downloads the diff as the index file exists and is older than entry version
val (fingerprint2, index2) = syncable.sync(
repo.copy(
versionInfo = repo.versionInfo.copy(
timestamp = index1!!.repo.timestamp
)
)
)
assert(index2 != null)
// Does not download anything
val (fingerprint3, index3) = syncable.sync(
repo.copy(
versionInfo = repo.versionInfo.copy(
timestamp = index2!!.repo.timestamp
)
)
)
assert(index3 == null)
// Check if all the packages are same
assertContentEquals(newIndex.packages.keys.sorted(), index2.packages.keys.sorted())
// Check if all the version hashes are same
assertContentEquals(
newIndex.packages.values.flatMap { it.versions.keys }.sorted(),
index2.packages.values.flatMap { it.versions.keys }.sorted(),
)
// Check if repo antifeatures are same
assertContentEquals(
newIndex.repo.antiFeatures.keys.sorted(),
index2.repo.antiFeatures.keys.sorted()
)
// Check if repo categories are same
assertContentEquals(
newIndex.repo.categories.keys.sorted(),
index2.repo.categories.keys.sorted()
)
assertEquals(fingerprint1, fingerprint2)
assertEquals(fingerprint2, fingerprint3)
}
}

View File

@@ -1,13 +0,0 @@
package com.looker.sync.fdroid
import com.looker.core.domain.model.Fingerprint
import java.util.jar.JarEntry
val FakeIndexValidator = object : IndexValidator {
override suspend fun validate(
jarEntry: JarEntry,
expectedFingerprint: Fingerprint?
): Fingerprint {
return expectedFingerprint ?: Fingerprint("0".repeat(64))
}
}

View File

@@ -1,24 +0,0 @@
package com.looker.sync.fdroid.common
import com.looker.network.DataSize
import kotlin.time.measureTime
internal inline fun memory(
extraMessage: String? = null,
block: () -> Unit,
) {
val runtime = Runtime.getRuntime()
if (extraMessage != null) {
println("=".repeat(50))
println(extraMessage)
}
println("=".repeat(50))
val initial = runtime.freeMemory()
val time = measureTime {
block()
}
val final = runtime.freeMemory()
println("Time Taken: ${time}, Usage: ${DataSize(initial - final)} / ${DataSize(runtime.maxMemory())}")
println("=".repeat(50))
println()
}

View File

@@ -1,20 +0,0 @@
package com.looker.sync.fdroid.common
import com.looker.core.domain.model.Authentication
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.Repo
import com.looker.core.domain.model.VersionInfo
val Izzy = Repo(
id = 1L,
enabled = true,
address = "https://apt.izzysoft.de/fdroid/repo",
name = "IzzyOnDroid F-Droid Repo",
description = "This is a repository of apps to be used with F-Droid. Applications in this repository are official binaries built by the original application developers, taken from their resp. repositories (mostly Github, GitLab, Codeberg). Updates for the apps are usually fetched daily, and you can expect daily index updates.",
fingerprint = Fingerprint("3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A"),
authentication = Authentication("", ""),
versionInfo = VersionInfo(0L, null),
mirrors = emptyList(),
antiFeatures = emptyList(),
categories = emptyList(),
)

View File

@@ -1,14 +0,0 @@
package com.looker.sync.fdroid.common
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.io.InputStream
fun getResource(name: String): File? {
val url = Thread.currentThread().contextClassLoader?.getResource(name) ?: return null
return File(url.file)
}
fun assets(name: String): InputStream {
return InstrumentationRegistry.getInstrumentation().context.assets.open(name)
}

View File

@@ -1,15 +0,0 @@
package com.looker.sync.fdroid
import com.looker.core.domain.model.Fingerprint
import com.looker.network.validation.ValidationException
import java.util.jar.JarEntry
interface IndexValidator {
@Throws(ValidationException::class)
suspend fun validate(
jarEntry: JarEntry,
expectedFingerprint: Fingerprint?,
): Fingerprint
}

View File

@@ -1,14 +0,0 @@
package com.looker.sync.fdroid
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.Repo
import java.io.File
interface Parser<out T> {
suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, T>
}

View File

@@ -1,21 +0,0 @@
package com.looker.sync.fdroid
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.Repo
import com.looker.sync.fdroid.v2.model.IndexV2
/**
* Expected Architecture: [https://excalidraw.com/#json=JqpGunWTJONjq-ecDNiPg,j9t0X4coeNvIG7B33GTq6A]
*
* Current Issue: When downloading entry.jar we need to re-call the synchronizer,
* which this arch doesn't allow.
*/
interface Syncable<T> {
val parser: Parser<T>
suspend fun sync(
repo: Repo,
): Pair<Fingerprint, IndexV2?>
}

View File

@@ -1,240 +0,0 @@
package com.looker.sync.fdroid.common
import com.looker.sync.fdroid.v1.model.AppV1
import com.looker.sync.fdroid.v1.model.IndexV1
import com.looker.sync.fdroid.v1.model.Localized
import com.looker.sync.fdroid.v1.model.PackageV1
import com.looker.sync.fdroid.v1.model.RepoV1
import com.looker.sync.fdroid.v2.model.AntiFeatureV2
import com.looker.sync.fdroid.v2.model.CategoryV2
import com.looker.sync.fdroid.v2.model.FeatureV2
import com.looker.sync.fdroid.v2.model.FileV2
import com.looker.sync.fdroid.v2.model.IndexV2
import com.looker.sync.fdroid.v2.model.LocalizedFiles
import com.looker.sync.fdroid.v2.model.LocalizedIcon
import com.looker.sync.fdroid.v2.model.LocalizedString
import com.looker.sync.fdroid.v2.model.ManifestV2
import com.looker.sync.fdroid.v2.model.MetadataV2
import com.looker.sync.fdroid.v2.model.MirrorV2
import com.looker.sync.fdroid.v2.model.PackageV2
import com.looker.sync.fdroid.v2.model.PermissionV2
import com.looker.sync.fdroid.v2.model.RepoV2
import com.looker.sync.fdroid.v2.model.ScreenshotsV2
import com.looker.sync.fdroid.v2.model.SignerV2
import com.looker.sync.fdroid.v2.model.UsesSdkV2
import com.looker.sync.fdroid.v2.model.VersionV2
private const val V1_LOCALE = "en-US"
internal fun IndexV1.toV2(): IndexV2 {
val antiFeatures: HashSet<String> = hashSetOf()
val categories: HashSet<String> = hashSetOf()
val packagesV2: HashMap<String, PackageV2> = hashMapOf()
apps.forEach { app ->
antiFeatures.addAll(app.antiFeatures)
categories.addAll(app.categories)
val versions = packages[app.packageName]
val preferredSigner = versions?.firstOrNull()?.signer
val whatsNew: LocalizedString? = app.localized
?.localizedString(null) { it.whatsNew }
val packageV2 = PackageV2(
versions = versions?.associate { packageV1 ->
packageV1.hash to packageV1.toVersionV2(
whatsNew = whatsNew,
packageAntiFeatures = app.antiFeatures + (packageV1.antiFeatures ?: emptyList())
)
} ?: emptyMap(),
metadata = app.toV2(preferredSigner)
)
packagesV2.putIfAbsent(app.packageName, packageV2)
}
return IndexV2(
repo = repo.toRepoV2(
categories = categories,
antiFeatures = antiFeatures
),
packages = packagesV2,
)
}
private fun RepoV1.toRepoV2(
categories: Set<String>,
antiFeatures: Set<String>,
): RepoV2 = RepoV2(
address = address,
timestamp = timestamp,
icon = mapOf(V1_LOCALE to FileV2("/icons/$icon")),
name = mapOf(V1_LOCALE to name),
description = mapOf(V1_LOCALE to description),
mirrors = mirrors.toMutableList()
.apply { add(0, address) }
.map { MirrorV2(url = it, isPrimary = (it == address).takeIf { it }) },
antiFeatures = antiFeatures.associateWith { name ->
AntiFeatureV2(
name = mapOf(V1_LOCALE to name),
icon = mapOf(V1_LOCALE to FileV2("/icons/ic_antifeature_${name.normalizeName()}.png")),
)
},
categories = categories.associateWith { name ->
CategoryV2(
name = mapOf(V1_LOCALE to name),
icon = mapOf(V1_LOCALE to FileV2("/icons/category_${name.normalizeName()}.png")),
)
},
)
private fun String.normalizeName(): String = lowercase().replace(" & ", "_")
private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
added = added ?: 0L,
lastUpdated = lastUpdated ?: 0L,
icon = localized?.localizedIcon(packageName, icon) { it.icon },
name = localized?.localizedString(name) { it.name },
description = localized?.localizedString(description) { it.description },
summary = localized?.localizedString(summary) { it.summary },
authorEmail = authorEmail,
authorName = authorName,
authorPhone = authorPhone,
authorWebsite = authorWebSite ?: webSite,
bitcoin = bitcoin,
categories = categories,
changelog = changelog,
donate = if (donate != null) listOf(donate) else emptyList(),
featureGraphic = localized?.localizedIcon(packageName) { it.featureGraphic },
flattrID = flattrID,
issueTracker = issueTracker,
liberapay = liberapay,
license = license,
litecoin = litecoin,
openCollective = openCollective,
preferredSigner = preferredSigner,
promoGraphic = localized?.localizedIcon(packageName) { it.promoGraphic },
screenshots = localized?.screenshotV2(packageName),
sourceCode = sourceCode,
translation = translation,
tvBanner = localized?.localizedIcon(packageName) { it.tvBanner },
video = localized?.localizedString(null) { it.video },
webSite = webSite,
)
private fun Map<String, Localized>.screenshotV2(
packageName: String,
): ScreenshotsV2? = ScreenshotsV2(
phone = localizedScreenshots { locale, screenshot ->
screenshot.phoneScreenshots?.map {
"/$packageName/$locale/phoneScreenshots/$it"
}
},
sevenInch = localizedScreenshots { locale, screenshot ->
screenshot.sevenInchScreenshots?.map {
"/$packageName/$locale/sevenInchScreenshots/$it"
}
},
tenInch = localizedScreenshots { locale, screenshot ->
screenshot.tenInchScreenshots?.map {
"/$packageName/$locale/tenInchScreenshots/$it"
}
},
tv = localizedScreenshots { locale, screenshot ->
screenshot.tvScreenshots?.map {
"/$packageName/$locale/tvScreenshots/$it"
}
},
wear = localizedScreenshots { locale, screenshot ->
screenshot.wearScreenshots?.map {
"/$packageName/$locale/wearScreenshots/$it"
}
},
).takeIf { !it.isNull }
private fun PackageV1.toVersionV2(
whatsNew: LocalizedString?,
packageAntiFeatures: List<String>,
): VersionV2 = VersionV2(
added = added ?: 0L,
file = FileV2(
name = "/$apkName",
sha256 = hash,
size = size,
),
src = srcName?.let { FileV2("/$it") },
signer = signer?.let { SignerV2(listOf(it)) },
whatsNew = whatsNew ?: emptyMap(),
antiFeatures = packageAntiFeatures.associateWith { mapOf(V1_LOCALE to it) },
manifest = ManifestV2(
versionName = versionName,
versionCode = versionCode ?: 0L,
signer = signer?.let { SignerV2(listOf(it)) },
usesSdk = sdkV2(),
minSdkVersion = minSdkVersion,
maxSdkVersion = maxSdkVersion,
usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) },
usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) },
features = features?.map { FeatureV2(it) } ?: emptyList(),
nativecode = nativeCode ?: emptyList()
),
)
private fun PackageV1.sdkV2(): UsesSdkV2? {
return if (minSdkVersion == null && targetSdkVersion == null) {
null
} else {
UsesSdkV2(
minSdkVersion = minSdkVersion ?: 1,
targetSdkVersion = targetSdkVersion ?: 1,
)
}
}
private inline fun Map<String, Localized>.localizedString(
default: String?,
crossinline block: (Localized) -> String?,
): LocalizedString? {
if (default != null) {
return mapOf(V1_LOCALE to default)
}
return mapValuesNotNull { (_, localized) ->
block(localized)
}.takeIf { it.isNotEmpty() }
}
private inline fun Map<String, Localized>.localizedIcon(
packageName: String,
default: String? = null,
crossinline block: (Localized) -> String?,
): LocalizedIcon? {
if (default != null) {
return mapOf(
V1_LOCALE to FileV2("/$packageName/$V1_LOCALE/$default")
)
}
return mapValuesNotNull { (locale, localized) ->
block(localized)?.let {
FileV2("/$packageName/$locale/$it")
}
}.takeIf { it.isNotEmpty() }
}
private inline fun Map<String, Localized>.localizedScreenshots(
crossinline block: (String, Localized) -> List<String>?,
): LocalizedFiles? {
return mapValuesNotNull { (locale, localized) ->
val files = block(locale, localized)
if (files.isNullOrEmpty()) null
else files.map(::FileV2)
}.takeIf { it.isNotEmpty() }
}
private inline fun <K, V, M> Map<K, V>.mapValuesNotNull(
block: (Map.Entry<K, V>) -> M?
): Map<K, M> {
val map = HashMap<K, M>()
forEach { entry ->
block(entry)?.let { map[entry.key] = it }
}
return map
}

View File

@@ -1,40 +0,0 @@
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
import kotlinx.coroutines.withContext
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 = Cache.getIndexFile(context, "repo_${repo.id}_$fileName")
downloadToFile(
url = url,
target = tempFile,
headers = {
if (repo.shouldAuthenticate) {
authentication(
repo.authentication.username,
repo.authentication.password
)
}
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"

View File

@@ -1,43 +0,0 @@
package com.looker.sync.fdroid.common
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.fingerprint
import com.looker.network.validation.invalid
import com.looker.sync.fdroid.IndexValidator
import com.looker.sync.fdroid.utils.certificate
import com.looker.sync.fdroid.utils.codeSigner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.util.jar.JarEntry
class IndexJarValidator(
private val dispatcher: CoroutineDispatcher
) : IndexValidator {
override suspend fun validate(
jarEntry: JarEntry,
expectedFingerprint: Fingerprint?
): Fingerprint = withContext(dispatcher) {
val fingerprint = try {
jarEntry
.codeSigner
.certificate
.fingerprint()
} catch (e: IllegalStateException) {
invalid(e.message ?: "Unknown Exception")
} catch (e: IllegalArgumentException) {
invalid(e.message ?: "Error creating Fingerprint object")
}
if (expectedFingerprint == null) {
fingerprint
} else {
if (expectedFingerprint.check(fingerprint)) {
expectedFingerprint
} else {
invalid(
"Expected Fingerprint: ${expectedFingerprint}, " +
"Acquired Fingerprint: $fingerprint"
)
}
}
}
}

View File

@@ -1,9 +0,0 @@
package com.looker.sync.fdroid.common
import kotlinx.serialization.json.Json
object JsonParser {
val parser = Json { ignoreUnknownKeys = true }
}

View File

@@ -1,19 +0,0 @@
package com.looker.sync.fdroid.utils
import java.io.File
import java.security.CodeSigner
import java.security.cert.Certificate
import java.util.jar.JarEntry
import java.util.jar.JarFile
fun File.toJarFile(verify: Boolean = true): JarFile = JarFile(this, verify)
@get:Throws(IllegalStateException::class)
val JarEntry.codeSigner: CodeSigner
get() = codeSigners?.singleOrNull()
?: error("index.jar must be signed by a single code signer, Current: $codeSigners")
@get:Throws(IllegalStateException::class)
val CodeSigner.certificate: Certificate
get() = signerCertPath?.certificates?.singleOrNull()
?: error("index.jar code signer should have only one certificate")

View File

@@ -1,35 +0,0 @@
package com.looker.sync.fdroid.v1
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.Repo
import com.looker.sync.fdroid.IndexValidator
import com.looker.sync.fdroid.Parser
import com.looker.sync.fdroid.utils.toJarFile
import com.looker.sync.fdroid.v1.model.IndexV1
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 V1Parser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
private val validator: IndexValidator,
) : Parser<IndexV1> {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, IndexV1> = withContext(dispatcher) {
val jar = file.toJarFile()
val entry = jar.getJarEntry("index-v1.json")
val indexV1 = jar.getInputStream(entry).use {
json.decodeFromStream(IndexV1.serializer(), it)
}
val validatedFingerprint: Fingerprint = validator.validate(entry, repo.fingerprint)
validatedFingerprint to indexV1
}
}

View File

@@ -1,43 +0,0 @@
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
import com.looker.sync.fdroid.common.toV2
import com.looker.sync.fdroid.v1.model.IndexV1
import com.looker.sync.fdroid.v2.model.IndexV2
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class V1Syncable(
private val context: Context,
private val downloader: Downloader,
private val dispatcher: CoroutineDispatcher,
) : Syncable<IndexV1> {
override val parser: Parser<IndexV1>
get() = V1Parser(
dispatcher = dispatcher,
json = JsonParser.parser,
validator = IndexJarValidator(dispatcher),
)
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2> =
withContext(dispatcher) {
val jar = downloader.downloadIndex(
context = context,
repo = repo,
url = repo.address.removeSuffix("/") + "/$INDEX_V1_NAME",
fileName = INDEX_V1_NAME,
)
val (fingerprint, indexV1) = parser.parse(jar, repo)
jar.delete()
fingerprint to indexV1.toV2()
}
}

View File

@@ -1,37 +0,0 @@
package com.looker.sync.fdroid.v1.model
import kotlinx.serialization.Serializable
@Serializable
data class AppV1(
val packageName: String,
val icon: String? = null,
val name: String? = null,
val description: String? = null,
val summary: String? = null,
val added: Long? = null,
val antiFeatures: List<String> = emptyList(),
val authorEmail: String? = null,
val authorName: String? = null,
val authorPhone: String? = null,
val authorWebSite: String? = null,
val binaries: String? = null,
val bitcoin: String? = null,
val categories: List<String> = emptyList(),
val changelog: String? = null,
val donate: String? = null,
val flattrID: String? = null,
val issueTracker: String? = null,
val lastUpdated: Long? = null,
val liberapay: String? = null,
val liberapayID: String? = null,
val license: String,
val litecoin: String? = null,
val localized: Map<String, Localized>? = null,
val openCollective: String? = null,
val sourceCode: String? = null,
val suggestedVersionCode: String? = null,
val translation: String? = null,
val webSite: String? = null,
)

View File

@@ -1,10 +0,0 @@
package com.looker.sync.fdroid.v1.model
import kotlinx.serialization.Serializable
@Serializable
data class IndexV1(
val repo: RepoV1,
val apps: List<AppV1> = emptyList(),
val packages: Map<String, List<PackageV1>> = emptyMap(),
)

View File

@@ -1,21 +0,0 @@
package com.looker.sync.fdroid.v1.model
import kotlinx.serialization.Serializable
@Serializable
data class Localized(
val icon: String? = null,
val name: String? = null,
val description: String? = null,
val summary: String? = null,
val featureGraphic: String? = null,
val phoneScreenshots: List<String>? = null,
val promoGraphic: String? = null,
val sevenInchScreenshots: List<String>? = null,
val tenInchScreenshots: List<String>? = null,
val tvBanner: String? = null,
val tvScreenshots: List<String>? = null,
val video: String? = null,
val wearScreenshots: List<String>? = null,
val whatsNew: String? = null,
)

View File

@@ -1,76 +0,0 @@
package com.looker.sync.fdroid.v1.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.encodeCollection
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class PackageV1(
val added: Long? = null,
val apkName: String,
val hash: String,
val hashType: String,
val minSdkVersion: Int? = null,
val maxSdkVersion: Int? = null,
val targetSdkVersion: Int? = minSdkVersion,
val packageName: String,
val sig: String? = null,
val signer: String? = null,
val size: Long,
@SerialName("srcname")
val srcName: String? = null,
@SerialName("uses-permission")
val usesPermission: List<PermissionV1> = emptyList(),
@SerialName("uses-permission-sdk-23")
val usesPermission23: List<PermissionV1> = emptyList(),
val versionCode: Long? = null,
val versionName: String,
@SerialName("nativecode")
val nativeCode: List<String>? = null,
val features: List<String>? = null,
val antiFeatures: List<String>? = null,
)
@Serializable(PermissionV1Serializer::class)
data class PermissionV1(
val name: String,
val maxSdk: Int? = null,
)
internal class PermissionV1Serializer : KSerializer<PermissionV1> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PermissionV1") {
element<String>("name")
element<Int?>("maxSdk")
}
override fun deserialize(decoder: Decoder): PermissionV1 {
decoder as? JsonDecoder ?: error("Not a JSON")
val array: JsonArray = decoder.decodeJsonElement().jsonArray
require(array.size == 2) { "Permission array is invalid: $array" }
require(array[0].jsonPrimitive.isString) { "Name is not the first element in permission: $array" }
val name: String = array[0].jsonPrimitive.content
val maxSdk: Int? = array[1].jsonPrimitive.intOrNull
return PermissionV1(name, maxSdk)
}
override fun serialize(encoder: Encoder, permission: PermissionV1) {
encoder.encodeCollection(JsonArray.serializer().descriptor, 2) {
encodeStringElement(descriptor, 0, permission.name)
encodeNullableSerializableElement(descriptor, 1, Int.serializer(), permission.maxSdk)
}
}
}

View File

@@ -1,14 +0,0 @@
package com.looker.sync.fdroid.v1.model
import kotlinx.serialization.Serializable
@Serializable
data class RepoV1(
val address: String,
val icon: String,
val name: String,
val description: String,
val timestamp: Long,
val version: Int,
val mirrors: List<String> = emptyList(),
)

View File

@@ -1,28 +0,0 @@
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.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(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
) : Parser<IndexV2Diff> {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun parse(file: File, repo: Repo): Pair<Fingerprint, IndexV2Diff> =
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
}
}

View File

@@ -1,35 +0,0 @@
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.IndexValidator
import com.looker.sync.fdroid.Parser
import com.looker.sync.fdroid.utils.toJarFile
import com.looker.sync.fdroid.v2.model.Entry
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 EntryParser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
private val validator: IndexValidator,
) : Parser<Entry> {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, Entry> = withContext(dispatcher) {
val jar = file.toJarFile()
val entry = jar.getJarEntry("entry.json")
val indexEntry = jar.getInputStream(entry).use {
json.decodeFromStream(Entry.serializer(), it)
}
val validatedFingerprint: Fingerprint = validator.validate(entry, repo.fingerprint)
validatedFingerprint to indexEntry
}
}

View File

@@ -1,91 +0,0 @@
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.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<Entry> {
override val parser: Parser<Entry>
get() = EntryParser(
dispatcher = dispatcher,
json = JsonParser.parser,
validator = IndexJarValidator(dispatcher),
)
private val indexParser: Parser<IndexV2> = V2Parser(
dispatcher = dispatcher,
json = JsonParser.parser,
)
private val diffParser: Parser<IndexV2Diff> = DiffParser(
dispatcher = dispatcher,
json = JsonParser.parser,
)
@OptIn(ExperimentalSerializationApi::class)
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2?> =
withContext(dispatcher) {
// example https://apt.izzysoft.de/fdroid/repo/entry.json
val jar = downloader.downloadIndex(
context = context,
repo = repo,
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)
// Already latest
?: return@withContext fingerprint to null
val indexPath = repo.address.removeSuffix("/") + index.name
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,
)
// TODO: Maybe parse in parallel
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
}
}

View File

@@ -1,31 +0,0 @@
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 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 V2Parser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
) : Parser<IndexV2> {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, IndexV2> = withContext(dispatcher) {
val indexV2 = file.inputStream().use {
json.decodeFromStream(IndexV2.serializer(), it)
}
requireNotNull(repo.fingerprint) {
"Fingerprint should not be null if index v2 is being fetched"
} to indexV2
}
}

View File

@@ -1,26 +0,0 @@
package com.looker.sync.fdroid.v2.model
import kotlinx.serialization.Serializable
@Serializable
data class Entry(
val timestamp: Long,
val version: Long,
val index: EntryFile,
val diffs: Map<Long, EntryFile>
) {
fun getDiff(timestamp: Long): EntryFile? {
return if (this.timestamp == timestamp) null
else diffs[timestamp] ?: index
}
}
@Serializable
data class EntryFile(
val name: String,
val sha256: String,
val size: Long,
val numPackages: Long,
)

View File

@@ -1,10 +0,0 @@
package com.looker.sync.fdroid.v2.model
import kotlinx.serialization.Serializable
@Serializable
data class FileV2(
val name: String,
val sha256: String? = null,
val size: Long? = null,
)

View File

@@ -1,34 +0,0 @@
package com.looker.sync.fdroid.v2.model
import kotlinx.serialization.Serializable
@Serializable
data class IndexV2(
val repo: RepoV2,
val packages: Map<String, PackageV2>
)
@Serializable
data class IndexV2Diff(
val repo: RepoV2Diff,
val packages: Map<String, PackageV2Diff?>
) {
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
}
}

View File

@@ -1,7 +0,0 @@
package com.looker.sync.fdroid.v2.model
typealias LocalizedString = Map<String, String>
typealias NullableLocalizedString = Map<String, String?>
typealias LocalizedIcon = Map<String, FileV2>
typealias LocalizedList = Map<String, List<String>>
typealias LocalizedFiles = Map<String, List<FileV2>>

View File

@@ -1,275 +0,0 @@
package com.looker.sync.fdroid.v2.model
import kotlinx.serialization.Serializable
@Serializable
data class PackageV2(
val metadata: MetadataV2,
val versions: Map<String, VersionV2>,
)
@Serializable
data class PackageV2Diff(
val metadata: MetadataV2Diff?,
val versions: Map<String, VersionV2Diff?>? = 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,
val summary: LocalizedString? = null,
val description: LocalizedString? = null,
val icon: LocalizedIcon? = null,
val added: Long,
val lastUpdated: Long,
val authorEmail: String? = null,
val authorName: String? = null,
val authorPhone: String? = null,
val authorWebsite: String? = null,
val bitcoin: String? = null,
val categories: List<String> = emptyList(),
val changelog: String? = null,
val donate: List<String> = 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 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<String> = emptyList(),
val changelog: String? = null,
val donate: List<String> = 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,
val file: FileV2,
val src: FileV2? = null,
val signer: SignerV2? = null,
val whatsNew: LocalizedString = emptyMap(),
val manifest: ManifestV2,
val antiFeatures: Map<String, LocalizedString> = 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<String, LocalizedString>? = 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,
val versionCode: Long,
val signer: SignerV2? = null,
val usesSdk: UsesSdkV2? = null,
val minSdkVersion: Int? = null,
val maxSdkVersion: Int? = null,
val usesPermission: List<PermissionV2> = emptyList(),
val usesPermissionSdk23: List<PermissionV2> = emptyList(),
val features: List<FeatureV2> = emptyList(),
val nativecode: List<String> = emptyList(),
)
@Serializable
data class UsesSdkV2(
val minSdkVersion: Int,
val targetSdkVersion: Int,
)
@Serializable
data class PermissionV2(
val name: String,
val maxSdkVersion: Int? = null,
)
@Serializable
data class FeatureV2(
val name: String,
)
@Serializable
data class SignerV2(
val sha256: List<String>,
val hasMultipleSigners: Boolean = false,
)
@Serializable
data class ScreenshotsV2(
val phone: LocalizedFiles? = null,
val sevenInch: LocalizedFiles? = null,
val tenInch: LocalizedFiles? = null,
val wear: LocalizedFiles? = null,
val tv: LocalizedFiles? = null,
) {
val isNull: Boolean =
phone == null && sevenInch == null && tenInch == null && wear == null && tv == null
}

View File

@@ -1,85 +0,0 @@
package com.looker.sync.fdroid.v2.model
import kotlinx.serialization.Serializable
@Serializable
data class RepoV2(
val address: String,
val icon: LocalizedIcon? = null,
val name: LocalizedString = emptyMap(),
val description: LocalizedString = emptyMap(),
val antiFeatures: Map<String, AntiFeatureV2> = emptyMap(),
val categories: Map<String, CategoryV2> = emptyMap(),
val mirrors: List<MirrorV2> = emptyList(),
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<String, AntiFeatureV2?>? = null,
val categories: Map<String, CategoryV2?>? = null,
val mirrors: List<MirrorV2>? = 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,
val isPrimary: Boolean? = null,
val location: String? = null
)
@Serializable
data class CategoryV2(
val icon: LocalizedIcon = emptyMap(),
val name: LocalizedString,
val description: LocalizedString = emptyMap(),
)
@Serializable
data class AntiFeatureV2(
val icon: LocalizedIcon = emptyMap(),
val name: LocalizedString,
val description: LocalizedString = emptyMap(),
)