Skip to content

Commit f56c5cb

Browse files
Move to the second iteration of Index-v2 Patching (#1281)
Co-authored-by: LooKeR <iamlooker@proton.me>
1 parent b725e61 commit f56c5cb

File tree

13 files changed

+754
-278
lines changed

13 files changed

+754
-278
lines changed

app/src/main/kotlin/com/looker/droidify/data/local/dao/IndexDao.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ interface IndexDao {
119119

120120
allCategoryAppRelations += metadata.categories.map { CategoryAppRelation(appId, it) }
121121

122-
allAppNames += metadata.name.localizedAppName(appId)
122+
allAppNames += metadata.name?.localizedAppName(appId)
123+
?: listOf(LocalizedAppNameEntity(appId, locale = "en-US", name = packageName))
123124
metadata.summary?.localizedAppSummary(appId)?.let { allAppSummaries += it }
124125
metadata.description?.localizedAppDescription(appId)?.let { allAppDescriptions += it }
125126
metadata.icon?.localizedAppIcon(appId)?.let { allAppIcons += it }

app/src/main/kotlin/com/looker/droidify/data/local/model/DonateEntity.kt

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@ fun MetadataV2.donateEntity(appId: Int): List<DonateEntity>? {
4242
if (openCollective != null) {
4343
add(DonateEntity(OPEN_COLLECTIVE_ID, openCollective, appId))
4444
}
45-
if (flattrID != null) {
46-
add(DonateEntity(FLATTR_ID, flattrID, appId))
47-
}
4845
if (!donate.isNullOrEmpty()) {
4946
add(DonateEntity(REGULAR, donate.joinToString(STRING_LIST_SEPARATOR), appId))
5047
}
@@ -60,12 +57,12 @@ fun List<DonateEntity>.toDonation(): Donation {
6057
var regular: List<String>? = null
6158
for (entity in this) {
6259
when (entity.type) {
63-
BITCOIN_ADD -> bitcoinAddress = entity.value
64-
FLATTR_ID -> flattrId = entity.value
65-
LIBERAPAY_ID -> liberapayId = entity.value
66-
LITECOIN_ADD -> litecoinAddress = entity.value
60+
BITCOIN_ADD -> bitcoinAddress = entity.value
61+
FLATTR_ID -> flattrId = entity.value
62+
LIBERAPAY_ID -> liberapayId = entity.value
63+
LITECOIN_ADD -> litecoinAddress = entity.value
6764
OPEN_COLLECTIVE_ID -> openCollectiveId = entity.value
68-
REGULAR -> regular = entity.value.split(STRING_LIST_SEPARATOR)
65+
REGULAR -> regular = entity.value.split(STRING_LIST_SEPARATOR)
6966
}
7067
}
7168

app/src/main/kotlin/com/looker/droidify/sync/common/IndexConverter.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,19 @@ internal fun IndexV1.toV2(): IndexV2 {
4646
versions = versions?.associate { packageV1 ->
4747
packageV1.hash to packageV1.toVersionV2(
4848
whatsNew = whatsNew,
49-
packageAntiFeatures = app.antiFeatures + (packageV1.antiFeatures ?: emptyList())
49+
packageAntiFeatures = app.antiFeatures + (packageV1.antiFeatures
50+
?: emptyList()),
5051
)
5152
} ?: emptyMap(),
52-
metadata = app.toV2(preferredSigner)
53+
metadata = app.toV2(preferredSigner),
5354
)
5455
packagesV2.putIfAbsent(app.packageName, packageV2)
5556
}
5657

5758
return IndexV2(
5859
repo = repo.toRepoV2(
5960
categories = categories,
60-
antiFeatures = antiFeatures
61+
antiFeatures = antiFeatures,
6162
),
6263
packages = packagesV2,
6364
)
@@ -108,7 +109,6 @@ private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
108109
changelog = changelog,
109110
donate = if (donate != null) listOf(donate) else emptyList(),
110111
featureGraphic = localized?.localizedIcon(packageName) { it.featureGraphic },
111-
flattrID = flattrID,
112112
issueTracker = issueTracker,
113113
liberapay = liberapay,
114114
license = license,
@@ -176,7 +176,7 @@ private fun PackageV1.toVersionV2(
176176
usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) },
177177
usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) },
178178
features = features?.map { FeatureV2(it) } ?: emptyList(),
179-
nativecode = nativeCode ?: emptyList()
179+
nativecode = nativeCode ?: emptyList(),
180180
),
181181
)
182182

@@ -240,7 +240,7 @@ private inline fun Map<String, Localized>.localizedScreenshots(
240240
}
241241

