also looking at this
test(mobile-e2e): prove Android reports approximate location as granted
#214
5 files
+85
-5
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -56,8 +56,8 @@ bun run test:mobile-e2e:android | ||
| 56 | 56 | ``` |
| 57 | 57 | |
| 58 | 58 | ``` |
| 59 | ok android-shim — 6/6 cases, no zig | |
| 60 | ok android-runtime — 6/6 cases, zig served registered:103, declines:0 | |
| 59 | ok android-shim — 7/7 cases, no zig | |
| 60 | ok android-runtime — 7/7 cases, zig served registered:103, declines:0 | |
| 61 | 61 | ``` |
| 62 | 62 | |
| 63 | 63 | One Android case needs a person: `share.dismissed.resolvesFalse` opens the |
| @@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { init } from '../../packages/android/src/index' |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, shareMenuInFront } from './protocol' | |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | 8 | /** |
| @@ -15,11 +15,29 @@ import { command, driverPage, waitForFile } from './support' | ||
| 15 | 15 | * Clipboard is not a configurable capability on Android - CraftBridge serves |
| 16 | 16 | * clipboardRead/clipboardWrite unconditionally - so the success case needs no |
| 17 | 17 | * flag, and asking for one would imply a gate that is not there. |
| 18 | * | |
| 19 | * Geolocation on, because that is what puts the location permissions in the | |
| 20 | * manifest, and `pm grant` refuses a permission the manifest does not declare. | |
| 21 | * The Kotlin does not otherwise read the flag (#209). | |
| 18 | 22 | */ |
| 19 | 23 | const CONFIG = { |
| 20 | 24 | enablePushNotifications: false, |
| 25 | enableGeolocation: true, | |
| 21 | 26 | } |
| 22 | 27 | |
| 28 | /** | |
| 29 | * The one location permission the harness grants: approximate, and not | |
| 30 | * precise. | |
| 31 | * | |
| 32 | * #192 was a device with location granted that still reported something | |
| 33 | * else. Granting both permissions would pass against a bridge that required | |
| 34 | * either one, and that is not the contract. Android 12 onwards lets a person | |
| 35 | * choose approximate only, and CraftPermissionPolicy counts that as `granted` | |
| 36 | * (780e952). Coarse alone is the stricter of the two tests: anything this | |
| 37 | * passes, a device with both permissions granted passes too. | |
| 38 | */ | |
| 39 | const GRANTED_LOCATION_PERMISSION = 'android.permission.ACCESS_COARSE_LOCATION' | |
| 40 | ||
| 23 | 41 | const PACKAGE = 'dev.craft.e2e.probe' |
| 24 | 42 | const APP_NAME = 'CraftE2EProbe' |
| 25 | 43 | |
| @@ -142,6 +160,19 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 142 | 160 | await adb(['uninstall', PACKAGE], { serial, allowFailure: true }) |
| 143 | 161 | await adb(['install', '-r', apk], { serial, logPath: join(evidence, 'adb.log') }) |
| 144 | 162 | |
| 163 | // Granted from the host before launch, so no dialog waits on a person. | |
| 164 | await adb(['shell', 'pm', 'grant', PACKAGE, GRANTED_LOCATION_PERMISSION], { serial, logPath: join(evidence, 'adb.log') }) | |
| 165 | ||
| 166 | // And read back, because the case is only as strict as the device state it | |
| 167 | // ran against: approximate granted, precise not. A device that had precise | |
| 168 | // granted as well would pass against a bridge requiring precise. | |
| 169 | const packageState = (await adb(['shell', 'dumpsys', 'package', PACKAGE], { serial })).stdout | |
| 170 | writeFileSync(join(evidence, 'package-permissions.txt'), packageState) | |
| 171 | if (runtimePermissionGranted(packageState, GRANTED_LOCATION_PERMISSION) !== true) | |
| 172 | failures.push(`${GRANTED_LOCATION_PERMISSION} is not granted after pm grant; see ${label}/package-permissions.txt`) | |
| 173 | if (runtimePermissionGranted(packageState, 'android.permission.ACCESS_FINE_LOCATION') === true) | |
| 174 | failures.push('ACCESS_FINE_LOCATION is granted too, so the location case cannot tell approximate-only from precise') | |
| 175 | ||
| 145 | 176 | // Not best-effort: the ring buffer outlives an uninstall, and a transcript |
| 146 | 177 | // left in it would otherwise be read as this run's. The plan event carries |
| 147 | 178 | // this run's nonce as a second guard. |
| @@ -170,8 +170,13 @@ | ||
| 170 | 170 | // already holds used to wait for an authorization change that never |
| 171 | 171 | // comes, because nothing changes. The harness grants it with |
| 172 | 172 | // `simctl privacy` before launch, so both calls must answer at once. |
| 173 | // | |
| 174 | // And #192 on Android, where a device with location granted reported | |
| 175 | // something other than `granted`. The harness grants approximate | |
| 176 | // location only, with `pm grant`, which is the case a bridge | |
| 177 | // requiring precise location gets wrong. | |
| 173 | 178 | name: 'permissions.location.granted', |
| 174 | platforms: ['ios'], | |
| 179 | platforms: ['ios', 'android'], | |
| 175 | 180 | run: function () { |
| 176 | 181 | return withTimeout(function () { return window.craft.permissions.check('location') }).then(function (checked) { |
| 177 | 182 | if (checked.state !== 'resolved') return 'permissions.check ' + checked.state + ' ' + (checked.error ? checked.error.message : '') |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -334,6 +334,32 @@ describe('share menu', () => { | ||
| 334 | 334 | }) |
| 335 | 335 | }) |
| 336 | 336 | |
| 337 | describe('runtime permission state', () => { | |
| 338 | // As `dumpsys package` prints a coarse-only grant on Android 14. | |
| 339 | const dumpsys = [ | |
| 340 | ' runtime permissions:', | |
| 341 | ' android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]', | |
| 342 | ' android.permission.ACCESS_COARSE_LOCATION: granted=true, flags=[ USER_SET|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]', | |
| 343 | ' android.permission.ACCESS_BACKGROUND_LOCATION: granted=false, flags=[ RESTRICTION_INSTALLER_EXEMPT]', | |
| 344 | ].join('\n') | |
| 345 | ||
| 346 | it('reads granted and not granted apart', () => { | |
| 347 | expect(runtimePermissionGranted(dumpsys, 'android.permission.ACCESS_COARSE_LOCATION')).toBe(true) | |
| 348 | expect(runtimePermissionGranted(dumpsys, 'android.permission.ACCESS_FINE_LOCATION')).toBe(false) | |
| 349 | }) | |
| 350 | ||
| 351 | it('says nothing about a permission the output never mentions', () => { | |
| 352 | expect(runtimePermissionGranted(dumpsys, 'android.permission.CAMERA')).toBeUndefined() | |
| 353 | }) | |
| 354 | ||
| 355 | it('does not read one permission as another that ends the same way', () => { | |
| 356 | // A dot left unescaped would match any character, and a name without a | |
| 357 | // boundary would match inside a longer one. | |
| 358 | expect(runtimePermissionGranted(dumpsys, 'permission.ACCESS_COARSE_LOCATION')).toBeUndefined() | |
| 359 | expect(runtimePermissionGranted('androidXpermissionXCAMERA: granted=true', 'android.permission.CAMERA')).toBeUndefined() | |
| 360 | }) | |
| 361 | }) | |
| 362 | ||
| 337 | 363 | describe('android declines', () => { |
| 338 | 364 | // The lines the first Android runtime run actually produced, plus the two |
| 339 | 365 | // decline kinds it did not hit, so all three are read the way they are |
| @@ -89,6 +89,7 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 89 | 89 | 'push.disabled.rejects', |
| 90 | 90 | 'share.empty.rejects', |
| 91 | 91 | 'share.dismissed.resolvesFalse', |
| 92 | 'permissions.location.granted', | |
| 92 | 93 | ], |
| 93 | 94 | } |
| 94 | 95 | |
| @@ -354,6 +355,23 @@ export function shareMenuInFront(dumpsysWindow: string): boolean { | ||
| 354 | 355 | .some(line => /mCurrentFocus=/.test(line) && /ChooserActivity/.test(line)) |
| 355 | 356 | } |
| 356 | 357 | |
| 358 | /** | |
| 359 | * Whether `dumpsys package` says a runtime permission is granted, or | |
| 360 | * `undefined` when the output does not mention it at all. | |
| 361 | * | |
| 362 | * Three answers rather than two, because "not mentioned" usually means the | |
| 363 | * manifest never declared the permission, and reading that as "not granted" | |
| 364 | * would hide the cause. | |
| 365 | */ | |
| 366 | export function runtimePermissionGranted(dumpsysPackage: string, permission: string): boolean | undefined { | |
| 367 | const escaped = permission.replaceAll('.', '\\.') | |
| 368 | // Whitespace or line start before the name, not a word boundary, which sits | |
| 369 | // after every dot, so `permission.CAMERA` would match inside | |
| 370 | // `android.permission.CAMERA`. | |
| 371 | const match = dumpsysPackage.match(new RegExp(`(?:^|\\s)${escaped}: granted=(true|false)`, 'm')) | |
| 372 | return match ? match[1] === 'true' : undefined | |
| 373 | } | |
| 374 | ||
| 357 | 375 | /** |
| 358 | 376 | * The actions the Zig dispatcher saw, by name. |
| 359 | 377 | * |