From c4560b9d4f31d09367216f81c977b7dc0c027e53 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 20 Jan 2023 18:18:34 +0100 Subject: [PATCH 1/3] fix(android): list installed packages on some Samsung devices --- .../android/android-application-manager.ts | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/common/mobile/android/android-application-manager.ts b/lib/common/mobile/android/android-application-manager.ts index fb8b46e350..9b0c0210b8 100644 --- a/lib/common/mobile/android/android-application-manager.ts +++ b/lib/common/mobile/android/android-application-manager.ts @@ -42,8 +42,51 @@ export class AndroidApplicationManager extends ApplicationManagerBase { } public async getInstalledApplications(): Promise { - const result = - (await this.adb.executeShellCommand(["pm", "list", "packages"])) || ""; + let result = ""; + try { + result = await this.adb.executeShellCommand(["pm", "list", "packages"]); + } catch (err) { + /** + * on some devices (Samsung) listing packages is prevented by a permission error + * notably, some system apps (bloatware) is installed under user 150 + * and listing these packages results in a permission error. + * if this happens, we have to first list all the users, and then loop through + * all the users and trying to list packages for that specific user, ignoring + * any errors. These are all then concatenated together and parsed normally. + * This is a slower operation, so we only do it in case listing failed in the first place. + */ + const userIDs: string[] = []; + const users = await this.adb.executeShellCommand(["pm", "list", "users"]); + /** + * Users: + * UserInfo{0:Owner:c13} running + */ + + const userIDRegex = /UserInfo{(\d+)[:}]/; + users.split(EOL).forEach((line: string) => { + const [, userID] = line.match(userIDRegex) ?? []; + + if (userID) { + userIDs.push(userID); + } + }); + + for (let id of userIDs) { + try { + result += + EOL + + (await this.adb.executeShellCommand([ + "pm", + "list", + "packages", + "--user", + id, + ])); + } catch (err) { + // ignore - likely permission denied. + } + } + } const regex = /package:(.+)/; return result .split(EOL) From b2ad40791ab32d9eea1eea33005e5eae82177766 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Wed, 9 Sep 2026 08:21:31 +0200 Subject: [PATCH 2/3] fix(android): fall back to per-user package listing when output is empty `executeShellCommand` never rejects on a non-zero exit code, so catching an error around `pm list packages` could not detect the SecurityException some Samsung devices raise for the Secure Folder user (150). Detect the failure from the empty package output instead, list packages per user, deduplicate the result, and keep using the per-user path for the rest of the session since the installed-apps check is polled frequently. --- .../android/android-application-manager.ts | 87 +++++---- .../unit-tests/android-application-manager.ts | 166 ++++++++++++++---- 2 files changed, 169 insertions(+), 84 deletions(-) diff --git a/lib/common/mobile/android/android-application-manager.ts b/lib/common/mobile/android/android-application-manager.ts index 9b0c0210b8..5e68306086 100644 --- a/lib/common/mobile/android/android-application-manager.ts +++ b/lib/common/mobile/android/android-application-manager.ts @@ -23,6 +23,7 @@ import { export class AndroidApplicationManager extends ApplicationManagerBase { public PID_CHECK_INTERVAL = 100; public PID_CHECK_TIMEOUT = 10000; // 10 secs + private listPackagesPerUser = false; constructor( private adb: Mobile.IDeviceAndroidDebugBridge, @@ -42,61 +43,55 @@ export class AndroidApplicationManager extends ApplicationManagerBase { } public async getInstalledApplications(): Promise { - let result = ""; - try { - result = await this.adb.executeShellCommand(["pm", "list", "packages"]); - } catch (err) { - /** - * on some devices (Samsung) listing packages is prevented by a permission error - * notably, some system apps (bloatware) is installed under user 150 - * and listing these packages results in a permission error. - * if this happens, we have to first list all the users, and then loop through - * all the users and trying to list packages for that specific user, ignoring - * any errors. These are all then concatenated together and parsed normally. - * This is a slower operation, so we only do it in case listing failed in the first place. - */ - const userIDs: string[] = []; - const users = await this.adb.executeShellCommand(["pm", "list", "users"]); - /** - * Users: - * UserInfo{0:Owner:c13} running - */ - - const userIDRegex = /UserInfo{(\d+)[:}]/; - users.split(EOL).forEach((line: string) => { - const [, userID] = line.match(userIDRegex) ?? []; - - if (userID) { - userIDs.push(userID); - } - }); - - for (let id of userIDs) { - try { - result += - EOL + - (await this.adb.executeShellCommand([ - "pm", - "list", - "packages", - "--user", - id, - ])); - } catch (err) { - // ignore - likely permission denied. - } + if (!this.listPackagesPerUser) { + const packages = this.parsePackageList( + await this.adb.executeShellCommand(["pm", "list", "packages"]), + ); + if (packages.length) { + return packages; } } + + // Listing without `--user` walks every user and prints nothing when shell + // is denied access to one of them (e.g. Samsung's Secure Folder, user 150) + // without rejecting. Listing per user only loses the inaccessible ones. + this.listPackagesPerUser = true; + const packages: string[] = []; + for (const userId of await this.getUserIds()) { + packages.push( + ...this.parsePackageList( + await this.adb.executeShellCommand([ + "pm", + "list", + "packages", + "--user", + userId, + ]), + ), + ); + } + + return _.uniq(packages); + } + + private parsePackageList(output: string): string[] { const regex = /package:(.+)/; - return result + return (output || "") .split(EOL) - .map((packageString: string) => { - const match = packageString.match(regex); + .map((line: string) => { + const match = line.match(regex); return match ? match[1] : null; }) .filter((parsedPackage: string) => parsedPackage !== null); } + private async getUserIds(): Promise { + const output: string = + (await this.adb.executeShellCommand(["pm", "list", "users"])) || ""; + const regex = /UserInfo\{(\d+):/g; + return Array.from(output.matchAll(regex), (match) => match[1]); + } + @hook("install") public async installApplication( packageFilePath: string, diff --git a/lib/common/test/unit-tests/android-application-manager.ts b/lib/common/test/unit-tests/android-application-manager.ts index 775972b194..2c05f523af 100644 --- a/lib/common/test/unit-tests/android-application-manager.ts +++ b/lib/common/test/unit-tests/android-application-manager.ts @@ -1,6 +1,7 @@ import { AndroidApplicationManager } from "../../mobile/android/android-application-manager"; import { Yok } from "../../yok"; import { assert } from "chai"; +import { EOL } from "os"; import * as _ from "lodash"; import { AndroidBundleToolServiceStub, @@ -79,15 +80,13 @@ class AndroidDebugBridgeStub { if (passedIdentifier === invalidIdentifier) { return "invalid output string"; } else { - const testString = this.validTestInput[ - AndroidDebugBridgeStub.methodCallCount - ]; + const testString = + this.validTestInput[AndroidDebugBridgeStub.methodCallCount]; return testString; } } else { - this.startedWithActivityManager = this.checkIfStartedWithActivityManager( - args - ); + this.startedWithActivityManager = + this.checkIfStartedWithActivityManager(args); if (this.startedWithActivityManager) { this.validIdentifierPassed = this.checkIfValidIdentifierPassed(args); } @@ -103,7 +102,7 @@ class AndroidDebugBridgeStub { public async pushFile( localFilePath: string, - deviceFilePath: string + deviceFilePath: string, ): Promise { await this.executeShellCommand(["push", localFilePath, deviceFilePath]); } @@ -121,9 +120,8 @@ class AndroidDebugBridgeStub { private checkIfValidIdentifierPassed(args: string[]): boolean { if (args && args.length) { const possibleIdentifier = args[args.length - 1]; - const validTestString = this.expectedValidTestInput[ - AndroidDebugBridgeStub.methodCallCount - ]; + const validTestString = + this.expectedValidTestInput[AndroidDebugBridgeStub.methodCallCount]; return possibleIdentifier === validTestString; } @@ -150,7 +148,7 @@ function createTestInjector(options?: { justLaunch?: boolean }): IInjector { testInjector.register("androidProcessService", AndroidProcessServiceStub); testInjector.register( "androidBundleToolService", - AndroidBundleToolServiceStub + AndroidBundleToolServiceStub, ); testInjector.register("fs", FileSystemStub); testInjector.register("httpClient", {}); @@ -171,7 +169,7 @@ describe("android-application-manager", () => { function setup(options?: { justLaunch?: boolean }) { testInjector = createTestInjector(options); androidApplicationManager = testInjector.resolve( - "androidApplicationManager" + "androidApplicationManager", ); androidDebugBridge = testInjector.resolve("adb"); logcatHelper = testInjector.resolve("logcatHelper"); @@ -217,7 +215,7 @@ describe("android-application-manager", () => { setup(); await androidApplicationManager.startApplication( - _.extend({}, validStartOptions, { justLaunch: true }) + _.extend({}, validStartOptions, { justLaunch: true }), ); assert.equal(logcatHelper.StartCallCount, 0); @@ -227,7 +225,7 @@ describe("android-application-manager", () => { setup({ justLaunch: true }); await androidApplicationManager.startApplication( - _.extend({}, validStartOptions, { justLaunch: false }) + _.extend({}, validStartOptions, { justLaunch: false }), ); assert.equal(logcatHelper.StartCallCount, 0); @@ -237,7 +235,7 @@ describe("android-application-manager", () => { setup({ justLaunch: true }); await androidApplicationManager.startApplication( - _.extend({}, validStartOptions, { justLaunch: true }) + _.extend({}, validStartOptions, { justLaunch: true }), ); assert.equal(logcatHelper.StartCallCount, 0); @@ -262,7 +260,7 @@ describe("android-application-manager", () => { assert.equal( deviceLogProvider.currentDevicePids[validDeviceIdentifier], - expectedPid + expectedPid, ); }); @@ -279,8 +277,8 @@ describe("android-application-manager", () => { assert.isTrue(logger.traceOutput.indexOf("Wasn't able to get pid") > -1); assert.isTrue( logger.output.indexOf( - `Unable to find running "${validIdentifier}" application on device ` - ) === -1 + `Unable to find running "${validIdentifier}" application on device `, + ) === -1, ); }); @@ -291,20 +289,19 @@ describe("android-application-manager", () => { androidApplicationManager.PID_CHECK_TIMEOUT = expectedPidTimeout; androidProcessService.GetAppProcessIdResult = null; - const startApplicationPromise = androidApplicationManager.startApplication( - validStartOptions - ); + const startApplicationPromise = + androidApplicationManager.startApplication(validStartOptions); startApplicationPromise.catch(() => { assert.isTrue(logcatHelper.DumpCallCount > 0); assert.isTrue( - logger.traceOutput.indexOf("Wasn't able to get pid") > -1 + logger.traceOutput.indexOf("Wasn't able to get pid") > -1, ); }); return assert.isRejected( startApplicationPromise, - `Unable to find running "${validIdentifier}" application on device ` + `Unable to find running "${validIdentifier}" application on device `, ); }); }); @@ -312,17 +309,19 @@ describe("android-application-manager", () => { describe("installApplication", () => { afterEach(function () { androidDebugBridge.calledInstallApplication = false; - const bundleToolService = testInjector.resolve< - AndroidBundleToolServiceStub - >("androidBundleToolService"); + const bundleToolService = + testInjector.resolve( + "androidBundleToolService", + ); bundleToolService.isBuildApksCalled = false; bundleToolService.isInstallApksCalled = false; }); it("should install apk using adb", async () => { - const bundleToolService = testInjector.resolve< - AndroidBundleToolServiceStub - >("androidBundleToolService"); + const bundleToolService = + testInjector.resolve( + "androidBundleToolService", + ); await androidApplicationManager.installApplication("myApp.apk"); @@ -332,9 +331,10 @@ describe("android-application-manager", () => { }); it("should install aab using bundletool", async () => { - const bundleToolService = testInjector.resolve< - AndroidBundleToolServiceStub - >("androidBundleToolService"); + const bundleToolService = + testInjector.resolve( + "androidBundleToolService", + ); await androidApplicationManager.installApplication("myApp.aab"); @@ -345,14 +345,15 @@ describe("android-application-manager", () => { it("should skip aab build when already built", async () => { const fsStub = testInjector.resolve("fs"); - const bundleToolService = testInjector.resolve< - AndroidBundleToolServiceStub - >("androidBundleToolService"); + const bundleToolService = + testInjector.resolve( + "androidBundleToolService", + ); await androidApplicationManager.installApplication( "myApp.aab", "my.app", - validSigning + validSigning, ); assert.isTrue(bundleToolService.isBuildApksCalled); @@ -365,7 +366,7 @@ describe("android-application-manager", () => { await androidApplicationManager.installApplication( "myApp.aab", "my.app", - validSigning + validSigning, ); assert.isFalse(bundleToolService.isBuildApksCalled); @@ -374,6 +375,95 @@ describe("android-application-manager", () => { }); }); + describe("getInstalledApplications", () => { + let shellCalls: string[]; + const users = [ + "Users:", + "\tUserInfo{0:Owner:c13} running", + "\tUserInfo{150:Secure Folder:1030} running", + ].join(EOL); + + function stubShellOutputs(outputs: Record) { + shellCalls = []; + androidDebugBridge.executeShellCommand = async (args: string[]) => { + const command = args.join(" "); + shellCalls.push(command); + return outputs[command] || ""; + }; + } + + it("parses the packages listed for all users", async () => { + setup(); + stubShellOutputs({ + "pm list packages": ["package:org.a", "package:org.b"].join(EOL), + }); + + const packages = + await androidApplicationManager.getInstalledApplications(); + + assert.deepEqual(packages, ["org.a", "org.b"]); + assert.deepEqual(shellCalls, ["pm list packages"]); + }); + + it("lists packages per user when listing for all users yields nothing", async () => { + setup(); + stubShellOutputs({ + "pm list packages": "", + "pm list users": users, + "pm list packages --user 0": ["package:org.a", "package:org.b"].join( + EOL, + ), + "pm list packages --user 150": "", + }); + + const packages = + await androidApplicationManager.getInstalledApplications(); + + assert.deepEqual(packages, ["org.a", "org.b"]); + assert.deepEqual(shellCalls, [ + "pm list packages", + "pm list users", + "pm list packages --user 0", + "pm list packages --user 150", + ]); + }); + + it("deduplicates packages installed for several users", async () => { + setup(); + stubShellOutputs({ + "pm list users": users, + "pm list packages --user 0": ["package:org.a", "package:org.b"].join( + EOL, + ), + "pm list packages --user 150": ["package:org.b", "package:org.c"].join( + EOL, + ), + }); + + const packages = + await androidApplicationManager.getInstalledApplications(); + + assert.deepEqual(packages, ["org.a", "org.b", "org.c"]); + }); + + it("keeps listing per user once listing for all users has failed", async () => { + setup(); + stubShellOutputs({ + "pm list users": users, + "pm list packages --user 0": "package:org.a", + }); + await androidApplicationManager.getInstalledApplications(); + shellCalls.length = 0; + + const packages = + await androidApplicationManager.getInstalledApplications(); + + assert.deepEqual(packages, ["org.a"]); + assert.notInclude(shellCalls, "pm list packages"); + assert.include(shellCalls, "pm list packages --user 0"); + }); + }); + describe("stopApplication", () => { it("should stop the logcat helper", async () => { setup(); @@ -398,7 +488,7 @@ describe("android-application-manager", () => { assert.equal( deviceLogProvider.currentDevicePids[validDeviceIdentifier], - null + null, ); }); }); From 9792f524ea79d298c1c77ca95d9943cddeb2608f Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Wed, 9 Sep 2026 08:35:55 +0200 Subject: [PATCH 3/3] docs(android): document package listing helpers --- .../mobile/android/android-application-manager.ts | 15 +++++++++++++++ .../unit-tests/android-application-manager.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/lib/common/mobile/android/android-application-manager.ts b/lib/common/mobile/android/android-application-manager.ts index 5e68306086..3de2163581 100644 --- a/lib/common/mobile/android/android-application-manager.ts +++ b/lib/common/mobile/android/android-application-manager.ts @@ -42,6 +42,12 @@ export class AndroidApplicationManager extends ApplicationManagerBase { super($logger, $hooksService, $deviceLogProvider); } + /** + * Lists the identifiers of all packages installed on the device. + * Falls back to listing packages per user when the plain listing yields + * nothing, which happens on devices where shell cannot access every user. + * @returns {Promise} Unique package identifiers across all users. + */ public async getInstalledApplications(): Promise { if (!this.listPackagesPerUser) { const packages = this.parsePackageList( @@ -74,6 +80,11 @@ export class AndroidApplicationManager extends ApplicationManagerBase { return _.uniq(packages); } + /** + * Extracts package identifiers from `pm list packages` output. + * @param {string} output Raw shell output, one `package:` line per package. + * @returns {string[]} The package identifiers, in output order. + */ private parsePackageList(output: string): string[] { const regex = /package:(.+)/; return (output || "") @@ -85,6 +96,10 @@ export class AndroidApplicationManager extends ApplicationManagerBase { .filter((parsedPackage: string) => parsedPackage !== null); } + /** + * Lists the ids of all Android users on the device via `pm list users`. + * @returns {Promise} User ids as printed by the device, e.g. ["0", "150"]. + */ private async getUserIds(): Promise { const output: string = (await this.adb.executeShellCommand(["pm", "list", "users"])) || ""; diff --git a/lib/common/test/unit-tests/android-application-manager.ts b/lib/common/test/unit-tests/android-application-manager.ts index 2c05f523af..83465c2bad 100644 --- a/lib/common/test/unit-tests/android-application-manager.ts +++ b/lib/common/test/unit-tests/android-application-manager.ts @@ -383,6 +383,11 @@ describe("android-application-manager", () => { "\tUserInfo{150:Secure Folder:1030} running", ].join(EOL); + /** + * Replaces the adb shell stub with one that records every command and + * answers from the given map, keyed by the space-joined command arguments. + * @param {Record} outputs Shell output per command; unknown commands return "". + */ function stubShellOutputs(outputs: Record) { shellCalls = []; androidDebugBridge.executeShellCommand = async (args: string[]) => {