Skip to content

Mobile Status Report [1/4]: the lookups and the system snapshot - #17641

Merged
malinajirka merged 15 commits into
trunkfrom
WOOMOB-3704-mobile-status-report-part-1
Aug 11, 2026
Merged

Mobile Status Report [1/4]: the lookups and the system snapshot#17641
malinajirka merged 15 commits into
trunkfrom
WOOMOB-3704-mobile-status-report-part-1

Conversation

@malinajirka

@malinajirka malinajirka commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 to nil rather than describing behaviour the app is not exhibiting. The new enum case makes every exhaustive switch over FeatureFlagAction incomplete, 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. true explains a store that keeps falling back to the slower paginated sync.
  • POS local catalog eligibility — exposes 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.
  • ConnectivityObserver — widened with the interface type and the expensive-path flag.
  • APNs device token on PushNotesManager, reported redacted.
  • English byte count formatter — the free-space figure must stay greppable in English on a non-English device.
  • POS visibility/eligibility logging — 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.
  • 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.


  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

The release note for the feature lands with part 4, where it becomes reachable.

malinajirka and others added 8 commits August 3, 2026 10:54
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
@malinajirka malinajirka added this to the 25.4 milestone Aug 3, 2026
@malinajirka malinajirka added type: enhancement A request for an enhancement. feature: app settings Related to settings accessed via the gear icon in the My Store section. priority: medium Planned tasks usually belonging to ongoing projects category: unit tests Related to unit testing. labels Aug 3, 2026
@wpmobilebot

wpmobilebot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr17641-a2711ff
Version25.3
Bundle IDcom.automattic.alpha.woocommerce
Commita2711ff
Installation URL6gjhjebl9mvt8
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@malinajirka malinajirka added the status: do not merge Dependent on another PR, ready for review but not ready for merge. label Aug 3, 2026
@iamgabrielma iamgabrielma self-assigned this Aug 5, 2026
Comment on lines +20 to +23
public extension ConnectivityObserver {
var isCurrentPathExpensive: Bool? { nil }
var isCurrentPathConstrained: Bool? { nil }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imho we should remove the nil defaults here, otherwise makes it easier to become a silent failure gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 >=.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +10 to +12
/// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — reworded in e79d900 and added MobileStatusReportSystemSnapshotTests for the injected mappings. The new test promptly caught String(describing:) rendering the imported enums as UNAuthorizationStatus(rawValue: 2) — fixed in a2711ff.

}
// 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))")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): …")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 220d2a7 — adopted your suggestion as-is.

Comment on lines +79 to +86
/// 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)".

Comment on lines +9 to +15
/// 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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — clarity for the support team wins here. Renamed to isConnectionMetered / isLowDataModeEnabled in 6a69e3a; the docs keep the NWPath.isExpensive/isConstrained cross-reference.

malinajirka and others added 7 commits August 5, 2026 14:54
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a bit tricky to wrap my head around this, but flagging just in case rings any bell. It says it mirrors exactly, but:

  1. isRemoteFeatureFlagEnabled checks three sources in order: override store, then cache, then refetch.
  2. remoteFeatureFlagsInEffect only 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, i think it should be fine, just wanted to bring it up because I wasn't clear on it either 👍

@iamgabrielma iamgabrielma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wpmobilebot wpmobilebot modified the milestones: 25.4, 25.5 Aug 7, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 25.4 has now entered code-freeze, so the milestone of this PR has been updated to 25.5.

@malinajirka malinajirka removed the status: do not merge Dependent on another PR, ready for review but not ready for merge. label Aug 11, 2026
@malinajirka
malinajirka merged commit 7419c07 into trunk Aug 11, 2026
20 of 21 checks passed
@malinajirka
malinajirka deleted the WOOMOB-3704-mobile-status-report-part-1 branch August 11, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category: unit tests Related to unit testing. feature: app settings Related to settings accessed via the gear icon in the My Store section. priority: medium Planned tasks usually belonging to ongoing projects type: enhancement A request for an enhancement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants