From 4d65affa7cddbb7ad0a3340a4dc36c12b9c13aea Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Mon, 2 Nov 2026 18:32:59 +0300 Subject: [PATCH] refactor(android): allow providers to install prepared app artifacts --- .../contracts/src/app-deployment-runtime.ts | 6 + .../src/deployment/runtime.test.ts | 141 ++++++++++++++++++ .../src/deployment/runtime.ts | 69 +++++---- packages/platform-android/src/mechanics.ts | 9 ++ 4 files changed, 196 insertions(+), 29 deletions(-) diff --git a/packages/contracts/src/app-deployment-runtime.ts b/packages/contracts/src/app-deployment-runtime.ts index d942032207..48d9e0096e 100644 --- a/packages/contracts/src/app-deployment-runtime.ts +++ b/packages/contracts/src/app-deployment-runtime.ts @@ -115,3 +115,9 @@ export type AndroidAppDeploymentExecutor = Readonly<{ options?: Readonly<{ installedOnly?: boolean }>, ): Promise; }>; + +/** Transfers an already materialized app to the selected target. */ +export type AppDeploymentInstaller = ( + artifact: MaterializedAppSource, + signal: AbortSignal, +) => Promise; diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index 7a01d362a1..f64c74f727 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -260,3 +260,144 @@ test('uninstall resolves, removes and invalidates on the device readiness return signal, ); }); + +test.each([false, true])( + 'reinstall removes data before the replacement is materialized with a hosted installer: %s', + async (hosted) => { + const events: string[] = []; + const signal = new AbortController().signal; + const artifact = { + installablePath: '/tmp/new.apk', + cleanup: async () => { + events.push('cleanup'); + }, + }; + const install = vi.fn(async () => { + events.push('install'); + return { packageName: 'com.example.app' }; + }); + native.uninstallAndroidPackage.mockImplementation(async () => { + events.push('uninstall'); + }); + native.installAndroidArtifact.mockImplementation(async () => { + events.push('install'); + return 'com.example.app'; + }); + const host = hostFixture({ + withInvalidatedAppResolutionCache: async (_device, operation) => { + events.push('invalidate-before'); + try { + return await operation(); + } finally { + events.push('invalidate-after'); + } + }, + prepareArtifact: async () => { + events.push('materialize'); + return artifact; + }, + }); + const operations = createAndroidAppDeploymentOperations({ + host, + device, + signal, + ...(hosted ? { install } : {}), + }); + expect( + await operations.deployApp!({ + app: 'com.example.app', + appPath: '/tmp/new.apk', + replaceExisting: true, + }), + ).toEqual({ packageName: 'com.example.app', launchTarget: 'com.example.app' }); + expect(events).toEqual([ + 'invalidate-before', + 'uninstall', + 'materialize', + 'install', + 'cleanup', + 'invalidate-after', + ]); + if (hosted) { + expect(install).toHaveBeenCalledExactlyOnceWith(artifact, signal); + expect(native.installAndroidArtifact).not.toHaveBeenCalled(); + } else { + expect(install).not.toHaveBeenCalled(); + expect(native.installAndroidArtifact).toHaveBeenCalledExactlyOnceWith( + host, + device, + artifact.installablePath, + undefined, + signal, + ); + } + }, +); + +test.each(['path', 'prepared'] as const)( + 'a hosted %s install uses the provider identity and leaves artifact cleanup with its owner', + async (source) => { + const cleanup = vi.fn(async () => {}); + const artifact = { installablePath: '/tmp/app.apk', cleanup }; + const prepareArtifact = vi.fn(async () => artifact); + const install = vi.fn(async () => ({ packageName: 'com.example.hosted' })); + const signal = new AbortController().signal; + const operations = createAndroidAppDeploymentOperations({ + host: hostFixture({ prepareArtifact }), + device, + signal, + install, + }); + + const result = + source === 'path' + ? await operations.deployApp!({ + app: '', + appPath: artifact.installablePath, + replaceExisting: false, + }) + : await operations.deployMaterializedApp!({ artifact }); + + expect(result).toEqual({ + packageName: 'com.example.hosted', + appName: 'hosted', + launchTarget: 'com.example.hosted', + }); + expect(install).toHaveBeenCalledExactlyOnceWith(artifact, signal); + expect(native.installAndroidArtifact).not.toHaveBeenCalled(); + expect(prepareArtifact).toHaveBeenCalledTimes(source === 'path' ? 1 : 0); + expect(cleanup).toHaveBeenCalledTimes(source === 'path' ? 1 : 0); + }, +); + +test.each(['path', 'prepared'] as const)( + 'a hosted %s install preserves provider metadata and falls back to materialized identity', + async (source) => { + const artifact = { + installablePath: '/tmp/app.apk', + packageName: 'com.example.materialized', + appName: 'Materialized app', + cleanup: vi.fn(async () => {}), + }; + const providerResult = { + appName: 'Provider app', + launchTarget: 'com.example.materialized/.MainActivity', + }; + const install = vi.fn(async () => providerResult); + const operations = createAndroidAppDeploymentOperations({ + host: hostFixture({ prepareArtifact: async () => artifact }), + device, + signal: new AbortController().signal, + install, + }); + const result = + source === 'path' + ? await operations.deployApp!({ + app: '', + appPath: artifact.installablePath, + replaceExisting: false, + }) + : await operations.deployMaterializedApp!({ artifact }); + expect(result).toEqual({ ...providerResult, packageName: artifact.packageName }); + }, +); diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index 877dccc1f4..7c4bdd10fe 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -1,5 +1,6 @@ import type { AppDeploymentInput, + AppDeploymentInstaller, AppDeploymentResult, AppDeploymentRuntimeOperations, AppUninstallInput, @@ -45,17 +46,19 @@ export function createAndroidAppDeploymentOperations(params: { host: PlatformRuntimeHost; device: DeviceInfo; signal: AbortSignal; + install?: AppDeploymentInstaller; }): Partial { const { host, device, signal } = params; + const install = params.install; const facts = androidAppDeploymentFacts(device); if (!facts.deployApp.available) return Object.freeze({}); return Object.freeze({ deployApp: async (input: AppDeploymentInput) => - await deployAndroidApp(host, device, input, signal), + await deployAndroidApp(host, device, input, signal, install), materializeAppSource: async (input: MaterializeAppSourceInput) => await host.androidDeployment.prepareArtifact(input, { signal }), deployMaterializedApp: async (input: DeployMaterializedAppInput) => - await deployPreparedAndroidApp(host, device, input, signal), + await deployPreparedAndroidApp(host, device, input, signal, install), sendPushNotification: async (input: PushNotificationInput) => await pushAndroidNotification(host, device, input, signal), uninstallApp: async (input: AppUninstallInput) => @@ -96,6 +99,7 @@ async function deployAndroidApp( device: DeviceInfo, input: AppDeploymentInput, signal: AbortSignal, + install?: AppDeploymentInstaller, ): Promise { if (input.replaceExisting) { // Reinstall resolves its fuzzy target during uninstall. Keep the old whole-operation @@ -113,7 +117,9 @@ async function deployAndroidApp( { source: { kind: 'path', path: input.appPath } }, { resolveIdentity: false, signal }, ); - await installAndroidArtifact(host, device, artifact.installablePath, undefined, signal); + if (install) await install(artifact, signal); + else + await installAndroidArtifact(host, device, artifact.installablePath, undefined, signal); return { packageName, launchTarget: packageName }; } finally { await artifact?.cleanup(); @@ -133,22 +139,8 @@ async function deployAndroidApp( { source: { kind: 'path', path: input.appPath } }, { resolveIdentity: true, signal }, ); - const packageName = await installAndroidArtifact( - host, - device, - artifact.installablePath, - artifact.packageName, - signal, - ); - // Plain install has historically succeeded even when the device cannot report the - // installed package identity. Source installation is stricter because its response - // contract promises an identity after materialization. - if (!packageName) return {}; - return { - packageName, - appName: inferAndroidAppName(packageName), - launchTarget: packageName, - }; + // Plain install permits an unknown identity; source install promises one. + return await installAndroidApp(host, device, artifact, signal, install); } finally { await artifact?.cleanup(); } @@ -159,24 +151,43 @@ async function deployPreparedAndroidApp( device: DeviceInfo, input: DeployMaterializedAppInput, signal: AbortSignal, + install?: AppDeploymentInstaller, ): Promise { - const packageName = await installAndroidArtifact( - host, - device, - input.artifact.installablePath, - input.artifact.packageName, - signal, - ); - if (!packageName) { + const result = await installAndroidApp(host, device, input.artifact, signal, install); + if (!result.packageName) { throw new AppError( 'COMMAND_FAILED', 'Installed Android app identity could not be resolved from the artifact or device state', ); } + return result; +} + +async function installAndroidApp( + host: PlatformRuntimeHost, + device: DeviceInfo, + artifact: MaterializedAppSource, + signal: AbortSignal, + install?: AppDeploymentInstaller, +): Promise { + const result = install + ? await install(artifact, signal) + : { + packageName: await installAndroidArtifact( + host, + device, + artifact.installablePath, + artifact.packageName, + signal, + ), + }; + const packageName = result.packageName ?? artifact.packageName; + if (!packageName) return result; return { + ...result, packageName, - appName: inferAndroidAppName(packageName), - launchTarget: packageName, + appName: result.appName ?? artifact.appName ?? inferAndroidAppName(packageName), + launchTarget: result.launchTarget ?? packageName, }; } diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 03d7c7f2dc..40d6cba9d4 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -372,3 +372,12 @@ export { type AndroidBlockingDialogObservation, type AndroidWindowDumpReader, } from './window-state.ts'; + +export async function createAndroidAppDeploymentOperations( + params: Parameters< + typeof import('./deployment/runtime.ts').createAndroidAppDeploymentOperations + >[0], +) { + const { createAndroidAppDeploymentOperations: bind } = await import('./deployment/runtime.ts'); + return bind(params); +}