Skip to content

Commit 393fc8f

Browse files
authored
feat: AppTarget versionCodes field to restrict patching to specific version code releases (#120)
1 parent a96e38e commit 393fc8f

3 files changed

Lines changed: 198 additions & 10 deletions

File tree

api/morphe-patcher.api

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -534,22 +534,28 @@ public final class app/morphe/patcher/patch/ApkFileType : java/lang/Enum {
534534
}
535535

536536
public final class app/morphe/patcher/patch/AppTarget : java/lang/Comparable {
537+
public fun <init> (Ljava/lang/String;IZLjava/lang/Integer;Ljava/lang/String;)V
538+
public synthetic fun <init> (Ljava/lang/String;IZLjava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
539+
public fun <init> (Ljava/lang/String;Ljava/util/Map;ZLjava/lang/Integer;Ljava/lang/String;)V
540+
public synthetic fun <init> (Ljava/lang/String;Ljava/util/Map;ZLjava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
537541
public fun <init> (Ljava/lang/String;ZLjava/lang/Integer;)V
538542
public synthetic fun <init> (Ljava/lang/String;ZLjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
539543
public fun <init> (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;)V
540544
public synthetic fun <init> (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
541545
public fun compareTo (Lapp/morphe/patcher/patch/AppTarget;)I
542546
public synthetic fun compareTo (Ljava/lang/Object;)I
543547
public final fun component1 ()Ljava/lang/String;
544-
public final fun component2 ()Z
545-
public final fun component3 ()Ljava/lang/Integer;
546-
public final fun component4 ()Ljava/lang/String;
547-
public final fun copy (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;)Lapp/morphe/patcher/patch/AppTarget;
548-
public static synthetic fun copy$default (Lapp/morphe/patcher/patch/AppTarget;Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;ILjava/lang/Object;)Lapp/morphe/patcher/patch/AppTarget;
548+
public final fun component2 ()Ljava/util/Map;
549+
public final fun component3 ()Z
550+
public final fun component4 ()Ljava/lang/Integer;
551+
public final fun component5 ()Ljava/lang/String;
552+
public final fun copy (Ljava/lang/String;Ljava/util/Map;ZLjava/lang/Integer;Ljava/lang/String;)Lapp/morphe/patcher/patch/AppTarget;
553+
public static synthetic fun copy$default (Lapp/morphe/patcher/patch/AppTarget;Ljava/lang/String;Ljava/util/Map;ZLjava/lang/Integer;Ljava/lang/String;ILjava/lang/Object;)Lapp/morphe/patcher/patch/AppTarget;
549554
public fun equals (Ljava/lang/Object;)Z
550555
public final fun getDescription ()Ljava/lang/String;
551556
public final fun getMinSdk ()Ljava/lang/Integer;
552557
public final fun getVersion ()Ljava/lang/String;
558+
public final fun getVersionCodes ()Ljava/util/Map;
553559
public fun hashCode ()I
554560
public final fun isExperimental ()Z
555561
public fun toString ()Ljava/lang/String;
@@ -883,6 +889,16 @@ public final class app/morphe/patcher/patch/ResourcePatchContext : app/morphe/pa
883889
public final fun getPackageMetadata ()Lapp/morphe/patcher/PackageMetadata;
884890
}
885891

892+
public final class app/morphe/patcher/patch/SupportedAbi : java/lang/Enum {
893+
public static final field ARM64_V8A Lapp/morphe/patcher/patch/SupportedAbi;
894+
public static final field ARMEABI_V7A Lapp/morphe/patcher/patch/SupportedAbi;
895+
public static final field X86 Lapp/morphe/patcher/patch/SupportedAbi;
896+
public static final field X86_64 Lapp/morphe/patcher/patch/SupportedAbi;
897+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
898+
public static fun valueOf (Ljava/lang/String;)Lapp/morphe/patcher/patch/SupportedAbi;
899+
public static fun values ()[Lapp/morphe/patcher/patch/SupportedAbi;
900+
}
901+
886902
public final class app/morphe/patcher/resource/CpuArchitecture : java/lang/Enum {
887903
public static final field ARM64_V8A Lapp/morphe/patcher/resource/CpuArchitecture;
888904
public static final field ARMEABI Lapp/morphe/patcher/resource/CpuArchitecture;

src/main/kotlin/app/morphe/patcher/patch/Compatibility.kt

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
package app.morphe.patcher.patch
77

8+
import android.R.attr.versionCode
9+
import kotlin.collections.all
10+
import kotlin.collections.isNotEmpty
11+
812
private val SHA_256_REGEX = Regex("^[0-9a-fA-F]{64}$")
913

1014
/**
@@ -41,13 +45,25 @@ enum class ApkFileType {
4145
get() = name.endsWith("_REQUIRED")
4246
}
4347

48+
enum class SupportedAbi {
49+
ARM64_V8A,
50+
ARMEABI_V7A,
51+
X86_64,
52+
X86
53+
}
54+
4455
/**
4556
* Instances are sortable from lowest to highest version, with any version (null) last.
4657
* Semantic versioning is handled and sorts correctly in situations such as 1.1.0 > 1.0.02
4758
* Non-semantic versioning is sorted alphabetically.
4859
*
4960
* @param version Version string. Null means any version and additionally can be used to
5061
* indicate any version is supported experimentally.
62+
* @param versionCodes Required app version codes. If the map is null, or an architecture
63+
* key value is null, then any app version is assumed to work. This declaration is only required
64+
* for apps that can have multiple releases under the same user facing version string (ie: 5.2.1)
65+
* but only one specific release is supported or recommended. This is common with Meta apps
66+
* but uncommon with most other apps.
5167
* @param isExperimental If this app target is supported under an experimental capacity.
5268
* @param minSdk Minimum device SDK version as found in [android.os.Build.VERSION_CODES].
5369
* Null means any SDK version.
@@ -56,19 +72,66 @@ enum class ApkFileType {
5672
*/
5773
data class AppTarget(
5874
val version: String?,
75+
val versionCodes: Map<SupportedAbi, Int>? = null,
5976
val isExperimental: Boolean = false,
6077
val minSdk: Int? = null,
6178
val description: String? = null
6279
) : Comparable<AppTarget> {
6380

6481
private val semanticParts: List<Int>? = parseSemantic(version)
6582

83+
/**
84+
* Convenience constructor for a universal APK app target,
85+
* where a specific app version is required.
86+
*
87+
* @param versionCode Specific required app version code.
88+
*/
89+
constructor(
90+
version: String,
91+
versionCode: Int,
92+
isExperimental: Boolean = false,
93+
minSdk: Int? = null,
94+
description: String? = null,
95+
) : this(
96+
version = version,
97+
versionCodes = SupportedAbi.entries.associateWith { versionCode },
98+
isExperimental = isExperimental,
99+
minSdk = minSdk,
100+
description = null
101+
)
102+
66103
// @Deprecated("Here only for binary backwards compatibility") // TODO: Remove after next major version bump.
67104
constructor(
68105
version: String?,
69106
isExperimental: Boolean = false,
70107
minSdk: Int? = null,
71-
) : this(version = version, isExperimental = isExperimental, minSdk = minSdk, description = null)
108+
) : this(
109+
version = version,
110+
versionCodes = null,
111+
isExperimental = isExperimental,
112+
minSdk = minSdk,
113+
description = null
114+
)
115+
116+
// @Deprecated("Here only for binary backwards compatibility") // TODO: Remove after next major version bump.
117+
constructor(
118+
version: String?,
119+
isExperimental: Boolean = false,
120+
minSdk: Int? = null,
121+
description: String? = null
122+
) : this(
123+
version = version,
124+
versionCodes = null,
125+
isExperimental = isExperimental,
126+
minSdk = minSdk,
127+
description = null
128+
)
129+
130+
init {
131+
if (version == null && !versionCodes.isNullOrEmpty()) {
132+
throw IllegalArgumentException("Version codes requires declaring a version string")
133+
}
134+
}
72135

73136
/**
74137
* Comparison using only the version field.
@@ -228,10 +291,7 @@ data class Compatibility(
228291
"App icon color must be #RRGGBB format: $color"
229292
}
230293

231-
val rgb = color.removePrefix("#").toInt(16)
232-
233-
// force full opacity
234-
return rgb or 0xFF000000.toInt()
294+
return color.removePrefix("#").toInt(16)
235295
}
236296

237297
fun fromLegacy(legacy: Pair<String, Set<String>?>): Compatibility {

src/test/kotlin/app/morphe/patcher/patch/CompatibilityTest.kt

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package app.morphe.patcher.patch
77

8+
import org.junit.jupiter.api.assertDoesNotThrow
89
import org.junit.jupiter.api.assertThrows
910
import kotlin.test.Test
1011
import kotlin.test.assertEquals
@@ -338,4 +339,115 @@ internal object CompatibilityTest {
338339
sorted
339340
)
340341
}
342+
343+
@Test
344+
fun `compatibility color string`() {
345+
val colorString = Compatibility(
346+
name = "Example app",
347+
packageName = "compatible.package",
348+
targets = listOf(
349+
AppTarget(version = "1.1.0", isExperimental = true),
350+
AppTarget(version = "1.0.0", isExperimental = true)
351+
),
352+
appIconColor = "#FF0000"
353+
)
354+
355+
val colorInt = Compatibility(
356+
name = "Example app",
357+
packageName = "compatible.package",
358+
targets = listOf(
359+
AppTarget(version = "1.1.0", isExperimental = true),
360+
AppTarget(version = "1.0.0", isExperimental = true)
361+
),
362+
appIconColor = 0xFF0000
363+
)
364+
365+
assertEquals(colorString.appIconColor, colorInt.appIconColor)
366+
367+
assertThrows<Exception> {
368+
Compatibility(
369+
name = "Example app",
370+
packageName = "compatible.package",
371+
targets = listOf(
372+
AppTarget(version = "1.1.0", isExperimental = true),
373+
AppTarget(version = "1.0.0", isExperimental = true)
374+
),
375+
appIconColor = "#00FF0000"
376+
)
377+
}
378+
379+
assertThrows<Exception> {
380+
Compatibility(
381+
name = "Example app",
382+
packageName = "compatible.package",
383+
targets = listOf(
384+
AppTarget(version = "1.1.0", isExperimental = true),
385+
AppTarget(version = "1.0.0", isExperimental = true)
386+
),
387+
appIconColor = "#0000"
388+
)
389+
}
390+
}
391+
392+
@Test
393+
fun `app version code`() {
394+
var compatibility = Compatibility(
395+
name = "Example app",
396+
packageName = "compatible.package",
397+
apkFileType = ApkFileType.APKM,
398+
targets = listOf(
399+
AppTarget(
400+
version = "1.0.0", versionCodes = mapOf(
401+
SupportedAbi.X86_64 to 100,
402+
SupportedAbi.ARMEABI_V7A to 300,
403+
SupportedAbi.ARM64_V8A to 400
404+
)
405+
)
406+
)
407+
)
408+
409+
var versionCodes = compatibility.targets.first().versionCodes!!
410+
411+
assertEquals(3, versionCodes.count())
412+
assertEquals(100, versionCodes[SupportedAbi.X86_64])
413+
assertEquals(null, versionCodes[SupportedAbi.X86])
414+
assertEquals(300, versionCodes[SupportedAbi.ARMEABI_V7A])
415+
assertEquals(400, versionCodes[SupportedAbi.ARM64_V8A])
416+
417+
// Universal APK
418+
compatibility = Compatibility(
419+
name = "Example app",
420+
packageName = "compatible.package",
421+
apkFileType = ApkFileType.APK,
422+
targets = listOf(
423+
AppTarget(version = "1.0.0", versionCode = 500)
424+
)
425+
)
426+
versionCodes = compatibility.targets.first().versionCodes!!
427+
428+
assertEquals(4, versionCodes.count())
429+
assertTrue(versionCodes.all { it.value == 500 })
430+
431+
432+
// No version codes
433+
compatibility = Compatibility(
434+
name = "Example app",
435+
packageName = "compatible.package",
436+
apkFileType = ApkFileType.APK,
437+
targets = listOf(
438+
AppTarget(version = "1.0.0")
439+
)
440+
)
441+
442+
assertEquals(null, compatibility.targets.first().versionCodes)
443+
444+
445+
assertThrows<IllegalArgumentException> {
446+
AppTarget(version = null, versionCodes = mapOf(SupportedAbi.ARM64_V8A to 123))
447+
}
448+
449+
assertDoesNotThrow {
450+
AppTarget(version = null, versionCodes = mapOf())
451+
}
452+
}
341453
}

0 commit comments

Comments
 (0)