242242
private inline fun <K, V, M> Map<K, V>.mapValuesNotNull(
243-
block: (Map.Entry<K, V>) -> M?
243+
block: (Map.Entry<K, V>) -> M?,
244244
): Map<K, M> {
245245
val map = HashMap<K, M>()
246246
forEach { entry ->

app/src/main/kotlin/com/looker/droidify/sync/v1/model/AppV1.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ data class AppV1(
2424
val categories: List<String> = emptyList(),
2525
val changelog: String? = null,
2626
val donate: String? = null,
27-
val flattrID: String? = null,
2827
val issueTracker: String? = null,
2928
val lastUpdated: Long? = null,
3029
val liberapay: String? = null,

app/src/main/kotlin/com/looker/droidify/sync/v2/EntrySyncable.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.looker.droidify.sync.v2
22

33
import android.content.Context
4+
import android.util.Log
45
import com.looker.droidify.data.model.Repo
56
import com.looker.droidify.network.Downloader
67
import com.looker.droidify.network.percentBy
@@ -14,14 +15,11 @@ import com.looker.droidify.sync.common.downloadIndex
1415
import com.looker.droidify.sync.toJarScope
1516
import com.looker.droidify.sync.v2.model.Entry
1617
import com.looker.droidify.sync.v2.model.IndexV2
17-
import com.looker.droidify.sync.v2.model.IndexV2Diff
18+
import com.looker.droidify.sync.v2.model.IndexV2Merger
1819
import com.looker.droidify.utility.common.cache.Cache
1920
import kotlinx.coroutines.CoroutineDispatcher
20-
import kotlinx.coroutines.async
2121
import kotlinx.coroutines.withContext
2222
import kotlinx.serialization.ExperimentalSerializationApi
23-
import kotlinx.serialization.json.Json
24-
import kotlinx.serialization.json.encodeToStream
2523

2624
class EntrySyncable(
2725
private val context: Context,
@@ -48,8 +46,8 @@ class EntrySyncable(
4846
block(
4947
SyncState.IndexDownload.Failure(
5048
repo.id,
51-
IllegalStateException("Empty entry v2 jar")
52-
)
49+
IllegalStateException("Empty entry v2 jar"),
50+
),
5351
)
5452
return@withContext
5553
} else {
@@ -93,17 +91,24 @@ class EntrySyncable(
9391
block(SyncState.IndexDownload.Progress(repo.id, percent))
9492
},
9593
)
96-
val diff = async {
97-
JsonParser.decodeFromString<IndexV2Diff>(diffFile.readBytes().decodeToString())
98-
}
99-
val oldIndex = async {
100-
JsonParser.decodeFromString<IndexV2>(indexFile.readBytes().decodeToString())
101-
}
10294
try {
103-
diff.await().patchInto(oldIndex.await()) { index ->
104-
diffFile.delete()
105-
Json.encodeToStream(index, indexFile.outputStream())
106-
}
95+
indexFile
96+
.takeIf { it.exists() && it.length() > 0 }
97+
?.let { indexFile ->
98+
IndexV2Merger(indexFile).use { merger ->
99+
merger.processDiff(
100+
diffFile.inputStream(),
101+
).let {
102+
Log.d(
103+
"EntrySyncable",
104+
"merged diff file $diffFile, success = $it, indexFile = $indexFile.",
105+
)
106+
}
107+
}
108+
JsonParser.decodeFromString<IndexV2>(
109+
indexFile.readBytes().decodeToString(),
110+
)
111+
}
107112
} catch (t: Throwable) {
108113
block(SyncState.JsonParsing.Failure(repo.id, t))
109114
return@withContext

app/src/main/kotlin/com/looker/droidify/sync/v2/model/IndexV2.kt

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,5 @@ import kotlinx.serialization.Serializable
99
@Serializable
1010
data class IndexV2(
1111
val repo: RepoV2,
12-
val packages: Map<String, PackageV2>
12+
val packages: Map<String, PackageV2>,
1313
)
14-
15-
@Serializable
16-
data class IndexV2Diff(
17-
val repo: RepoV2Diff,
18-
val packages: Map<String, PackageV2Diff?>
19-
) {
20-
fun patchInto(index: IndexV2, saveIndex: (IndexV2) -> Unit): IndexV2 {
21-
val packagesToRemove = packages.filter { it.value == null }.keys
22-
val packagesToAdd = packages
23-
.mapNotNull { (key, value) ->
24-
value?.let { value ->
25-
if (index.packages.keys.contains(key))
26-
index.packages[key]?.let { value.patchInto(it) }
27-
else value.toPackage()
28-
}?.let { key to it }
29-
}
30-
31-
val newIndex = index.copy(
32-
repo = repo.patchInto(index.repo),
33-
packages = index.packages.minus(packagesToRemove).plus(packagesToAdd),
34-
)
35-
saveIndex(newIndex)
36-
return newIndex
37-
}
38-
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package com.looker.droidify.sync.v2.model
2+
3+
import android.util.Log
4+
import kotlinx.serialization.ExperimentalSerializationApi
5+
import kotlinx.serialization.json.Json
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonNull
8+
import kotlinx.serialization.json.JsonObject
9+
import kotlinx.serialization.json.JsonPrimitive
10+
import kotlinx.serialization.json.decodeFromStream
11+
import kotlinx.serialization.json.encodeToStream
12+
import kotlinx.serialization.json.jsonObject
13+
import kotlinx.serialization.json.longOrNull
14+
import java.io.File
15+
import java.io.InputStream
16+
17+
/**
18+
* Merger for applying JSON Merge Patch (RFC 7386) to IndexV2 instances.
19+
* Adapted from Neo Store.
20+
*/
21+
class IndexV2Merger(private val baseFile: File) : AutoCloseable {
22+
private val json = Json {
23+
ignoreUnknownKeys = true
24+
explicitNulls = true
25+
}
26+
27+
@OptIn(ExperimentalSerializationApi::class)
28+
fun getCurrentIndex(): IndexV2? = json.decodeFromStream(baseFile.inputStream())
29+
30+
fun processDiff(
31+
diffStream: InputStream,
32+
): Boolean {
33+
val tempFile = File.createTempFile("merged_", ".json")
34+
35+
try {
36+
val hasChanged = merge(baseFile, diffStream, tempFile)
37+
38+
if (hasChanged) {
39+
// Save the merged result
40+
tempFile.copyTo(baseFile, overwrite = true)
41+
tempFile.inputStream().use { inputStream ->
42+
val mergedElement = json.decodeFromStream<JsonElement>(inputStream)
43+
val timestamp = getTimestamp(mergedElement)
44+
baseFile.setLastModified(timestamp)
45+
}
46+
}
47+
48+
Log.d("IndexV2Merger", "Merged a diff JSON into the base: $hasChanged")
49+
return hasChanged
50+
} finally {
51+
tempFile.delete()
52+
}
53+
}
54+
55+
private fun merge(baseFile: File, diffStream: InputStream, outputFile: File): Boolean {
56+
val baseElement =
57+
runCatching { baseFile.inputStream().use { json.decodeFromStream<JsonElement>(it) } }
58+
.fold(
59+
onSuccess = { it },
60+
onFailure = {
61+
throw Exception(it.message.orEmpty(), it)
62+
},
63+
)
64+
val diffElement = runCatching { json.decodeFromStream<JsonElement>(diffStream) }
65+
.fold(
66+
onSuccess = { it },
67+
onFailure = {
68+
throw Exception(it.message.orEmpty(), it)
69+
},
70+
)
71+
72+
// No need to apply a diff older or same as base
73+
val baseTimestamp = getTimestamp(baseElement)
74+
val diffTimestamp = getTimestamp(diffElement)
75+
76+
if (diffTimestamp <= baseTimestamp) {
77+
baseFile.copyTo(outputFile, overwrite = true)
78+
return false
79+
}
80+
81+
// Apply the merge patch
82+
val mergedElement = mergePatch(baseElement, diffElement)
83+
84+
// Ensure the timestamp is updated
85+
val mergedObj = mergedElement.jsonObject.toMutableMap()
86+
val repoObj = (mergedObj["repo"] as? JsonObject)?.toMutableMap() ?: run {
87+
baseFile.copyTo(outputFile, overwrite = true)
88+
return false
89+
}
90+
91+
repoObj["timestamp"] = JsonPrimitive(diffTimestamp)
92+
val finalResult = JsonObject(mergedObj + ("repo" to JsonObject(repoObj)))
93+
94+
outputFile.outputStream().use { outputStream ->
95+
json.encodeToStream(finalResult, outputStream)
96+
}
97+
98+
return baseElement != finalResult
99+
}
100+
101+
/**
102+
* Applies a JSON Merge Patch (RFC 7386) to the target JSON element. RFC 7386 rules:
103+
* - If patch is not an object, replace target entirely
104+
* - If patch value is null, remove the key from target
105+
* - If patch value is an object, recursively merge with target
106+
* - Otherwise, replace target value with patch value
107+
*/
108+
private fun mergePatch(target: JsonElement, patch: JsonElement): JsonElement {
109+
if (patch !is JsonObject || target !is JsonObject) return patch
110+
val result = target.jsonObject.toMutableMap()
111+
112+
for ((key, value) in patch) {
113+
// No change when object is empty
114+
if (value is JsonObject && value.jsonObject.isEmpty()) continue
115+
116+
when (value) {
117+
// Remove null objects
118+
is JsonNull -> {
119+
result.remove(key)
120+
}
121+
122+
// Recursively merge objects
123+
is JsonObject -> {
124+
val targetValue = target.jsonObject[key]
125+
result[key] = if (targetValue is JsonObject) {
126+
mergePatch(targetValue, value)
127+
} else {
128+
// If target doesn't have this key or it's not an object, use the patch value
129+
value
130+
}
131+
}
132+
133+
// Replace primitive values entirely
134+
else -> {
135+
result[key] = value
136+
}
137+
}
138+
}
139+
140+
return JsonObject(result)
141+
}
142+
143+
private fun getTimestamp(element: JsonElement): Long {
144+
return (element.jsonObject["repo"]?.jsonObject?.get("timestamp") as? JsonPrimitive)?.longOrNull
145+
?: 0L
146+
}
147+
148+
override fun close() {
149+
// Cleanup when needed
150+
}
151+
}

0 commit comments

Comments
 (0)