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 8cae06e6e2eb7a92851d9403539ab6cbfc0613d3
parent 7ad4815ea00185d58b0d70649924fa629d88a505
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 16:51:55 +0000

today: make context transitions presentation atomic

- Reconcile runtime context snapshots and retain only valid selected choices.
- Clear scoped cards and supporting stores before publishing a new scope.
- Reset detail and sheet navigation on account or context presentation changes.
- Verify seven context regressions with unchanged artifacts and quality ceilings.

Diffstat:
MPackage.swift | 1+
MTera.xcodeproj/project.pbxproj | 4++++
MTera/App/TeraProductStores.swift | 7+++++++
MTera/App/TeraRootShell.swift | 28+++++-----------------------
ATera/App/TeraTodayNavigation.swift | 43+++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraStoreObservation.swift | 2++
MTera/State/TeraTodayStore.swift | 33+++++++++++++++++++--------------
MTeraTests/TeraScopeFixtures.swift | 5+++--
ATeraTests/TeraTodayContextTests.swift | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 1+
Mrelease/provenance.json | 2+-
11 files changed, 293 insertions(+), 40 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -54,6 +54,7 @@ let package = Package( "App/TeraRemoteQualification.swift", "App/TeraRemoteQualificationEvidence.swift", "App/TeraRootShell.swift", + "App/TeraTodayNavigation.swift", "Runtime/TeraAddMediaCoordinator.swift", "Runtime/TeraBackgroundUploadRequest.swift", "Runtime/TeraCheckedTime.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */; }; 912C8743116D929BB6A02778 /* TeraRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */; }; 9384AAECB22885E2D05C54A8 /* TeraScopedMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */; }; + 9995BC15D67E1C9C29B4A7DC /* TeraTodayContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */; }; 9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */; }; 9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */; }; A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */; }; @@ -136,6 +137,7 @@ E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; }; E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedObservationTests.swift; sourceTree = "<group>"; }; EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedMediaTests.swift; sourceTree = "<group>"; }; + EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayContextTests.swift; sourceTree = "<group>"; }; F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceFixtures.swift; sourceTree = "<group>"; }; FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -241,6 +243,7 @@ BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */, C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */, 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */, + EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */, 99BE248D1450D76476255626 /* TeraTodayPresentationTests.swift */, 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */, 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */, @@ -492,6 +495,7 @@ 30EDD3EB0C09D864FC1DD3D4 /* TeraStoreRecoveryTests.swift in Sources */, 2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */, E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */, + 9995BC15D67E1C9C29B4A7DC /* TeraTodayContextTests.swift in Sources */, AA7C3FC1F46B6723A39B1D09 /* TeraTodayPresentationTests.swift in Sources */, 64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */, 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */, diff --git a/Tera/App/TeraProductStores.swift b/Tera/App/TeraProductStores.swift @@ -21,6 +21,13 @@ final class TeraProductStores { me = TeraMeStore(runtimeClient: runtimeClient) settings = TeraSettingsStore(runtimeClient: runtimeClient) media = TeraMediaStore(runtimeClient: runtimeClient) + today.scopeWillChange = { [search, me, media] context in + search.stop() + search.configure(context: context) + me.stop() + me.configure(context: context) + media.reset() + } } deinit { diff --git a/Tera/App/TeraRootShell.swift b/Tera/App/TeraRootShell.swift @@ -43,31 +43,13 @@ struct TeraRootShell: View { var body: some View { TabView(selection: selection) { - NavigationStack { + Group { if let stores { - TeraTodayView( - snapshot: snapshot, - store: stores.today, - searchStore: stores.search, - meStore: stores.me, - addStore: stores.add, - settingsStore: stores.settings, - mediaStore: stores.media, - revise: { card in - Task { - await stores.add.retractAndRevise(card) - storedSelection = TeraRootTab.add.rawValue - } - }, - retract: { card in - Task { - await stores.add.retract(card) - storedSelection = TeraRootTab.add.rawValue - } - } - ) + TeraTodayNavigation(snapshot: snapshot, stores: stores) { + storedSelection = TeraRootTab.add.rawValue + } } else { - TeraTodayLanding(snapshot: snapshot) + NavigationStack { TeraTodayLanding(snapshot: snapshot) } } } .tabItem { Label("Today", systemImage: "sun.max.fill") } diff --git a/Tera/App/TeraTodayNavigation.swift b/Tera/App/TeraTodayNavigation.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// A scope change also ends the lifetime of its details and supporting sheets. +struct TeraTodayNavigation: View { + let snapshot: TeraRuntimeSnapshot + let stores: TeraProductStores + let selectAdd: () -> Void + @ObservedObject private var today: TeraTodayStore + + init(snapshot: TeraRuntimeSnapshot, stores: TeraProductStores, selectAdd: @escaping () -> Void) { + self.snapshot = snapshot + self.stores = stores + self.selectAdd = selectAdd + today = stores.today + } + + var body: some View { + NavigationStack { + TeraTodayView( + snapshot: snapshot, + store: today, + searchStore: stores.search, + meStore: stores.me, + addStore: stores.add, + settingsStore: stores.settings, + mediaStore: stores.media, + revise: { card in + Task { + await stores.add.retractAndRevise(card) + selectAdd() + } + }, + retract: { card in + Task { + await stores.add.retract(card) + selectAdd() + } + } + ) + } + .id(today.scopeGeneration) + } +} diff --git a/Tera/State/TeraStoreObservation.swift b/Tera/State/TeraStoreObservation.swift @@ -3,11 +3,13 @@ import Foundation /// Stable configuration inputs, excluding changing service observation evidence. struct TeraPresentationConfiguration: Equatable { let publicKey: String + let relayProfile: String? let context: TeraLocalNetwork let blossom: TeraBlossomConfigurationStatus? init(snapshot: TeraRuntimeSnapshot) { publicKey = snapshot.identity.publicKeyHex + relayProfile = snapshot.relay?.profile context = .defaultContext(snapshot: snapshot) blossom = snapshot.blossomConfiguration } diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift @@ -8,6 +8,8 @@ final class TeraTodayStore: ObservableObject { @Published private(set) var presentation = TeraTodayPresentation() @Published private(set) var isLoadingNextPage = false @Published private(set) var observationState: TeraRuntimeObservationState = .inactive + @Published private(set) var scopeGeneration = TeraSessionGeneration.initial + var scopeWillChange: (TeraLocalNetwork?) -> Void = { _ in } private let runtimeClient: TeraRuntimeClient private let pageSize: UInt16 @@ -58,14 +60,14 @@ final class TeraTodayStore: ObservableObject { func configure(snapshot: TeraRuntimeSnapshot) { let updated = TeraPresentationConfiguration(snapshot: snapshot) guard configuration != updated else { return } - let previous = configuration let reload = observation.isActive - invalidatePresentation() + invalidatePresentation(for: updated.context) configuration = updated - if previous != nil || contexts.isEmpty { - contexts = [updated.context] - selectedContextID = updated.context.id - } + // The runtime snapshot currently supplies one default local network. + // Reconcile even the first configuration; injected choices are not an + // authority for a different account or runtime profile. + contexts = [updated.context] + selectedContextID = updated.context.id if reload { scheduleReload() } @@ -89,7 +91,7 @@ final class TeraTodayStore: ObservableObject { presentation.stop() } - private func invalidatePresentation() { + private func invalidatePresentation(for context: TeraLocalNetwork?) { requestGeneration = requestGeneration.invalidated() reloadTask?.cancel() reloadTask = nil @@ -98,6 +100,8 @@ final class TeraTodayStore: ObservableObject { nextCursor = nil isLoadingNextPage = false presentation = TeraTodayPresentation() + scopeWillChange(context) + scopeGeneration = scopeGeneration.invalidated() if observation.isActive { observation.stop() startObservation() @@ -106,23 +110,24 @@ final class TeraTodayStore: ObservableObject { func selectContext(id: String) { guard id != selectedContextID, - contexts.contains(where: { $0.id == id }) + let context = contexts.first(where: { $0.id == id }) else { return } - invalidatePresentation() + invalidatePresentation(for: context) selectedContextID = id scheduleReload() } func replaceContexts(_ updatedContexts: [TeraLocalNetwork], selectedID: String?) { let updatedContexts = Self.unique(updatedContexts) - invalidatePresentation() + let selected = [selectedID, selectedContextID].compactMap(\.self).first { requested in + updatedContexts.contains(where: { $0.id == requested }) + } ?? updatedContexts.first?.id + guard contexts != updatedContexts || selectedContextID != selected else { return } + invalidatePresentation(for: updatedContexts.first(where: { $0.id == selected })) contexts = updatedContexts - selectedContextID = - selectedID.flatMap { requested in - updatedContexts.contains(where: { $0.id == requested }) ? requested : nil - } ?? updatedContexts.first?.id + selectedContextID = selected scheduleReload() } diff --git a/TeraTests/TeraScopeFixtures.swift b/TeraTests/TeraScopeFixtures.swift @@ -4,12 +4,13 @@ import XCTest enum TeraScopeFixtures { static func snapshot( - account: String = "a", relay: String = "first", evidence: TeraBlossomEvidence? = nil + account: String = "a", relay: String = "first", evidence: TeraBlossomEvidence? = nil, + profile: String = "simulator" ) -> TeraRuntimeSnapshot { TeraRuntimeSnapshot( identity: TeraRuntimeIdentity(publicKeyHex: String(repeating: account, count: 64), hostSignerConfigured: true), relay: TeraRelayStatus( - profile: "simulator", state: "configured", readAvailability: "unobserved", writeAvailability: "unobserved", + profile: profile, state: "configured", readAvailability: "unobserved", writeAvailability: "unobserved", relays: [TeraRelayEndpointStatus( url: "wss://\(relay).example", access: .readWrite, readState: "unobserved", writeState: "unobserved", readLastAttemptUnixMilliseconds: nil, writeLastAttemptUnixMilliseconds: nil, diff --git a/TeraTests/TeraTodayContextTests.swift b/TeraTests/TeraTodayContextTests.swift @@ -0,0 +1,207 @@ +import Combine +@testable import TeraApp +import XCTest + +@MainActor +final class TeraTodayContextTests: XCTestCase { + func testReplacementPreservesOnlyPresentSelectionAndIgnoresUnknownChoice() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let first = context("first"), second = context("second") + let store = TeraTodayStore(runtimeClient: client, contexts: [first, second], selectedContextID: second.id) + await store.reload(refreshProjection: false) + let original = store.scopeGeneration + store.replaceContexts([first, second, second], selectedID: nil) + store.selectContext(id: "absent") + XCTAssertEqual(store.scopeGeneration, original) + XCTAssertEqual(store.cards.map(\.id), second.relayURLs) + store.replaceContexts([second, first], selectedID: "absent") + XCTAssertEqual(store.selectedContext, second) + store.replaceContexts([first], selectedID: second.id) + XCTAssertEqual(store.selectedContext, first) + XCTAssertTrue(store.cards.isEmpty) + store.replaceContexts([], selectedID: nil) + XCTAssertNil(store.selectedContext) + XCTAssertFalse(store.canLoadNextPage) + store.stop() + _ = try await client.stop() + } + + func testFirstConfigurationReconcilesInjectedChoicesWithRuntimeSnapshot() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let stale = context("old") + let store = TeraTodayStore(runtimeClient: client, contexts: [stale]) + await store.reload(refreshProjection: false) + let snapshot = TeraScopeFixtures.snapshot() + store.configure(snapshot: snapshot) + XCTAssertTrue(store.cards.isEmpty) + XCTAssertEqual(store.contexts, [.defaultContext(snapshot: snapshot)]) + XCTAssertEqual(store.selectedContext?.id, "default") + store.stop() + _ = try await client.stop() + } + + func testAccountAndProfileChangesInvalidateSameNamedScopeButEvidenceDoesNot() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let store = TeraTodayStore(runtimeClient: client) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.reload(refreshProjection: false) + var generation = store.scopeGeneration + store.configure(snapshot: TeraScopeFixtures.snapshot(evidence: TeraScopeFixtures.evidence(observedAt: 9))) + XCTAssertEqual(store.scopeGeneration, generation) + XCTAssertFalse(store.cards.isEmpty) + for snapshot in [TeraScopeFixtures.snapshot(profile: "public"), TeraScopeFixtures.snapshot(account: "b", profile: "public")] { + let paused = await backend.pause(.page) + let old = Task { await store.reload(refreshProjection: false) } + await paused.entered.wait() + store.configure(snapshot: snapshot) + XCTAssertNotEqual(store.scopeGeneration, generation) + XCTAssertTrue(store.cards.isEmpty) + XCTAssertEqual(store.selectedContext?.id, "default") + await paused.resume.open() + await old.value + XCTAssertTrue(store.cards.isEmpty) + generation = store.scopeGeneration + await store.reload(refreshProjection: false) + XCTAssertEqual(store.scopeGeneration, generation) + } + store.stop() + _ = try await client.stop() + } + + func testFirstPageSwitchClearsBeforeLabelForCachedAndEmptyDestinations() async throws { + for cached in [false, true] { + try await assertSwitch(nextPage: false, cached: cached) + } + } + + func testSameIDReplacementClearsBeforePublishingChangedLabelAndDefinition() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let original = context("first") + let store = TeraTodayStore(runtimeClient: client, contexts: [original]) + await store.reload(refreshProjection: false) + let paused = await backend.pause(.page) + let old = Task { await store.reload(refreshProjection: false) } + await paused.entered.wait() + let updated = TeraLocalNetwork( + schemaVersion: 1, id: original.id, label: "Updated network", relayURLs: ["wss://second.example"], + locality: nil, followedAuthors: [], generation: 2 + ) + var published = false + let subscription = store.$contexts.dropFirst().sink { values in + published = true + XCTAssertEqual(values, [updated]) + XCTAssertTrue(store.cards.isEmpty) + XCTAssertEqual(store.presentation.content, .notLoaded) + } + store.replaceContexts([updated], selectedID: nil) + XCTAssertTrue(published) + XCTAssertEqual(store.selectedContext, updated) + await TeraScopeFixtures.eventually { store.cards.map(\.id) == updated.relayURLs } + await paused.resume.open() + await old.value + XCTAssertEqual(store.cards.map(\.id), updated.relayURLs) + subscription.cancel() + store.stop() + _ = try await client.stop() + } + + func testNextPageSwitchClearsBeforeLabelForCachedAndEmptyDestinations() async throws { + for cached in [false, true] { + try await assertSwitch(nextPage: true, cached: cached) + } + } + + func testContextSwitchClearsSupportingStoresBeforeLabelAndRejectsLateResults() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let stores = TeraProductStores(runtimeClient: client) + stores.configure(snapshot: TeraScopeFixtures.snapshot()) + stores.search.updateQuery("old") + await stores.search.search() + await stores.me.reload() + XCTAssertFalse(stores.search.results.isEmpty) + XCTAssertNotNil(stores.me.snapshot) + let searchPause = await backend.pause(.search), mePause = await backend.pause(.me) + let search = Task { await stores.search.search() }, me = Task { await stores.me.reload() } + await searchPause.entered.wait() + await mePause.entered.wait() + var published = false + let subscription = stores.today.$selectedContextID.dropFirst().sink { selected in + published = true + XCTAssertEqual(selected, "second") + XCTAssertTrue(stores.search.results.isEmpty) + XCTAssertNil(stores.me.snapshot) + XCTAssertEqual(stores.me.observationState, .stopped) + } + stores.today.replaceContexts([context("second")], selectedID: nil) + XCTAssertTrue(published) + await searchPause.resume.open() + await mePause.resume.open() + await search.value + await me.value + XCTAssertTrue(stores.search.results.isEmpty) + XCTAssertNil(stores.me.snapshot) + await stores.me.reload() + let actual = await backend.lastMeContext + XCTAssertEqual(actual, stores.today.selectedContext) + subscription.cancel() + stores.stop() + _ = try await client.stop() + } + + private func assertSwitch(nextPage: Bool, cached: Bool) async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let first = context("first"), second = context("second") + let store = TeraTodayStore(runtimeClient: client, contexts: [first, second]) + await backend.setPage(page(["old"], cursor: "next")) + await backend.setPage(page(["old-next"]), cursor: "next") + await store.reload(refreshProjection: false) + let paused = await backend.pause(.page) + let old = Task { + if nextPage { + await store.loadNextPage() + } else { + await store.reload(refreshProjection: false) + } + } + await paused.entered.wait() + let expected = cached ? ["new"] : [] + await backend.setPage(page(expected)) + var changes = 0 + let subscription = store.$selectedContextID.dropFirst().sink { selected in + changes += 1 + XCTAssertEqual(selected, second.id) + XCTAssertTrue(store.cards.isEmpty) + XCTAssertEqual(store.presentation.content, .notLoaded) + XCTAssertFalse(store.isLoadingNextPage) + XCTAssertFalse(store.canLoadNextPage) + } + let generation = store.scopeGeneration + store.selectContext(id: second.id) + XCTAssertNotEqual(store.scopeGeneration, generation) + XCTAssertEqual(changes, 1) + XCTAssertEqual(store.selectedContext?.label, "second") + await TeraScopeFixtures.eventually { store.presentation.content == (cached ? .available : .empty) } + await paused.resume.open() + await old.value + XCTAssertEqual(store.cards.map(\.id), expected) + XCTAssertEqual(store.selectedContext, second) + subscription.cancel() + store.stop() + _ = try await client.stop() + } + + private func context(_ id: String) -> TeraLocalNetwork { + TeraLocalNetwork(schemaVersion: 1, id: id, label: id, relayURLs: ["wss://\(id).example"], + locality: nil, followedAuthors: [], generation: 1) + } + + private func page(_ ids: [String], cursor: String? = nil) -> TeraTodayPage { + TeraTodayPage(asOfUnixSeconds: 1, items: ids.map(TeraScopeFixtures.card), nextCursor: cursor) + } +} diff --git a/project.yml b/project.yml @@ -63,6 +63,7 @@ targets: - path: TeraTests/TeraTodayStoreTests.swift - path: TeraTests/TeraTodayPresentationTests.swift - path: TeraTests/TeraTodayCacheFirstTests.swift + - path: TeraTests/TeraTodayContextTests.swift - path: TeraTests/TeraProductStartupTests.swift - path: TeraTests/TeraAddSchemaFixtures.swift - path: TeraTests/TeraScopeFixtures.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": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", - "xcode_project_sha256": "06f5970c0e75efb3ef97ad2bf0b3f13aad54b0b99787f24aa44076606fbb778a" + "xcode_project_sha256": "5738745e0ad43c494bb7127ddb0a56479cccaae839634b4c0bb9b4c278efe386" }, "disposition": "unsigned", "platforms": [