From d6d5a81e800d8a1f24e49708cd8ca6a8c34f231d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 20 Oct 2026 07:51:05 -0500 Subject: [PATCH 1/2] fix(device): report failed Android permission changes Grant and Revoke ignored every `pm` failure, so a mistyped or uninstalled app ID, or a permission the app does not declare, left the permission unchanged while the Device panel reported success. Keep running each compatibility permission, and fail with the first command error only when none of them applied, so Photos still works on images without READ_MEDIA_IMAGES. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- apps/server/src/device/DeviceActions.test.ts | 64 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 11 +++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 2d05f8343651f..ea1d64c9cd56c 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -227,6 +227,70 @@ describe("runDeviceAction", () => { }), ); + it.effect.each([ + ["camera", "grant", 1], + ["photos", "revoke", 2], + ] as const)( + "reports an Android %s %s that no pm command applied", + ([permission, decision, commands]) => + Effect.gen(function* () { + const { ready, calls } = makeReady((call) => ({ + code: 1, + stderr: `Unknown package: ${call.args.at(-1)}`, + })); + const error = yield* Effect.flip( + runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.missing", + permission, + decision, + }), + ); + expect(calls).toHaveLength(commands); + expect(error._tag).toBe("DeviceOperationError"); + expect(error.operation).toBe("permission"); + const firstPermission = calls[0]?.args.at(-1); + expect(firstPermission).toBe( + permission === "camera" + ? "android.permission.CAMERA" + : "android.permission.READ_MEDIA_IMAGES", + ); + expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ + stderr: `Unknown package: ${firstPermission}`, + }); + }), + ); + + it.effect("keeps a permission change that one compatibility permission applied", () => + Effect.gen(function* () { + // Images before Android 13 do not define READ_MEDIA_IMAGES. + const { ready, calls } = makeReady((call) => + call.args.at(-1) === "android.permission.READ_MEDIA_IMAGES" ? { code: 1 } : {}, + ); + yield* runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.app", + permission: "photos", + decision: "grant", + }); + expect(calls.map((call) => call.args.at(-1))).toEqual([ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_EXTERNAL_STORAGE", + ]); + expect(calls.at(-1)?.args).toEqual([ + "-s", + "emulator-5554", + "shell", + "pm", + "grant", + "com.example.app", + "android.permission.READ_EXTERNAL_STORAGE", + ]); + }), + ); + it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () => Effect.gen(function* () { const { ready, calls } = makeReady(); diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 5d2ddca0f740b..96c39a6416c53 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -17,6 +17,7 @@ import { type DeviceTextSize, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -436,9 +437,13 @@ const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( }); } const verb = input.decision === "grant" ? "grant" : "revoke"; - for (const permission of permissions) { - yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore); - } + // `pm` rejects a permission the app or system image does not declare, and Photos lists both + // the Android 13 and the older storage permission, so fail only when none of them applied. + const results = yield* Effect.forEach(permissions, (permission) => + shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.result), + ); + const failure = results.find(Result.isFailure); + if (failure && !results.some(Result.isSuccess)) return yield* failure.failure; return; } case "openUrl": From db5935ee7c5a37d477f08f6f0e4cfceb405ca4c1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 05:21:43 +0000 Subject: [PATCH 2/2] test(device): cover partial android permission success --- apps/server/src/device/DeviceActions.test.ts | 40 ++++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index e83f4671913e7..9655d1957ee7d 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -89,22 +89,30 @@ describe("runDeviceAction", () => { }), ); - it.effect("keeps a permission change that one compatibility permission applied", () => - Effect.gen(function* () { - // Images before Android 13 only allow the older storage permission. - const { ready } = makeReady((call) => { - if (call.args.at(-1) !== "android.permission.READ_EXTERNAL_STORAGE") return { code: 1 }; - return {}; - }); - const result = yield* runDeviceAction(ready, "android", { - type: "setPermission", - deviceId: "emulator-5554", - appId: "com.example.app", - permission: "photos", - decision: "grant", - }); - expect(result).toBeUndefined(); - }), + it.effect.each([ + [ + "photos", + ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], + 1, + ], + ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], 0], + ] as const)( + "keeps a partially applied %s permission change", + ([permission, permissions, success]) => + Effect.gen(function* () { + const { ready, calls } = makeReady((call) => ({ + code: call.args.at(-1) === permissions[success] ? 0 : 1, + })); + const result = yield* runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.app", + permission, + decision: "grant", + }); + expect(result).toBeUndefined(); + expect(calls.map((call) => call.args.at(-1))).toEqual(permissions); + }), ); });