ReviewOS

also looking at this

craft-native/craft

fix(android): enforce the API 26 runtime contract

#301
Merged glennmichael123 wants to merge codex/android-minimum-sdk into main
3 files +46 -14
packages/android/README.mdmodified+5-1
Changes to packages/android/README.md
@@ -145,7 +145,11 @@ also enables foreground geolocation. Push notifications require a regular
145145`googleServicesFile` whose JSON contains a client matching the generated
146146Android package. Deep links require at least one valid URI scheme; scheme names
147147are normalized to lowercase.
148Health Connect normalizes `minSdk` to at least 26 and `compileSdk` to at least 36.
148The generated runtime supports Android 8.0 / API 26 and newer, exported as
149`ANDROID_MIN_SDK`. `minSdk` defaults to that floor; lower values are rejected
150before project files are written, including when Health Connect is enabled.
151Health Connect normalizes `compileSdk` to at least 36.
152The same effective `minSdk` is written to Gradle and both configuration files.
149153A custom `appIconPath` must be a regular PNG, WebP, JPG, or GIF file; its supported
150154extension is preserved in the generated drawable resource.
151155
packages/android/src/index.test.tsmodified+32-10
Changes to packages/android/src/index.test.ts
@@ -1,8 +1,8 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44import { describe, expect, it } from 'bun:test'
5import { build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index'
5import { ANDROID_MIN_SDK, build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index'
66
77function generatedFiles(path: string): string[] {
88 return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
@@ -12,6 +12,34 @@ function generatedFiles(path: string): string[] {
1212}
1313
1414describe('Craft Android builder', () => {
15 it('rejects Android API levels below the runtime floor before writing a project', async () => {
16 const root = mkdtempSync(join(tmpdir(), 'craft-android-sdk-floor-'))
17 try {
18 for (const enableHealthConnect of [false, true]) {
19 const output = join(root, String(enableHealthConnect))
20 await expect(init({ name: 'OldDevice', output, runtimeDir: null, config: { minSdk: 25, enableHealthConnect } }))
21 .rejects.toThrow('Android minSdk must be at least 26')
22 expect(existsSync(output)).toBe(false)
23 }
24 }
25 finally { rmSync(root, { recursive: true, force: true }) }
26 })
27
28 it('keeps the default and supported Android API levels aligned across generated metadata', async () => {
29 const root = mkdtempSync(join(tmpdir(), 'craft-android-sdk-supported-'))
30 try {
31 for (const minSdk of [undefined, 26, 28]) {
32 const output = join(root, String(minSdk))
33 await init({ name: 'SupportedDevice', output, runtimeDir: null, config: minSdk === undefined ? {} : { minSdk } })
34 const expected = minSdk ?? 26
35 expect(JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')).minSdk).toBe(expected)
36 expect(JSON.parse(readFileSync(join(output, 'app/src/main/assets/craft.config.json'), 'utf8')).minSdk).toBe(expected)
37 expect(readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')).toContain(`minSdk = ${expected}`)
38 }
39 }
40 finally { rmSync(root, { recursive: true, force: true }) }
41 })
42
1543 it('routes every Kotlin template through the project generator', () => {
1644 const templates = readdirSync(join(import.meta.dir, '../templates'))
1745 .filter(name => name.endsWith('.kt.template'))
@@ -1298,7 +1326,7 @@ describe('Craft Android builder', () => {
12981326 name: 'WildLoop',
12991327 packageName: 'org.wildloop.app',
13001328 output,
1301 config: { compileSdk: 34, enableHealthConnect: true, minSdk: 24 },
1329 config: { compileSdk: 34, enableHealthConnect: true, minSdk: ANDROID_MIN_SDK },
13021330 })
13031331
13041332 const bridge = readFileSync(join(output, 'app/src/main/java/org/wildloop/app/CraftBridge.kt'), 'utf8')
@@ -1489,12 +1517,6 @@ describe('the Zig library and the generated app agree on how old a device may be
14891517
14901518 expect(declared).not.toBeNull()
14911519
1492 // DEFAULT_CONFIG is not exported, so read it the same way: from the source
1493 // that defines it, which is the thing that would have to change.
1494 const generator = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8')
1495 const minSdk = generator.match(/minSdk:\s*(\d+)/)
1496
1497 expect(minSdk).not.toBeNull()
1498 expect(Number(declared![1])).toBeLessThanOrEqual(Number(minSdk![1]))
1520 expect(Number(declared![1])).toBeLessThanOrEqual(ANDROID_MIN_SDK)
14991521 })
15001522})
packages/android/src/index.tsmodified+9-3
Changes to packages/android/src/index.ts
@@ -42,6 +42,9 @@ const KOTLIN_KEYWORDS = new Set([
4242 'while',
4343])
4444
45/** Oldest Android API level supported by the generated Kotlin runtime. */
46export const ANDROID_MIN_SDK = 26
47
4548export interface CraftAndroidConfig {
4649 appName: string
4750 packageName: string
@@ -67,6 +70,7 @@ export interface CraftAndroidConfig {
6770 googleServicesFile?: string
6871 devServerURL?: string
6972 hasBundledFallback?: boolean
73 /** Android API level, at least ANDROID_MIN_SDK (26). */
7074 minSdk?: number
7175 compileSdk?: number
7276 targetSdk?: number
@@ -125,7 +129,7 @@ const DEFAULT_CONFIG: Omit<CraftAndroidConfig, 'appName' | 'packageName'> = {
125129 enableDeepLinks: false,
126130 urlSchemes: [],
127131 trustedOrigins: [],
128 minSdk: 26,
132 minSdk: ANDROID_MIN_SDK,
129133 compileSdk: 36,
130134 targetSdk: 35,
131135}
@@ -258,6 +262,9 @@ function validateAndroidConfig(config: CraftAndroidConfig): void {
258262 }
259263 }
260264
265 if (Number(config.minSdk) < ANDROID_MIN_SDK) {
266 throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`)
267 }
261268 if (Number(config.minSdk) > Number(config.targetSdk)) {
262269 throw new Error('Android minSdk must not exceed targetSdk')
263270 }
@@ -462,7 +469,6 @@ export async function init(options: InitOptions): Promise<void> {
462469 }
463470 if (config.enableBackgroundLocation) config.enableGeolocation = true
464471 if (config.enableHealthConnect) {
465 config.minSdk = Math.max(config.minSdk ?? 26, 26)
466472 config.compileSdk = Math.max(config.compileSdk ?? 36, 36)
467473 }
468474 normalizeAndroidNetworkConfig(config)
@@ -614,7 +620,7 @@ export async function init(options: InitOptions): Promise<void> {
614620 .replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName)
615621 .replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || '1.0.0'))
616622 .replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1))
617 .replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk || 24))
623 .replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK))
618624 .replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36))
619625 .replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35))
620626 .replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : '')