Skip to content

Commit 6b2548d

Browse files
authored
Merge pull request #162 from ififi2017/fix/ios-scene-aware-orientation
fix(ios): make the QA orientation hook actually turn the window
2 parents 0fd9db1 + d5efd79 commit 6b2548d

5 files changed

Lines changed: 99 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,15 @@ orientation is reported as a miss with the reason, because a screenshot of the
426426
Home Screen is a perfectly valid PNG. `IOS_QA_SCENES`, `IOS_QA_THEME=both`,
427427
`IOS_QA_LANGUAGE`, `IOS_QA_IPHONE` / `IOS_QA_IPAD` and `IOS_QA_SKIP_BUILD=1`
428428
narrow or redirect it; it builds into its own DerivedData so it never fights
429-
Xcode. Known gap: the `qaOrientation` landscape hook does not currently rotate,
430-
so those columns report "still portrait" until it is fixed — the app writes the
431-
reason to `ios.native.qaOrientationError`.
429+
Xcode. The `qaOrientation` hook goes through `AppOrientationPolicy`, which pins
430+
the requested mask before asking for the geometry update: requesting landscape
431+
on its own does not hold, because iOS re-reads the root controller's
432+
`supportedInterfaceOrientations`, still sees portrait allowed, and turns a
433+
physically portrait simulator straight back — that is what made those columns
434+
report "still portrait". A pin lasts the life of the process, so it belongs
435+
only in the DEBUG build. The app writes any geometry error to
436+
`ios.native.qaOrientationError`. The fix has not yet been confirmed by a full
437+
sweep; re-run one before trusting the landscape columns.
432438

433439
`npm run check:ios` guards the shipping configuration of that project — bundle
434440
ids against Universal Purchase, the SwiftUI entry point, iPhone/iPad

src-mobile/ios/App/App/AppDelegate.swift

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,52 @@ final class AppOrientationPolicy {
7474
}
7575
}
7676

77+
/// The mask the policy should be reporting, given first-run state and any
78+
/// orientation a QA screenshot run has pinned.
79+
///
80+
/// A pin outranks the ordinary policy for the life of the process. Without
81+
/// that, the next `.active` transition would hand the policy back and turn
82+
/// the window away from the orientation the sweep asked for.
83+
static func resolvedMask(
84+
onboardingComplete: Bool,
85+
qaPinned: UIInterfaceOrientationMask?
86+
) -> UIInterfaceOrientationMask {
87+
qaPinned ?? mask(onboardingComplete: onboardingComplete)
88+
}
89+
7790
func update(onboardingComplete: Bool) {
78-
let requested = Self.mask(onboardingComplete: onboardingComplete)
91+
let requested = Self.resolvedMask(
92+
onboardingComplete: onboardingComplete,
93+
qaPinned: qaPinnedOrientations
94+
)
7995
let changed = supportedOrientations != requested
8096
supportedOrientations = requested
8197
RootOrientationSwizzle.installOnKeyWindow()
8298
applyToWindows(forceGeometryUpdate: changed)
8399
}
84100

101+
#if DEBUG
102+
private var qaPinnedOrientations: UIInterfaceOrientationMask?
103+
104+
/// Turns the window for a QA screenshot run and keeps it turned.
105+
///
106+
/// `requestGeometryUpdate` on its own does not hold. iOS re-reads the root
107+
/// controller's `supportedInterfaceOrientations` the moment the request
108+
/// lands, and while that still answers `.allButUpsideDown` the physically
109+
/// portrait simulator wins and the window turns straight back — which is
110+
/// why every landscape column of the sweep came back "still portrait".
111+
/// Narrowing the policy to the requested orientation first is what makes
112+
/// the turn stick.
113+
func pinOrientationsForQA(_ mask: UIInterfaceOrientationMask) {
114+
qaPinnedOrientations = mask
115+
supportedOrientations = mask
116+
RootOrientationSwizzle.installOnKeyWindow()
117+
applyToWindows(forceGeometryUpdate: true)
118+
}
119+
#else
120+
private var qaPinnedOrientations: UIInterfaceOrientationMask? { nil }
121+
#endif
122+
85123
private func applyToWindows(forceGeometryUpdate: Bool) {
86124
let mask = supportedOrientations
87125
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
@@ -98,6 +136,13 @@ final class AppOrientationPolicy {
98136
orientationLog.error(
99137
"Geometry update failed: \(error.localizedDescription, privacy: .public)"
100138
)
139+
#if DEBUG
140+
// The screenshot sweep reads this key to explain a miss.
141+
UserDefaults.standard.set(
142+
error.localizedDescription,
143+
forKey: "ios.native.qaOrientationError"
144+
)
145+
#endif
101146
}
102147
}
103148
}

