From 3aaa6a4890b3f6294fdfb71c8fe1ad2e32fc9719 Mon Sep 17 00:00:00 2001 From: Microck Date: Fri, 3 Apr 2026 03:39:21 +0000 Subject: [PATCH 1/2] feat: support multiple paired anchors --- ios/ancla-app/app-view-model.swift | 91 ++++--- ios/ancla-app/content-view.swift | 242 +++++++++++++----- ios/ancla-shared/ancla-core.swift | 10 +- ios/ancla-shared/ancla-models.swift | 34 ++- .../ancla-runtime-diagnostics.swift | 36 ++- .../shield-configuration-extension.swift | 9 +- ios/ancla-tests/app-view-model-tests.swift | 156 ++++++++++- 7 files changed, 458 insertions(+), 120 deletions(-) diff --git a/ios/ancla-app/app-view-model.swift b/ios/ancla-app/app-view-model.swift index 46a7f9f..437849c 100644 --- a/ios/ancla-app/app-view-model.swift +++ b/ios/ancla-app/app-view-model.swift @@ -8,7 +8,6 @@ enum AppActionID: Equatable { case refresh case authorize case pairAnchor - case replaceAnchor case armSession case releaseSession case emergencyUnbrick @@ -111,6 +110,32 @@ final class AppViewModel { AnclaCore.recentHistory(in: snapshot) } + var pairedTagsForDisplay: [PairedTag] { + guard let activePairedTag else { + return snapshot.pairedTags + } + + return snapshot.pairedTags.sorted { lhs, rhs in + if lhs.id == activePairedTag.id { + return true + } + + if rhs.id == activePairedTag.id { + return false + } + + return lhs.createdAt < rhs.createdAt + } + } + + var activePairedTag: PairedTag? { + guard let activeSession = snapshot.activeSession else { + return nil + } + + return AnclaCore.pairedTag(for: activeSession.pairedTagId, in: snapshot) + } + var isNFCAvailable: Bool { diagnostics.items.first(where: { $0.id == "nfc" })?.value == "Ready" } @@ -256,28 +281,19 @@ final class AppViewModel { func pairSticker() async { await runTask(action: .pairAnchor) { [self] in let uidHash = try await stickerPairingService.scanSticker() - let trimmedName = draftTagName.trimmingCharacters(in: .whitespacesAndNewlines) - let displayName = trimmedName.isEmpty ? "Desk anchor" : trimmedName - snapshot.pairedTag = PairedTag( - uidHash: uidHash, - displayName: displayName - ) - try persist() - feedback = ActionFeedback(message: "\(displayName) paired.", tone: .success) - } - } + guard AnclaCore.matchedPairedTag(for: uidHash, in: snapshot) == nil else { + throw ValidationError.duplicatePairedTag + } - func replaceSticker() async { - await runTask(action: .replaceAnchor) { [self] in - let uidHash = try await stickerPairingService.scanSticker() let trimmedName = draftTagName.trimmingCharacters(in: .whitespacesAndNewlines) let displayName = trimmedName.isEmpty ? "Desk anchor" : trimmedName - snapshot.pairedTag = PairedTag( + let pairedTag = PairedTag( uidHash: uidHash, displayName: displayName ) + snapshot.pairedTags.append(pairedTag) try persist() - feedback = ActionFeedback(message: "\(displayName) paired as the new anchor.", tone: .success) + feedback = ActionFeedback(message: "\(displayName) paired.", tone: .success) } } @@ -319,14 +335,17 @@ final class AppViewModel { } let scannedHash = try await stickerPairingService.scanSticker() - guard scannedHash == snapshot.pairedTag?.uidHash else { + guard let pairedTag = AnclaCore.pairedTag(for: activeSession.pairedTagId, in: snapshot) else { + throw ValidationError.missingPairedTag + } + + guard scannedHash == pairedTag.uidHash else { snapshot.activeSession?.state = .mismatchedTag try persist() throw ValidationError.mismatchedTag } guard - let pairedTag = snapshot.pairedTag, let mode = snapshot.modes.first(where: { $0.id == activeSession.modeId }) else { throw ValidationError.missingMode @@ -358,7 +377,7 @@ final class AppViewModel { } guard - let pairedTag = snapshot.pairedTag, + let pairedTag = AnclaCore.pairedTag(for: activeSession.pairedTagId, in: snapshot), let mode = snapshot.modes.first(where: { $0.id == activeSession.modeId }) else { throw ValidationError.missingMode @@ -419,25 +438,30 @@ final class AppViewModel { } } - func renamePairedSticker(_ name: String) async { + func renamePairedSticker(_ tagID: UUID, name: String) async { await runTask(action: .renameAnchor) { [self] in - guard snapshot.pairedTag != nil else { + guard let index = snapshot.pairedTags.firstIndex(where: { $0.id == tagID }) else { throw ValidationError.missingPairedTag } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let displayName = trimmedName.isEmpty ? "Desk anchor" : trimmedName - snapshot.pairedTag?.displayName = displayName - draftTagName = snapshot.pairedTag?.displayName ?? "Desk anchor" + snapshot.pairedTags[index].displayName = displayName + draftTagName = snapshot.pairedTags[index].displayName try persist() feedback = ActionFeedback(message: "Anchor renamed to \(displayName).", tone: .success) } } - func unpairSticker() async { + func unpairSticker(_ tagID: UUID) async { await runTask(action: .removeAnchor, successMessage: "Anchor removed.") { [self] in - snapshot.pairedTag = nil - if snapshot.activeSession != nil { + guard let index = snapshot.pairedTags.firstIndex(where: { $0.id == tagID }) else { + throw ValidationError.missingPairedTag + } + + let removedTag = snapshot.pairedTags.remove(at: index) + + if snapshot.activeSession?.pairedTagId == removedTag.id { shieldingService.clear() snapshot.activeSession = nil } @@ -459,6 +483,10 @@ final class AppViewModel { return snapshot.modes.first(where: { $0.id == selectedModeID }) } + func pairedTag(_ tagID: UUID) -> PairedTag? { + snapshot.pairedTags.first(where: { $0.id == tagID }) + } + func preferredMode() -> BlockMode? { AnclaCore.preferredMode(in: snapshot) } @@ -535,12 +563,12 @@ final class AppViewModel { throw ValidationError.missingAuthorization } - guard let pairedTag = snapshot.pairedTag else { + guard !snapshot.pairedTags.isEmpty else { throw ValidationError.missingPairedTag } let scannedHash = try await stickerPairingService.scanSticker() - guard scannedHash == pairedTag.uidHash else { + guard let pairedTag = AnclaCore.matchedPairedTag(for: scannedHash, in: snapshot) else { throw ValidationError.mismatchedTagOnArm } @@ -651,6 +679,7 @@ enum ValidationError: LocalizedError { case missingMode case noTargetsSelected case noEmergencyUnbricksRemaining + case duplicatePairedTag case mismatchedTagOnArm case mismatchedTag case sessionNotArmed @@ -667,10 +696,12 @@ enum ValidationError: LocalizedError { return "Choose at least one app, category, or domain." case .noEmergencyUnbricksRemaining: return "No emergency unbricks remain on this iPhone." + case .duplicatePairedTag: + return "That NFC anchor is already paired on this iPhone." case .mismatchedTagOnArm: - return "Scan the paired anchor to start this session." + return "Scan any paired anchor to start this session." case .mismatchedTag: - return "That anchor does not match the paired release key." + return "That anchor does not match the release anchor for this session." case .sessionNotArmed: return "Start a session before attempting release." } diff --git a/ios/ancla-app/content-view.swift b/ios/ancla-app/content-view.swift index ec51a8b..b5c998b 100644 --- a/ios/ancla-app/content-view.swift +++ b/ios/ancla-app/content-view.swift @@ -20,7 +20,7 @@ struct ContentView: View { private let bottomActionBarClearance: CGFloat = 132 @State private var isModeEditorPresented = false - @State private var isRenamingAnchor = false + @State private var renamingAnchorID: UUID? @State private var anchorNameDraft = "" var body: some View { @@ -54,7 +54,7 @@ struct ContentView: View { ) .presentationBackground(.clear) } - .sheet(isPresented: $isRenamingAnchor) { + .sheet(isPresented: renameAnchorPresented) { renameAnchorSheet .presentationBackground(.clear) } @@ -65,9 +65,9 @@ struct ContentView: View { .task { viewModel.refreshDiagnostics() } - .onChange(of: isRenamingAnchor) { _, isOpen in - if isOpen { - anchorNameDraft = viewModel.snapshot.pairedTag?.displayName ?? "" + .onChange(of: renamingAnchorID) { _, tagID in + if let tagID, let pairedTag = viewModel.pairedTag(tagID) { + anchorNameDraft = pairedTag.displayName } } } @@ -163,17 +163,17 @@ struct ContentView: View { surfaceRow( label: "Anchor", - value: viewModel.snapshot.pairedTag?.displayName ?? "Not paired", + value: anchorValue, detail: anchorDetail ) - if viewModel.snapshot.pairedTag != nil { + if let anchorPreviewTag { surfaceDivider surfaceRow( - label: "Anchor ID", + label: anchorPreviewLabel, value: fingerprintValue, - detail: "Short preview of the paired anchor fingerprint.", + detail: fingerprintDetail, monospaced: true ) } @@ -315,70 +315,33 @@ struct ContentView: View { surfaceDivider sectionBlock( - title: "Anchor", + title: "Anchors", content: { VStack(spacing: 12) { - if let pairedTag = viewModel.snapshot.pairedTag { + if viewModel.pairedTagsForDisplay.isEmpty { informativeRow( - title: pairedTag.displayName, - detail: "This anchor is currently paired to release active sessions on this iPhone.", + title: "No anchor paired", + detail: "Pair one NFC anchor to set the physical release key for this iPhone.", accentColor: AnclaTheme.primaryText, - highlight: true, - trailingText: "Paired" + highlight: false ) - - Button { - isRenamingAnchor = true - } label: { - actionRow( - icon: "pencil.line", - title: "Rename anchor", - detail: "Update the visible label for the paired anchor.", - isLoading: false - ) + } else { + ForEach(viewModel.pairedTagsForDisplay) { pairedTag in + pairedAnchorCard(pairedTag) } - .buttonStyle(AnclaPressableButtonStyle()) - .disabled(viewModel.isBusy) Button { - Task { await viewModel.replaceSticker() } + Task { await viewModel.pairSticker() } } label: { actionRow( - icon: "arrow.triangle.2.circlepath", - title: "Pair replacement anchor", - detail: "Scan a different NFC anchor and make it the new release key.", - isLoading: viewModel.isActionInProgress(.replaceAnchor) + icon: "plus", + title: "Pair another anchor", + detail: "Scan one more NFC anchor that can start its own session on this iPhone.", + isLoading: viewModel.isActionInProgress(.pairAnchor) ) } .buttonStyle(AnclaPressableButtonStyle()) .disabled(viewModel.isBusy) - - Button { - Task { await viewModel.unpairSticker() } - } label: { - actionRow( - icon: "trash", - title: "Remove anchor", - detail: "Clear the current paired anchor from this iPhone.", - isLoading: viewModel.isActionInProgress(.removeAnchor), - isDestructive: true - ) - } - .buttonStyle( - AnclaPressableButtonStyle( - background: AnclaTheme.panelInteractive, - pressedBackground: AnclaTheme.panelRaised, - stroke: AnclaTheme.errorText.opacity(0.32) - ) - ) - .disabled(viewModel.isBusy) - } else { - informativeRow( - title: "No anchor paired", - detail: "Pair one NFC anchor to set the physical release key for this iPhone.", - accentColor: AnclaTheme.primaryText, - highlight: false - ) } } } @@ -387,6 +350,51 @@ struct ContentView: View { } } + private func pairedAnchorCard(_ pairedTag: PairedTag) -> some View { + VStack(spacing: 12) { + informativeRow( + title: pairedTag.displayName, + detail: pairedAnchorDetail(for: pairedTag), + accentColor: isActiveAnchor(pairedTag.id) ? AnclaTheme.warningText : AnclaTheme.primaryText, + highlight: isActiveAnchor(pairedTag.id), + trailingText: pairedAnchorBadge(for: pairedTag) + ) + + Button { + renamingAnchorID = pairedTag.id + } label: { + actionRow( + icon: "pencil.line", + title: "Rename \(pairedTag.displayName)", + detail: "Update the visible label for this paired anchor.", + isLoading: false + ) + } + .buttonStyle(AnclaPressableButtonStyle()) + .disabled(viewModel.isBusy) + + Button { + Task { await viewModel.unpairSticker(pairedTag.id) } + } label: { + actionRow( + icon: "trash", + title: "Remove \(pairedTag.displayName)", + detail: removeAnchorDetail(for: pairedTag), + isLoading: viewModel.isActionInProgress(.removeAnchor), + isDestructive: true + ) + } + .buttonStyle( + AnclaPressableButtonStyle( + background: AnclaTheme.panelInteractive, + pressedBackground: AnclaTheme.panelRaised, + stroke: AnclaTheme.errorText.opacity(0.32) + ) + ) + .disabled(viewModel.isBusy) + } + } + private func surface( title: String, @ViewBuilder content: () -> Content @@ -699,7 +707,7 @@ struct ContentView: View { HStack { Button("Cancel") { - isRenamingAnchor = false + renamingAnchorID = nil } .font(.ancla(14, weight: .medium)) .foregroundStyle(AnclaTheme.secondaryText) @@ -716,7 +724,7 @@ struct ContentView: View { Spacer() - Text("Rename anchor") + Text(currentRenamingAnchor?.displayName ?? "Rename anchor") .font(.ancla(18, weight: .bold)) .foregroundStyle(AnclaTheme.primaryText) @@ -724,9 +732,12 @@ struct ContentView: View { Button("Save") { Task { - await viewModel.renamePairedSticker(anchorNameDraft) + guard let renamingAnchorID else { + return + } + await viewModel.renamePairedSticker(renamingAnchorID, name: anchorNameDraft) if viewModel.lastError == nil { - isRenamingAnchor = false + self.renamingAnchorID = nil } } } @@ -791,15 +802,26 @@ struct ContentView: View { } private var anchorDetail: String { - guard viewModel.snapshot.pairedTag != nil else { + switch viewModel.snapshot.pairedTags.count { + case 0: return "No anchor is paired to this iPhone yet." - } + case 1: + if let activePairedTag = viewModel.activePairedTag { + return "\(activePairedTag.displayName) started the current session and must also release it." + } + + return "The paired anchor can start a session and must also release it." + default: + if let activePairedTag = viewModel.activePairedTag { + return "\(viewModel.snapshot.pairedTags.count) anchors are paired. \(activePairedTag.displayName) must release the current session." + } - return "The paired anchor is required to start and release sessions on this iPhone." + return "\(viewModel.snapshot.pairedTags.count) anchors are paired on this iPhone." + } } private var fingerprintValue: String { - guard let uidHash = viewModel.snapshot.pairedTag?.uidHash else { + guard let uidHash = anchorPreviewTag?.uidHash else { return "Awaiting pair" } @@ -931,7 +953,7 @@ struct ContentView: View { return .unavailable } - if viewModel.snapshot.pairedTag == nil { + if viewModel.snapshot.pairedTags.isEmpty { return .pairAnchor } @@ -1050,7 +1072,89 @@ struct ContentView: View { } private var sessionWaitingDetail: String { - "The current session remains active until the paired anchor is scanned. \(emergencyCountSentence)" + if let activePairedTag = viewModel.activePairedTag { + return "The current session remains active until \(activePairedTag.displayName) is scanned. \(emergencyCountSentence)" + } + + return "The current session remains active until the release anchor is scanned. \(emergencyCountSentence)" + } + + private var renameAnchorPresented: Binding { + Binding( + get: { renamingAnchorID != nil }, + set: { isPresented in + if !isPresented { + renamingAnchorID = nil + } + } + ) + } + + private var currentRenamingAnchor: PairedTag? { + guard let renamingAnchorID else { + return nil + } + + return viewModel.pairedTag(renamingAnchorID) + } + + private var activePairedTag: PairedTag? { + viewModel.activePairedTag + } + + private var anchorValue: String { + if let activePairedTag { + return activePairedTag.displayName + } + + switch viewModel.snapshot.pairedTags.count { + case 0: + return "Not paired" + case 1: + return viewModel.snapshot.pairedTags[0].displayName + default: + return "\(viewModel.snapshot.pairedTags.count) paired" + } + } + + private var anchorPreviewTag: PairedTag? { + activePairedTag ?? viewModel.snapshot.pairedTags.first + } + + private var anchorPreviewLabel: String { + activePairedTag == nil ? "Anchor ID" : "Active anchor ID" + } + + private var fingerprintDetail: String { + if let activePairedTag { + return "\(activePairedTag.displayName) is the anchor that must release the current session." + } + + return "Short preview of a paired anchor fingerprint." + } + + private func isActiveAnchor(_ tagID: UUID) -> Bool { + viewModel.activePairedTag?.id == tagID + } + + private func pairedAnchorDetail(for pairedTag: PairedTag) -> String { + if isActiveAnchor(pairedTag.id) { + return "This anchor started the current session and is the only one that can release it." + } + + return "This anchor can start a new session on this iPhone." + } + + private func pairedAnchorBadge(for pairedTag: PairedTag) -> String { + isActiveAnchor(pairedTag.id) ? "Active" : "Paired" + } + + private func removeAnchorDetail(for pairedTag: PairedTag) -> String { + if isActiveAnchor(pairedTag.id) { + return "Remove this anchor and clear the active session from this iPhone." + } + + return "Remove this paired anchor from this iPhone." } } diff --git a/ios/ancla-shared/ancla-core.swift b/ios/ancla-shared/ancla-core.swift index 0002548..2c0d035 100644 --- a/ios/ancla-shared/ancla-core.swift +++ b/ios/ancla-shared/ancla-core.swift @@ -48,11 +48,19 @@ enum AnclaCore { static func canArmSelectedMode(_ snapshot: AppSnapshot) -> Bool { snapshot.isAuthorized - && snapshot.pairedTag != nil + && !snapshot.pairedTags.isEmpty && !snapshot.modes.isEmpty && !activeSessionIsBlocking(snapshot) } + static func pairedTag(for id: UUID, in snapshot: AppSnapshot) -> PairedTag? { + snapshot.pairedTags.first(where: { $0.id == id }) + } + + static func matchedPairedTag(for uidHash: String, in snapshot: AppSnapshot) -> PairedTag? { + snapshot.pairedTags.first(where: { $0.uidHash == uidHash }) + } + static func recentHistory( in snapshot: AppSnapshot, limit: Int = 10 diff --git a/ios/ancla-shared/ancla-models.swift b/ios/ancla-shared/ancla-models.swift index fac3b4f..bb7964e 100644 --- a/ios/ancla-shared/ancla-models.swift +++ b/ios/ancla-shared/ancla-models.swift @@ -110,9 +110,41 @@ struct SessionHistoryEntry: Codable, Equatable, Identifiable { struct AppSnapshot: Codable, Equatable { var isAuthorized = false - var pairedTag: PairedTag? + var pairedTags: [PairedTag] = [] var modes: [BlockMode] = [] var activeSession: AnchorSession? var sessionHistory: [SessionHistoryEntry] = [] var emergencyUnbricksRemaining = 5 + + init( + isAuthorized: Bool = false, + pairedTag: PairedTag? = nil, + pairedTags: [PairedTag] = [], + modes: [BlockMode] = [], + activeSession: AnchorSession? = nil, + sessionHistory: [SessionHistoryEntry] = [], + emergencyUnbricksRemaining: Int = 5 + ) { + self.isAuthorized = isAuthorized + self.pairedTags = pairedTags.isEmpty ? (pairedTag.map { [$0] } ?? []) : pairedTags + self.modes = modes + self.activeSession = activeSession + self.sessionHistory = sessionHistory + self.emergencyUnbricksRemaining = emergencyUnbricksRemaining + } + + var pairedTag: PairedTag? { + get { pairedTags.first } + set { + if let newValue { + if pairedTags.isEmpty { + pairedTags = [newValue] + } else { + pairedTags[0] = newValue + } + } else { + pairedTags = [] + } + } + } } diff --git a/ios/ancla-shared/ancla-runtime-diagnostics.swift b/ios/ancla-shared/ancla-runtime-diagnostics.swift index 50d7c39..99d287a 100644 --- a/ios/ancla-shared/ancla-runtime-diagnostics.swift +++ b/ios/ancla-shared/ancla-runtime-diagnostics.swift @@ -136,11 +136,9 @@ extension AnclaCore { RuntimeDiagnosticItem( id: "sticker", title: "Anchor", - value: snapshot.pairedTag?.displayName ?? "Not paired", - detail: snapshot.pairedTag == nil - ? "Pair the NFC anchor that should release sessions on this iPhone." - : "The paired anchor is the only release key for this iPhone.", - tone: snapshot.pairedTag == nil ? .attention : .ready + value: anchorValue(snapshot), + detail: anchorDetail(snapshot), + tone: snapshot.pairedTags.isEmpty ? .attention : .ready ), RuntimeDiagnosticItem( id: "mode", @@ -185,7 +183,7 @@ extension AnclaCore { return "Controls unavailable" } - if snapshot.pairedTag == nil { + if snapshot.pairedTags.isEmpty { return "Pair an anchor" } @@ -218,8 +216,8 @@ extension AnclaCore { return environment.screenTimeAuthorization.detail } - if snapshot.pairedTag == nil { - return "Pair one NFC anchor to set the physical release key for this iPhone." + if snapshot.pairedTags.isEmpty { + return "Pair at least one NFC anchor to set the physical release keys for this iPhone." } if snapshot.modes.isEmpty { @@ -267,4 +265,26 @@ extension AnclaCore { return .neutral } } + + private static func anchorValue(_ snapshot: AppSnapshot) -> String { + switch snapshot.pairedTags.count { + case 0: + return "Not paired" + case 1: + return snapshot.pairedTags[0].displayName + default: + return "\(snapshot.pairedTags.count) paired" + } + } + + private static func anchorDetail(_ snapshot: AppSnapshot) -> String { + switch snapshot.pairedTags.count { + case 0: + return "Pair the NFC anchors that should be allowed to start and release sessions on this iPhone." + case 1: + return "The paired anchor can start a session and must also release it." + default: + return "Any paired anchor can start a session. The same anchor must release the session it started." + } + } } diff --git a/ios/ancla-shield-extension/shield-configuration-extension.swift b/ios/ancla-shield-extension/shield-configuration-extension.swift index c26873c..7710cb6 100644 --- a/ios/ancla-shield-extension/shield-configuration-extension.swift +++ b/ios/ancla-shield-extension/shield-configuration-extension.swift @@ -30,7 +30,7 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { private func makeConfiguration(title: String) -> ShieldConfiguration { let snapshot = (try? store.load()) ?? AppSnapshot() let activeModeName = activeModeName(in: snapshot) ?? "Focus mode" - let anchorName = snapshot.pairedTag?.displayName ?? "paired anchor" + let anchorName = activeAnchorName(in: snapshot) ?? "paired anchor" return ShieldConfiguration( backgroundBlurStyle: .systemThinMaterialDark, @@ -62,4 +62,11 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { } return snapshot.modes.first(where: { $0.id == session.modeId })?.name } + + private func activeAnchorName(in snapshot: AppSnapshot) -> String? { + guard let session = snapshot.activeSession else { + return snapshot.pairedTag?.displayName + } + return AnclaCore.pairedTag(for: session.pairedTagId, in: snapshot)?.displayName + } } diff --git a/ios/ancla-tests/app-view-model-tests.swift b/ios/ancla-tests/app-view-model-tests.swift index 73acdff..1f487d5 100644 --- a/ios/ancla-tests/app-view-model-tests.swift +++ b/ios/ancla-tests/app-view-model-tests.swift @@ -101,7 +101,7 @@ final class AppViewModelTests: XCTestCase { XCTAssertEqual(viewModel.snapshot.activeSession?.modeId, mode.id) } - func testPairRenameAndUnpairSticker() async { + func testPairStickerAppendsAnchorsAndRejectsDuplicates() async { let store = InMemorySnapshotStore( snapshot: AppSnapshot( isAuthorized: true, @@ -110,7 +110,9 @@ final class AppViewModelTests: XCTestCase { activeSession: nil ) ) - let stickerService = FakeStickerPairingService(nextHashes: ["tag-hash-001"]) + let stickerService = FakeStickerPairingService( + nextHashes: ["tag-hash-001", "tag-hash-002", "tag-hash-002"] + ) let viewModel = AppViewModel( store: store, authorizationClient: FakeAuthorizationClient(), @@ -120,14 +122,43 @@ final class AppViewModelTests: XCTestCase { viewModel.draftTagName = " Desk " await viewModel.pairSticker() - XCTAssertEqual(viewModel.snapshot.pairedTag?.displayName, "Desk") - XCTAssertEqual(viewModel.snapshot.pairedTag?.uidHash, "tag-hash-001") + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Desk"]) + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.uidHash), ["tag-hash-001"]) + + viewModel.draftTagName = "Bag anchor" + await viewModel.pairSticker() + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Desk", "Bag anchor"]) + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.uidHash), ["tag-hash-001", "tag-hash-002"]) + + viewModel.draftTagName = "Duplicate" + await viewModel.pairSticker() + XCTAssertEqual(viewModel.lastError, ValidationError.duplicatePairedTag.errorDescription) + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Desk", "Bag anchor"]) + } + + func testRenameAndRemoveSpecificPairedAnchor() async { + let firstTag = PairedTag(uidHash: "tag-hash-001", displayName: "Desk anchor") + let secondTag = PairedTag(uidHash: "tag-hash-002", displayName: "Bag anchor") + let store = InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [firstTag, secondTag], + modes: [], + activeSession: nil + ) + ) + let viewModel = AppViewModel( + store: store, + authorizationClient: FakeAuthorizationClient(), + shieldingService: FakeShieldingService(), + stickerPairingService: FakeStickerPairingService() + ) - await viewModel.renamePairedSticker("Office") - XCTAssertEqual(viewModel.snapshot.pairedTag?.displayName, "Office") + await viewModel.renamePairedSticker(secondTag.id, name: "Office anchor") + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Desk anchor", "Office anchor"]) - await viewModel.unpairSticker() - XCTAssertNil(viewModel.snapshot.pairedTag) + await viewModel.unpairSticker(firstTag.id) + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Office anchor"]) } func testEditModeCanPromoteDefaultAndRefreshActiveShield() async throws { @@ -208,6 +239,77 @@ final class AppViewModelTests: XCTestCase { XCTAssertEqual(shielding.clearCallCount, 1) } + func testArmSelectedModeBindsSessionToMatchedAnchorInsteadOfFirstAnchor() async throws { + let selection = FamilyActivitySelection() + let mode = try BlockMode(name: "Work", selection: selection, isDefault: true) + let firstTag = PairedTag(uidHash: "paired-hash-1", displayName: "Desk anchor") + let secondTag = PairedTag(uidHash: "paired-hash-2", displayName: "Bag anchor") + let store = InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [firstTag, secondTag], + modes: [mode], + activeSession: nil + ) + ) + let shielding = FakeShieldingService() + let stickerService = FakeStickerPairingService(nextHashes: ["paired-hash-2"]) + let viewModel = AppViewModel( + store: store, + authorizationClient: FakeAuthorizationClient(), + shieldingService: shielding, + stickerPairingService: stickerService + ) + + await viewModel.armSelectedMode() + + XCTAssertNil(viewModel.lastError) + XCTAssertEqual(shielding.appliedModeIDs, [mode.id]) + XCTAssertEqual(viewModel.snapshot.activeSession?.state, .armed) + XCTAssertEqual(viewModel.snapshot.activeSession?.pairedTagId, secondTag.id) + XCTAssertEqual(viewModel.activePairedTag?.displayName, "Bag anchor") + } + + func testReleaseRequiresAnchorThatStartedSession() async throws { + let selection = FamilyActivitySelection() + let mode = try BlockMode(name: "Work", selection: selection, isDefault: true) + let firstTag = PairedTag(uidHash: "paired-hash-1", displayName: "Desk anchor") + let secondTag = PairedTag(uidHash: "paired-hash-2", displayName: "Bag anchor") + let activeSession = AnchorSession( + pairedTagId: secondTag.id, + modeId: mode.id, + state: .armed + ) + let store = InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [firstTag, secondTag], + modes: [mode], + activeSession: activeSession + ) + ) + let shielding = FakeShieldingService() + let stickerService = FakeStickerPairingService(nextHashes: ["paired-hash-1", "paired-hash-2"]) + let viewModel = AppViewModel( + store: store, + authorizationClient: FakeAuthorizationClient(), + shieldingService: shielding, + stickerPairingService: stickerService + ) + + await viewModel.releaseActiveSession() + XCTAssertEqual(viewModel.lastError, ValidationError.mismatchedTag.errorDescription) + XCTAssertEqual(viewModel.snapshot.activeSession?.state, .mismatchedTag) + XCTAssertEqual(viewModel.snapshot.sessionHistory.count, 0) + + await viewModel.releaseActiveSession() + XCTAssertNil(viewModel.lastError) + XCTAssertEqual(viewModel.snapshot.activeSession?.state, .released) + XCTAssertEqual(viewModel.snapshot.sessionHistory.count, 1) + XCTAssertEqual(viewModel.snapshot.sessionHistory[0].pairedTagName, "Bag anchor") + XCTAssertEqual(shielding.clearCallCount, 1) + } + func testReleaseAppendsUsageHistoryEntry() async throws { let selection = FamilyActivitySelection() let mode = try BlockMode(name: "Work", selection: selection, isDefault: true) @@ -316,6 +418,40 @@ final class AppViewModelTests: XCTestCase { XCTAssertEqual(shielding.clearCallCount, 0) } + func testRemovingActiveSessionAnchorClearsSessionAndShielding() async throws { + let selection = FamilyActivitySelection() + let mode = try BlockMode(name: "Work", selection: selection, isDefault: true) + let firstTag = PairedTag(uidHash: "paired-hash-1", displayName: "Desk anchor") + let secondTag = PairedTag(uidHash: "paired-hash-2", displayName: "Bag anchor") + let activeSession = AnchorSession( + pairedTagId: secondTag.id, + modeId: mode.id, + state: .armed + ) + let store = InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [firstTag, secondTag], + modes: [mode], + activeSession: activeSession + ) + ) + let shielding = FakeShieldingService() + let viewModel = AppViewModel( + store: store, + authorizationClient: FakeAuthorizationClient(), + shieldingService: shielding, + stickerPairingService: FakeStickerPairingService() + ) + + await viewModel.unpairSticker(secondTag.id) + + XCTAssertNil(viewModel.lastError) + XCTAssertEqual(viewModel.snapshot.pairedTags.map(\.displayName), ["Desk anchor"]) + XCTAssertNil(viewModel.snapshot.activeSession) + XCTAssertEqual(shielding.clearCallCount, 1) + } + func testLoadRepairsMissingDefaultAndSelectsFirstMode() async throws { let selection = FamilyActivitySelection() let firstMode = try BlockMode(name: "Focus", selection: selection, isDefault: false) @@ -354,8 +490,8 @@ final class AppViewModelTests: XCTestCase { viewModel.draftTagName = "Desk anchor" await viewModel.pairSticker() - XCTAssertEqual(viewModel.snapshot.pairedTag?.displayName, "Desk anchor") - XCTAssertNotNil(viewModel.snapshot.pairedTag?.uidHash) + XCTAssertEqual(viewModel.snapshot.pairedTags.first?.displayName, "Desk anchor") + XCTAssertNotNil(viewModel.snapshot.pairedTags.first?.uidHash) viewModel.draftModeName = "Phone break" await viewModel.saveMode() From 21ca25614e897a87ec2e62e4625a4beb29bc8be8 Mon Sep 17 00:00:00 2001 From: Microck Date: Fri, 3 Apr 2026 03:45:19 +0000 Subject: [PATCH 2/2] fix: only mark anchors active during blocking sessions --- ios/ancla-app/app-view-model.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ios/ancla-app/app-view-model.swift b/ios/ancla-app/app-view-model.swift index 437849c..6dfb656 100644 --- a/ios/ancla-app/app-view-model.swift +++ b/ios/ancla-app/app-view-model.swift @@ -129,7 +129,10 @@ final class AppViewModel { } var activePairedTag: PairedTag? { - guard let activeSession = snapshot.activeSession else { + guard + let activeSession = snapshot.activeSession, + activeSession.state == .armed || activeSession.state == .mismatchedTag + else { return nil }