From 9926739cb673c69b0bbef5eeaac7fb3eab8ec40a Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 27 Jul 2026 16:43:42 -0400 Subject: [PATCH] Jul 27 - Update code for scheduled tasks 4 --- JanitorialQC.xcodeproj/project.pbxproj | 2 - JanitorialQC/CLAUDE.md | 4 ++ JanitorialQC/Info.plist | 22 ++++++++- JanitorialQC/JanitorialQCApp.swift | 45 +++++++++++++++++-- JanitorialQC/PLACEMENT_MAP.txt | 42 ----------------- JanitorialQC/Sync/SyncManager.swift | 40 +++++++++++++++++ JanitorialQC/Views/Dashboard/IssuesView.swift | 4 ++ .../Views/Dashboard/MyInspectionsView.swift | 7 ++- PLACEMENT_MAP.txt | 45 +++++++++++++++++++ 9 files changed, 162 insertions(+), 49 deletions(-) delete mode 100644 JanitorialQC/PLACEMENT_MAP.txt create mode 100644 PLACEMENT_MAP.txt diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 7b1970f..e51bab4 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -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."; diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index e8b0f37..fd2b239 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -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 /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. | --- diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist index 0c67376..b9ecf73 100644 --- a/JanitorialQC/Info.plist +++ b/JanitorialQC/Info.plist @@ -1,5 +1,25 @@ - + + + BGTaskSchedulerPermittedIdentifiers + + com.jqc.sync + + UIBackgroundModes + + processing + + diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index a0effae..ebd886b 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -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)") + } } diff --git a/JanitorialQC/PLACEMENT_MAP.txt b/JanitorialQC/PLACEMENT_MAP.txt deleted file mode 100644 index a0188de..0000000 --- a/JanitorialQC/PLACEMENT_MAP.txt +++ /dev/null @@ -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 = ; - 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" diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 07d91f5..f959269 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -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) diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index 2a7085a..f4c3ae7 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -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) } } diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 548dde4..14079fc 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -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) { diff --git a/PLACEMENT_MAP.txt b/PLACEMENT_MAP.txt new file mode 100644 index 0000000..7a8759e --- /dev/null +++ b/PLACEMENT_MAP.txt @@ -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.