Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/plans/2026-03-29-iphone-refresh-reliability-fix-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# iPhone Refresh Reliability Fix Design

## Goal

Make iPhone Day Wrapped refresh reliable by fixing:

- overlapping foreground refreshes that repeatedly recreate the hidden Screen Time report host
- missing credentials in widget and background bridge requests
- weak diagnostics around superseded refreshes and shared credential availability

## Current Failures

- The iPhone app presents the hidden `DeviceActivityReport` host, but the report extension often never starts.
- `sceneBecameActive()` is triggered from multiple SwiftUI lifecycle hooks, which causes duplicate selected-day refreshes and repeated report-host presentation.
- Background and widget bridge requests can fail with `Identity not available` because signing keys are stored in a way that is not consistently readable outside the foreground app process.
- The app falls back to the Mac-only snapshot, which hides the actual failure stage from the user.

## Chosen Approach

Implement a full reliability fix in three layers:

1. Serialize selected-day refreshes in the iPhone app.
2. Remove duplicate scene-activation refresh triggers and stale refresh observers.
3. Move signing credentials to a shared keychain access group so app, widget, and related extension code can read the same identity material when needed.

## Refresh Orchestration

- Add a refresh generation token to `AppModel`.
- When a new refresh starts, it supersedes any older wait loop.
- Guard `refreshSelectedDay()` so only one selected-day refresh is active at a time for a given generation.
- Keep the hidden `DeviceActivityReport` host mounted, but only change its token when a real refresh begins.
- Ensure scene activation only schedules one foreground refresh attempt.

## Shared Credentials

- Add a shared keychain access group entitlement to the iPhone app, widget extension, and report extension.
- Update `AuthStore` to read and write keys using that access group.
- Add a small helper that validates whether both identity and key material are available before bridge calls.
- Use the stronger credential check in background and widget-facing flows so logs distinguish missing identity from missing keys.

## Diagnostics

- Log when a refresh is skipped because another refresh is already active.
- Log when a refresh wait loop exits because it was superseded by a newer request.
- Log when shared credential material is unavailable for background or widget work.
- Preserve the existing report lifecycle markers so diagnostics still point to the exact failed stage.

## Testing

- Validate that foreground activation no longer emits duplicate refresh/report-host sequences.
- Validate that manual refresh can produce a fresh `mobile-day-<day>.json` without being replaced by a second activation refresh.
- Validate that widget and background snapshot fetches use shared credentials successfully.
- Run iOS-targeted build or project validation plus repository typecheck/lint as applicable.

## Risks

- Shared keychain access requires matching entitlements across all relevant iOS targets.
- `DeviceActivityReport` remains platform-controlled, so extension startup can still be slow; the fix focuses on removing app-side races and credential failures.
5 changes: 5 additions & 0 deletions electron/main/features/__tests__/virtualTimeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ describe("buildVirtualTimelineItems", () => {
showPagination: true,
spacingAfter: 16,
});
expect(
items[0]?.type === "header"
? items[0].events.map((event) => event.id)
: [],
).toEqual(["1", "2", "3"]);
expect(items[1]).toMatchObject({
type: "row",
date: "Today",
Expand Down
12 changes: 12 additions & 0 deletions electron/main/infra/db/repositories/EventRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ export function getEvents(options: GetEventsOptions): Event[] {
);
}

if (options.needsAddictionReview) {
conditions.push(
"e.addiction_candidate IS NOT NULL AND e.tracked_addiction IS NULL",
);
}

if (options.appBundleId) {
conditions.push("e.app_bundle_id = ?");
params.push(options.appBundleId);
Expand Down Expand Up @@ -255,6 +261,12 @@ export function getEventsCount(options: GetEventsOptions): number {
);
}

if (options.needsAddictionReview) {
conditions.push(
"addiction_candidate IS NOT NULL AND tracked_addiction IS NULL",
);
}

if (options.appBundleId) {
conditions.push("app_bundle_id = ?");
params.push(options.appBundleId);
Expand Down
1 change: 1 addition & 0 deletions electron/main/ipc/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const zGetEventsOptions = z
projectProgress: z.boolean().optional(),
trackedAddiction: zLimitedString(200).optional(),
hasTrackedAddiction: z.boolean().optional(),
needsAddictionReview: z.boolean().optional(),
appBundleId: zLimitedString(500).optional(),
urlHost: zLimitedString(500).optional(),
startDate: z.number().int().optional(),
Expand Down
1 change: 1 addition & 0 deletions electron/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export interface GetEventsOptions {
projectProgress?: boolean;
trackedAddiction?: string;
hasTrackedAddiction?: boolean;
needsAddictionReview?: boolean;
appBundleId?: string;
urlHost?: string;
startDate?: number;
Expand Down
4 changes: 4 additions & 0 deletions ios/ScreencapMobile/ScreencapMobile.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.screencap.mobile.shared</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.screencap.mobile</string>
Expand Down
Loading
Loading