src-mobile/ios/App/App/Native/Services/ShareSheet.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,15 @@ enum SystemShare {
5050
/// sidesteps that entirely.
5151
@MainActor
5252
static func present(items: [Any]) {
53+
// `connectedScenes` is unordered, so falling straight back to `.first`
54+
// could hand the sheet a backgrounded iPad window and put it up where
55+
// the user is not looking. Same ordering as `manageSubscriptions`:
56+
// on screen and active, then on screen, then anything at all.
57+
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
5358
guard
54-
let scene = UIApplication.shared.connectedScenes
55-
.compactMap({ $0 as? UIWindowScene })
56-
.first(where: { $0.activationState == .foregroundActive })
57-
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first,
59+
let scene = scenes.first(where: { $0.activationState == .foregroundActive })
60+
?? scenes.first(where: { $0.activationState == .foregroundInactive })
61+
?? scenes.first,
5862
let root = scene.windows.first(where: \.isKeyWindow)?.rootViewController
5963
else { return }
6064

src-mobile/ios/App/App/Native/Views/RootView.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,11 +301,11 @@ struct OffWorkCountdownRootView: View {
301301
defaults.removeObject(forKey: "ios.native.qaOrientationError")
302302
let orientations: UIInterfaceOrientationMask = requested == "landscape" ? .landscape : .portrait
303303
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
304-
guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first else { return }
305-
scene.windows.first?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
306-
scene.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) { error in
307-
UserDefaults.standard.set(error.localizedDescription, forKey: "ios.native.qaOrientationError")
308-
}
304+
// The policy owns every window of every connected scene. Reaching
305+
// for `connectedScenes.first` here turned whichever scene the
306+
// system happened to hand back first, which on an iPad running
307+
// Stage Manager is not necessarily the one being photographed.
308+
AppOrientationPolicy.shared.pinOrientationsForQA(orientations)
309309
}
310310
#endif
311311
}

src-mobile/ios/App/AppTests/OffWorkStoreTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2758,6 +2758,37 @@ func orientationPolicyUnlocksAfterOnboarding() {
27582758
#expect(unlocked.contains(.landscapeRight))
27592759
}
27602760

2761+
@MainActor
2762+
@Test("A QA orientation pin outranks the ordinary policy")
2763+
func qaOrientationPinOutranksPolicy() {
2764+
// The screenshot sweep asks for landscape and then the app becomes active,
2765+
// which re-runs `update(onboardingComplete:)`. While that handed back
2766+
// `.allButUpsideDown`, iOS re-read it, saw the simulator was physically
2767+
// portrait, and turned the window back — every landscape column of the
2768+
// sweep came out "still portrait".
2769+
let pinned = AppOrientationPolicy.resolvedMask(
2770+
onboardingComplete: true,
2771+
qaPinned: .landscape
2772+
)
2773+
#expect(pinned == .landscape)
2774+
#expect(!pinned.contains(.portrait))
2775+
2776+
// A pin also holds through first-run, where the policy wants portrait only.
2777+
#expect(
2778+
AppOrientationPolicy.resolvedMask(onboardingComplete: false, qaPinned: .landscape)
2779+
== .landscape
2780+
)
2781+
2782+
// With nothing pinned the policy is unchanged.
2783+
#expect(
2784+
AppOrientationPolicy.resolvedMask(onboardingComplete: false, qaPinned: nil) == .portrait
2785+
)
2786+
#expect(
2787+
AppOrientationPolicy.resolvedMask(onboardingComplete: true, qaPinned: nil)
2788+
== AppOrientationPolicy.mask(onboardingComplete: true)
2789+
)
2790+
}
2791+
27612792
@MainActor
27622793
@Test("A warm session remembers the shift-end celebration")
27632794
func celebrationSurvivesWarmSession() throws {

0 commit comments

Comments
 (0)