Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/contracts/src/app-deployment-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,9 @@ export type AndroidAppDeploymentExecutor = Readonly<{
options?: Readonly<{ installedOnly?: boolean }>,
): Promise<string>;
}>;

/** Transfers an already materialized app to the selected target. */
export type AppDeploymentInstaller = (
artifact: MaterializedAppSource,
signal: AbortSignal,
) => Promise<AppDeploymentResult>;
141 changes: 141 additions & 0 deletions packages/platform-android/src/deployment/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
);
69 changes: 40 additions & 29 deletions packages/platform-android/src/deployment/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
AppDeploymentInput,
AppDeploymentInstaller,
AppDeploymentResult,
AppDeploymentRuntimeOperations,
AppUninstallInput,
Expand Down Expand Up @@ -45,17 +46,19 @@ export function createAndroidAppDeploymentOperations(params: {
host: PlatformRuntimeHost;
device: DeviceInfo;
signal: AbortSignal;
install?: AppDeploymentInstaller;
}): Partial<AppDeploymentRuntimeOperations> {
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) =>
Expand Down Expand Up @@ -96,6 +99,7 @@ async function deployAndroidApp(
device: DeviceInfo,
input: AppDeploymentInput,
signal: AbortSignal,
install?: AppDeploymentInstaller,
): Promise<AppDeploymentResult> {
if (input.replaceExisting) {
// Reinstall resolves its fuzzy target during uninstall. Keep the old whole-operation
Expand All @@ -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();
Expand All @@ -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();
}
Expand All @@ -159,24 +151,43 @@ async function deployPreparedAndroidApp(
device: DeviceInfo,
input: DeployMaterializedAppInput,
signal: AbortSignal,
install?: AppDeploymentInstaller,
): Promise<AppDeploymentResult> {
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<AppDeploymentResult> {
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,
};
}

Expand Down
9 changes: 9 additions & 0 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading