Jul 27 - Update code for scheduled tasks 4
This commit is contained in:
@@ -416,7 +416,6 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||||
@@ -460,7 +459,6 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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()`. |
|
| 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
@@ -1,5 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<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>
|
</plist>
|
||||||
|
|||||||
@@ -128,7 +128,11 @@ struct JanitorialQCApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func registerBackgroundTasks() {
|
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",
|
forTaskWithIdentifier: "com.jqc.sync",
|
||||||
using: nil
|
using: nil
|
||||||
) { task in
|
) { task in
|
||||||
@@ -138,16 +142,44 @@ struct JanitorialQCApp: App {
|
|||||||
}
|
}
|
||||||
handleBackgroundSync(task: processingTask)
|
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) {
|
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()
|
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()
|
await SyncManager.shared.triggerSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
task.expirationHandler = {
|
task.expirationHandler = {
|
||||||
syncTask.cancel()
|
syncTask.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await syncTask.value
|
await syncTask.value
|
||||||
task.setTaskCompleted(success: !syncTask.isCancelled)
|
task.setTaskCompleted(success: !syncTask.isCancelled)
|
||||||
@@ -178,5 +210,12 @@ func scheduleBackgroundSync() {
|
|||||||
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
||||||
request.requiresNetworkConnectivity = true
|
request.requiresNetworkConnectivity = true
|
||||||
request.requiresExternalPower = false
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -10,6 +10,7 @@ import SwiftData
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import UIKit // beginBackgroundTask — see beginSyncBackgroundTask()
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class SyncManager: ObservableObject {
|
class SyncManager: ObservableObject {
|
||||||
@@ -110,6 +111,36 @@ class SyncManager: ObservableObject {
|
|||||||
pollTask = nil
|
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.
|
/// Called on logout so the next login starts a clean fetch.
|
||||||
func resetNotificationPoller() {
|
func resetNotificationPoller() {
|
||||||
lastNotificationFetch = nil
|
lastNotificationFetch = nil
|
||||||
@@ -232,6 +263,15 @@ class SyncManager: ObservableObject {
|
|||||||
syncError = nil
|
syncError = nil
|
||||||
defer { isSyncing = false }
|
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 processPhotoQueue(context: context)
|
||||||
await processInspectionQueue(context: context)
|
await processInspectionQueue(context: context)
|
||||||
await processIssueQueue(context: context)
|
await processIssueQueue(context: context)
|
||||||
|
|||||||
@@ -145,11 +145,15 @@ struct IssuesListView: View {
|
|||||||
|
|
||||||
// New Issue — borderedProminent so it stands out clearly
|
// New Issue — borderedProminent so it stands out clearly
|
||||||
// from the filter icon and is easy to find at a glance.
|
// 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 {
|
Button {
|
||||||
showNewIssue = true
|
showNewIssue = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("New Issue", systemImage: "plus")
|
Label("New Issue", systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,14 @@ struct MyInspectionsView: View {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
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: {
|
Button { showNewInspection = true } label: {
|
||||||
Image(systemName: "plus")
|
Label("New Inspection", systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showNewInspection) {
|
.fullScreenCover(isPresented: $showNewInspection) {
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user