also looking at this
fix(native): preserve workspace sources and native overlay lifecycle
#384
20 files
+500
-227
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -448,6 +448,9 @@ The generated runtime supports Android 8.0 / API 26 and newer, exported as | ||
| 448 | 448 | `ANDROID_MIN_SDK`. `minSdk` defaults to that floor; lower values are rejected |
| 449 | 449 | before project files are written, including when Health Connect is enabled. |
| 450 | 450 | Health Connect normalizes `compileSdk` to at least 36. |
| 451 | Opting into Health Services selects `minSdk: 30` (Android 11) when omitted, | |
| 452 | matching its AndroidX library requirement. Explicit minimums below 30 are | |
| 453 | rejected before writing the project; higher supported minimums are retained. | |
| 451 | 454 | The same effective `minSdk` is written to Gradle and both configuration files. |
| 452 | 455 | A custom `appIconPath` must be a regular PNG, WebP, JPG, or GIF file; its supported |
| 453 | 456 | extension is preserved in the generated drawable resource. |
| @@ -863,6 +863,36 @@ describe('Craft Android builder', () => { | ||
| 863 | 863 | finally { rmSync(root, { recursive: true, force: true }) } |
| 864 | 864 | }) |
| 865 | 865 | |
| 866 | it('selects Health Services API 30 by default and retains higher explicit minimums', async () => { | |
| 867 | const root = createTempDir('craft-android-health-services-sdk-') | |
| 868 | try { | |
| 869 | for (const renderer of ['native', 'web'] as const) { | |
| 870 | for (const minSdk of [undefined, 30, 33]) { | |
| 871 | const output = join(root, renderer, String(minSdk)) | |
| 872 | await init({ name: 'HealthServicesDevice', output, runtimeDir: null, config: { renderer, enableHealthServices: true, minSdk } }) | |
| 873 | const expected = minSdk ?? 30 | |
| 874 | expect(JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')).minSdk).toBe(expected) | |
| 875 | expect(JSON.parse(readFileSync(join(output, 'app/src/main/assets/craft.config.json'), 'utf8')).minSdk).toBe(expected) | |
| 876 | expect(readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')).toContain(`minSdk = ${expected}`) | |
| 877 | } | |
| 878 | } | |
| 879 | } | |
| 880 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 881 | }) | |
| 882 | ||
| 883 | it('rejects explicitly incompatible Health Services minimums before writing a project', async () => { | |
| 884 | const root = createTempDir('craft-android-health-services-sdk-rejected-') | |
| 885 | try { | |
| 886 | for (const minSdk of [26, 29]) { | |
| 887 | const output = join(root, String(minSdk)) | |
| 888 | await expect(init({ name: 'UnsupportedHealthServicesDevice', output, runtimeDir: null, config: { enableHealthServices: true, minSdk } })) | |
| 889 | .rejects.toThrow('Android Health Services requires minSdk at least 30') | |
| 890 | expect(existsSync(output)).toBe(false) | |
| 891 | } | |
| 892 | } | |
| 893 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 894 | }) | |
| 895 | ||
| 866 | 896 | it('routes every Kotlin template through the project generator', () => { |
| 867 | 897 | const templates = readdirSync(join(import.meta.dir, '../templates')) |
| 868 | 898 | .filter(name => name.endsWith('.kt.template')) |
| @@ -127,6 +127,7 @@ const KOTLIN_KEYWORDS = new Set([ | ||
| 127 | 127 | |
| 128 | 128 | /** Oldest Android API level supported by the generated Kotlin runtime. */ |
| 129 | 129 | export const ANDROID_MIN_SDK = 26 |
| 130 | const ANDROID_HEALTH_SERVICES_MIN_SDK = 30 | |
| 130 | 131 | |
| 131 | 132 | export interface CraftAndroidConfig { |
| 132 | 133 | /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */ |
| @@ -162,7 +163,7 @@ export interface CraftAndroidConfig { | ||
| 162 | 163 | */ |
| 163 | 164 | enableHeartRate?: boolean |
| 164 | 165 | enableHealthConnect?: boolean |
| 165 | /** Android Health Services live exercise tracking for native screens. */ | |
| 166 | /** Android Health Services live exercise tracking for native screens. Requires minSdk 30. */ | |
| 166 | 167 | enableHealthServices?: boolean |
| 167 | 168 | /** Google Wallet save-link handoff for native screens. */ |
| 168 | 169 | enableWallet?: boolean |
| @@ -211,7 +212,7 @@ export interface CraftAndroidConfig { | ||
| 211 | 212 | mapsApiKey?: string |
| 212 | 213 | devServerURL?: string |
| 213 | 214 | hasBundledFallback?: boolean |
| 214 | /** Android API level, at least ANDROID_MIN_SDK (26). */ | |
| 215 | /** Android API level, at least ANDROID_MIN_SDK (26), or 30 with Health Services. */ | |
| 215 | 216 | minSdk?: number |
| 216 | 217 | compileSdk?: number |
| 217 | 218 | targetSdk?: number |
| @@ -550,6 +551,9 @@ function validateAndroidConfig(config: CraftAndroidConfig): void { | ||
| 550 | 551 | if (Number(config.minSdk) < ANDROID_MIN_SDK) { |
| 551 | 552 | throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`) |
| 552 | 553 | } |
| 554 | if (config.enableHealthServices && Number(config.minSdk) < ANDROID_HEALTH_SERVICES_MIN_SDK) { | |
| 555 | throw new Error(`Android Health Services requires minSdk at least ${ANDROID_HEALTH_SERVICES_MIN_SDK} (Android 11)`) | |
| 556 | } | |
| 553 | 557 | if (Number(config.minSdk) > Number(config.targetSdk)) { |
| 554 | 558 | throw new Error('Android minSdk must not exceed targetSdk') |
| 555 | 559 | } |
| @@ -959,6 +963,7 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 959 | 963 | appName: name, |
| 960 | 964 | packageName: finalPackageName, |
| 961 | 965 | } |
| 966 | if (config.enableHealthServices && given.minSdk === undefined) config.minSdk = ANDROID_HEALTH_SERVICES_MIN_SDK | |
| 962 | 967 | if (config.enableBackgroundLocation) config.enableGeolocation = true |
| 963 | 968 | if (config.enableHealthConnect) { |
| 964 | 969 | config.compileSdk = Math.max(config.compileSdk ?? 36, 36) |
| @@ -186,6 +186,21 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca | ||
| 186 | 186 | }) |
| 187 | 187 | |
| 188 | 188 | describe('the native app shell in a generated project', () => { |
| 189 | it('declares one location permission when live health and Bluetooth both need it', () => { | |
| 190 | const permissions = renderAndroidPermissions({ | |
| 191 | appName: 'Live health', packageName: 'dev.craft.livehealth', renderer: 'native', | |
| 192 | enableHealthServices: true, enableHeartRate: true, | |
| 193 | }) | |
| 194 | expect(permissions.match(/android\.permission\.ACCESS_FINE_LOCATION/g)).toHaveLength(1) | |
| 195 | expect(permissions).toContain('<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />') | |
| 196 | expect(permissions).not.toContain('ACCESS_FINE_LOCATION" android:maxSdkVersion') | |
| 197 | const bluetooth = renderAndroidPermissions({ | |
| 198 | appName: 'Bluetooth', packageName: 'dev.craft.bluetooth', renderer: 'native', enableHeartRate: true, | |
| 199 | }) | |
| 200 | expect(bluetooth.match(/android\.permission\.ACCESS_FINE_LOCATION/g)).toHaveLength(1) | |
| 201 | expect(bluetooth).toContain('ACCESS_FINE_LOCATION" android:maxSdkVersion="30"') | |
| 202 | }) | |
| 203 | ||
| 189 | 204 | it('declares only the Health Connect permissions the app reads and writes', () => { |
| 190 | 205 | const permissions = healthConnectPermissions({ healthConnect: { read: ['sleep', 'workouts'], write: ['mindful'], background: true } }) |
| 191 | 206 | expect(permissions).toContain('android.permission.health.READ_SLEEP') |
| @@ -40,6 +40,10 @@ internal class CraftHealthServicesService : Service() { | ||
| 40 | 40 | createNotificationChannel() |
| 41 | 41 | exerciseClient = HealthServices.getClient(this).exerciseClient |
| 42 | 42 | callback = object : ExerciseUpdateCallback { |
| 43 | override fun onRegistered() = Unit | |
| 44 | override fun onRegistrationFailed(throwable: Throwable) { | |
| 45 | emitError(throwable.message ?: "Health Services callback registration failed") | |
| 46 | } | |
| 43 | 47 | override fun onExerciseUpdateReceived(update: ExerciseUpdate) = emit(update) |
| 44 | 48 | override fun onAvailabilityChanged(dataType: DataType<*, *>, availability: Availability) { |
| 45 | 49 | emitError("${dataType.name} is unavailable ($availability)") |