v0.6.4
This is a test if updates work
This commit is contained in:
1
sync/fdroid/.gitignore
vendored
Normal file
1
sync/fdroid/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
20
sync/fdroid/build.gradle.kts
Normal file
20
sync/fdroid/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
}
|
||||
1
sync/fdroid/src/androidTest/assets/izzy_diff.json
Normal file
1
sync/fdroid/src/androidTest/assets/izzy_diff.json
Normal file
File diff suppressed because one or more lines are too long
BIN
sync/fdroid/src/androidTest/assets/izzy_entry.jar
Normal file
BIN
sync/fdroid/src/androidTest/assets/izzy_entry.jar
Normal file
Binary file not shown.
1
sync/fdroid/src/androidTest/assets/izzy_entry.json
Normal file
1
sync/fdroid/src/androidTest/assets/izzy_entry.json
Normal file
@@ -0,0 +1 @@
|
||||
{"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}}}
|
||||
BIN
sync/fdroid/src/androidTest/assets/izzy_index_v1.jar
Normal file
BIN
sync/fdroid/src/androidTest/assets/izzy_index_v1.jar
Normal file
Binary file not shown.
1
sync/fdroid/src/androidTest/assets/izzy_index_v1.json
Normal file
1
sync/fdroid/src/androidTest/assets/izzy_index_v1.json
Normal file
File diff suppressed because one or more lines are too long
1
sync/fdroid/src/androidTest/assets/izzy_index_v2.json
Normal file
1
sync/fdroid/src/androidTest/assets/izzy_index_v2.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,64 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
|
||||
}
|
||||
14
sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/Parser.kt
Normal file
14
sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/Parser.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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?>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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"
|
||||
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.looker.sync.fdroid.common
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object JsonParser {
|
||||
|
||||
val parser = Json { ignoreUnknownKeys = true }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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")
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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>>
|
||||
@@ -0,0 +1,275 @@
|
||||
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
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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(),
|
||||
)
|
||||
Reference in New Issue
Block a user