Skip to content

Commit f6fc99f

Browse files
feat(android): hand rotation back to the sensor with orientation auto (#3822)
* feat(android): hand rotation back to the sensor with `orientation auto` `orientation <rotation>` on Android turns auto-rotate off and nothing turned it back on, so one rotation left the device locked for the rest of the session and every later one. A QA step that rotates to landscape for a full-screen player could not leave the device as it found it, and later checks that the app follows physical rotation ran against a locked device. `orientation auto` binds a new `unlockOrientation` runtime operation that writes `accelerometer_rotation=1`, reads it back, and leaves `user_rotation` alone. It does not run the display settle loop: the sensor decides the next rotation, so there is no target to wait for. The mode stays out of `DeviceRotation`; it is its own argument vocabulary (`OrientationRequest`) and its own admission cell, so every rotation reader and provider keeps its one-to-one rotation mapping. Only ADB-backed Android (local and Limrun) declares the operation; every other owner refuses `auto` with `UNSUPPORTED_OPERATION` before binding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCtjQqvC57Xaa9PMVF34c8 * refactor(orientation): shrink orientation auto to its owning seams Cut the change to what `orientation auto` needs: - `orientationRuntimeOperationFacts` refuses `unlock` by default, so only owners that serve it (ADB-backed Android, local and Limrun) declare it; Apple, web, HarmonyOS, WebDriver, and Limrun iOS need no edits and the refusal hint has one owner. - `parseOrientationRequest` wraps the unchanged `parseDeviceRotation`. - `unlockAndroidOrientation` is the single `settings put`; the read-back nobody asked for is gone. - The daemon adds an `auto` branch and leaves the rotation path untouched. - Drop the dedicated unlock binder tests the catalog conformance row covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCtjQqvC57Xaa9PMVF34c8 * fix(orientation): keep the orientation facet's eager closure type-only The default unlock refusal and the method guard were static value imports, so importing `orientation-runtime.ts` evaluated seven more modules than at the merge-base. Declare the refusal as a frozen literal and reach `requireInteractorMethod` through a function-scoped import, as the keyboard, clipboard, alert, and settings facets do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCtjQqvC57Xaa9PMVF34c8 * fix(orientation): classify the unlock refusal as runner-free on Apple plans Every Apple leaf refuses orientation auto at admission, so no plan step can wait on the runner for unlockOrientation. Classify it like sendTelephonyEvent, which states the same rule, and cover it in the runner-free plan test. * refactor(orientation): project both orientation intents through one builder The auto branch restated the appBundleId and execution projection that the rotation path already owned. One builder, two callers. * test(orientation): drive the unlock refusal through the conformance matrix The orientation auto argument is its own admission cell on the orientation descriptor, so it owes its own conformance row plus a refusal assertion on each owner that never declares the cell. * refactor(contracts): keep parseDeviceRotation module-private behind parseOrientationRequest The facade re-export lost its last production consumer when the orientation command switched to parseOrientationRequest, so the production-exports gate flagged it. Fold its tests into the public entry and drop the re-export. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 16d7041 commit f6fc99f

41 files changed

Lines changed: 314 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/command-registry/src/registry.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
hoverRuntimeUses,
6363
keyboardRuntimePlanUses,
6464
longPressRuntimeUses,
65-
orientationRuntimeUse,
65+
orientationRuntimePlanUses,
6666
perfRuntimePlanUses,
6767
pressRuntimeUses,
6868
resolveSelectorCaptureRuntimePlan,
@@ -1500,9 +1500,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
15001500
catalog: { group: 'public' },
15011501
frameworkTier: 'extended',
15021502
// R44 retires this command's capability bucket and its `dispatch` leaf together: admission is
1503-
// the owner's `setOrientation` fact, and the only execution is the bound operation.
1503+
// the owner's `setOrientation` fact (`unlockOrientation` for `auto`); only that operation runs.
15041504
...GENERIC_MUTATING_COMMAND_TRAITS,
1505-
platformExecution: { kind: 'device-runtime', uses: [orientationRuntimeUse] },
1505+
platformExecution: { kind: 'device-runtime', uses: orientationRuntimePlanUses },
15061506
},
15071507
{
15081508
name: 'fold',

‎packages/contracts/src/client-system.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { BackMode } from './back-mode.ts';
55
import type { SelectorSnapshotCommandOptions } from './client-capture.ts';
66
import type { DeviceCommandBaseOptions } from './client-connection.ts';
77
import type { SettleCommandOptions } from './client-gesture.ts';
8-
import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts';
8+
import type { OrientationRequest, SetFoldPoseInput } from './device-rotation.ts';
99
import type { TelephonyEventInput } from './telephony-input.ts';
1010
import type { TvRemoteButton } from './tv-remote.ts';
1111

@@ -88,7 +88,8 @@ export type BackCommandOptions = DeviceCommandBaseOptions & {
8888
export type HomeCommandOptions = DeviceCommandBaseOptions;
8989

9090
export type OrientationCommandOptions = DeviceCommandBaseOptions & {
91-
orientation: DeviceRotation;
91+
/** A fixed rotation, or `auto` to hand rotation back to the sensor (ADB-backed Android only). */
92+
orientation: OrientationRequest;
9293
};
9394

9495
export type FoldCommandOptions = DeviceCommandBaseOptions & SetFoldPoseInput;

‎packages/contracts/src/device-rotation.test.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,29 @@ import {
33
DEVICE_ROTATIONS,
44
deviceRotationFromSurfaceIndex,
55
isDeviceRotation,
6-
parseDeviceRotation,
6+
parseOrientationRequest,
77
} from './device-rotation.ts';
88

9-
describe('parseDeviceRotation', () => {
9+
describe('parseOrientationRequest', () => {
1010
test('accepts the canonical orientation names', () => {
11-
expect(parseDeviceRotation('portrait')).toBe('portrait');
12-
expect(parseDeviceRotation('portrait-upside-down')).toBe('portrait-upside-down');
13-
expect(parseDeviceRotation('landscape-left')).toBe('landscape-left');
14-
expect(parseDeviceRotation('landscape-right')).toBe('landscape-right');
11+
expect(parseOrientationRequest('portrait')).toBe('portrait');
12+
expect(parseOrientationRequest('portrait-upside-down')).toBe('portrait-upside-down');
13+
expect(parseOrientationRequest('landscape-left')).toBe('landscape-left');
14+
expect(parseOrientationRequest('landscape-right')).toBe('landscape-right');
1515
});
1616

1717
test('accepts the documented short aliases', () => {
18-
expect(parseDeviceRotation('upside-down')).toBe('portrait-upside-down');
19-
expect(parseDeviceRotation('left')).toBe('landscape-left');
20-
expect(parseDeviceRotation('right')).toBe('landscape-right');
18+
expect(parseOrientationRequest('upside-down')).toBe('portrait-upside-down');
19+
expect(parseOrientationRequest('left')).toBe('landscape-left');
20+
expect(parseOrientationRequest('right')).toBe('landscape-right');
2121
});
2222

2323
test('is case-insensitive and trims surrounding whitespace', () => {
24-
expect(parseDeviceRotation(' Landscape-LEFT ')).toBe('landscape-left');
24+
expect(parseOrientationRequest(' Landscape-LEFT ')).toBe('landscape-left');
2525
});
2626

2727
test('throws a helpful error when the orientation is missing', () => {
28-
expect(() => parseDeviceRotation(undefined)).toThrow(
28+
expect(() => parseOrientationRequest(undefined)).toThrow(
2929
expect.objectContaining({
3030
code: 'INVALID_ARGS',
3131
message: expect.stringContaining('orientation requires an orientation argument'),
@@ -34,13 +34,19 @@ describe('parseDeviceRotation', () => {
3434
});
3535

3636
test('throws on an unrecognized orientation and echoes the bad input', () => {
37-
expect(() => parseDeviceRotation('sideways')).toThrow(
37+
expect(() => parseOrientationRequest('sideways')).toThrow(
3838
expect.objectContaining({
3939
code: 'INVALID_ARGS',
4040
message: expect.stringContaining('Invalid rotation: sideways'),
4141
}),
4242
);
4343
});
44+
45+
test('adds auto to the rotations and keeps it out of DeviceRotation', () => {
46+
expect(parseOrientationRequest(' AUTO ')).toBe('auto');
47+
expect(DEVICE_ROTATIONS).not.toContain('auto');
48+
expect(isDeviceRotation('auto')).toBe(false);
49+
});
4450
});
4551

4652
describe('deviceRotationFromSurfaceIndex', () => {

‎packages/contracts/src/device-rotation.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export function deviceRotationOrientation(rotation: DeviceRotation): 'PORTRAIT'
4545
return DEVICE_ROTATION_SURFACE_INDEX[rotation] % 2 === 0 ? 'PORTRAIT' : 'LANDSCAPE';
4646
}
4747

48-
export function parseDeviceRotation(input: string | undefined): DeviceRotation {
48+
/** The public entry for parsing one is `parseOrientationRequest`; this stays module-private. */
49+
function parseDeviceRotation(input: string | undefined): DeviceRotation {
4950
if (input === undefined) {
5051
throw new AppError(
5152
'INVALID_ARGS',
@@ -73,6 +74,14 @@ export function parseDeviceRotation(input: string | undefined): DeviceRotation {
7374
}
7475
}
7576

77+
/** A rotation to lock the device in, or `auto` to hand rotation back to the sensor. */
78+
export const ORIENTATION_REQUESTS = [...DEVICE_ROTATIONS, 'auto'] as const;
79+
export type OrientationRequest = (typeof ORIENTATION_REQUESTS)[number];
80+
81+
export function parseOrientationRequest(input: string | undefined): OrientationRequest {
82+
return input?.trim().toLowerCase() === 'auto' ? 'auto' : parseDeviceRotation(input);
83+
}
84+
7685
// ---- Hinge pose ------------------------------------------------------------------------------
7786
// Lives beside the rotation vocabulary because every entry that reads one device pose already
7887
// evaluates this module; a module of its own would join the eager closure of entries that can

‎packages/contracts/src/facades/device.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export {
4343
deviceRotationSurfaceDegrees,
4444
foldPoseForHingeAngle,
4545
isDeviceRotation,
46-
parseDeviceRotation,
46+
ORIENTATION_REQUESTS,
47+
parseOrientationRequest,
4748
parseFoldPose,
4849
} from '../device-rotation.ts';
4950
export {
@@ -56,6 +57,7 @@ export {
5657
} from '../telephony-input.ts';
5758
export type {
5859
DeviceRotation,
60+
OrientationRequest,
5961
FoldPose,
6062
FoldKeyframe,
6163
FoldScreenCoordinateSpace,

‎packages/contracts/src/interactor-operation-catalog-conformance.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const EXPECTATIONS: {
2727
back: { method: 'back', input: {} },
2828
home: { method: 'home', input: {} },
2929
setOrientation: { method: 'setOrientation', input: { rotation: 'portrait' } },
30+
unlockOrientation: { method: 'unlockOrientation', input: {} },
3031
tvRemote: { method: 'tvRemote', input: { button: 'select' } },
3132
keyboardStatus: { method: 'keyboardStatus', input: {} },
3233
keyboardDismiss: { method: 'keyboardDismiss', input: {} },

‎packages/contracts/src/interactor-operation-catalog.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
bindClipboardWrite,
99
} from './clipboard-runtime.ts';
1010
import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts';
11-
import { bindOrientation } from './orientation-runtime.ts';
11+
import { bindOrientation, bindOrientationUnlock } from './orientation-runtime.ts';
1212
import { bindReadSetting, bindSetSetting } from './settings-runtime.ts';
1313
import { TV_REMOTE_LABEL, bindTvRemote } from './tv-remote-runtime.ts';
1414
import {
@@ -62,6 +62,7 @@ export const INTERACTOR_OPERATIONS = [
6262
bind: (signal, resolve) => bindSystemButton('home', signal, resolve),
6363
},
6464
{ operation: 'setOrientation', label: 'orientation', bind: bindOrientation },
65+
{ operation: 'unlockOrientation', label: 'orientation auto', bind: bindOrientationUnlock },
6566
{ operation: 'tvRemote', label: TV_REMOTE_LABEL, bind: bindTvRemote },
6667
{
6768
operation: 'keyboardStatus',

‎packages/contracts/src/interactor-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ export type Interactor = {
404404
*/
405405
home?(): Promise<void>;
406406
setOrientation(orientation: DeviceRotation): Promise<{ orientation?: DeviceRotation } | void>;
407+
/** Hands rotation back to the sensor; only an owner whose `setOrientation` locks it carries this. */
408+
unlockOrientation?(): Promise<void>;
407409
performGesture?(plan: GesturePlan): Promise<Record<string, unknown> | void>;
408410
/**
409411
* Optional (parity with `home`): opens the recents surface, a control not every owner carries.

‎packages/contracts/src/orientation-runtime.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ test('builds the exact orientation operation fact catalog', () => {
1515
const orientation = { available: true } as const;
1616
expect(orientationRuntimeOperationFacts({ orientation })).toEqual({
1717
setOrientation: orientation,
18+
unlockOrientation: {
19+
available: false,
20+
reason: 'unsupported-platform-leaf',
21+
hint: 'orientation auto is only supported on ADB-backed Android.',
22+
},
23+
});
24+
expect(orientationRuntimeOperationFacts({ orientation, unlock: orientation })).toEqual({
25+
setOrientation: orientation,
26+
unlockOrientation: orientation,
1827
});
1928
});
2029

‎packages/contracts/src/orientation-runtime.ts‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
55

66
/**
77
* Neutral intent for one orientation change. `rotation` is already parsed by the caller
8-
* (`parseDeviceRotation`); the operation names no command, request, session, or CLI flag.
8+
* (`parseOrientationRequest`); the operation names no command, request, session, or CLI flag.
99
*/
1010
export type SetOrientationInput = Readonly<{
1111
rotation: DeviceRotation;
@@ -22,16 +22,28 @@ export type SetOrientationResult = Readonly<{ orientation?: DeviceRotation }>;
2222

2323
export type OrientationRuntimeOperations = Readonly<{
2424
setOrientation(input: SetOrientationInput): Promise<SetOrientationResult | void>;
25+
unlockOrientation(input: Omit<SetOrientationInput, 'rotation'>): Promise<void>;
2526
}>;
2627

2728
export type OrientationRuntimeOperationFacts = Readonly<{
2829
setOrientation: RuntimeOperationFact;
30+
unlockOrientation: RuntimeOperationFact;
2931
}>;
3032

33+
const unlockUnavailable = Object.freeze({
34+
available: false,
35+
reason: 'unsupported-platform-leaf',
36+
hint: 'orientation auto is only supported on ADB-backed Android.',
37+
} as const);
38+
39+
/** An owner that leaves `unlock` undeclared refuses `orientation auto`. */
3140
export function orientationRuntimeOperationFacts(
32-
input: Readonly<{ orientation: RuntimeOperationFact }>,
41+
input: Readonly<{ orientation: RuntimeOperationFact; unlock?: RuntimeOperationFact }>,
3342
): OrientationRuntimeOperationFacts {
34-
return Object.freeze({ setOrientation: input.orientation });
43+
return Object.freeze({
44+
setOrientation: input.orientation,
45+
unlockOrientation: input.unlock ?? unlockUnavailable,
46+
});
3547
}
3648

3749
/**
@@ -42,7 +54,7 @@ export function orientationRuntimeOperationFacts(
4254
export function bindOrientation(
4355
signal: AbortSignal,
4456
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
45-
): OrientationRuntimeOperations {
57+
): Pick<OrientationRuntimeOperations, 'setOrientation'> {
4658
return Object.freeze({
4759
setOrientation: async (input: SetOrientationInput) => {
4860
signal.throwIfAborted();
@@ -55,3 +67,23 @@ export function bindOrientation(
5567
},
5668
});
5769
}
70+
71+
export function bindOrientationUnlock(
72+
signal: AbortSignal,
73+
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
74+
): Pick<OrientationRuntimeOperations, 'unlockOrientation'> {
75+
return Object.freeze({
76+
unlockOrientation: async (input: Omit<SetOrientationInput, 'rotation'>) => {
77+
signal.throwIfAborted();
78+
const interactor = await resolveInteractor({
79+
...input.execution,
80+
appBundleId: input.options?.appBundleId,
81+
signal,
82+
});
83+
const { requireInteractorMethod } = await import('./interactor-operation-binding.ts');
84+
await requireInteractorMethod(interactor.unlockOrientation, 'orientation auto').call(
85+
interactor,
86+
);
87+
},
88+
});
89+
}

0 commit comments

Comments
 (0)