Mobile Status Report [1/4]: the lookups and the system snapshot - #17641
Conversation
Reports what the app is acting on, which is not the same as what is in the cache: an expired cache is not consulted, so it resolves to nil rather than describing behaviour the app is not exhibiting. The new case makes every exhaustive switch over `FeatureFlagAction` incomplete, so the mocks and the tests carrying their own handlers resolve it the way the store does before a fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
The marker is recorded when a catalog file sync fails because the host blocked the file and cleared when it succeeds again, so reading it explains a store that keeps falling back to the slower paginated sync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
Reads the cached decision only. The live check refreshes on a cache miss, which can fetch, and a status report must not change what it reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
The observer already tracks reachability; the report also needs to say which interface serves the store and whether the path is metered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
Reported redacted: the last characters are enough to match against server logs, which is the only reason to carry it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
The report's free-space figure must stay greppable in English on a non-English device, so it does not go through the localized formatter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
The checks that decide POS visibility write their result as a side effect of evaluating, so the report must not recompute them to explain a hidden tab. Logging the reason puts it on the same ticket instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
The device, app and connectivity facts the report reads, gathered behind one value so the provider stays free of direct UIKit and system calls and can be tested against a fixed snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDBD7YWv1GKq2t7GyntSaY
|
|
| public extension ConnectivityObserver { | ||
| var isCurrentPathExpensive: Bool? { nil } | ||
| var isCurrentPathConstrained: Bool? { nil } | ||
| } |
There was a problem hiding this comment.
imho we should remove the nil defaults here, otherwise makes it easier to become a silent failure gap.
There was a problem hiding this comment.
Fixed in 28b7e89 — defaults removed; all three conformers now implement the properties explicitly.
|
|
||
| /// Apple's Push Notification device token, as last received from APNs. | ||
| /// | ||
| /// `nil` means the app never obtained one, which blocks both push systems. |
There was a problem hiding this comment.
This one it's also nil after PushNotificationRegistrationState.swift:136 clears it on unregistration, worth checking if we should need to change something else around this logic.
There was a problem hiding this comment.
Good catch — it is also cleared on unregistration (logout, and when APNs registration fails). Doc fixed in 43bab1d. No logic change needed: either way the app holds no token, so the report reading "missing" stands.
| /// that leave the device: support tickets and status reports are read by Happiness Engineers rather than by | ||
| /// the merchant, and must not follow the device's language. | ||
| /// | ||
| var englishByteCountRepresentable: String { |
There was a problem hiding this comment.
I think we have some duplication here, see pre-existing SupportFormMetadataProvider.swift:161-172 inside getDeviceFreeSpace().
Additional claude comments:
1. they diverge: the loop condition. A has the && sizeAbbreviationsIndex < sizeAbbreviations.count - 1 bound; B doesn't. B would run sizeAbbreviationsIndex past YB and crash on index-out-of-range for inputs above ~1 YB — unreachable in practice for free disk space, so this is a latent inconsistency rather than a live bug. The real cost is that the two will drift on any future fix, which is exactly what the commit set out to prevent.
There's also a smaller duplication the PR adds one level up: MobileStatusReportSystemSnapshot.freeSpace() (lines 82-88 of the new file) repeats the same URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityKey]) / volumeAvailableCapacity / fallback-to-"unknown" block that getDeviceFreeSpace() already has at SupportFormMetadataProvider.swift:153-156. So after this PR, two places compute device free space and two places format bytes in English.
The fix for both is the same: have getDeviceFreeSpace() return Int64(capacityBytes).englishByteCountRepresentable, and ideally have the snapshot call a single shared free-space helper.
2. No test for englishByteCountRepresentable. It's a hand-rolled numeric formatter — the one thing in this PR that's pure input→output and trivially testable — and it has no Int64+HelpersTests. It also has an off-by-one: while capacity > 1024 renders exactly 1024 bytes as 1024.00 bytes rather than 1.00 KB. Should be >=.
There was a problem hiding this comment.
Fixed in 1fdbc46 — getDeviceFreeSpace() and the snapshot now share one free-space read (UIDevice.freeDiskSpaceInEnglish) and one formatter, the 1024 off-by-one is fixed (>=), and Int64HelpersTests covers it.
| /// Values are captured already formatted. Everything in `current()` reads a live `Bundle`, `UIDevice`, | ||
| /// `UNUserNotificationCenter` or the app-wide connectivity observer and so cannot be exercised by a test at all; | ||
| /// keeping the mapping there |
There was a problem hiding this comment.
This comment contradicts the func signature, it can be tested!
Some can't, like Bundle.main, UIDevice.current, UIScreen.main, UIApplication.shared, ... but many others are DI'd and testable.
| } | ||
| // The literal prefix is quoted by the Mobile Status Report's Point of Sale hint line — keep the | ||
| // two in step. | ||
| DDLogInfo("POS cannot be launched for site \(siteID): \(String(describing: reason))") |
There was a problem hiding this comment.
Is this necessary? We're logging it twice with slight differences:
- POSTabEligibilityChecker.swift:212 → DDLogInfo("POS cannot be launched for site \(siteID): …")
- POSTabVisibilityChecker.swift:133 → DDLogInfo("POS tab not visible for site \(site.siteID): …")
There was a problem hiding this comment.
Intentional — they are different gates with different reason sets: the visibility checker logs why the tab is hidden (device idiom, iOS version, country, feature flags), the eligibility checker why POS cannot launch (WC plugin version, feature switch, currency). A site can pass one and fail the other, and a later part of the report quotes each prefix separately so HEs can grep them independently.
There was a problem hiding this comment.
With this change there are some cases that do not log anything, see:
if case .notReachable = connectivityObserver.currentStatus {
return eligibilityService.loadCachedPOSTabVisibility(siteID: site.siteID) ?? false
}
When that yields false, either nothing cached, or the last known state was hidden then the tab disappears and no log line is written. Suggestion:
if case .notReachable = connectivityObserver.currentStatus {
guard let cached = eligibilityService.loadCachedPOSTabVisibility(siteID: site.siteID) else {
logNotVisible("offline with no cached visibility for the site")
return false
}
if !cached {
logNotVisible("offline, and the last known visibility was hidden")
}
return cached
}
There was a problem hiding this comment.
Fixed in 220d2a7 — adopted your suggestion as-is.
| /// Mirrors exactly what `isRemoteFeatureFlagEnabled` would consult, so a report built from this can never | ||
| /// contradict the values the app is actually acting on: `nil` unless a fetch succeeded and has not aged out. | ||
| var remoteFeatureFlagsInEffect: [RemoteFeatureFlag: Bool]? { | ||
| guard let cachedFeatureFlags, !isCacheExpired else { | ||
| return nil | ||
| } | ||
| return cachedFeatureFlags | ||
| } |
There was a problem hiding this comment.
Here returns nil in two situations that are easy to conflate: nothing cached at all, and something cached but stale, but I think is fine?
There was a problem hiding this comment.
Yes — intentional: it mirrors exactly what isRemoteFeatureFlagEnabled consults, and the app behaves identically in both nil cases (falls back to defaults). The report copy spells the ambiguity out: "false (nothing fetched this session, or the last fetch aged out)".
| /// Whether the system considers the current network path expensive — typically cellular or a personal | ||
| /// hotspot. `nil` until the first path update arrives. | ||
| var isCurrentPathExpensive: Bool? { get } | ||
|
|
||
| /// Whether Low Data Mode constrains the current network path. `nil` until the first path update arrives. | ||
| var isCurrentPathConstrained: Bool? { get } | ||
|
|
There was a problem hiding this comment.
Is there a "better" name for these two? I have no specific suggestions but it's unclear to me if I'd understand what expensive vs constrained really mean. Perhaps isConnectionMetered, isLowDataModeEnabled?
There was a problem hiding this comment.
Agreed — clarity for the support team wins here. Renamed to isConnectionMetered / isLowDataModeEnabled in 6a69e3a; the docs keep the NWPath.isExpensive/isConstrained cross-reference.
A conformer that forgot to implement them would silently report nil — rendered as "unknown" in the status report — forever. With only three conformers, the protocol-extension defaults save nothing worth that gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The token is also cleared by unregistration — on logout and when APNs registration fails — so nil is not only "never obtained". Either way the app holds no token, so the report's "missing" reading stands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extraction had left a second copy of the byte loop in getDeviceFreeSpace() and a second volumeAvailableCapacity read in the snapshot — already diverging on the unit-index bound. Both now go through UIDevice.freeDiskSpaceInEnglish and the shared formatter, which also gains tests and renders exactly 1024 bytes as 1.00 KB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The doc claimed current() could not be exercised by a test while its signature injects the connectivity observer and notification center. Reword it and cover those two mappings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The offline branch could return false — nothing cached, or the last known state hidden — without a log line, in exactly the scenario where a merchant notices POS missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isCurrentPathExpensive and isCurrentPathConstrained leaned on NWPath jargon; isConnectionMetered and isLowDataModeEnabled say what a reader of the status report needs. The docs keep the NWPath cross-reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new snapshot test caught String(describing:) rendering the ObjC-imported enums as UNAuthorizationStatus(rawValue: 2) — the same held for the background refresh status and the interface idiom. Map each to its case name so the report stays greppable English. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // | ||
| private extension FeatureFlagStore { | ||
|
|
||
| /// Mirrors exactly what `isRemoteFeatureFlagEnabled` would consult, so a report built from this can never |
There was a problem hiding this comment.
Is a bit tricky to wrap my head around this, but flagging just in case rings any bell. It says it mirrors exactly, but:
isRemoteFeatureFlagEnabledchecks three sources in order: override store, then cache, then refetch.remoteFeatureFlagsInEffectonly reads the second. So when an override is set, the property returns the cached value while the app is acting on the overridden one, no?
This may cause oddness when testing perhaps, since for example Settings > Debug Panel > Override Feature Flags on the alpha build, toggles something on while server has it off.
If this is an actual issue (i'm not super clear), maybe the fix is just to read the override first, same order as isRemoteFeatureFlagEnabled?
There was a problem hiding this comment.
Unless I'm missing something, override is available only to developers right? So I didn't worry about it, since I don't assume developers will be reaching out to support for help. Wdyt @iamgabrielma ?
There was a problem hiding this comment.
Yeah, i think it should be fine, just wanted to bring it up because I wasn't clear on it either 👍
iamgabrielma
left a comment
There was a problem hiding this comment.
LGTM, dropped an additional comment on https://github.com/woocommerce/woocommerce-ios/pull/17641/changes#r3725638526
|
Version |

Description
Part 1 of 4 building the Mobile Status Report (
WOOMOB-3704) — the app-level counterpart to the server-side System Status Report, which carries no device or app information and is empty on tickets filed from the login screen.This part adds no report of its own. It adds the reads the report needs, each of which is useful to review on its own terms:
FeatureFlagAction.loadRemoteFeatureFlagsInEffect— reports what the app is acting on, which is not the same as what is in the cache: an expired cache is not consulted, so it resolves tonilrather than describing behaviour the app is not exhibiting. The new enum case makes every exhaustive switch overFeatureFlagActionincomplete, which is why the mocks and several unrelated test files are touched.AppSettingsAction.getPOSCatalogFileBlockedByHost— the marker recorded when a catalog file sync fails because the host blocked the file, and cleared when it succeeds.trueexplains a store that keeps falling back to the slower paginated sync.ConnectivityObserver— widened with the interface type and the expensive-path flag.PushNotesManager, reported redacted.MobileStatusReportSystemSnapshot— the device, app and connectivity facts gathered behind one value, so the provider stays free of direct UIKit and system calls and can be tested against a fixed snapshot.The snapshot type has no consumer until part 2; that is the nature of a stacked split.
Stack: part 1 (this PR) → part 2 → part 3 → part 4. Each part is under 300 lines of non-test code, and every commit builds on its own.
Test Steps
Test on the last PR in this chain - I plan to merge all the PRs at the same time.
Screenshots
N/A — no user-facing change in this part.
RELEASE-NOTES.txtif necessary.The release note for the feature lands with part 4, where it becomes reachable.