Jul 27 - Update code for scheduled tasks 4

This commit is contained in:
Nguyen Ngo
2026-07-27 16:43:42 -04:00
parent 4132373fee
commit 9926739cb6
9 changed files with 162 additions and 49 deletions
-2
View File
@@ -416,7 +416,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = JanitorialQC/Info.plist;
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
@@ -460,7 +459,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = JanitorialQC/Info.plist;
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
+4
View File
@@ -698,6 +698,10 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 69 | **`ExecuteInspectionView.resolveAndFulfillSchedule()` links the submission to its schedule and invalidates the cached row — it never computes the next due date** | Two entry points reach the identical form (Scheduled row → prefilled; `+` → hand-picked), but only the first sets `scheduledInspectionServerId`, so a `+`-started inspection lands with `scheduled_inspection_id = NULL` and the schedule stays **Active** on the web. The fallback matches an active `LocalScheduledInspection` on facility + template, gated to `dueDateString <= today`, assignee `nil`-or-self, earliest due first, and skipped for re-inspections. Roll-forward stays server-side: the local model has no phase43 recurrence detail (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the row is **flagged `fulfilledLocally`, never deleted** (see rule 71), and `pullScheduledInspections()` writes the authoritative `next_due_date` via `update(from:)` — which also self-heals a submission that never lands. |
| 70 | **Snapshot schedule instructions into `@State` in `onAppear` — never read them from SwiftData during `body`** | `ExecuteInspectionView` shows the schedule's instructions above the form, but `resolveAndFulfillSchedule()` **deletes** that `LocalScheduledInspection` the instant Submit is tapped and the view stays up for another 2.5 s showing the success banner. A computed lookup would re-read a deleted `PersistentModel` in that window and trap. `loadScheduleInstructions()` copies the `String` once, at appear. Same reasoning as `ScheduledStartTarget` (rule 68): once the fulfilment path can delete a cached row mid-flow, every consumer must hold a value, not the model. |
| 71 | **Never delete a cached row the server is going to send again — flag it** | The first cut of `resolveAndFulfillSchedule()` deleted the `LocalScheduledInspection` at submit. One-time schedules were fine (the server deactivates them and never returns them again), but every **recurring** schedule is returned again on its next occurrence, so each completion became delete-then-reinsert against an `@Attribute(.unique)` serverId — and the reinserted row did not reliably carry the rolled-forward date. A daily schedule kept showing today's date after being completed. Fix: `fulfilledLocally: Bool = false` hides the row locally; `init(from:)`/`update(from:)` clear it, so the pull remains the only thing that ever writes a cached schedule's dates. Deletion of schedule rows now happens in exactly one place — the "server no longer returns it" branch of `pullScheduledInspections()`. |
| 72 | **A toolbar `Label` needs `.labelStyle(.titleAndIcon)` or SwiftUI renders it icon-only** | `IssuesView`'s New Issue button was written as `Label("New Issue", systemImage: "plus")` and still appeared on the iPad as a bare "+" — SwiftUI decides toolbar label styling itself and drops the title. Having the text in the source is not enough; state the style explicitly. Both creation entry points (`MyInspectionsView` → New Inspection, `IssuesView` → New Issue) now pin `.titleAndIcon` alongside `.borderedProminent`. |
| 73 | **Both background-sync Info.plist keys must live in `Info.plist` itself — `INFOPLIST_KEY_*` cannot express them** | `BGTaskSchedulerPermittedIdentifiers` and `UIBackgroundModes` are both **arrays**. `INFOPLIST_KEY_*` build settings only merge Xcode's recognised key list and only as **strings**, so `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync` never produced a valid entry — it sat inert in the pbxproj while `BGTaskScheduler.submit()` failed with `.notPermitted` under a `try?`. Adding `UIBackgroundModes` then made App Store Connect check, and the upload was rejected with **error 90771**. Both keys now live in `JanitorialQC/Info.plist` as arrays and the build setting is deleted from both configurations. Do not reintroduce it: a build setting overwrites the file's value at merge time. Keep the identifier string in sync with `BGTaskScheduler.register` / `BGProcessingTaskRequest` in `JanitorialQCApp`. Verify a build before uploading: `plutil -p <built .app>/Info.plist \| grep -A2 BGTask` must show an array. |
| 74 | **Two different background mechanisms — do not confuse them** | `BGProcessingTask` (JanitorialQCApp) asks iOS to **wake us later**: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. `beginBackgroundTask` (`SyncManager.beginSyncBackgroundTask`) asks iOS **not to suspend us right now**: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs `MainActor.assumeIsolated` because the closure is nonisolated under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. |
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
---
+21 -1
View File
@@ -1,5 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<!-- Background sync. BOTH keys are required and BOTH must live in this
file, not in build settings.
INFOPLIST_KEY_* only merges Xcode's recognised key list and it merges
STRING values. BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so
the old INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers build setting
never produced a valid entry — App Store Connect rejected the upload
with error 90771 as soon as UIBackgroundModes made the validator look.
That build setting has been removed from project.pbxproj; do not put it
back. Keep the identifier below in sync with the string passed to
BGTaskScheduler.register/BGProcessingTaskRequest in JanitorialQCApp. -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.jqc.sync</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
</array>
</dict>
</plist>
+42 -3
View File
@@ -128,7 +128,11 @@ struct JanitorialQCApp: App {
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
// register() returns false when the identifier is not declared in
// BGTaskSchedulerPermittedIdentifiers or the `processing` background
// mode is missing. Both are easy to lose in a project settings change
// and the failure is otherwise completely silent, so it is logged.
let registered = BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.jqc.sync",
using: nil
) { task in
@@ -138,16 +142,44 @@ struct JanitorialQCApp: App {
}
handleBackgroundSync(task: processingTask)
}
if !registered {
print("[JQC] BGTaskScheduler.register FAILED for com.jqc.sync — "
+ "check UIBackgroundModes contains 'processing' and "
+ "BGTaskSchedulerPermittedIdentifiers contains com.jqc.sync")
}
}
private func handleBackgroundSync(task: BGProcessingTask) {
// Re-arm first: if anything below throws or the task is killed, a
// request is already queued for the next opportunity.
scheduleBackgroundSync()
let syncTask = Task {
let syncTask = Task { @MainActor in
// A BGTaskScheduler launch does not render ContentView, so the
// `.task { restoreSession() }` there never runs and
// AuthManager.isAuthenticated is still false. triggerSync() guards
// on it and would return having done nothing at all.
if !AuthManager.shared.isAuthenticated {
await AuthManager.shared.restoreSession()
}
// The SwiftData container is created by the `.modelContainer`
// scene modifier, so on a COLD background launch (process was
// terminated, no scene connected) there is no context to drain.
// The common case app suspended but still resident has one.
guard SyncManager.shared.modelContext != nil else {
print("[JQC] background sync skipped — no model context "
+ "(cold launch, no scene)")
return
}
await SyncManager.shared.triggerSync()
}
task.expirationHandler = {
syncTask.cancel()
}
Task {
await syncTask.value
task.setTaskCompleted(success: !syncTask.isCancelled)
@@ -178,5 +210,12 @@ func scheduleBackgroundSync() {
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// Was `try?`. Submitting without the `processing` background mode fails
// with BGTaskSchedulerError.notPermitted, which is exactly how this
// whole path stayed dead unnoticed. Never swallow it again.
print("[JQC] BGTaskScheduler.submit failed: \(error)")
}
}
-42
View File
@@ -1,42 +0,0 @@
JQC iOS — recurring schedules keep showing the old due date
===========================================================
Repo: jqc_ios_app iOS ONLY — no server change, no migration.
OVERWRITE (paths relative to the folder containing JanitorialQC.xcodeproj):
JanitorialQC/Models/LocalScheduledInspection.swift
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
JanitorialQC/CLAUDE.md
No new files, none removed, no .xcodeproj edit.
SwiftData: `fulfilledLocally: Bool = false` is a new stored property, but it is
non-optional WITH an inline default -> lightweight migration. Existing rows read
as false. NO need to delete the app from the iPad.
BUILD
Product > Clean Build Folder (Shift-Cmd-K), then run.
VERIFY
1. DAILY schedule due today. Complete + submit on the iPad.
- SCHEDULED card clears immediately
- within one sync cycle (<=60s) the row REAPPEARS showing TOMORROW
Cross-check the server agrees:
SELECT id, frequency, next_due_date, last_completed_at
FROM scheduled_inspections WHERE id = <id>;
2. WEEKLY Mon/Wed/Fri: complete on Monday -> row returns showing Wednesday.
3. MONTHLY day-of-month: complete -> row returns showing next month.
4. ONE-TIME: complete -> row clears and STAYS gone (server deactivates it).
This is the case that always worked and must not regress.
5. Offline: airplane mode, complete a daily schedule
-> card clears at once, row stays hidden while offline
-> re-enable network -> row returns with tomorrow's date
6. Failed submit: if the inspection never syncs, the row comes back with its
ORIGINAL date (still genuinely due) rather than staying hidden.
IF STEP 1 STILL FAILS
Check which side is wrong before changing the app again:
- server shows tomorrow, iPad shows today -> client, reopen this fix
- server still shows today -> fulfill() never ran; check
sudo journalctl -u janitorial_qc | grep "schedule fulfilled"
+40
View File
@@ -10,6 +10,7 @@ import SwiftData
import SwiftUI
import Combine
import UserNotifications
import UIKit // beginBackgroundTask see beginSyncBackgroundTask()
@MainActor
class SyncManager: ObservableObject {
@@ -110,6 +111,36 @@ class SyncManager: ObservableObject {
pollTask = nil
}
// Background task assertion
// Keeps the process alive across a suspend so an in-flight sync can finish.
// Distinct from the BGProcessingTask in JanitorialQCApp: that one asks iOS
// to WAKE us later, this one asks it not to suspend us right now.
private var syncBackgroundTaskId: UIBackgroundTaskIdentifier = .invalid
private func beginSyncBackgroundTask() {
// triggerSync() is re-entrancy guarded, but assert defensively anyway:
// beginning a second assertion would leak the first identifier.
guard syncBackgroundTaskId == .invalid else { return }
syncBackgroundTaskId = UIApplication.shared.beginBackgroundTask(
withName: "JQC.syncDrain"
) {
// Called on the main thread when the grace period runs out. It MUST
// end the assertion or iOS terminates the app. assumeIsolated is
// required because the handler is a nonisolated closure under
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
MainActor.assumeIsolated {
SyncManager.shared.endSyncBackgroundTask()
}
}
}
private func endSyncBackgroundTask() {
guard syncBackgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(syncBackgroundTaskId)
syncBackgroundTaskId = .invalid
}
/// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() {
lastNotificationFetch = nil
@@ -232,6 +263,15 @@ class SyncManager: ObservableObject {
syncError = nil
defer { isSyncing = false }
// Ask iOS to keep the process alive long enough to finish the drain.
// The case this covers: an inspector taps Submit and immediately locks
// the iPad or swipes to another app. Without an assertion the process
// suspends mid-upload and the work waits for the next launch.
// Roughly 30 s of grace; the expiration handler ends it cleanly so iOS
// never force-kills us. Harmless in the foreground it simply ends.
beginSyncBackgroundTask()
defer { endSyncBackgroundTask() }
await processPhotoQueue(context: context)
await processInspectionQueue(context: context)
await processIssueQueue(context: context)
@@ -145,11 +145,15 @@ struct IssuesListView: View {
// New Issue borderedProminent so it stands out clearly
// from the filter icon and is easy to find at a glance.
// `.titleAndIcon` is required: without it SwiftUI collapses the
// Label to icon-only in a toolbar, so this rendered as a bare
// "+" despite having a title in code.
Button {
showNewIssue = true
} label: {
Label("New Issue", systemImage: "plus")
}
.labelStyle(.titleAndIcon)
.buttonStyle(.borderedProminent)
}
}
@@ -101,9 +101,14 @@ struct MyInspectionsView: View {
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
// Labelled, not a bare "+". `.titleAndIcon` is required:
// SwiftUI collapses a toolbar Label to icon-only on its own,
// which is what made this read as an unlabelled plus sign.
Button { showNewInspection = true } label: {
Image(systemName: "plus")
Label("New Inspection", systemImage: "plus")
}
.labelStyle(.titleAndIcon)
.buttonStyle(.borderedProminent)
}
}
.fullScreenCover(isPresented: $showNewInspection) {
+45
View File
@@ -0,0 +1,45 @@
JQC iOS — FIX for App Store Connect error 90771
===============================================
Missing Info.plist value: BGTaskSchedulerPermittedIdentifiers
This supersedes the Info.plist from the previous background-sync drop.
The Swift files from that drop are unchanged and still correct.
OVERWRITE:
JanitorialQC/Info.plist <- now has BOTH keys, as arrays
JanitorialQC.xcodeproj/project.pbxproj <- removes 2 inert lines
JanitorialQC/CLAUDE.md
WHAT WAS WRONG
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync
was set in build settings (Debug + Release). INFOPLIST_KEY_* only merges
Xcode's recognised keys, and only as STRINGS.
BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so nothing valid ever
reached the built Info.plist. It went unnoticed until UIBackgroundModes was
added, which is what makes the App Store validator check for it.
pbxproj CHANGE IS EXACTLY 2 LINE DELETIONS (verified by diff):
line 419 (Debug) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
line 463 (Release) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
Nothing else touched. If you prefer not to take a pbxproj file, delete those
two lines by hand, or in Xcode: target > Build Settings > search "BGTask" >
select the row > Delete.
DO NOT put that build setting back. A build setting overwrites the plist
file's value at merge time and the array becomes a string again.
BUILD + VERIFY BEFORE UPLOADING
1. Product > Clean Build Folder (Shift-Cmd-K)
2. Product > Archive
3. In Organizer: right-click the archive > Show in Finder >
Show Package Contents > Products/Applications/JanitorialQC.app
plutil -p JanitorialQC.app/Info.plist | grep -A3 -E "BGTask|UIBackground"
EXPECT:
"BGTaskSchedulerPermittedIdentifiers" => [ 0 => "com.jqc.sync" ]
"UIBackgroundModes" => [ 0 => "processing" ]
Both must be arrays. If either shows a bare string, stop - the build
setting is back.
4. Then upload. Error 90771 should be gone.
5. On device, confirm the console shows NO
"[JQC] BGTaskScheduler.register FAILED" line at launch.