Skip to content

Commit 10a1861

Browse files
authored
Merge pull request #6139 from EdgeApp/matthew/abi-split-apks
Add per-ABI split APKs to the Android release pipeline
2 parents b88c884 + 78adcb7 commit 10a1861

5 files changed

Lines changed: 215 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased (develop)
44

55
- added: Cash App Pay as a MoonPay buy and sell payment method, for US customers.
6+
- added: Per-ABI Android release APKs (arm64-v8a and armeabi-v7a) alongside the universal APK, for distribution outside Google Play. Each is roughly 31 MB smaller than the universal APK. Branches opt in through a `splitArchitectures` list in their deploy-config block, which maps each ABI to its own Zealot channel. The universal APK keeps serving the main channel and direct downloads.
67
- added: Verbose logging for exchange rate queries: the request body, resolved/rate-less counts, and errors are captured when the Verbose Logging setting is enabled.
78
- added: Exchange-rate cache snapshot in the support log output, plus a `rates-cache-replay` script that re-runs those queries against the rates server and reports the result for each pair.
89
- added: "-m" tag on the version number in the Help scene for Maestro test builds

android/app/build.gradle

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,63 @@ android {
105105
}
106106
}
107107

108+
// Edge addition: sideloadable per-ABI APKs for distribution outside
109+
// Google Play (Play already serves per-ABI installs from the AAB).
110+
// Each one is roughly 31 MB smaller than the universal APK (about
111+
// 78-80 MB vs 110 MB as measured). Off by default so normal builds
112+
// are unchanged; the deploy script opts in:
113+
// ./gradlew assembleRelease -PabiSplits
114+
// A split build emits three APKs: the universal plus one per ABI,
115+
// so the universal stays available across the board. The property is
116+
// a boolean flag, so -PabiSplits=false (from the command line,
117+
// gradle.properties, or CI) disables the feature:
118+
def abiSplitsEnabled = project.hasProperty('abiSplits') &&
119+
project.property('abiSplits') != 'false'
120+
splits {
121+
abi {
122+
reset()
123+
enable abiSplitsEnabled
124+
universalApk true
125+
// Adding an ABI here also needs an abiVersionOffsets entry
126+
// below and a matching supportedAbis entry in
127+
// scripts/deploy.ts, which validates the deploy-config:
128+
include 'armeabi-v7a', 'arm64-v8a' // Exclude Intel
129+
}
130+
}
131+
132+
// Edge addition: distinct versionCodes for the split APKs, required
133+
// by Google Play multi-APK uploads (each APK in a release needs a
134+
// unique versionCode). Play serves the highest compatible code, so
135+
// arm64-v8a must outrank armeabi-v7a, and the universal APK keeps
136+
// the unmodified base code as the lowest-priority fallback.
137+
//
138+
// The offsets stay small deliberately. getBuildNumber() returns this
139+
// value on Android, and the info server filters promo and info cards
140+
// by comparing minBuildNum/maxBuildNum/exactBuildNum against it as
141+
// strings, so the universal APK that nearly every user installs must
142+
// keep reporting the plain build number. The build counter advances
143+
// by 3 per build (see scripts/gitVersionFile.ts), so these offsets
144+
// never collide with a neighboring build's codes:
145+
def abiVersionOffsets = ['armeabi-v7a': 1, 'arm64-v8a': 2]
146+
applicationVariants.all { variant ->
147+
variant.outputs.each { output ->
148+
def abi = output.getFilter(OutputFile.ABI)
149+
if (abi != null) {
150+
// Defaulting a missing ABI to 0 would hand that split
151+
// APK the universal APK's code, which Play rejects as a
152+
// duplicate. Fail the build instead, so an ABI added to
153+
// the include list above without an offset here cannot
154+
// ship silently:
155+
def abiOffset = abiVersionOffsets[abi]
156+
if (abiOffset == null) {
157+
throw new GradleException(
158+
"No versionCode offset defined for ABI '${abi}'")
159+
}
160+
output.versionCodeOverride = variant.versionCode + abiOffset
161+
}
162+
}
163+
}
164+
108165
signingConfigs {
109166
release {
110167
}
@@ -134,6 +191,13 @@ android {
134191
useLegacyPackaging true
135192
}
136193

194+
// Compress dex, matching how the universal APK that bundletool
195+
// derives from the AAB is packaged. AGP stores dex uncompressed
196+
// by default at minSdk 28+, which adds ~28 MB to a sideloaded APK:
197+
dex {
198+
useLegacyPackaging true
199+
}
200+
137201
// Edge hacks for zcash and piratechain conflicts:
138202
resources {
139203
pickFirst 'compact_formats.proto'

deploy-config.sample.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
},
2828
"android": {
2929
"master": {
30-
"hockeyAppId": "xxxxxxxxx"
30+
"hockeyAppId": "xxxxxxxxx",
31+
"splitArchitectures": [
32+
{ "abi": "arm64-v8a", "zealotChannelKey": "xxxxxxxxxx" },
33+
{ "abi": "armeabi-v7a", "zealotChannelKey": "xxxxxxxxxx" }
34+
]
3135
},
3236
"develop": {
3337
"hockeyAppId": "xxxxxxxxx"

scripts/deploy.ts

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ const now = new Date()
2121
const cutoffDate = new Date()
2222
cutoffDate.setMonth(now.getMonth() - BUILD_ARCHIVE_MONTHS)
2323

24+
/**
25+
* One per-ABI split APK build: the ABI to package, and the Zealot
26+
* channel that receives it.
27+
*/
28+
interface SplitArchitecture {
29+
abi: string
30+
zealotChannelKey?: string
31+
}
32+
2433
/**
2534
* Things we expect to be set in the config file:
2635
*/
@@ -46,6 +55,11 @@ interface BuildConfigFile {
4655
zealotApiToken?: string
4756
zealotChannelKey?: string
4857
zealotMaestroChannelKey?: string
58+
// Per-ABI split APKs to build, archive, and upload. Android only.
59+
// Belongs in a branch block of the android config, so each branch
60+
// opts in on its own. Entries without a zealotChannelKey are archived
61+
// but not uploaded:
62+
splitArchitectures?: SplitArchitecture[]
4963
hockeyAppId: string
5064
hockeyAppTags: string
5165
hockeyAppToken: string
@@ -81,6 +95,7 @@ interface BuildObj extends BuildConfigFile {
8195
dSymFile: string
8296
dSymZip: string
8397
ipaFile: string // Also APK
98+
abiApkFiles?: Record<string, string> // Android split APKs, by ABI
8499
}
85100

86101
interface LatestTestFile {
@@ -146,8 +161,6 @@ function makeProject(buildObj: BuildObj): void {
146161
buildObj,
147162
config[project][buildObj.platformType][buildObj.repoBranch]
148163
)
149-
150-
console.log(buildObj)
151164
}
152165

153166
function makeCommonPost(buildObj: BuildObj): void {
@@ -519,11 +532,51 @@ function buildAndroid(buildObj: BuildObj): void {
519532
const universalApk = join(apkPathDir, 'universal.apk')
520533
buildObj.ipaFile = join(apkPathDir, `${outfile}.apk`)
521534
fs.renameSync(universalApk, buildObj.ipaFile)
535+
536+
// Branches configured with splitArchitectures also archive sideloadable
537+
// per-ABI APKs for distribution outside Google Play (Play already
538+
// serves per-ABI installs from the AAB). Each one is roughly 31 MB
539+
// smaller than the universal APK (about 78-80 MB vs 110 MB as
540+
// measured). The gradle daemon reuses the compile work from the bundle
541+
// task above, so this only pays for packaging and signing. Maestro
542+
// builds skip this entirely:
543+
const { splitArchitectures } = buildObj
544+
if (
545+
splitArchitectures != null &&
546+
splitArchitectures.length > 0 &&
547+
!maestroBuild
548+
) {
549+
// The gradle splits block can only emit these ABIs, so anything else
550+
// in the config is a typo. This list must match the splits.abi
551+
// include list in android/app/build.gradle. Checking before the
552+
// build turns a typo into an immediate, clear error rather than a
553+
// low-signal ENOENT from the copy below after a multi-minute
554+
// assemble, and keeps unvalidated config values out of the path
555+
// construction:
556+
const supportedAbis = ['arm64-v8a', 'armeabi-v7a']
557+
for (const { abi } of splitArchitectures) {
558+
if (!supportedAbis.includes(abi)) {
559+
throw new Error(`Unsupported abi "${abi}" in splitArchitectures`)
560+
}
561+
}
562+
563+
call('./gradlew assembleRelease -PabiSplits')
564+
const splitApkDir = join(guiPlatformDir, 'app/build/outputs/apk/release')
565+
566+
buildObj.abiApkFiles = {}
567+
for (const { abi } of splitArchitectures) {
568+
const archivedApk = join(archiveDir, `${outfile}-${abi}.apk`)
569+
fs.copyFileSync(join(splitApkDir, `app-${abi}-release.apk`), archivedApk)
570+
buildObj.abiApkFiles[abi] = archivedApk
571+
}
572+
}
522573
}
523574

524575
function buildCommonPost(buildObj: BuildObj): void {
525576
const {
577+
abiApkFiles,
526578
maestroBuild,
579+
splitArchitectures,
527580
zealotApiToken,
528581
zealotChannelKey,
529582
zealotMaestroChannelKey,
@@ -564,14 +617,16 @@ function buildCommonPost(buildObj: BuildObj): void {
564617
mylog('\nUploaded to HockeyApp')
565618
}
566619

620+
// Shared by both Zealot uploads below:
621+
const branch = encodeURIComponent(buildObj.repoBranch)
622+
const gitCommit = encodeURIComponent(buildObj.guiHash)
623+
567624
// Maestro test builds upload to their own channel so they do not pollute the
568625
// production channel's release list. Production builds use zealotChannelKey.
569626
// A maestro build with no zealotMaestroChannelKey configured skips Zealot.
570627
const channelKey = maestroBuild ? zealotMaestroChannelKey : zealotChannelKey
571628

572629
if (zealotApiToken != null && zealotUrl != null && channelKey != null) {
573-
const branch = encodeURIComponent(buildObj.repoBranch)
574-
const gitCommit = encodeURIComponent(buildObj.guiHash)
575630
chdir(buildObj.guiDir)
576631
const changes = cmd(
577632
`git diff HEAD^ HEAD CHANGELOG.md | { grep '^+[^+]' || true; }`
@@ -582,12 +637,40 @@ function buildCommonPost(buildObj: BuildObj): void {
582637
'***********************************************************************\n'
583638
)
584639

585-
call(
586-
`curl -X POST "${zealotUrl}/api/apps/upload?token=${zealotApiToken}&channel_key=${channelKey}&branch=${branch}&git_commit=${gitCommit}&changelog=${changelog}" -F "file=@${buildObj.ipaFile}"`
640+
const token = encodeURIComponent(zealotApiToken)
641+
const encodedChannelKey = encodeURIComponent(channelKey)
642+
callRedacted(
643+
`curl -X POST "${zealotUrl}/api/apps/upload?token=${token}&channel_key=${encodedChannelKey}&branch=${branch}&git_commit=${gitCommit}&changelog=${changelog}" -F "file=@${buildObj.ipaFile}"`,
644+
[token, encodedChannelKey]
587645
)
588646
mylog('\n*** Upload to Zealot Complete ***')
589647
}
590648

649+
// Architecture-specific APKs go to their own Zealot channels, one per
650+
// ABI, so each channel's latest build is always the right architecture.
651+
// The universal APK above keeps serving the main channel and direct
652+
// downloads. Only branches whose config block lists splitArchitectures
653+
// build these at all, and maestro builds never do:
654+
if (
655+
zealotApiToken != null &&
656+
zealotUrl != null &&
657+
abiApkFiles != null &&
658+
splitArchitectures != null
659+
) {
660+
const token = encodeURIComponent(zealotApiToken)
661+
for (const { abi, zealotChannelKey: abiChannelKey } of splitArchitectures) {
662+
const apkFile = abiApkFiles[abi]
663+
if (abiChannelKey == null || apkFile == null) continue
664+
const channelKey = encodeURIComponent(abiChannelKey)
665+
mylog(`\n\nUploading ${abi} APK to Zealot: ${zealotUrl}`)
666+
callRedacted(
667+
`curl -X POST "${zealotUrl}/api/apps/upload?token=${token}&channel_key=${channelKey}&branch=${branch}&git_commit=${gitCommit}" -F "file=@${apkFile}"`,
668+
[token, channelKey]
669+
)
670+
mylog(`\n*** Upload of ${abi} APK to Zealot Complete ***`)
671+
}
672+
}
673+
591674
if (buildObj.rsyncLocation != null) {
592675
const {
593676
buildNum,
@@ -713,8 +796,11 @@ function chdir(path: string): void {
713796
_currentPath = path
714797
}
715798

716-
function call(cmdstring: string): void {
717-
console.log('call: ' + cmdstring)
799+
/**
800+
* Runs a command, inheriting our stdio. Shared by `call` and
801+
* `callRedacted` so both stay on the same execution options.
802+
*/
803+
function execCommand(cmdstring: string): void {
718804
childProcess.execSync(cmdstring, {
719805
encoding: 'utf8',
720806
timeout: 3600000,
@@ -724,6 +810,35 @@ function call(cmdstring: string): void {
724810
})
725811
}
726812

813+
/**
814+
* Like `call`, but logs the command with the given secrets masked, so
815+
* tokens and keys do not land in the CI build log. A failed execSync
816+
* throws an error whose message embeds the raw command, so the failure
817+
* path gets the same masking as the log line.
818+
*/
819+
function callRedacted(cmdstring: string, secrets: string[]): void {
820+
const redact = (text: string): string => {
821+
let out = text
822+
for (const secret of secrets) {
823+
if (secret !== '') out = out.split(secret).join('<redacted>')
824+
}
825+
return out
826+
}
827+
828+
console.log('call: ' + redact(cmdstring))
829+
try {
830+
execCommand(cmdstring)
831+
} catch (error) {
832+
const message = error instanceof Error ? error.message : String(error)
833+
throw new Error(redact(message))
834+
}
835+
}
836+
837+
function call(cmdstring: string): void {
838+
console.log('call: ' + cmdstring)
839+
execCommand(cmdstring)
840+
}
841+
727842
function cmd(cmdstring: string): string {
728843
console.log('cmd: ' + cmdstring)
729844
const r = childProcess.execSync(cmdstring, {

scripts/gitVersionFile.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,28 @@ function updateVersionFile(branch: string, version: string): void {
8888
const { build: previousBuild } = JSON.parse(result)
8989
if (typeof previousBuild !== 'number')
9090
throw new Error(`Invalid previous buildNum ${previousBuild}`)
91-
build = Math.max(previousBuild + 1, newBuildNum)
91+
// Advance by 3, not 1, so each build owns a block of three
92+
// versionCodes: the Android split APKs add per-ABI offsets to the
93+
// build number (universal +0, armeabi-v7a +1, arm64-v8a +2, see
94+
// app/build.gradle), and Google Play rejects any versionCode it
95+
// has ever seen, so consecutive builds must never overlap blocks.
96+
// The stride must stay >= the number of APK flavors per build,
97+
// which is pinned by the gradle splits include list. Widening the
98+
// gap the other way, by giving the splits their own numeric range
99+
// (build * 10 + offset) and leaving this at +1, does not work: the
100+
// next build's universal APK would then carry a lower code than
101+
// the previous build's splits, which Play treats as a downgrade,
102+
// and the universal APK has to keep reporting the plain build
103+
// number because getBuildNumber() returns the versionCode and the
104+
// info server string-compares it against minBuildNum/maxBuildNum/
105+
// exactBuildNum rules.
106+
//
107+
// Costs, both accepted: iOS build numbers share this counter and
108+
// simply skip by 3, and same-day capacity drops from 99 builds to
109+
// 33 before the date-shaped number bleeds into the next day's
110+
// range. That bleed already existed at 99, and it only misreads
111+
// the date -- build numbers stay unique and increasing either way:
112+
build = Math.max(previousBuild + 3, newBuildNum)
92113
} else {
93114
build = newBuildNum
94115
}

0 commit comments

Comments
 (0)