Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Manage Emulators automatically #55

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ The following example collects the traffic for an app in the Android emulator. I

zner0L marked this conversation as resolved.
Show resolved Hide resolved
zner0L marked this conversation as resolved.
Show resolved Hide resolved
targetOptions: {
snapshotName: '<snapshot name>',
startEmulatorOptions: {
emulatorName: '<emulator name>',
},
emulatorName: '<emulator name>',
},
});

Expand Down
65 changes: 29 additions & 36 deletions docs/README.md

Large diffs are not rendered by default.

68 changes: 43 additions & 25 deletions examples/multiple-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,31 @@ import { pause, startAnalysis } from '../src/index';
// `npx tsx examples/multiple-apps.ts <emulator name> <snapshot name> <path to a folder of single APKs>`

(async () => {
const emulatorName = process.argv[2] || 'emulator-name';
const snapshotName = process.argv[3] || 'snapshot-with-setup-emu';
const apkFolder = process.argv[4] || 'path/to/app-files';
const apkFolder = process.argv[2];
const emulatorName = process.argv[3];
const snapshotName = process.argv[4];
zner0L marked this conversation as resolved.
Show resolved Hide resolved

if (!apkFolder) throw Error('Please provide a folder of APKs as the first argument.');

const analysis = await startAnalysis({
platform: 'android',
runTarget: 'emulator',
capabilities: ['frida', 'certificate-pinning-bypass'],
targetOptions: {
snapshotName,
startEmulatorOptions: {
emulatorName,
},
},
targetOptions:
emulatorName && snapshotName
? {
snapshotName,
emulatorName,
}
: {
createEmulator: {
infix: 'test',
variant: 'google_apis',
architecture: 'x86_64',
attemptRebuilds: 0,
apiLevel: 33,
},
},
});

await analysis.ensureDevice();
Expand All @@ -29,28 +40,35 @@ import { pause, startAnalysis } from '../src/index';
// so you can easily loop through an array of apps.
const apks = await readdir(apkFolder);
for (const apkFile of apks) {
const appAnalysis = await analysis.startAppAnalysis(path.join(apkFolder, apkFile) as `${string}.apk`);
try {
await analysis.ensureDevice();
zner0L marked this conversation as resolved.
Show resolved Hide resolved

const appAnalysis = await analysis.startAppAnalysis(path.join(apkFolder, apkFile) as `${string}.apk`);

await analysis.resetDevice();
// await analysis.ensureTrackingDomainResolution();
await analysis.resetDevice();
// await analysis.ensureTrackingDomainResolution();

await appAnalysis.installApp();
await appAnalysis.setAppPermissions();
await appAnalysis.startTrafficCollection();
await appAnalysis.startApp();
await appAnalysis.installApp();
await appAnalysis.setAppPermissions();
await appAnalysis.startTrafficCollection();
await appAnalysis.startApp();

// Pause to wait for the app to generate network traffic.
await pause(6_000);
// Pause to wait for the app to generate network traffic.
await pause(6_000);

await appAnalysis.stopTrafficCollection();
await appAnalysis.stopTrafficCollection();

const result = await appAnalysis.stop();
const result = await appAnalysis.stop();

console.dir(result, { depth: null });
// {
// app: { id: '<app id>', name: '<app name>', version: '<app version>', ... },
// traffic: { '2023-03-27T10:29:44.197Z': { log: ... } } <- The traffic collections are named by a timestamp and contain the collected requests in the HAR format.
// }
console.dir(result, { depth: null });
// {
// app: { id: '<app id>', name: '<app name>', version: '<app version>', ... },
// traffic: { '2023-03-27T10:29:44.197Z': { log: ... } } <- The traffic collections are named by a timestamp and contain the collected requests in the HAR format.
// }
} catch (error: any) {
// Handle the error here, e.g. queue the app for analysis again etc.
if (error.name === 'EmulatorError') console.error(error.message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling EmulatorError isn't enough. Here's what I got when closing the emulator during an analysis (having added an else throw error;):

file:///home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/node_modules/andromatic/node_modules/execa/lib/error.js:59
		error = new Error(message);
		        ^

Error: Command failed with exit code 1: /home/benni/.cache/andromatic/platform-tools/adb root
adb: unable to connect for root: device offline
    at makeError (file:///home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/node_modules/andromatic/node_modules/execa/lib/error.js:59:11)
    at handlePromise (file:///home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/node_modules/andromatic/node_modules/execa/index.js:124:26)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at Object.requireRoot (/home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/src/android.ts:299:17)
    at Object.overlayTmpfs (/home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/src/android.ts:339:38)
    at Object.removeCertificateAuthority (/home/benni/coding/JS/tweasel/cyanoacrylate/node_modules/appstraction/src/android.ts:841:9)
    at <anonymous> (/home/benni/coding/JS/tweasel/cyanoacrylate/src/index.ts:652:17)
    at async Promise.all (index 0)
    at stopTrafficCollection (/home/benni/coding/JS/tweasel/cyanoacrylate/src/index.ts:634:23)
    at Object.stopTrafficCollection (/home/benni/coding/JS/tweasel/cyanoacrylate/src/index.ts:863:33) {
  shortMessage: 'Command failed with exit code 1: /home/benni/.cache/andromatic/platform-tools/adb root',
  command: '/home/benni/.cache/andromatic/platform-tools/adb root',
  escapedCommand: '"/home/benni/.cache/andromatic/platform-tools/adb" root',
  exitCode: 1,
  signal: undefined,
  signalDescription: undefined,
  stdout: '',
  stderr: 'adb: unable to connect for root: device offline',
  failed: true,
  timedOut: false,
  isCanceled: false,
  killed: false
}

}
}

await analysis.stop();
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@
"prettier": "@baltpeter/prettier-config",
"dependencies": {
"@types/har-format": "^1.2.10",
"andromatic": "^1.1.1",
"appstraction": "1.3.1",
"andromatic": "^1.1.2",
"appstraction": "file:.yalc/appstraction",
"autopy": "^1.1.1",
"cross-fetch": "^3.1.5",
"ctrlc-windows": "^2.1.0",
"execa": "^7.0.0",
"execa": "7.1.1",
"global-cache-dir": "^4.4.0",
"js-ini": "^1.6.0",
"p-timeout": "^6.1.1",
Expand All @@ -83,6 +83,7 @@
"lint-staged": "13.1.1",
"parcel": "2.8.3",
"prettier": "2.8.4",
"type-fest": "^4.27.0",
"typedoc": "0.23.25",
"typedoc-plugin-markdown": "3.14.0",
"typescript": "4.9.5"
Expand Down
245 changes: 245 additions & 0 deletions src/emulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recovery from broken snapshots doesn't seem to be handled. I deleted the snapshot (by deleting the folder in ~/.android/avd/cyanoacrylate-examples-multiple-apps-9a2f000670072430e8fe9f968a2a1d15.avd/snapshots) while an analysis was running. The analysis for the current app finished without issues, but every subsequent app failed:

Analyzing com.airbnb.android-28003318.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: different renderer configured.
Analyzing com.apprope.wordsearch-192.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: different renderer configured.
Analyzing com.booking-24681.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: different renderer configured.
Analyzing com.digibites.accubattery-201004.apk…

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After restarting the analysis program, it did recover.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue when corrupting the emulator itself by deleting userdata-qemu.img:

Analyzing com.airbnb.android-28003318.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.apprope.wordsearch-192.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.booking-24681.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.digibites.accubattery-201004.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.easybrain.block.puzzle.games-22101001.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.flightradar24free-91902797.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.gsoftteam.connectbubbles-216313.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.hbwares.wordfeud.free-4242.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.kiloo.subwaysurf-69038.apk…
Ensuring device…
(node:2294899) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]. Use events.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.monefy.app.lite-2207.apk…
Ensuring device…
Failed to load snapshot: KO: Error -1 from the snapshot callback
KO: Snapshot load failure: system image changed.
Analyzing com.motionvolt.flipdiving-10103660.apk…
Ensuring device…
^C%                                                                                                                                              

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one didn't even recover after restarting the analysis program:

❯ tsx examples/multiple-apps.ts ~/Downloads/test-complaints/
Analyzing be.stib.mivb.mobile-203137.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.airbnb.android-28003318.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.apprope.wordsearch-192.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.booking-24681.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.digibites.accubattery-201004.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.easybrain.block.puzzle.games-22101001.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.flightradar24free-91902797.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.gsoftteam.connectbubbles-216313.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.hbwares.wordfeud.free-4242.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.kiloo.subwaysurf-69038.apk…
Ensuring device…
(node:2327458) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]. Use events.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.monefy.app.lite-2207.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.motionvolt.flipdiving-10103660.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.sparklingsociety.cityisland5-85898.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.viber.voip-651257.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing com.yopeso.lieferando-1610000497.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing de.mobileconcepts.cyberghost-2925.apk…
Ensuring device…
Failed to load snapshot: KO: Device 'cache' does not have the requested snapshot 'cyanoacrylate-ensured'
KO: Snapshot load failure: snapshot doesn't exist.
Analyzing har…
Ensuring device…
^C

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snapshot does exist though, despite the error message:

❯ tree /home/benni/.android/avd/cyanoacrylate-examples-multiple-apps-9a2f000670072430e8fe9f968a2a1d15.avd/snapshots
/home/benni/.android/avd/cyanoacrylate-examples-multiple-apps-9a2f000670072430e8fe9f968a2a1d15.avd/snapshots
├── cyanoacrylate-ensured
│   ├── hardware.ini
│   ├── ram.bin
│   ├── screenshot.png
│   ├── snapshot.pb
│   └── textures.bin
└── default_boot
    ├── ram.img
    └── snapshot.pb

3 directories, 7 files

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, figures. If adb realizes that the snapshot is broken before it sends it off to the emu, it doesn’t register an error.

createEmulator,
ensureSdkmanager,
getAndroidDevToolPath,
runAndroidDevTool,
type EmulatorOptions,
} from 'andromatic';
import { createHash } from 'crypto';
import { execa, type ExecaChildProcess, type ExecaError } from 'execa';
import type { AndroidEmulatorRunTargetOptions, SupportedCapability, SupportedPlatform } from '.';
import { killProcess } from './util';

/** Uses `avdmanager` to list currently existing emulators. */
export const listEmulators = async () => {
// -c makes avdmanager return just a list of names separated by newlines.
zner0L marked this conversation as resolved.
Show resolved Hide resolved
const { stdout } = await runAndroidDevTool('avdmanager', ['list', 'avd', '-c']);
return stdout.split('\n');
};

export const listSnapshots = async (): Promise<{ [name: string]: string[] }> => {
// This returns a list of snapshots of all devices
const { stdout } = await runAndroidDevTool('emulator', ['-snapshot-list']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that need a specific emu version?

❯ emulator -snapshot-list
INFO    | Android emulator version 32.1.12.0 (build_id 9751036) (CL:N/A)
ERROR   | No AVD specified. Use '@foo' or '-avd foo' to launch a virtual device named 'foo'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, at least for your version, this should be available, according to the documentation close to the release date. I am using 35.2.10.0, though. Have you set the correct ANDROID_HOME?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snippet above was using my system emu (with correct ANDROID_HOME). Doesn't work with the andromatic one, either:

export ANDROID_HOME=~/.cache/andromatic/
❯ ~/.cache/andromatic/emulator/emulator -snapshot-list
INFO    | Android emulator version 33.1.24.0 (build_id 11237101) (CL:N/A)
ERROR   | No AVD specified. Use '@foo' or '-avd foo' to launch a virtual device named 'foo'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the docs are wrong then. Just tested on my Macbook where I have version 32 as well and got the same error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specifying an AVD name as in the docs you linked starts the emu and doesn't list the snapshots:

❯ ~/.cache/andromatic/emulator/emulator @mitm11-honey -snapshot-list
INFO    | Android emulator version 33.1.24.0 (build_id 11237101) (CL:N/A)
INFO    | Found systemPath /home/benni/.cache/andromatic//system-images/android-30/google_apis/x86_64/
INFO    | Storing crashdata in: , detection is enabled for process: 1494826
INFO    | Duplicate loglines will be removed, if you wish to see each individual line launch with the -log-nofilter flag.
WARNING | Please update the emulator to one that supports the feature(s): Vulkan
INFO    | Increasing RAM size to 2048MB
WARNING | FeatureControl is requesting a non existing feature.
Fontconfig warning: "/usr/share/fontconfig/conf.avail/05-reset-dirs-sample.conf", line 6: unknown element "reset-dirs"
library_mode host gpu mode host
I1125 13:52:53.193065 1494826 HealthMonitor.cpp:279] HealthMonitor disabled.
cannot add library libvulkan.so: failed
added library libvulkan.so.1
createGlobalVkEmulation:963 Selecting Vulkan device: NVIDIA GeForce RTX 2060
initialize: Supports id properties, got a vulkan device UUID
I1125 13:52:53.423908 1494826 VkCommonOperations.cpp:1225] Initializing VkEmulation features:
I1125 13:52:53.423929 1494826 VkCommonOperations.cpp:1226]     glInteropSupported: true
I1125 13:52:53.423936 1494826 VkCommonOperations.cpp:1227]     useDeferredCommands: true
I1125 13:52:53.423944 1494826 VkCommonOperations.cpp:1229]     createResourceWithRequirements: true
I1125 13:52:53.423952 1494826 VkCommonOperations.cpp:1230]     useVulkanComposition: false
I1125 13:52:53.423958 1494826 VkCommonOperations.cpp:1231]     useVulkanNativeSwapchain: false
I1125 13:52:53.423964 1494826 VkCommonOperations.cpp:1232]     enable guestRenderDoc: false
I1125 13:52:53.423970 1494826 VkCommonOperations.cpp:1233]     ASTC LDR emulation mode: 2
I1125 13:52:53.423977 1494826 VkCommonOperations.cpp:1234]     enable ETC2 emulation: true
I1125 13:52:53.423982 1494826 VkCommonOperations.cpp:1235]     enable Ycbcr emulation: false
I1125 13:52:53.423988 1494826 VkCommonOperations.cpp:1236]     guestUsesAngle: false
I1125 13:52:53.423995 1494826 VkCommonOperations.cpp:1237]     useDedicatedAllocations: false
I1125 13:52:53.425509 1494826 FrameBuffer.cpp:486] Graphics Adapter Vendor Google (NVIDIA Corporation)
I1125 13:52:53.425517 1494826 FrameBuffer.cpp:487] Graphics Adapter Android Emulator OpenGL ES Translator (NVIDIA GeForce RTX 2060/PCIe/SSE2)
I1125 13:52:53.425523 1494826 FrameBuffer.cpp:488] Graphics API Version OpenGL ES 3.0 (4.5.0 NVIDIA 535.183.01)
I1125 13:52:53.425541 1494826 FrameBuffer.cpp:489] Graphics API Extensions GL_OES_EGL_sync GL_OES_EGL_image GL_OES_EGL_image_external GL_OES_depth24 GL_OES_depth32 GL_OES_element_index_uint GL_OES_texture_float GL_OES_texture_float_linear GL_OES_compressed_paletted_texture GL_OES_compressed_ETC1_RGB8_texture GL_OES_depth_texture GL_OES_texture_half_float GL_OES_texture_half_float_linear GL_OES_packed_depth_stencil GL_OES_vertex_half_float GL_OES_texture_npot GL_OES_rgb8_rgba8 GL_EXT_color_buffer_float GL_EXT_color_buffer_half_float GL_EXT_texture_format_BGRA8888 GL_APPLE_texture_format_BGRA8888 GL_EXT_texture_buffer 
I1125 13:52:53.425551 1494826 FrameBuffer.cpp:490] Graphics Device Extensions N/A
WARNING | The emulator now requires a signed jwt token for gRPC access! Use the -grpc flag if you really want an open unprotected grpc port
INFO    | Using security allow list from: /home/benni/.cache/andromatic/emulator/lib/emulator_access.json
WARNING | *** Basic token auth should only be used by android-studio ***
INFO    | The active JSON Web Key Sets can be found here: /run/user/1000/avd/running/1494826/jwks/d41f381f-cacb-409b-9689-b47fcd8dfa97/active.jwk
INFO    | Scanning /run/user/1000/avd/running/1494826/jwks/d41f381f-cacb-409b-9689-b47fcd8dfa97 for jwk keys.
INFO    | Started GRPC server at 127.0.0.1:8554, security: Local, auth: +token
INFO    | Advertising in: /run/user/1000/avd/running/pid_1494826.ini
List of snapshots present on all disks:
ID        TAG                 VM SIZE                DATE       VM CLOCK
--        default_boot           1.1M 2024-11-25 13:52:40   00:00:04.588
I1125 13:52:53.648558 1494867 FrameBuffer.cpp:3682] setDisplayConfigs w 1080 h 1920 dpiX 420 dpiY 420
I1125 13:52:53.648601 1494867 FrameBuffer.cpp:3695] setDisplayActiveConfig 0
INFO    | Loading snapshot 'default_boot'...
INFO    | Successfully loaded snapshot 'default_boot'
INFO    | Info: Sandboxing disabled by user. ((null):0, (null))
INFO    | Wait for emulator (pid 1494826) 20 seconds to shutdown gracefully before kill;you can set environment variable ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL(in seconds) to change the default value (20 seconds)
[1494826:1495037:1125/135257.138795:ERROR:nss_util.cc(748)] After loading Root Certs, loaded==false: NSS error code: -8018
INFO    | Critical: Uncaught SyntaxError: Unexpected token . (https://maps.googleapis.com/maps/api/js?libraries=places&key=AIzaSyABrfflV49aFWF4Kohsf1VSx68td8f-sFY&callback=initMap&v=3.53:30, (null))
INFO    | Saving with gfxstream=1
INFO    | Critical: Uncaught SyntaxError: Unexpected token . (https://maps.googleapis.com/maps/api/js?libraries=places&key=AIzaSyABrfflV49aFWF4Kohsf1VSx68td8f-sFY&callback=initMap&v=3.53:30, (null))
ERROR   | stop: Not implemented

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s hard to tell, there is a -no-snapstorage options, but I don’t care to test this. I guess I’ll just use the folders as a fallback?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But again, there is no way to pass to pass any of these options to CA anyway, is there?

Copy link
Contributor Author

@zner0L zner0L Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, no. We also don’t use this function right now, so maybe I shouldn’t care really? As I said in the comment, I don’t know if I really want to introduce the added complexity of snapshot management anyway.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said in the comment, I don’t know if I really want to introduce the added complexity of snapshot management anyway.

But you included most of that already, how much more is there to do? Also, we decided to move the honey data automation from cli to CA.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still needs to be addressed.

return stdout.split('\n').reduce((acc, line) => {
const [emulatorName, snapshots] = line.replaceAll(/ |\t/g, '').split(':');
if (emulatorName && snapshots) acc = { [emulatorName]: snapshots?.split(',').filter((s) => s !== ''), ...acc };
return acc;
}, {});
};

export const snapshotHasCapabilties = <Platform extends SupportedPlatform>(
snapshotName: string,
capabilities: SupportedCapability<Platform>[]
) => {
const snapshotRegex = /^cyanoacrylate-ensured-(.*)$/;
const capabilityString = snapshotName.match(snapshotRegex)?.[1];
return capabilityString && capabilityString === capabilities.sort().join('_');
};

export const deleteSnapshot = (snapshotName: string) =>
runAndroidDevTool('adb', ['emu', 'avd', 'snapshot', 'delete', snapshotName]);

export class AndroidEmulator {
private _abortController: AbortController;
private _proc: ExecaChildProcess<string> | undefined;
/** The name of the emulator used by the android tools such as `avdmanager` */
name: string | undefined;
/** List of arguments the `emulator` command should be started with. */
startArgs: string[] = ['-no-boot-anim'];
/**
* The name of the snapshot this emulator should be reset to, if needed. If the emulator was created by the library,
* this will be `cyanoacrylate-ensured`.
*/
resetSnapshotName: string | undefined;
/**
* Number of runs in which the emulator encountered an error and crashed du to it. This is reset if you rebuild the
zner0L marked this conversation as resolved.
Show resolved Hide resolved
* emulator.
*/
failedStarts = 0;
/** The error which produced the most recent crash. */
lastError: ExecaError | undefined;
/** Whether the emulator is managed by cyanoacrylate completely. */
createdByLib = false;
/**
* The options for `createEmulator()` used to create this emulator. Will be `undefined` if `createdByLib` is
* `false`.
*/
zner0L marked this conversation as resolved.
Show resolved Hide resolved
createOptions: EmulatorOptions | undefined;
/** How many times this emulator was deleted and newly created in the current session. */
rebuilds = 0;
/** This is true, if the emulator process stopped. The reverse is not necessarily the case. */
hasExited = false;

constructor() {
zner0L marked this conversation as resolved.
Show resolved Hide resolved
this._abortController = new AbortController();
}

/** Construct an instance of Emulator from the `runTarget` passed to `startAnalysis()`. */
static async fromRunTarget(emulatorRunTargetOptions: AndroidEmulatorRunTargetOptions): Promise<AndroidEmulator> {
const emulator = new AndroidEmulator();

if (emulatorRunTargetOptions.createEmulator) {
const { infix, ...emulatorOptions } = emulatorRunTargetOptions.createEmulator;
emulator.createOptions = emulatorOptions;

const optionsHash = createHash('md5').update(JSON.stringify(emulatorOptions)).digest('hex');

emulator.name = `cyanoacrylate-${infix}-${optionsHash}`;

const emuNameRegex = new RegExp(`cyanoacrylate-${infix}-(.*)`);

const emulatorList = await listEmulators();
const existingEmulators = (
await Promise.all(
emulatorList
.map((name) => ({ name, hash: name.match(emuNameRegex)?.[1] }))
.filter((emu) => !!emu.hash)
.map(async (emu) => {
if (emu.hash === optionsHash) return emu.name;
await deleteEmulator(emu.name);
zner0L marked this conversation as resolved.
Show resolved Hide resolved
return undefined;
})
)
).filter((emuName) => !!emuName);

if (existingEmulators.length > 1) throw Error('Emulator name is not unique. This should never happen.');
else if (existingEmulators.length === 1 && existingEmulators[0]) {
// TODO: We skip this for now, until we implement proper management of snapshot states, so that the snapshot has the correct capabilities.
// The current emulator exists already in the correct config, lets check for a snapshot.
// const snapshotList = (await listSnapshots())[existingEmulators[0]];
// emulator.resetSnapshotName = snapshotList?.find((s) => s.startsWith('cyanoacrylate-ensured'));
} else await createEmulator(emulator.name, emulatorOptions);

emulator.createdByLib = true;
} else if (emulatorRunTargetOptions.emulatorName) {
emulator.name = emulatorRunTargetOptions.emulatorName;
emulator.resetSnapshotName = emulatorRunTargetOptions.snapshotName;
} else throw new Error('Could not start emulator: No emulator config or name.');

const startEmulatorOptions = emulatorRunTargetOptions.startEmulatorOptions;
if (startEmulatorOptions?.headless === true) emulator.startArgs.push('-no-window');
if (startEmulatorOptions?.audio !== true) emulator.startArgs.push('-no-audio');
if (startEmulatorOptions?.ephemeral !== false) emulator.startArgs.push('-no-snapshot-save');
if (startEmulatorOptions?.hardwareAcceleration?.mode)
emulator.startArgs.push('-accel', startEmulatorOptions?.hardwareAcceleration?.mode);
if (startEmulatorOptions?.hardwareAcceleration?.gpuMode)
emulator.startArgs.push('-gpu', startEmulatorOptions?.hardwareAcceleration?.gpuMode);

return emulator;
}

/**
* Start the emulator. If it is already running, this will do nothing, expect if `options.forceRestart` is set.
* Then, it kills the running process and starts the emulator again.
*
* This also creates a new `AbortController` if the current one had been activated.
*
* @param options
*
* @returns An `AbortSignal` which is triggered if the emulator crashes.
*/
async start(options?: { forceRestart: boolean }) {
if (!this.name) throw new Error('A name is missing. The emulator was not initialized.');

if (options?.forceRestart && !this.hasExited && this._proc) {
// We can do this, since `this.start()` throws away the previous process and its listeners.
this._proc.on('exit', () => this.start());
return this.kill();
baltpeter marked this conversation as resolved.
Show resolved Hide resolved
}
if (this._proc && !this.hasExited) return;

const { env } = await ensureSdkmanager();
const toolPath = await getAndroidDevToolPath('emulator');

// AbortControllers are single use, so we need to recreate one, if the old one was used already.
if (this._abortController.signal.aborted) this._abortController = new AbortController();

this._proc = execa(toolPath, ['-avd', this.name, ...this.startArgs], { env, reject: true });
this._proc.catch(async (error: ExecaError) => {
if (error.signal === 'SIGTERM') return; // The process was killed intentionally.
if (error.stdout?.includes('Failed to load snapshot')) {
// We are trying to load a snapshot
if (error.stdout.includes('The snapshot requires the feature'))
this._abortController.abort(
new EmulatorError(
`Loading the emulator state failed. Maybe you are trying to load a snapshot from a different emulator configuration (e.g. headless mode)?`
)
);
zner0L marked this conversation as resolved.
Show resolved Hide resolved
}

this.failedStarts++;
this.lastError = error;
if (!error.killed && !error.isCanceled) await killProcess(this._proc);
// We need to rethrow the error in this context to halt execution.
this._abortController.abort(EmulatorError.fromExecaError(error));
});
this._proc.on('exit', () => (this.hasExited = true));
this.lastError = undefined;
this.hasExited = false;

return this._abortController.signal;
}

/** Kills the emulator process if it is running. */
async kill() {
await killProcess(this._proc);
// This prevents a memory leak when we overwrite `this._proc`.
this._proc?.removeAllListeners();
}

/** Deletes and recreates the emulator with the same configuration as the current one. */
async rebuild() {
if (!this.name) return;
if (!this.createOptions)
throw new Error('Emulators can only by rebuilt if they have been created by this library previously.');
await this.kill();
await deleteEmulator(this.name);
await createEmulator(this.name, this.createOptions);

this.failedStarts = 0;
this.rebuilds++;
this.hasExited = false;
this._proc = undefined;
this.resetSnapshotName = undefined;
}

/** @returns An `AbortSignal` which is triggered if the emulator crashes. */
getAbortSignal() {
return this._abortController.signal;
}
}

export class EmulatorError extends Error {
/** The emulator process’ output in stout and stderr concatenated up until the crash. */
consoleOutput: string | undefined;
/** The command use to start the emulator. */
zner0L marked this conversation as resolved.
Show resolved Hide resolved
emulatorCommand: string | undefined;
/** The signal received by the emulator process. */
signal: string | undefined;

constructor(message: string, context?: { consoleOutput?: string; emulatorCommand?: string; signal?: string }) {
super(message);
this.name = 'EmulatorError';
this.consoleOutput = context?.consoleOutput;
this.emulatorCommand = context?.emulatorCommand;
this.signal = context?.signal;
}

static fromExecaError(error: ExecaError) {
return new EmulatorError(error.shortMessage, {
consoleOutput: error.stdout + error.stderr,
emulatorCommand: error.command,
signal: error.signal,
});
}
}

export const deleteEmulator = async (emulatorName: string) => {
zner0L marked this conversation as resolved.
Show resolved Hide resolved
const { env } = await ensureSdkmanager();
const toolPath = await getAndroidDevToolPath('avdmanager');

return execa(toolPath, ['delete', 'avd', '--name', emulatorName], {
env,
reject: true,
});
zner0L marked this conversation as resolved.
Show resolved Hide resolved
};
Loading