field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 872a7906956353d86e400848d6ea0a6631dc829f
parent 8b864bac875632600193ec8a9bbbc0a3ba0d677f
Author: triesap <tyson@radroots.org>
Date:   Thu,  1 Oct 2026 17:21:43 +0000

ios: retain navigation during durable publication

- Preserve publication while a separate composer is created
- Keep status and explicit stop bound to the original request
- Fence uncertain capture and retain one bounded worker
- Verify native navigation recovery and exact consumer inputs

Diffstat:
MTera.xcodeproj/project.pbxproj | 4++++
MTera/State/TeraAddAffordances.swift | 8++++++--
MTera/State/TeraAddStore.swift | 6+++---
MTera/State/TeraPublicationActions.swift | 4++--
MTera/State/TeraSubmissionStore.swift | 23+++++++++++++++++++++--
MTera/Views/TeraAddView.swift | 2+-
ATeraTests/TeraPublicationNavigationTests.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 1+
Mrelease/provenance.json | 2+-
9 files changed, 168 insertions(+), 11 deletions(-)

diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ 7B67600F1C1DA7DD780FA515 /* TeraNativeRepairPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0B5B9169842E64FF792D88 /* TeraNativeRepairPreviewTests.swift */; }; 7B8C7C130DC15AF0266F2544 /* TeraEditingReplacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */; }; 7C41A38BE082F1CA28DD4C4F /* TeraUploadRenewalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D9E98DC275CB5F2FB9BA16 /* TeraUploadRenewalTests.swift */; }; + 7D220F7F83A80A2D312AA5B0 /* TeraPublicationNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */; }; 7DFBB14B53D57555AB0E0BF0 /* TeraScopeFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */; }; 80B4E24CE4411B5985839462 /* TeraSubmissionFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62DD95FB04526E6E69AB3D8 /* TeraSubmissionFormTests.swift */; }; 80F3B531DAA8C2F6CE17D357 /* TeraDurableMediaRootsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22BEF874B8D521920E1D16B7 /* TeraDurableMediaRootsTests.swift */; }; @@ -253,6 +254,7 @@ 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddStoreTests.swift; sourceTree = "<group>"; }; 9F54FC4930051FC4611B37D3 /* tera */ = {isa = PBXFileReference; lastKnownFileType = folder; name = tera; path = .; sourceTree = SOURCE_ROOT; }; 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraOperationRecoveryTests.swift; sourceTree = "<group>"; }; + A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraPublicationNavigationTests.swift; sourceTree = "<group>"; }; A1CC48A95774B931BE8D1E4A /* TeraAddSignerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSignerFixture.swift; sourceTree = "<group>"; }; A48EE7852ECF14C15F7DD902 /* TeraRevisionTestBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRevisionTestBackend.swift; sourceTree = "<group>"; }; A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRecoveryUITests.swift; sourceTree = "<group>"; }; @@ -425,6 +427,7 @@ 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */, 7AF60EF9DCFDC4ECE77AED67 /* TeraProductStartupTests.swift */, 3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */, + A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */, 56F7B42264CD896F76189F33 /* TeraPublicationRetryTests.swift */, 635EB4A552C563E85557A485 /* TeraPublicationStageTests.swift */, 293D968D4E2ACA3BA414BF8D /* TeraPublicationTargetTests.swift */, @@ -753,6 +756,7 @@ 026981088C27CB79A49D9833 /* TeraOperationRecoveryTests.swift in Sources */, C46F8EB5B196FFDD37E0279A /* TeraProductStartupTests.swift in Sources */, 093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */, + 7D220F7F83A80A2D312AA5B0 /* TeraPublicationNavigationTests.swift in Sources */, AA8B25877C9A3BD331B1B1AE /* TeraPublicationRetryTests.swift in Sources */, A92BADDE348A4D5C56EDE2A1 /* TeraPublicationStageTests.swift in Sources */, 4D24610694C7F80F7B5DA904 /* TeraPublicationTargetTests.swift in Sources */, diff --git a/Tera/State/TeraAddAffordances.swift b/Tera/State/TeraAddAffordances.swift @@ -1,8 +1,12 @@ import Foundation extension TeraAddStore { + var canCreateNewComposer: Bool { + !isWorking && submissions.canReplaceEditing && !protection.isWorking && !protection.failed + } + func selectType(_ type: TeraAddCommandType) { - guard !isWorking, !submissions.isWorking, isFormEditable, form.commandType != type else { return } + guard canCreateNewComposer, isFormEditable, form.commandType != type else { return } newDraft(type: type) } @@ -24,7 +28,7 @@ extension TeraAddStore { } var canSubmit: Bool { - if activeDraft == nil, submissions.hasAction { + if activeDraft == nil, submissions.usesCurrentAction { return isProductReady && !isWorking && submissions.canContinue } return isProductReady && !isWorking && !submissions.isWorking diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift @@ -91,7 +91,7 @@ final class TeraAddStore: ObservableObject { } func newDraft(type: TeraAddCommandType? = nil) { - guard !isWorking, !submissions.isWorking, !protection.isWorking, !protection.failed else { return } + guard canCreateNewComposer else { return } if needsEditingPreservation { protection.schedule(kind: .editing, save: preservation(), apply: replacement { store, _ in store.replaceWithNew(type: type) }) } else { @@ -100,7 +100,7 @@ final class TeraAddStore: ObservableObject { } private func replaceWithNew(type: TeraAddCommandType?) { - guard !submissions.isWorking else { return } + guard submissions.canReplaceEditing else { return } submissions.newAction() composer.reset(scope: composer.scope) generation = generation.invalidated() @@ -254,7 +254,7 @@ final class TeraAddStore: ObservableObject { func submit(locale: Locale = .current) async { // Only a new editable request may translate entry text. Existing capture, // retry, revision operation and signed bytes retain their original inputs. - if canSubmit, !protection.isWorking, isFormEditable, !submissions.hasAction, activeDraft?.isRevision != true { + if canSubmit, !protection.isWorking, isFormEditable, !submissions.usesCurrentAction, activeDraft?.isRevision != true { form = TeraFoodDecimalEntry.form(form, locale: locale) } if activeDraft == nil, revisionTarget == nil { diff --git a/Tera/State/TeraPublicationActions.swift b/Tera/State/TeraPublicationActions.swift @@ -29,7 +29,7 @@ extension TeraSubmissionStore { extension TeraAddStore { var submitAccessibilityValue: String { - if activeDraft == nil, submissions.hasAction { + if activeDraft == nil, submissions.usesCurrentAction { let message = submissions.actionExplanation if let code = submissions.failureCode { return "\(message) Error code \(code)" @@ -52,7 +52,7 @@ extension TeraAddStore { } var submitLabel: String { - if activeDraft == nil, submissions.hasAction { + if activeDraft == nil, submissions.usesCurrentAction { return submissions.actionLabel } if activeDraft?.kind == .retraction { diff --git a/Tera/State/TeraSubmissionStore.swift b/Tera/State/TeraSubmissionStore.swift @@ -22,6 +22,7 @@ final class TeraSubmissionStore: ObservableObject { private var worker: Task<Void, Never>? private var paused = false private let stopControl = TeraSubmissionStopControl() + private(set) var isNewComposer = false init(client: TeraRuntimeClient, composer: TeraComposerAutosave, media: (any TeraAddMediaHandling)?) { self.client = client @@ -36,6 +37,14 @@ final class TeraSubmissionStore: ObservableObject { request != nil || capture != nil } + var usesCurrentAction: Bool { + hasAction && !isNewComposer + } + + var canReplaceEditing: Bool { + worker == nil || (capture == nil && status != nil) + } + func configure(scope: TeraComposerScope, context: TeraLocalNetwork) { self.context = context guard self.scope != scope else { return } @@ -47,6 +56,7 @@ final class TeraSubmissionStore: ObservableObject { stopControl.reset() message = nil failureCode = nil + isNewComposer = false inventory.configure(scope: scope) changed() } @@ -107,6 +117,9 @@ final class TeraSubmissionStore: ObservableObject { if let worker { await worker.value; return } + if isNewComposer { + newAction() + } do { if !hasAction { capture = try composer.beginSubmissionCapture(TeraComposerForm(editing: form)) @@ -136,7 +149,7 @@ final class TeraSubmissionStore: ObservableObject { /// New is explicit. Preserve the old captured source in its own composer row /// before the editing interlock saves newer input under a fresh composer ID. func preserveForReplacement(currentForm: () -> TeraAddForm) async throws { - guard worker == nil else { + guard canReplaceEditing else { throw TeraRuntimeFailure.local(operation: "submission.new", code: "operation_in_progress", safeMessage: "The original submission is still returning. Keep editing and retry New when it finishes.") } @@ -153,7 +166,13 @@ final class TeraSubmissionStore: ObservableObject { } func newAction() { - guard worker == nil else { return } + guard canReplaceEditing else { return } + if worker != nil { + isNewComposer = true + changed() + return + } + isNewComposer = false capture = nil request = nil status = nil diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift @@ -129,7 +129,7 @@ struct TeraAddView: View { Label("New", systemImage: "square.and.pencil") } .accessibilityIdentifier("radroots.add.new") - .disabled(store.submissions.isWorking) + .disabled(!store.canCreateNewComposer) } ToolbarItemGroup(placement: .keyboard) { Spacer() diff --git a/TeraTests/TeraPublicationNavigationTests.swift b/TeraTests/TeraPublicationNavigationTests.swift @@ -0,0 +1,129 @@ +import Foundation +@testable import TeraApp +import XCTest + +@MainActor +final class TeraPublicationNavigationTests: XCTestCase { + func testNewDuringUploadAndAdvanceRetainsWorkerAndCreatesSeparateComposer() async throws { + for upload in [false, true] { + let fixture = try await NavigationFixture(upload: upload) + let store = fixture.store + let original = try XCTUnwrap(store.submissions.status) + XCTAssertTrue(store.canCreateNewComposer) + // Navigation cancels the view waiter and observation, not publication. + fixture.waiter.cancel() + store.suspend() + await store.start() + store.newDraft(type: .createAsk) + await TeraScopeFixtures.eventually { !store.protection.isWorking } + XCTAssertFalse(store.protection.failed) + XCTAssertEqual(store.form.commandType, .createAsk) + XCTAssertTrue(store.form.content.isEmpty) + XCTAssertTrue(store.submissions.isWorking) + XCTAssertEqual(store.submissions.request, original.request) + XCTAssertEqual(store.submissions.status?.captured, original.captured) + XCTAssertEqual(store.submitLabel, "Submit") + XCTAssertFalse(store.canSubmit, "One bounded worker; New does not start another effect") + store.updateForm(\.content, "separate new editing") + await store.save() + let editing = try XCTUnwrap(store.savedComposer) + XCTAssertNotEqual(editing.id, original.captured.id) + XCTAssertEqual(editing.form.content, "separate new editing") + await fixture.pause.resume.open() + await fixture.waiter.value + XCTAssertEqual(store.submissions.status?.request, original.request) + XCTAssertEqual(store.submissions.status?.captured, original.captured) + XCTAssertEqual(store.form.content, "separate new editing") + XCTAssertTrue(store.canSubmit) + await store.submit() + let next = try XCTUnwrap(store.submissions.status) + XCTAssertNotEqual(next.request.commandID, original.request.commandID) + XCTAssertNotEqual(next.captured.id, original.captured.id) + XCTAssertEqual(next.captured.form.content, "separate new editing") + let count = await fixture.backend.submissionBackend.prepareCount + XCTAssertEqual(count, 2, "Exactly one original and one explicit new submission") + store.stop() + _ = try await fixture.client.stop() + } + } + + func testNewRefusesUnacknowledgedPreparationWithoutDiscardingRequest() async throws { + let backend = AddBackend() + let client = try await TeraAddStoreTests.startedClient(backend) + let store = TeraAddStore(runtimeClient: client) + await store.configure(snapshot: backend.snapshot()) + await store.start() + store.updateForm(\.content, "pending capture") + let pause = ResourceTestPause() + await backend.submissionBackend.pausePrepare(pause) + let waiter = Task { await store.submit() } + await pause.entered.wait() + let request = store.submissions.request + XCTAssertFalse(store.canCreateNewComposer) + store.newDraft() + XCTAssertEqual(store.form.content, "pending capture") + XCTAssertEqual(store.submissions.request, request) + await pause.resume.open() + await waiter.value + store.stop() + _ = try await client.stop() + } + + func testExplicitStopAfterNewStillTargetsOriginalPublication() async throws { + let fixture = try await NavigationFixture(upload: false) + let store = fixture.store + let request = try XCTUnwrap(store.submissions.request) + store.newDraft(type: .createAsk) + await TeraScopeFixtures.eventually { !store.protection.isWorking } + store.updateForm(\.content, "retain separate editing") + await store.submissions.requestStop() + XCTAssertEqual(store.submissions.request, request) + XCTAssertEqual(store.submissions.status?.delivery.isStopped, true) + await fixture.pause.resume.open() + await fixture.waiter.value + XCTAssertEqual(store.form.content, "retain separate editing") + XCTAssertEqual(store.submissions.request, request) + let count = await fixture.backend.submissionBackend.prepareCount + XCTAssertEqual(count, 1) + store.stop() + _ = try await fixture.client.stop() + } +} + +@MainActor +private struct NavigationFixture { + let backend: AddBackend + let client: TeraRuntimeClient + let store: TeraAddStore + let pause: ResourceTestPause + let waiter: Task<Void, Never> + + init(upload: Bool) async throws { + let backend = AddBackend() + let client = try await TeraAddStoreTests.startedClient(backend) + let media = AddMediaHarness(foreground: true) + let store = TeraAddStore(runtimeClient: client, media: media) + await store.configure(snapshot: backend.snapshot()) + await store.start() + if upload { + store.selectType(.createPhotoUpdate) + await TeraScopeFixtures.eventually { !store.protection.isWorking } + let photo = await media.captureImage() + store.updateForm(\.media, [photo]) + } + store.updateForm(\.content, "frozen original") + let pause = ResourceTestPause() + if upload { + await backend.submissionBackend.pauseForeground(pause) + } else { + await backend.submissionBackend.pauseAdvance(pause) + } + let waiter = Task { await store.submit() } + await pause.entered.wait() + self.backend = backend + self.client = client + self.store = store + self.pause = pause + self.waiter = waiter + } +} diff --git a/project.yml b/project.yml @@ -115,6 +115,7 @@ targets: - path: TeraTests/TeraStateMigrationTests.swift - path: TeraTests/TeraIdentityRecoveryTests.swift - path: TeraTests/TeraKeyRemovalTests.swift + - path: TeraTests/TeraPublicationNavigationTests.swift - path: TeraTests/TeraIdentityCancellationTests.swift - path: TeraTests/TeraTodayStoreTests.swift - path: TeraTests/TeraTodaySyncFixtures.swift diff --git a/release/provenance.json b/release/provenance.json @@ -6,7 +6,7 @@ "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "904302bddb22ac1e4f9e79ab1145e590a8eeac7dd75e42539fb72eb0b7ebd4b1", - "xcode_project_sha256": "39b5a8f581ac7f1cf858c54d7cdea5f6de1174b1ab77b07b992f73bc8dc5ac9b" + "xcode_project_sha256": "085b1ac33c28beffcc9c551dff69ff7323eea99469e7092bb61998db2f8f0c9e" }, "disposition": "unsigned", "platforms": [