From bdbd7089c53ecf28388a1ab08e331481a4448975 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 13:20:10 -0400 Subject: [PATCH 01/19] feat: App Intents for Spotlight chat search, Send Cash, and Siri Suggestions Index DM chats into CoreSpotlight (with avatars) so they're searchable and open on tap, donate open chats as Siri Suggestions/Handoff, and add a Send Cash app shortcut that opens the send flow for a contact. Spotlight taps and the shortcut route through the app's existing deep-link navigation. --- Flipcash/Core/AppDelegate.swift | 26 +++ .../Core/AppIntents/AppIntentContext.swift | 54 ++++++ Flipcash/Core/AppIntents/ContactEntity.swift | 65 +++++++ .../Core/AppIntents/FlipcashShortcuts.swift | 25 +++ Flipcash/Core/AppIntents/SendCashIntent.swift | 54 ++++++ Flipcash/Core/FlipcashApp.swift | 7 + .../Conversation/ConversationScreen.swift | 13 ++ .../Core/Session/SessionAuthenticator.swift | 9 + Flipcash/Core/Spotlight/AppUserActivity.swift | 23 +++ .../Core/Spotlight/ChatSpotlightIndexer.swift | 166 +++++++++++++++++ .../Core/Spotlight/ChatSpotlightItem.swift | 83 +++++++++ Flipcash/Supporting Files/Info.plist | 1 + .../ConversationIdentifiers.swift | 11 ++ .../ConversationIdentifierTests.swift | 19 +- FlipcashTests/ChatSpotlightItemTests.swift | 174 ++++++++++++++++++ FlipcashTests/ContactEntityTests.swift | 55 ++++++ 16 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/AppIntents/AppIntentContext.swift create mode 100644 Flipcash/Core/AppIntents/ContactEntity.swift create mode 100644 Flipcash/Core/AppIntents/FlipcashShortcuts.swift create mode 100644 Flipcash/Core/AppIntents/SendCashIntent.swift create mode 100644 Flipcash/Core/Spotlight/AppUserActivity.swift create mode 100644 Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift create mode 100644 Flipcash/Core/Spotlight/ChatSpotlightItem.swift create mode 100644 FlipcashTests/ChatSpotlightItemTests.swift create mode 100644 FlipcashTests/ContactEntityTests.swift diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index e05ac910..15960fe3 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -7,6 +7,7 @@ import UIKit import SwiftUI +import CoreSpotlight import FlipcashUI import FlipcashCore @@ -40,6 +41,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ]) self.container = Container() super.init() + + // Point App Intents (which run outside the SwiftUI environment) at the + // live session so the Send shortcut can reach contacts and the router. + AppIntentContext.sessionAuthenticator = container.sessionAuthenticator } // MARK: - Launch - @@ -170,6 +175,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { handleOpenURL(url: url) } + /// Routes a continued `NSUserActivity` — a Spotlight chat tap or a Handoff / + /// Siri-suggestion of an opened chat — into the deep-link pipeline by + /// rebuilding the `flipcash://chat/{id}` URL the activity carries. + func handleContinue(_ activity: NSUserActivity) { + let chatID: String? = switch activity.activityType { + case CSSearchableItemActionType: + activity.userInfo?[CSSearchableItemActivityIdentifier] as? String + case AppUserActivity.openChat: + activity.userInfo?[AppUserActivity.chatIDKey] as? String + default: + nil + } + + guard let chatID, let url = URL(string: "flipcash://chat/\(chatID)") else { + logger.warning("User activity continuation missing chat id", metadata: ["type": "\(activity.activityType)"]) + return + } + + handleOpenURL(url: url) + } + // MARK: - Push Notifications - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { diff --git a/Flipcash/Core/AppIntents/AppIntentContext.swift b/Flipcash/Core/AppIntents/AppIntentContext.swift new file mode 100644 index 00000000..bfc5e16a --- /dev/null +++ b/Flipcash/Core/AppIntents/AppIntentContext.swift @@ -0,0 +1,54 @@ +// +// AppIntentContext.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Main-actor-confined bridge from App Intents (which run outside the SwiftUI +/// environment) to the live session. `AppDelegate` points it at the +/// `SessionAuthenticator` at launch; intents and entity queries read through it. +/// +/// Everything degrades to "no session": a logged-out or send-disabled state +/// yields no contacts and a `false` `canSend`, so the Send shortcut stays inert +/// until the feature is available. +@MainActor +enum AppIntentContext { + + static var sessionAuthenticator: SessionAuthenticator? + + private static var loggedInContainer: SessionContainer? { + guard case .loggedIn(let container) = sessionAuthenticator?.state else { return nil } + return container + } + + /// Whether the signed-in user can send — gates both the contact query and + /// the intent. Mirrors `Session.canSend` (beta flag OR server flag). + static var canSend: Bool { + loggedInContainer?.session.canSend ?? false + } + + /// On-Flipcash contacts the user can send to, or `[]` when logged out or + /// send is unavailable. Invite-only (non-Flipcash) contacts are excluded — + /// they can't receive cash. + static func sendableContacts() async -> [ResolvedContact] { + guard canSend, let container = loggedInContainer else { return [] } + return await RecipientLoader.load(database: container.database).onFlipcash + } + + static func contact(withID id: String) async -> ResolvedContact? { + await sendableContacts().first { $0.id == id } + } + + /// Foregrounds the app on `contact`'s conversation with the Send Cash amount + /// entry stacked on top, so dismissing it lands back in the chat — the same + /// router path the in-chat Send Cash button drives. + static func openSendFlow(to contact: ResolvedContact) { + guard let router = loggedInContainer?.appRouter else { return } + router.present(.conversation(.contact(contact))) + router.presentNested(.sendAmount(contact)) + } +} diff --git a/Flipcash/Core/AppIntents/ContactEntity.swift b/Flipcash/Core/AppIntents/ContactEntity.swift new file mode 100644 index 00000000..1ebb59d4 --- /dev/null +++ b/Flipcash/Core/AppIntents/ContactEntity.swift @@ -0,0 +1,65 @@ +// +// ContactEntity.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AppIntents +import FlipcashCore + +/// A sendable Flipcash contact, exposed to Siri and the Shortcuts app as the +/// recipient parameter of ``SendCashIntent``. Lean by design: the live +/// `ResolvedContact` (with its image and chat id) is re-resolved by `id` at +/// `perform()` time so the entity payload stays small and never goes stale. +struct ContactEntity: AppEntity { + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact") + static let defaultQuery = ContactEntityQuery() + + /// The `ResolvedContact.id` composite (`contactId|e164`). + let id: String + let displayName: String + let nationalPhone: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(displayName)", subtitle: "\(nationalPhone)") + } +} + +extension ContactEntity { + init(_ contact: ResolvedContact) { + self.id = contact.id + self.displayName = contact.displayName + self.nationalPhone = contact.nationalPhone + } +} + +/// Resolves ``ContactEntity`` values from the live sendable-contact list. +/// Returns nothing when the user is logged out or can't send, so the parameter +/// offers no suggestions and the shortcut stays inert. +struct ContactEntityQuery: EntityQuery { + + func entities(for identifiers: [String]) async throws -> [ContactEntity] { + let wanted = Set(identifiers) + return await AppIntentContext.sendableContacts() + .filter { wanted.contains($0.id) } + .map(ContactEntity.init) + } + + func suggestedEntities() async throws -> [ContactEntity] { + await AppIntentContext.sendableContacts().map(ContactEntity.init) + } +} + +extension ContactEntityQuery: EntityStringQuery { + func entities(matching string: String) async throws -> [ContactEntity] { + let lowered = string.lowercased() + return await AppIntentContext.sendableContacts() + .filter { + $0.displayName.lowercased().contains(lowered) + || $0.nationalPhone.contains(string) + } + .map(ContactEntity.init) + } +} diff --git a/Flipcash/Core/AppIntents/FlipcashShortcuts.swift b/Flipcash/Core/AppIntents/FlipcashShortcuts.swift new file mode 100644 index 00000000..fd202567 --- /dev/null +++ b/Flipcash/Core/AppIntents/FlipcashShortcuts.swift @@ -0,0 +1,25 @@ +// +// FlipcashShortcuts.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AppIntents + +/// Surfaces the app's intents to Siri and Spotlight without user setup. Auto- +/// discovered by the system — no Info.plist entry needed. +struct FlipcashShortcuts: AppShortcutsProvider { + + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SendCashIntent(), + phrases: [ + "Send cash with \(.applicationName)", + "Send money with \(.applicationName)", + ], + shortTitle: "Send Cash", + systemImageName: "paperplane.circle.fill" + ) + } +} diff --git a/Flipcash/Core/AppIntents/SendCashIntent.swift b/Flipcash/Core/AppIntents/SendCashIntent.swift new file mode 100644 index 00000000..7dbe8d72 --- /dev/null +++ b/Flipcash/Core/AppIntents/SendCashIntent.swift @@ -0,0 +1,54 @@ +// +// SendCashIntent.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AppIntents + +/// Opens the app at the send amount-entry screen for a chosen contact. Launches +/// the app rather than completing the payment headlessly — entering an amount +/// and confirming always happens in-app, matching Apple's money-handling +/// guidance and the verified-state invariant a headless send would break. +/// +/// `contact` is required so Siri/Spotlight prompt "who?" using the on-Flipcash +/// contacts the query offers, then proceed once one is chosen. +struct SendCashIntent: AppIntent { + + static let title: LocalizedStringResource = "Send Cash" + static let description = IntentDescription("Start sending cash to one of your Flipcash contacts.") + + /// Bring the app to the foreground so the user finishes the send in-app. + static let openAppWhenRun = true + + @Parameter(title: "To") + var contact: ContactEntity + + func perform() async throws -> some IntentResult { + guard await AppIntentContext.canSend else { + throw SendCashIntentError.sendUnavailable + } + guard let resolved = await AppIntentContext.contact(withID: contact.id) else { + throw SendCashIntentError.recipientUnavailable + } + await AppIntentContext.openSendFlow(to: resolved) + return .result() + } + + static var parameterSummary: some ParameterSummary { + Summary("Send cash to \(\.$contact)") + } +} + +enum SendCashIntentError: Error, CustomLocalizedStringResourceConvertible { + case sendUnavailable + case recipientUnavailable + + var localizedStringResource: LocalizedStringResource { + switch self { + case .sendUnavailable: "Sending isn’t available yet." + case .recipientUnavailable: "That contact isn’t available to send to." + } + } +} diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index dbefc39d..268b5ba8 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CoreSpotlight import FlipcashUI /// The main entry point for Flipcash. @@ -26,6 +27,12 @@ struct FlipcashApp: App { .onOpenURL { url in appDelegate.handleOpenURL(url: url) } + .onContinueUserActivity(CSSearchableItemActionType) { activity in + appDelegate.handleContinue(activity) + } + .onContinueUserActivity(AppUserActivity.openChat) { activity in + appDelegate.handleContinue(activity) + } .withDialogWindow( sessionAuthenticator: appDelegate.container.sessionAuthenticator ) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 33ab8c82..66164ab4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -232,6 +232,19 @@ struct ConversationScreen: View { conversationController.visibleConversationID = nil } } + // Donate the open chat for Siri prediction, Handoff, and Spotlight. + // Only an existing chat carries an id worth resuming; a contact without + // a chat yet has nothing to hand off to. + .userActivity(AppUserActivity.openChat, isActive: chatExists && conversationID != nil) { activity in + guard let conversationID else { return } + activity.title = title + activity.userInfo = [AppUserActivity.chatIDKey: conversationID.base64URLEncoded] + activity.requiredUserInfoKeys = [AppUserActivity.chatIDKey] + activity.persistentIdentifier = conversationID.base64URLEncoded + activity.isEligibleForSearch = true + activity.isEligibleForHandoff = true + activity.isEligibleForPrediction = true + } } /// Marks this conversation as the one on screen — the gate for foreground-banner suppression and diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 4e8894da..321173da 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -427,6 +427,7 @@ final class SessionAuthenticator { container.pushController.prepareForLogout() container.usdcSweepOperation.cancel() container.conversationController.stop() + container.chatSpotlightIndexer.stop() QuickActionsController.clear() } @@ -459,6 +460,7 @@ struct SessionContainer { let usdcSweepOperation: UsdcSweepOperation let quickActionsController: QuickActionsController let conversationController: ConversationController + let chatSpotlightIndexer: ChatSpotlightIndexer init( session: Session, @@ -538,6 +540,13 @@ struct SessionContainer { pushController.isViewingConversation = { [weak conversationController] conversationID in conversationController?.visibleConversationID == conversationID } + + let chatSpotlightIndexer = ChatSpotlightIndexer( + controller: conversationController, + contactSyncController: contactSyncController + ) + chatSpotlightIndexer.start() + self.chatSpotlightIndexer = chatSpotlightIndexer } fileprivate func injectingEnvironment(into view: SomeView) -> some View where SomeView: View { diff --git a/Flipcash/Core/Spotlight/AppUserActivity.swift b/Flipcash/Core/Spotlight/AppUserActivity.swift new file mode 100644 index 00000000..29f84057 --- /dev/null +++ b/Flipcash/Core/Spotlight/AppUserActivity.swift @@ -0,0 +1,23 @@ +// +// AppUserActivity.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Identifiers for the `NSUserActivity` the app donates and continues. +/// Shared by the donation site (`ConversationScreen`), the continuation +/// handler (`AppDelegate`), and the scene-root modifiers (`FlipcashApp`). +/// +/// The activity type must also be declared in `NSUserActivityTypes` in +/// Info.plist or the system drops the donation. +enum AppUserActivity { + /// A DM conversation the user opened. Donated for Siri prediction, Handoff, + /// and Spotlight; carries the chat id under ``chatIDKey``. + static let openChat = "com.flipcash.app.openChat" + + /// `userInfo` key holding a conversation's `base64URLEncoded` id. + static let chatIDKey = "conversationID" +} diff --git a/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift b/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift new file mode 100644 index 00000000..8de0213f --- /dev/null +++ b/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift @@ -0,0 +1,166 @@ +// +// ChatSpotlightIndexer.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import CoreSpotlight +import FlipcashCore +import FlipcashUI + +nonisolated private let logger = Logger(label: "flipcash.spotlight") + +/// Mirrors `ConversationController.conversations` into the on-device Spotlight +/// index so DM chats are searchable and open on tap. Session-scoped: a sibling +/// of `ConversationController` on `SessionContainer`, started after the feed +/// hydrates and torn down on logout. +/// +/// Observes the feed via a re-arming `withObservationTracking` loop, debounced +/// so a burst of stream events collapses into one reindex. +@MainActor +final class ChatSpotlightIndexer { + + private let controller: ConversationController + private let contactSyncController: ContactSyncController + private let index: CSSearchableIndex + + private var debounce: Task? + /// Identifiers currently in the index, so a reindex can delete the ones + /// that dropped out of the feed without clearing the whole domain. + private var indexedIdentifiers: Set = [] + /// Rendered avatar PNGs keyed by `id|displayName|imageBytes`, so reindexes + /// reuse unchanged avatars instead of re-rasterizing on the main actor. + private var avatarCache: [String: Data] = [:] + + init( + controller: ConversationController, + contactSyncController: ContactSyncController, + index: CSSearchableIndex = .default() + ) { + self.controller = controller + self.contactSyncController = contactSyncController + self.index = index + } + + /// Indexes the current feed and arms observation. Idempotent enough for the + /// single call site, but observation re-arms itself on every change. + func start() { + observe() + } + + /// Clears every chat item from the index — called on logout so one user's + /// conversations never surface under another account. + func stop() { + debounce?.cancel() + debounce = nil + indexedIdentifiers = [] + avatarCache = [:] + index.deleteSearchableItems(withDomainIdentifiers: [ChatSpotlightItem.domainIdentifier]) { error in + if let error { + logger.error("Failed to clear chat Spotlight index", metadata: ["error": "\(error)"]) + } + } + } + + private func observe() { + // Read every observable the items depend on — the feed *and* the + // resolved contact directory (`displayName(for:)` reads it) — so a + // change re-arms and reindexes. Contact sync resolves names and photos + // after launch; tracking only the feed would leave a chat indexed under + // its phone-number fallback with no avatar. No image rendering here — + // that's reindex()'s job, off the debounce. + _ = withObservationTracking { + controller.conversations.map { controller.displayName(for: $0) } + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.scheduleReindex() + self.observe() + } + } + scheduleReindex() + } + + private func scheduleReindex() { + debounce?.cancel() + debounce = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, let self else { return } + self.reindex() + } + } + + private func reindex() { + let items = controller.conversations.map { conversation -> ChatSpotlightItem in + let contact = counterpartContact(for: conversation) + let displayName = controller.displayName(for: conversation) + return ChatSpotlightItem( + conversation: conversation, + displayName: displayName, + counterpartPhoneE164: conversation.counterpart(excluding: controller.selfUserID)?.phoneE164, + thumbnailData: avatar( + forID: contact?.contactId ?? conversation.id.description, + displayName: displayName, + imageData: contact?.imageData + ) + ) + } + let currentIdentifiers = Set(items.map(\.uniqueIdentifier)) + let removed = indexedIdentifiers.subtracting(currentIdentifiers) + indexedIdentifiers = currentIdentifiers + + index.indexSearchableItems(items.map(\.searchableItem)) { error in + if let error { + logger.error("Failed to index chats in Spotlight", metadata: ["error": "\(error)"]) + } else { + logger.info("Indexed chats in Spotlight", metadata: ["count": "\(items.count)"]) + } + } + + guard !removed.isEmpty else { return } + index.deleteSearchableItems(withIdentifiers: Array(removed)) { error in + if let error { + logger.error("Failed to remove stale chats from Spotlight", metadata: ["error": "\(error)"]) + } + } + } + + /// The synced contact behind a DM, matched on the pre-assigned chat id — + /// the source of the counterpart's photo (nil for an unsaved number). + private func counterpartContact(for conversation: Conversation) -> ResolvedContact? { + contactSyncController.resolvedContacts.onFlipcash.first { $0.dmChatID == conversation.id.data } + } + + /// Cached avatar PNG, keyed by the inputs that affect the image. Most + /// reindexes fire for an unrelated change (a new last message), where no + /// avatar changed — the cache returns the existing PNG instead of + /// re-rasterizing every conversation's avatar on the main actor. + private func avatar(forID id: String, displayName: String, imageData: Data?) -> Data? { + let key = "\(id)|\(displayName)|\(imageData?.count ?? 0)" + if let cached = avatarCache[key] { return cached } + let rendered = renderAvatar(id: id, displayName: displayName, imageData: imageData) + avatarCache[key] = rendered + return rendered + } + + /// Renders the app's own avatar (contact photo, or initials/person + /// monogram) to PNG for the Spotlight thumbnail, so results match the + /// in-app avatar instead of falling back to a bare app icon. + /// + /// Rendered at the avatar's canonical 44pt size: its monogram font is tuned + /// for that, so a larger frame would shrink the initials to a tiny fraction + /// of the circle. The color scheme is pinned to `.dark` (the app's only + /// scheme) so the asset colors resolve the way the avatar was designed, + /// rather than the renderer's default light variant. `scale = 3` matches the + /// highest real device scale (Spotlight's slot is system-fixed, not ours). + private func renderAvatar(id: String, displayName: String, imageData: Data?) -> Data? { + let renderer = ImageRenderer( + content: ContactAvatarView(id: id, displayName: displayName, imageData: imageData, size: 44) + .environment(\.colorScheme, .dark) + ) + renderer.scale = 3 + return renderer.uiImage?.pngData() + } +} diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift new file mode 100644 index 00000000..22d6b2b2 --- /dev/null +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -0,0 +1,83 @@ +// +// ChatSpotlightItem.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreSpotlight +import UniformTypeIdentifiers +import FlipcashCore + +/// A single DM conversation prepared for the on-device Spotlight index. The +/// mapping from `Conversation` to searchable attributes is pure so it can be +/// unit-tested without touching `CSSearchableIndex`. +nonisolated struct ChatSpotlightItem { + + /// Domain for every chat item, so the whole set can be cleared in one call + /// on logout without disturbing other Spotlight contributions. + static let domainIdentifier = "com.flipcash.chat" + + /// The conversation's `base64URLEncoded` id — the same token the + /// `flipcash://chat/{id}` deep link carries, so a Spotlight tap routes + /// through the existing deep-link path. + let uniqueIdentifier: String + let title: String + let contentDescription: String? + /// Extra searchable terms. Critical for a chat with no contact name: its + /// title is a formatted phone number like "(586) 980-2333", and Spotlight + /// won't match a bare "586" inside that punctuation — so the raw and + /// digits-only number forms go here to make it findable by digits. + let keywords: [String] + /// PNG of the counterpart's avatar, shown as the result thumbnail (with + /// Spotlight's automatic app-icon badge). Nil falls back to the app icon. + let thumbnailData: Data? + + init(conversation: Conversation, displayName: String, counterpartPhoneE164: String?, thumbnailData: Data?) { + self.uniqueIdentifier = conversation.id.base64URLEncoded + self.title = displayName + self.contentDescription = Self.preview(of: conversation.lastMessage) + self.keywords = Self.keywords(displayName: displayName, phoneE164: counterpartPhoneE164) + self.thumbnailData = thumbnailData + } + + var searchableItem: CSSearchableItem { + let attributes = CSSearchableItemAttributeSet(contentType: .text) + attributes.title = title + attributes.contentDescription = contentDescription + attributes.keywords = keywords + attributes.thumbnailData = thumbnailData + return CSSearchableItem( + uniqueIdentifier: uniqueIdentifier, + domainIdentifier: Self.domainIdentifier, + attributeSet: attributes + ) + } + + /// Mirrors the recipient picker's last-message subtitle. + private static func preview(of message: ConversationMessage?) -> String? { + switch message?.content { + case .text(let text): text + case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" + case nil: nil + } + } + + /// The display name plus the counterpart number in every form a user might + /// type: E.164, national, and digits-only of each (so a national-digits + /// token like "5869802333" prefix-matches "586"). De-duplicated, empties + /// dropped. + private static func keywords(displayName: String, phoneE164: String?) -> [String] { + var terms: [String] = [displayName] + if let phoneE164, !phoneE164.isEmpty { + terms.append(phoneE164) + terms.append(phoneE164.filter(\.isNumber)) + if let national = Phone(phoneE164)?.national { + terms.append(national) + terms.append(national.filter(\.isNumber)) + } + } + var seen: Set = [] + return terms.filter { !$0.isEmpty && seen.insert($0).inserted } + } +} diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index 4b24ed28..451b9a8d 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -20,6 +20,7 @@ NSUserActivityTypes INSendMessageIntent + com.flipcash.app.openChat SQLiteVersion 20 diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift index af4e8328..e306b2c4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift @@ -43,6 +43,17 @@ public struct ConversationID: Hashable, Sendable, CustomStringConvertible { .with { $0.value = data } } + /// The base64url form the server uses in `/chat/{chatId}` deep links — + /// the inverse of ``init(base64URLEncoded:)``. Unpadded, with the URL-safe + /// `-`/`_` alphabet, so it drops straight into a `flipcash://chat/` path + /// without percent-encoding. + public var base64URLEncoded: String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + public var description: String { data.hexString() } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationIdentifierTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationIdentifierTests.swift index 039cc7d6..37e6e1ad 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationIdentifierTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationIdentifierTests.swift @@ -9,7 +9,7 @@ import Testing import Foundation @testable import FlipcashCore -@Suite("ConversationID base64url decoding") +@Suite("ConversationID base64url") struct ConversationIdentifierTests { @Test("Decodes the server's padded base64url form") @@ -57,4 +57,21 @@ struct ConversationIdentifierTests { func rejectsGarbage() { #expect(ConversationID(base64URLEncoded: "not.valid.base64") == nil) } + + @Test("Encodes to unpadded URL-safe base64") + func encodesURLSafeUnpadded() { + let id = ConversationID(data: Data(repeating: 0xFB, count: 32)) + let encoded = id.base64URLEncoded + #expect(!encoded.contains("=")) + #expect(!encoded.contains("+")) + #expect(!encoded.contains("/")) + #expect(encoded.contains("-") || encoded.contains("_")) + } + + @Test("Encode/decode round-trips", arguments: [0x00, 0x7F, 0xFB, 0xFF]) + func roundTrips(byte: Int) throws { + let original = ConversationID(data: Data(repeating: UInt8(byte), count: 32)) + let decoded = try #require(ConversationID(base64URLEncoded: original.base64URLEncoded)) + #expect(decoded == original) + } } diff --git a/FlipcashTests/ChatSpotlightItemTests.swift b/FlipcashTests/ChatSpotlightItemTests.swift new file mode 100644 index 00000000..46f01861 --- /dev/null +++ b/FlipcashTests/ChatSpotlightItemTests.swift @@ -0,0 +1,174 @@ +// +// ChatSpotlightItemTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Chat Spotlight item mapping") +struct ChatSpotlightItemTests { + + private func conversation(byte: UInt8, lastMessage: ConversationMessage?) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: 32)), + members: [], + lastMessage: lastMessage, + lastActivity: Date(timeIntervalSince1970: 0) + ) + } + + private func textMessage(_ text: String) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 1), + senderID: nil, + content: .text(text), + date: Date(timeIntervalSince1970: 0), + unreadSeq: 0 + ) + } + + private func cashMessage(_ amount: ExchangedFiat) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 2), + senderID: nil, + content: .cash(amount), + date: Date(timeIntervalSince1970: 0), + unreadSeq: 0 + ) + } + + private func usd(_ value: Decimal) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(quarks: 0, mint: .usdf), + nativeAmount: FiatAmount(value: value, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + ) + } + + @Test("Identifier is the conversation's base64url id, so a tap routes through the chat deep link") + func identifierMatchesDeepLinkToken() { + let chat = conversation(byte: 0x07, lastMessage: nil) + let item = ChatSpotlightItem(conversation: chat, displayName: "Anna", counterpartPhoneE164: nil, thumbnailData: nil) + + #expect(item.uniqueIdentifier == chat.id.base64URLEncoded) + #expect(ConversationID(base64URLEncoded: item.uniqueIdentifier) == chat.id) + } + + @Test("Title is the resolved display name passed in, not a member field") + func titleUsesResolvedName() { + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x01, lastMessage: nil), + displayName: "Mom", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.title == "Mom") + } + + @Test("Text last message becomes the content description verbatim") + func textPreview() { + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x01, lastMessage: textMessage("see you soon")), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.contentDescription == "see you soon") + } + + @Test("Cash last message renders as a formatted 'Cash · ' description") + func cashPreview() { + let amount = usd(0.50) + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x05, lastMessage: cashMessage(amount)), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.contentDescription == "Cash · \(amount.nativeAmount.formatted())") + } + + @Test("A conversation with no messages has no content description") + func emptyPreview() { + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x01, lastMessage: nil), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.contentDescription == nil) + } + + @Test("A phone-only chat is searchable by its digits via keywords") + func keywordsIncludePhoneDigitForms() { + // Distinct display name so each expected keyword is attributable to a + // specific branch (name vs E.164 vs national, digits or not). + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x03, lastMessage: nil), + displayName: "Anna", + counterpartPhoneE164: "+15869802333", + thumbnailData: nil + ) + #expect(item.keywords.contains("Anna")) // display name + #expect(item.keywords.contains("+15869802333")) // E.164 + #expect(item.keywords.contains("15869802333")) // E.164 digits-only + #expect(item.keywords.contains("(586) 980-2333")) // national + #expect(item.keywords.contains("5869802333")) // national digits → "586" prefix matches + } + + @Test("Keywords are de-duplicated when a display name equals a phone form") + func keywordsDeduplicated() { + // Display name equal to the national form would otherwise appear twice. + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x06, lastMessage: nil), + displayName: "(586) 980-2333", + counterpartPhoneE164: "+15869802333", + thumbnailData: nil + ) + #expect(Set(item.keywords).count == item.keywords.count) + } + + @Test("An unparseable phone number degrades to name + raw form without crashing") + func keywordsTolerateInvalidPhone() { + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x08, lastMessage: nil), + displayName: "Anna", + counterpartPhoneE164: "not-a-number", + thumbnailData: nil + ) + // The national-form branch no-ops on an unparseable number, and the + // empty digits-only form is dropped — leaving just the name and raw value. + #expect(item.keywords == ["Anna", "not-a-number"]) + } + + @Test("Keywords carry the display name even with no phone number") + func keywordsIncludeDisplayName() { + let item = ChatSpotlightItem( + conversation: conversation(byte: 0x04, lastMessage: nil), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.keywords == ["Anna"]) + } + + @Test("The searchable item carries the chat domain and the mapped attributes") + func searchableItemAttributes() { + let chat = conversation(byte: 0x02, lastMessage: textMessage("hi")) + let avatar = Data([0x89, 0x50, 0x4E, 0x47]) + let item = ChatSpotlightItem(conversation: chat, displayName: "Anna", counterpartPhoneE164: nil, thumbnailData: avatar) + let searchable = item.searchableItem + + #expect(searchable.uniqueIdentifier == chat.id.base64URLEncoded) + #expect(searchable.domainIdentifier == ChatSpotlightItem.domainIdentifier) + #expect(searchable.attributeSet.title == "Anna") + #expect(searchable.attributeSet.contentDescription == "hi") + #expect(searchable.attributeSet.keywords == ["Anna"]) + #expect(searchable.attributeSet.thumbnailData == avatar) + } +} diff --git a/FlipcashTests/ContactEntityTests.swift b/FlipcashTests/ContactEntityTests.swift new file mode 100644 index 00000000..f436df85 --- /dev/null +++ b/FlipcashTests/ContactEntityTests.swift @@ -0,0 +1,55 @@ +// +// ContactEntityTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Contact App Entity") +struct ContactEntityTests { + + private func contact(_ name: String) -> ResolvedContact { + ResolvedContact( + contactId: "contact-\(name)", + displayName: name, + phoneE164: "+15555550\(name.count)", + nationalPhone: "(555) 555-010\(name.count)", + imageData: Data([0x01]), + dmChatID: nil + ) + } + + @Test("Maps a resolved contact onto the lean entity, dropping image and chat id") + func mapsResolvedContact() { + let resolved = contact("Anna") + let entity = ContactEntity(resolved) + + #expect(entity.id == resolved.id) + #expect(entity.displayName == "Anna") + #expect(entity.nationalPhone == resolved.nationalPhone) + } + + @Test("Entity id matches the resolved contact id, so perform() can re-resolve it") + func idRoundTripsForReResolution() { + let resolved = contact("Ben") + let entity = ContactEntity(resolved) + + // The id is the (contactId|e164) composite the live list is keyed by. + #expect(entity.id == "\(resolved.contactId)|\(resolved.phoneE164)") + } + + @Test("Display representation shows the name as title and number as subtitle") + func displayRepresentation() throws { + let resolved = contact("Anna") + let representation = ContactEntity(resolved).displayRepresentation + + #expect(String(localized: representation.title) == "Anna") + let subtitle = try #require(representation.subtitle) + #expect(String(localized: subtitle) == resolved.nationalPhone) + } +} From 76f85ea244c8586df39abaff1ca9e5e65b7606f4 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 13:36:04 -0400 Subject: [PATCH 02/19] feat: shared chat notification category identifiers --- .../Notifications/ChatNotificationCategory.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift new file mode 100644 index 00000000..b943707f --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Identifiers for the chat-message notification category. Shared by the app +/// (registers the category), `NotificationService` (tags the push), and the +/// content extension (declares + handles the category). +public enum ChatNotificationCategory { + public static let id = "CHAT_MESSAGE" + public static let replyActionID = "CHAT_REPLY" + public static let sendCashActionID = "CHAT_SEND_CASH" +} From fa3a9d251465e444f80d8adc10837fadeadcbd98 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 13:44:20 -0400 Subject: [PATCH 03/19] feat: resolve Send Cash target from a chat notification --- .../Core/Controllers/ContactDirectory.swift | 2 +- .../ChatNotificationRouter.swift | 12 +++++++ .../ChatNotificationRouterTests.swift | 34 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift create mode 100644 FlipcashTests/ChatNotificationRouterTests.swift diff --git a/Flipcash/Core/Controllers/ContactDirectory.swift b/Flipcash/Core/Controllers/ContactDirectory.swift index 2509de35..e162fc33 100644 --- a/Flipcash/Core/Controllers/ContactDirectory.swift +++ b/Flipcash/Core/Controllers/ContactDirectory.swift @@ -48,7 +48,7 @@ extension ResolvedContact { /// works before the person is a synced contact. `nil` when no number is on /// file. The phone resolves the recipient and `dmChatID` carries the chat /// payment metadata; the display fields aren't read by the send flow. - init?(counterpart member: ConversationMember, dmChatID: Data?) { + nonisolated init?(counterpart member: ConversationMember, dmChatID: Data?) { guard let phoneE164 = member.phoneE164, !phoneE164.isEmpty else { return nil } let national = member.formattedPhoneNumber ?? phoneE164 self.init( diff --git a/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift b/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift new file mode 100644 index 00000000..ac66184f --- /dev/null +++ b/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift @@ -0,0 +1,12 @@ +import FlipcashCore + +nonisolated enum ChatNotificationRouter { + /// The contact a Send Cash from the notification should pay: the synced + /// contact when present, else a target built from the counterpart's shared + /// phone number (mirrors `ConversationScreen.sendTarget`, #382). + static func sendTarget(forChatID id: ConversationID, conversation: Conversation?, selfUserID: UserID) -> ResolvedContact? { + guard let counterpart = conversation?.counterpart(excluding: selfUserID), + counterpart.phoneE164?.isEmpty == false else { return nil } + return ResolvedContact(counterpart: counterpart, dmChatID: id.data) + } +} diff --git a/FlipcashTests/ChatNotificationRouterTests.swift b/FlipcashTests/ChatNotificationRouterTests.swift new file mode 100644 index 00000000..ab20119c --- /dev/null +++ b/FlipcashTests/ChatNotificationRouterTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Chat notification Send Cash target") +struct ChatNotificationRouterTests { + @Test("Builds a send target from the conversation's counterpart phone") + func targetFromCounterpart() { + let id = ConversationID(data: Data(repeating: 0x01, count: 32)) + let me = UUID(); let them = UUID() + let convo = Conversation( + id: id, + members: [ + ConversationMember(userID: me, displayName: "Me"), + ConversationMember(userID: them, displayName: "", phoneE164: "+15551234567"), + ], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0) + ) + let target = ChatNotificationRouter.sendTarget(forChatID: id, conversation: convo, selfUserID: me) + #expect(target?.phoneE164 == "+15551234567") + #expect(target?.dmChatID == id.data) + } + + @Test("No counterpart phone yields no target") + func noTarget() { + let id = ConversationID(data: Data(repeating: 0x02, count: 32)) + let me = UUID() + let convo = Conversation(id: id, members: [ConversationMember(userID: me, displayName: "Me")], + lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 0)) + #expect(ChatNotificationRouter.sendTarget(forChatID: id, conversation: convo, selfUserID: me) == nil) + } +} From 9f10082723a7f508e94657407758007b8cb7a794 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 13:56:08 -0400 Subject: [PATCH 04/19] feat: register chat notification category and route Send Cash action - PushController registers the CHAT_MESSAGE category on init with Reply (text-input) and Send Cash (.foreground) actions - didReceive posts flipcash://chat/{id}/send to the deep-link pipeline when the Send Cash action is tapped, decoding the chat ID from the push payload navigation - Route.Path gains .chatSendCash(ConversationID); /chat/{id}/send parses to it while /chat/{id} stays .chat - DeepLinkController handles .chatSendCash: resolves the conversation counterpart via ChatNotificationRouter and navigates to .sendAmount when canSend is true - ScanViewModel.canScanQR updated for exhaustive switch - RouteTests gains two cases verifying /chat/{id}/send and /chat/{id} parse correctly --- .../Deep Links/DeepLinkController.swift | 35 ++++++++++++--- .../Core/Controllers/Deep Links/Route.swift | 4 ++ .../Core/Controllers/PushController.swift | 45 ++++++++++++++++--- .../Core/Screens/Main/ScanViewModel.swift | 2 +- FlipcashTests/RouteTests.swift | 34 ++++++++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index a43004a3..0eaef183 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -97,6 +97,9 @@ final class DeepLinkController { case .chat(let conversationID): return actionForChat(conversationID: conversationID) + case .chatSendCash(let conversationID): + return actionForChatSendCash(conversationID: conversationID) + case .give: return actionForOpenSheet(.give) @@ -157,6 +160,13 @@ final class DeepLinkController { ) } + private func actionForChatSendCash(conversationID: ConversationID) -> DeepLinkAction { + DeepLinkAction( + kind: .chatSendCash(conversationID), + sessionAuthenticator: sessionAuthenticator + ) + } + private func actionForOpenSheet(_ sheet: AppRouter.SheetPresentation) -> DeepLinkAction { DeepLinkAction( kind: .openSheet(sheet), @@ -223,6 +233,19 @@ struct DeepLinkAction { container.appRouter.present(.conversation(.existing(conversationID))) } + case .chatSendCash(let conversationID): + if case .loggedIn(let container) = sessionAuthenticator.state { + Analytics.deeplinkRouted(kind: kind) + let conversation = container.conversationController.conversation(withID: conversationID) + if let target = ChatNotificationRouter.sendTarget( + forChatID: conversationID, + conversation: conversation, + selfUserID: container.session.userID + ), container.session.canSend { + container.appRouter.navigate(to: .sendAmount(contact: target)) + } + } + case .openSheet(let sheet): if case .loggedIn(let container) = sessionAuthenticator.state { Analytics.deeplinkRouted(kind: kind) @@ -253,6 +276,7 @@ extension DeepLinkAction { case verifyEmail(VerificationDescription) case currencyInfo(PublicKey) case chat(ConversationID) + case chatSendCash(ConversationID) case openSheet(AppRouter.SheetPresentation) } } @@ -260,11 +284,12 @@ extension DeepLinkAction { extension DeepLinkAction.Kind { var analyticsName: String { switch self { - case .accessKey: "Login" - case .receiveCashLink: "CashLink" - case .verifyEmail: "EmailVerification" - case .currencyInfo: "TokenInfo" - case .chat: "Chat" + case .accessKey: "Login" + case .receiveCashLink: "CashLink" + case .verifyEmail: "EmailVerification" + case .currencyInfo: "TokenInfo" + case .chat: "Chat" + case .chatSendCash: "ChatSendCash" case .openSheet(let sheet): "Sheet:\(sheet)" } } diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index 4034d7fa..e10ecea3 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -86,6 +86,7 @@ nonisolated extension Route { case verifyEmail case token(PublicKey) case chat(ConversationID) + case chatSendCash(ConversationID) case give case balance case discover @@ -120,6 +121,9 @@ nonisolated extension Route { guard components.count > 1, let id = ConversationID(base64URLEncoded: components[1]) else { return nil } + if components.count > 2, components[2] == "send" { + return .chatSendCash(id) + } return .chat(id) case "give": return .give diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index c4bce4cd..968bfc00 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -59,6 +59,8 @@ class PushController { center.delegate = delegate Messaging.messaging().delegate = delegate + registerNotificationCategories() + activeObserver = NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, @@ -156,6 +158,30 @@ class PushController { ) } + // MARK: - Categories - + + private func registerNotificationCategories() { + let reply = UNTextInputNotificationAction( + identifier: ChatNotificationCategory.replyActionID, + title: "Reply", + options: [], + textInputButtonTitle: "Send", + textInputPlaceholder: "Message" + ) + let sendCash = UNNotificationAction( + identifier: ChatNotificationCategory.sendCashActionID, + title: "Send Cash", + options: [.foreground] + ) + let category = UNNotificationCategory( + identifier: ChatNotificationCategory.id, + actions: [reply, sendCash], + intentIdentifiers: [], + options: [] + ) + center.setNotificationCategories([category]) + } + // MARK: - Registration - private func registerAPNS() { @@ -236,14 +262,23 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { logger.info("Received notification response", metadata: ["action": "\(response.actionIdentifier)"]) - + Messaging.messaging().appDidReceiveMessage(response.notification.request.content.userInfo) - + postContactJoinIfNeeded(response.notification.request.content.userInfo) - let aps = response.notification.request.content.userInfo["aps"] as? [String: Any] - handleTargetUrlIfNeeded(aps?["target_url"] as? String) - + if response.actionIdentifier == ChatNotificationCategory.sendCashActionID, + let payload = NotificationPayload.decode(response.notification.request.content.userInfo), + case .chatID(let chatID) = payload.navigation.type + { + let conversationID = ConversationID(chatID) + let url = URL(string: "flipcash://chat/\(conversationID.base64URLEncoded)/send")! + handleTargetUrlIfNeeded(url.absoluteString) + } else { + let aps = response.notification.request.content.userInfo["aps"] as? [String: Any] + handleTargetUrlIfNeeded(aps?["target_url"] as? String) + } + Task { @MainActor in NotificationCenter.default.post(name: .pushNotificationReceived, object: nil) } diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index b273761d..4695b19f 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -115,7 +115,7 @@ class ScanViewModel { switch route.path { case .cash, .token: return true - case .login, .verifyEmail, .chat, .give, .balance, .discover, .send, .unknown: + case .login, .verifyEmail, .chat, .chatSendCash, .give, .balance, .discover, .send, .unknown: return false } } diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index de816164..5dbe0513 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -167,6 +167,40 @@ struct RouteTests { #expect(Route(url: URL(string: "https://app.flipcash.com/chat")!) == nil) } + @Test("Chat send-cash route parses /chat/{id}/send to .chatSendCash") + func chatSendCashRoute() { + let idData = Data((0..<32).map { UInt8($0) }) + let encoded = ConversationID(data: idData).base64URLEncoded + + // /chat/{id}/send → .chatSendCash + let deepLink = URL(string: "flipcash://chat/\(encoded)/send")! + let universalLink = URL(string: "https://app.flipcash.com/chat/\(encoded)/send")! + + if case .chatSendCash(let id) = Route(url: deepLink)?.path { + #expect(id.data == idData) + } else { + Issue.record("Deep link /chat/{id}/send should parse as .chatSendCash") + } + + if case .chatSendCash(let id) = Route(url: universalLink)?.path { + #expect(id.data == idData) + } else { + Issue.record("Universal link /chat/{id}/send should parse as .chatSendCash") + } + } + + @Test("Plain /chat/{id} still parses as .chat, not .chatSendCash") + func chatRouteUnaffectedBySendCashPath() { + let idData = Data((0..<32).map { UInt8($0) }) + let encoded = ConversationID(data: idData).base64URLEncoded + + if case .chat(let id) = Route(url: URL(string: "flipcash://chat/\(encoded)")!)?.path { + #expect(id.data == idData) + } else { + Issue.record("Plain /chat/{id} should still parse as .chat") + } + } + // MARK: - Sheet Routes - // // The home-screen quick actions open sheets via these routes. The From 61d91449018b24fb922740a5f94c203a9f5d4039 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 14:00:48 -0400 Subject: [PATCH 05/19] feat: tag chat pushes with the CHAT_MESSAGE category --- NotificationService/NotificationService.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index d172d93c..94510abe 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -71,6 +71,12 @@ final class NotificationService: UNNotificationServiceExtension { ) bestAttemptContent.threadIdentifier = payload.groupKey + // Tag chat pushes with the category so the Reply and Send Cash actions + // (registered in the app) attach to the notification. + if payload.category == .chat { + bestAttemptContent.categoryIdentifier = ChatNotificationCategory.id + } + // "Sent You Cash" (CHAT) renders as a communication notification so the // sender's avatar — or the system monogram fallback — shows like a chat // app. Other categories keep the Flipcash app icon. From 7c65d43b0b2e256da773de1a3009596af0f407df Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 14:27:27 -0400 Subject: [PATCH 06/19] feat: store the owner key in a shared keychain group with zero-logout migration Route the owner account (.currentUserAccount) through a runtime-derived shared keychain access group so a notification extension can authenticate. Reads fall back to the legacy groupless location and a one-time migration copies the existing item into the shared group, guaranteeing no logout for existing users on upgrade. --- Flipcash/Core/Session/AccountManager.swift | 2 +- .../Core/Session/SessionAuthenticator.swift | 4 + .../Session/SharedKeychainMigration.swift | 51 ++++++ Flipcash/Keychain/Secure.swift | 39 +++- .../Supporting Files/Flipcash.entitlements | 4 + .../FlipcashCore/Utilities/Keychain.swift | 117 ++++++++++-- .../SharedKeychainMigrationTests.swift | 167 ++++++++++++++++++ 7 files changed, 357 insertions(+), 27 deletions(-) create mode 100644 Flipcash/Core/Session/SharedKeychainMigration.swift create mode 100644 FlipcashTests/SharedKeychainMigrationTests.swift diff --git a/Flipcash/Core/Session/AccountManager.swift b/Flipcash/Core/Session/AccountManager.swift index bddaf2a3..0a30bb30 100644 --- a/Flipcash/Core/Session/AccountManager.swift +++ b/Flipcash/Core/Session/AccountManager.swift @@ -149,7 +149,7 @@ class AccountManager { // MARK: - Keychain - private extension Keychain { - @SecureCodable(.currentUserAccount) + @SecureCodable(.currentUserAccount, sharedGroup: true) static var userAccount: UserAccount? @SecureCodable(.historicalAccounts, sync: true) diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 321173da..a0748d33 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -98,6 +98,10 @@ final class SessionAuthenticator { return } + // Copy the owner key into the shared access group so a notification + // extension can authenticate. Idempotent; preserves the legacy item. + SharedKeychainMigration.migrateOwnerKeyIfNeeded() + initializeState { userAccount in let initializedAccount = InitializedAccount( keyAccount: userAccount.keyAccount, diff --git a/Flipcash/Core/Session/SharedKeychainMigration.swift b/Flipcash/Core/Session/SharedKeychainMigration.swift new file mode 100644 index 00000000..cc9830cc --- /dev/null +++ b/Flipcash/Core/Session/SharedKeychainMigration.swift @@ -0,0 +1,51 @@ +// +// SharedKeychainMigration.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +private nonisolated let logger = Logger(label: "flipcash.shared-keychain-migration") + +/// One-time migration that copies the owner key into the shared keychain +/// access group so a notification extension in the same group can read it. +/// +/// The copy preserves the legacy groupless item, so an existing user is never +/// logged out: reads fall back to the legacy item until the migration lands, +/// and the migration only ever adds the shared-group copy. +enum SharedKeychainMigration { + + /// Copies the owner key from the legacy groupless location into the shared + /// access group when it isn't already there. Idempotent: a no-op when the + /// key is already in the shared group or absent everywhere. + nonisolated static func migrateOwnerKeyIfNeeded() { + guard let sharedGroup = Keychain.sharedAccessGroup else { + logger.warning("Shared access group unavailable, skipping owner-key migration") + return + } + + migrateIfNeeded(key: SecureKey.currentUserAccount.rawValue, sharedGroup: sharedGroup) + } + + /// Copies the value at `key` from the legacy groupless location into + /// `sharedGroup` when it isn't already there. Idempotent. + nonisolated static func migrateIfNeeded(key: String, sharedGroup: String) { + // Already migrated. + guard Keychain.data(for: key, accessGroup: sharedGroup) == nil else { + return + } + + // Nothing to migrate. + guard let legacyData = Keychain.data(for: key) else { + return + } + + let didStore = Keychain.set(legacyData, for: key, accessGroup: sharedGroup) + logger.info("Migrated owner key into shared access group", metadata: [ + "succeeded": "\(didStore)" + ]) + } +} diff --git a/Flipcash/Keychain/Secure.swift b/Flipcash/Keychain/Secure.swift index 73fb0c4c..c883d15f 100644 --- a/Flipcash/Keychain/Secure.swift +++ b/Flipcash/Keychain/Secure.swift @@ -41,31 +41,52 @@ struct SecureString { @propertyWrapper struct SecureCodable where T: Codable { - + var wrappedValue: T? { get { - decode(Keychain.data(for: key.rawValue)) + if sharedGroup { + // Read from the shared access group first, then fall back to + // the legacy groupless item. The fallback is the zero-logout + // guarantee: an existing user whose owner key predates the + // shared group still authenticates on upgrade. + let group = Keychain.sharedAccessGroup + if let group, let data = Keychain.data(for: key.rawValue, accessGroup: group) { + return decode(data) + } + return decode(Keychain.data(for: key.rawValue)) + } else { + return decode(Keychain.data(for: key.rawValue)) + } } set { + let group = sharedGroup ? Keychain.sharedAccessGroup : nil if let newValue = encode(newValue) { - Keychain.set(newValue, for: key.rawValue, useSynchronization: sync) + Keychain.set(newValue, for: key.rawValue, useSynchronization: sync, accessGroup: group) } else { - Keychain.delete(key.rawValue) + Keychain.delete(key.rawValue, accessGroup: group) + if sharedGroup { + // Also clear any legacy copy. A groupless delete spans every + // accessible access group, so a stale pre-migration item + // can't survive logout and resurrect via the fallback read. + Keychain.delete(key.rawValue) + } } } } - + private let key: SecureKey private let sync: Bool - + private let sharedGroup: Bool + private let encoder = JSONEncoder() private let decoder = JSONDecoder() - + // MARK: - Init - - - init(_ key: SecureKey, sync: Bool = false) { + + init(_ key: SecureKey, sync: Bool = false, sharedGroup: Bool = false) { self.key = key self.sync = sync + self.sharedGroup = sharedGroup } // MARK: - Codable - diff --git a/Flipcash/Supporting Files/Flipcash.entitlements b/Flipcash/Supporting Files/Flipcash.entitlements index 22874021..807f30a4 100644 --- a/Flipcash/Supporting Files/Flipcash.entitlements +++ b/Flipcash/Supporting Files/Flipcash.entitlements @@ -13,5 +13,9 @@ com.apple.developer.usernotifications.communication + keychain-access-groups + + $(AppIdentifierPrefix)com.flipcash.shared + diff --git a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift index 59bb134e..3c7f0635 100644 --- a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift +++ b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift @@ -24,59 +24,142 @@ public class Keychain { } @discardableResult - public static func set(_ data: Data, for key: String, useSynchronization: Bool = false) -> Bool { - let query = Query( + public static func set(_ data: Data, for key: String, useSynchronization: Bool = false, accessGroup: String? = nil) -> Bool { + var query = Query( .service("Flipcash (\(key))"), .account(key), .class(.genericPassword), .value(data), .isSynchronizable(useSynchronization ? .true : .false) ) - + if let accessGroup { + query.insert(.accessGroup(accessGroup)) + } + // Keychain will reject any insert queries for // duplicate items. We have to delete before // inserting any potential duplicates. - delete(key) - + delete(key, accessGroup: accessGroup) + return addItem(query: query) } - + // MARK: - Getters - - + public static func string(for key: String) -> String? { if let data = data(for: key) { return String(data: data, encoding: .utf8) } return nil } - - public static func data(for key: String) -> Data? { - let query = Query( + + public static func data(for key: String, accessGroup: String? = nil) -> Data? { + var query = Query( .account(key), .matchLimit(.one), .class(.genericPassword), .isSynchronizable(.any), .shouldReturnData(true) ) - + if let accessGroup { + query.insert(.accessGroup(accessGroup)) + } + return copyMatching(query: query) } - + // MARK: - Delete - - + @discardableResult - public static func delete(_ key: String) -> Bool { - let query = Query( + public static func delete(_ key: String, accessGroup: String? = nil) -> Bool { + var query = Query( .account(key), .class(.genericPassword), .isSynchronizable(.any) ) - + if let accessGroup { + query.insert(.accessGroup(accessGroup)) + } + return SecItemDelete(query.dictionary) == noErr } + // MARK: - Shared Access Group - + + /// The team-prefixed shared keychain access group + /// (`".com.flipcash.shared"`), or `nil` if the team prefix + /// can't be resolved at runtime. Used to store the owner key where a + /// notification extension in the same access group can read it. + /// + /// The team prefix is derived at runtime — never hardcoded — by probing + /// the keychain for the app's default access group and taking its first + /// dot-separated component. + public static var sharedAccessGroup: String? { + sharedAccessGroupLock.lock() + defer { sharedAccessGroupLock.unlock() } + + if let cached = cachedSharedAccessGroup { + return cached.value + } + + let resolved = resolveTeamPrefix().map { "\($0).com.flipcash.shared" } + cachedSharedAccessGroup = Cached(value: resolved) + return resolved + } + + private struct Cached { + let value: String? + } + + private static let sharedAccessGroupLock = NSLock() + nonisolated(unsafe) private static var cachedSharedAccessGroup: Cached? + + /// Resolves the app's keychain access-group prefix (the team identifier + /// prefix) by adding a throwaway generic-password item with no explicit + /// access group, reading back its `kSecAttrAccessGroup` attribute, and + /// taking the first dot-separated component. The probe item is deleted. + private static func resolveTeamPrefix() -> String? { + let probeKey = "com.flipcash.keychain.teamPrefixProbe" + let probeQuery = Query( + .service("Flipcash (\(probeKey))"), + .account(probeKey), + .class(.genericPassword), + .value(Data([0x00])) + ) + + SecItemDelete(probeQuery.dictionary) + guard SecItemAdd(probeQuery.dictionary, nil) == errSecSuccess else { + logger.warning("Failed to add keychain probe item for team prefix") + return nil + } + + defer { + SecItemDelete(probeQuery.dictionary) + } + + let readQuery = Query( + .account(probeKey), + .class(.genericPassword), + .matchLimit(.one), + .shouldReturnAttributes(true) + ) + + var result: AnyObject? + guard + SecItemCopyMatching(readQuery.dictionary, &result) == errSecSuccess, + let attributes = result as? [String: Any], + let accessGroup = attributes[kSecAttrAccessGroup as String] as? String, + let prefix = accessGroup.split(separator: ".").first + else { + logger.warning("Failed to read keychain access group for team prefix") + return nil + } + + return String(prefix) + } + // MARK: - Security - - + private static func copyMatching(query: Query) -> Data? { var result: AnyObject? let status = SecItemCopyMatching(query.dictionary, &result) diff --git a/FlipcashTests/SharedKeychainMigrationTests.swift b/FlipcashTests/SharedKeychainMigrationTests.swift new file mode 100644 index 00000000..525245ee --- /dev/null +++ b/FlipcashTests/SharedKeychainMigrationTests.swift @@ -0,0 +1,167 @@ +// +// SharedKeychainMigrationTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// Proves the zero-logout guarantee of routing the owner key through the +/// shared keychain access group. +/// +/// Once the app declares `keychain-access-groups`, the *default* group for +/// groupless writes becomes the first listed group (`...com.flipcash.shared`), +/// so a test can't model a legacy item with a plain groupless write — it would +/// land in the shared group. These tests instead write the legacy item into +/// the runtime-derived application-identifier group (`.`), +/// faithfully reproducing an existing user's pre-migration owner key. +@Suite("Shared Keychain Migration", .serialized) +struct SharedKeychainMigrationTests { + + // A unique, test-only key. Never `.currentUserAccount`, so the real + // owner-key state is never touched. Each test cleans up all variants. + private static func uniqueKey() -> String { + "com.flipcash.tests.sharedKeychain.\(UUID().uuidString)" + } + + /// The shared access group, resolved at runtime. Skips the test cleanly if + /// the simulator keychain can't resolve it. + private func sharedGroup() throws -> String { + try #require( + Keychain.sharedAccessGroup, + "Shared access group must resolve at runtime" + ) + } + + /// The legacy application-identifier access group an existing user's owner + /// key lives in (`.`), derived at runtime — never + /// hardcoded. This is the group groupless writes used *before* the + /// `keychain-access-groups` entitlement existed. + private func legacyGroup() throws -> String { + let shared = try sharedGroup() + let prefix = try #require(shared.split(separator: ".").first.map(String.init)) + let bundleId = try #require(Bundle.main.bundleIdentifier) + return "\(prefix).\(bundleId)" + } + + private func cleanup(_ key: String, groups: [String]) { + Keychain.delete(key) + for group in groups { + Keychain.delete(key, accessGroup: group) + } + } + + // MARK: - Zero-logout: legacy item is still recoverable - + + @Test("A legacy item is recovered by the groupless fallback read") + func legacyItemRecoveredByGrouplessFallback() throws { + let shared = try sharedGroup() + let legacy = try legacyGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared, legacy]) } + + // Simulate an existing user's owner key, written to the legacy + // application-identifier group before the shared-group entitlement. + let payload = Data("legacy-owner-key".utf8) + #expect(Keychain.set(payload, for: key, accessGroup: legacy)) + + // The shared-group read misses (the item predates the migration)... + #expect(Keychain.data(for: key, accessGroup: shared) == nil) + // ...but the groupless fallback read recovers it. This is the + // zero-logout guarantee: the user authenticates on upgrade. + #expect(Keychain.data(for: key) == payload) + } + + // MARK: - Shared group round trip - + + @Test("Data written to the shared group reads back from the shared group") + func sharedGroupRoundTrip() throws { + let shared = try sharedGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared]) } + + let payload = Data("shared-owner-key".utf8) + #expect(Keychain.set(payload, for: key, accessGroup: shared)) + #expect(Keychain.data(for: key, accessGroup: shared) == payload) + } + + // MARK: - Migration: copy legacy → shared, idempotent - + + @Test("Migration copies a legacy value into the shared group") + func migrationCopiesLegacyIntoSharedGroup() throws { + let shared = try sharedGroup() + let legacy = try legacyGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared, legacy]) } + + let payload = Data("legacy-owner-key".utf8) + #expect(Keychain.set(payload, for: key, accessGroup: legacy)) + + // Pre-condition: absent from the shared group. + #expect(Keychain.data(for: key, accessGroup: shared) == nil) + + SharedKeychainMigration.migrateIfNeeded(key: key, sharedGroup: shared) + + // Copied into the shared group, and the legacy item is preserved. + #expect(Keychain.data(for: key, accessGroup: shared) == payload) + #expect(Keychain.data(for: key, accessGroup: legacy) == payload) + } + + @Test("Running the migration again is a no-op") + func migrationIsIdempotent() throws { + let shared = try sharedGroup() + let legacy = try legacyGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared, legacy]) } + + let payload = Data("legacy-owner-key".utf8) + #expect(Keychain.set(payload, for: key, accessGroup: legacy)) + + SharedKeychainMigration.migrateIfNeeded(key: key, sharedGroup: shared) + // Second run must not throw, duplicate, or clear the value. + SharedKeychainMigration.migrateIfNeeded(key: key, sharedGroup: shared) + + #expect(Keychain.data(for: key, accessGroup: shared) == payload) + #expect(Keychain.data(for: key, accessGroup: legacy) == payload) + } + + @Test("Migration is a no-op when the legacy value is absent") + func migrationNoOpWhenAbsent() throws { + let shared = try sharedGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared]) } + + SharedKeychainMigration.migrateIfNeeded(key: key, sharedGroup: shared) + + #expect(Keychain.data(for: key, accessGroup: shared) == nil) + } + + // MARK: - Logout clears every group (no resurrection) - + + @Test("Logout teardown clears both the shared and legacy copies") + func logoutClearsAllGroups() throws { + let shared = try sharedGroup() + let legacy = try legacyGroup() + let key = Self.uniqueKey() + defer { cleanup(key, groups: [shared, legacy]) } + + // A migrated owner key exists in both the legacy and shared groups. + let payload = Data("owner-key".utf8) + #expect(Keychain.set(payload, for: key, accessGroup: legacy)) + #expect(Keychain.set(payload, for: key, accessGroup: shared)) + + // Production logout teardown for a shared-group key: delete the shared + // copy, then a groupless delete that spans every accessible group. + Keychain.delete(key, accessGroup: shared) + Keychain.delete(key) + + // Nothing survives — the fallback read can't resurrect a stale copy. + #expect(Keychain.data(for: key, accessGroup: shared) == nil) + #expect(Keychain.data(for: key, accessGroup: legacy) == nil) + #expect(Keychain.data(for: key) == nil) + } +} From 5fe4ebbb1c118501583d5eac6c90ca8e6ed77646 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 14:34:04 -0400 Subject: [PATCH 07/19] feat: lean chat messaging client for the notification extension --- .../Clients/Chat/ChatNotificationClient.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift new file mode 100644 index 00000000..c03f895b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -0,0 +1,62 @@ +// +// ChatNotificationClient.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import GRPC + +/// A lean gRPC façade for fetching and sending chat messages from a +/// notification extension. Constructs only the `ChatMessagingService` — +/// no `FlipClient` or `Client` graph is required. +public final class ChatNotificationClient { + + private let messagingService: ChatMessagingService + + // MARK: - Init - + + public init(network: Network = .mainNet) { + let queue = DispatchQueue(label: "flipcash.chat-notification-client", qos: .userInitiated) + let channel = ClientConnection.appConnection( + host: network.hostForCore, + port: network.port + ) + self.messagingService = ChatMessagingService(channel: channel, queue: queue) + } + + // MARK: - Messages - + + /// Returns the newest `limit` messages in a conversation, oldest-first. + public func getMessages( + owner: KeyPair, + conversationID: ConversationID, + limit: Int = 3 + ) async throws -> [ConversationMessage] { + try await withCheckedThrowingContinuation { continuation in + messagingService.getMessages( + owner: owner, + conversationID: conversationID, + pageSize: limit, + pagingToken: nil + ) { continuation.resume(with: $0) } + } + } + + /// Sends a text message and returns the server-confirmed `ConversationMessage`. + @discardableResult + public func sendMessage( + owner: KeyPair, + conversationID: ConversationID, + text: String + ) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { continuation in + messagingService.sendMessage( + owner: owner, + conversationID: conversationID, + text: text + ) { continuation.resume(with: $0) } + } + } +} From b3823018ca9bf9c44b7d98cae88413a36977c893 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 14:43:32 -0400 Subject: [PATCH 08/19] feat: add ChatItem.preview(from:selfUserID:limit:) mapping for notification previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public FlipcashUI extension that maps [ConversationMessage] to the last 3 ChatItems (sorted chronologically, oldest-first) — usable by the notification content extension which cannot link the main app target. --- FlipcashTests/ChatPreviewMappingTests.swift | 177 ++++++++++++++++++ .../FlipcashUI/Chat/ChatPreviewMapping.swift | 54 ++++++ 2 files changed, 231 insertions(+) create mode 100644 FlipcashTests/ChatPreviewMappingTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift new file mode 100644 index 00000000..7a5df046 --- /dev/null +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -0,0 +1,177 @@ +// +// ChatPreviewMappingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashUI + +@Suite("ChatItem preview mapping") @MainActor +struct ChatPreviewMappingTests { + + // MARK: - Helpers + + private let meID = UUID() + private let otherID = UUID() + + private func textMessage(id: UInt64, senderID: UUID?, text: String) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: senderID, + content: .text(text), + date: Date(timeIntervalSince1970: Double(id)), + unreadSeq: 0 + ) + } + + private func cashMessage(id: UInt64, senderID: UUID?, amount: Decimal) -> ConversationMessage { + let fiat = ExchangedFiat( + onChainAmount: TokenAmount(quarks: 0, mint: .usdf), + nativeAmount: FiatAmount(value: amount, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + ) + return ConversationMessage( + id: MessageID(value: id), + senderID: senderID, + content: .cash(fiat), + date: Date(timeIntervalSince1970: Double(id)), + unreadSeq: 0 + ) + } + + // MARK: - Sender resolution + + @Test("My messages map to .me sender") + func mySenderMapsToMe() { + let messages = [textMessage(id: 1, senderID: meID, text: "hello")] + let items = ChatItem.preview(from: messages, selfUserID: meID) + + guard case .message(let msg) = items.first else { + Issue.record("Expected a .message item") + return + } + #expect(msg.sender == .me) + } + + @Test("Other party messages map to .other sender") + func otherSenderMapsToOther() { + let messages = [textMessage(id: 1, senderID: otherID, text: "hi")] + let items = ChatItem.preview(from: messages, selfUserID: meID) + + guard case .message(let msg) = items.first else { + Issue.record("Expected a .message item") + return + } + #expect(msg.sender == .other) + } + + // MARK: - Content mapping + + @Test("Text message maps to .text content") + func textContentMaps() { + let messages = [textMessage(id: 1, senderID: meID, text: "see you soon")] + let items = ChatItem.preview(from: messages, selfUserID: meID) + + guard case .message(let msg) = items.first else { + Issue.record("Expected a .message item") + return + } + #expect(msg.content == .text("see you soon")) + } + + @Test("Cash message maps to .cash content with formatted amount and 'Cash' token") + func cashContentMaps() { + let messages = [cashMessage(id: 1, senderID: otherID, amount: 5.00)] + let items = ChatItem.preview(from: messages, selfUserID: meID) + + guard case .message(let msg) = items.first else { + Issue.record("Expected a .message item") + return + } + guard case .cash(let cash) = msg.content else { + Issue.record("Expected .cash content") + return + } + #expect(cash.token == "Cash") + // Amount is a non-empty formatted string (locale-sensitive; just check non-empty) + #expect(!cash.amount.isEmpty) + } + + @Test("Message id is the string form of MessageID.value") + func messageIDMapsToString() { + let messages = [textMessage(id: 42, senderID: meID, text: "test")] + let items = ChatItem.preview(from: messages, selfUserID: meID) + + guard case .message(let msg) = items.first else { + Issue.record("Expected a .message item") + return + } + #expect(msg.id == "42") + } + + // MARK: - Ordering (newest last, oldest first within slice) + + @Test("Items are returned in chronological order (oldest first)") + func orderIsChronological() { + let messages = [ + textMessage(id: 3, senderID: meID, text: "c"), + textMessage(id: 1, senderID: meID, text: "a"), + textMessage(id: 2, senderID: meID, text: "b"), + ] + let items = ChatItem.preview(from: messages, selfUserID: meID) + let ids = items.compactMap { item -> String? in + guard case .message(let m) = item else { return nil } + return m.id + } + #expect(ids == ["1", "2", "3"]) + } + + // MARK: - Capping + + @Test("More than 3 messages: only the 3 most recent are returned") + func capsAtDefaultLimit() { + let messages = (1...5).map { i in + textMessage(id: UInt64(i), senderID: meID, text: "msg \(i)") + } + let items = ChatItem.preview(from: messages, selfUserID: meID) + let ids = items.compactMap { item -> String? in + guard case .message(let m) = item else { return nil } + return m.id + } + // Should be messages 3, 4, 5 (the most recent three), oldest first + #expect(ids == ["3", "4", "5"]) + } + + @Test("Fewer than 3 messages: all are returned") + func returnsAllWhenFewMessages() { + let messages = [ + textMessage(id: 1, senderID: meID, text: "one"), + textMessage(id: 2, senderID: otherID, text: "two"), + ] + let items = ChatItem.preview(from: messages, selfUserID: meID) + #expect(items.count == 2) + } + + @Test("Custom limit parameter is respected") + func customLimitIsRespected() { + let messages = (1...5).map { i in + textMessage(id: UInt64(i), senderID: meID, text: "msg \(i)") + } + let items = ChatItem.preview(from: messages, selfUserID: meID, limit: 2) + let ids = items.compactMap { item -> String? in + guard case .message(let m) = item else { return nil } + return m.id + } + #expect(ids == ["4", "5"]) + } + + @Test("Empty input returns empty array") + func emptyInputReturnsEmpty() { + let items = ChatItem.preview(from: [], selfUserID: meID) + #expect(items.isEmpty) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift new file mode 100644 index 00000000..a777b712 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift @@ -0,0 +1,54 @@ +// +// ChatPreviewMapping.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashCore +import Foundation + +extension ChatItem { + + /// Maps a flat list of `ConversationMessage` values to the last `limit` chat rows, sorted + /// chronologically (oldest first). Intended for notification content-extension previews and + /// other contexts that can link `FlipcashUI` but not the main app target. + /// + /// - Parameters: + /// - messages: The full or partial list of conversation messages in any order. + /// - selfUserID: The current user's ID; messages whose `senderID` matches are rendered on + /// the `.me` side. + /// - limit: Maximum number of rows to return. Defaults to 3. The *most recent* messages + /// (by `MessageID`) are kept, presented oldest-first. + public static func preview( + from messages: [ConversationMessage], + selfUserID: UserID, + limit: Int = 3 + ) -> [ChatItem] { + let sorted = messages.sorted { $0.id < $1.id } + let slice = sorted.suffix(limit) + + return slice.map { message in + let sender: ChatMessage.Sender = message.senderID == selfUserID ? .me : .other + + let content: ChatMessage.Content + switch message.content { + case .text(let text): + content = .text(text) + case .cash(let fiat): + content = .cash(ChatCashContent( + amount: fiat.nativeAmount.formatted(), + token: "Cash" + )) + } + + return .message(ChatMessage( + id: String(message.id.value), + content: content, + sender: sender, + isContinuationFromPrevious: false, + isContinuedByNext: false + )) + } + } +} From 5978d7f20d10ce9240509fe6a762aed90d5dbb9e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 15:00:15 -0400 Subject: [PATCH 09/19] feat: add OwnerKeyStore to load owner KeyPair from shared keychain group Bridges the shared keychain access group to the notification content extension, which has no access to the main app's private group. --- .../Clients/Chat/OwnerKeyStore.swift | 38 ++++++++++++ FlipcashTests/OwnerKeyStoreTests.swift | 60 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift create mode 100644 FlipcashTests/OwnerKeyStoreTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift new file mode 100644 index 00000000..42e53d67 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift @@ -0,0 +1,38 @@ +// +// OwnerKeyStore.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Reads the signed-in user's owner `KeyPair` from the shared keychain +/// access group so that out-of-process extensions (e.g. the notification +/// content extension) can authenticate without access to the main app's +/// private keychain group. +public enum OwnerKeyStore { + + /// The keychain account key under which the owner `UserAccount` is stored. + /// Must stay in sync with `SecureKey.currentUserAccount.rawValue` in the + /// main app target. + public static let ownerAccountKey = "com.flipcash.account.userAccount" + + /// Loads the owner `KeyPair` from the shared keychain access group. + /// + /// Returns `nil` when: + /// - No user is signed in. + /// - The shared access group can't be resolved at runtime. + /// - The stored data can't be decoded as a `UserAccount`. + /// + /// The notification content extension has no access to the app's + /// legacy application-identifier group, so there is no fallback read. + public static func loadOwnerKeyPair() -> KeyPair? { + guard + let group = Keychain.sharedAccessGroup, + let data = Keychain.data(for: ownerAccountKey, accessGroup: group), + let account = try? JSONDecoder().decode(UserAccount.self, from: data) + else { return nil } + return account.keyAccount.owner + } +} diff --git a/FlipcashTests/OwnerKeyStoreTests.swift b/FlipcashTests/OwnerKeyStoreTests.swift new file mode 100644 index 00000000..cd25e904 --- /dev/null +++ b/FlipcashTests/OwnerKeyStoreTests.swift @@ -0,0 +1,60 @@ +// +// OwnerKeyStoreTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("OwnerKeyStore", .serialized) +struct OwnerKeyStoreTests { + + /// A `UserAccount` built from the shared mock mnemonic — stable across + /// test runs and identical to what `@SecureCodable` persists. + private static let mockAccount = UserAccount( + userID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + keyAccount: .mock + ) + + private func sharedGroup() throws -> String { + try #require( + Keychain.sharedAccessGroup, + "Shared access group must resolve at runtime" + ) + } + + // MARK: - Round-trip - + + @Test("loadOwnerKeyPair returns the correct public key after a shared-group write") + func loadOwnerKeyPair_sharedGroupWrite_returnsCorrectPublicKey() throws { + let group = try sharedGroup() + defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } + + let data = try JSONEncoder().encode(Self.mockAccount) + #expect(Keychain.set(data, for: OwnerKeyStore.ownerAccountKey, accessGroup: group)) + + let loaded = OwnerKeyStore.loadOwnerKeyPair() + #expect(loaded?.publicKey == Self.mockAccount.keyAccount.owner.publicKey) + } + + @Test("loadOwnerKeyPair returns nil when no account is stored") + func loadOwnerKeyPair_noAccount_returnsNil() throws { + let group = try sharedGroup() + // Ensure the key is absent before the call. + Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) + defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } + + #expect(OwnerKeyStore.loadOwnerKeyPair() == nil) + } + + // MARK: - Drift guard - + + @Test("ownerAccountKey matches SecureKey.currentUserAccount.rawValue") + func ownerAccountKey_matchesSecureKey() { + #expect(OwnerKeyStore.ownerAccountKey == SecureKey.currentUserAccount.rawValue) + } +} From a0cf523d4b316d31b1136c25c7ea770ae47e26b6 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 15:11:46 -0400 Subject: [PATCH 10/19] feat: add NotificationContent extension target Notification content extension (CHAT_MESSAGE category) linking FlipcashCore + FlipcashUI, embedded in the app, with the shared keychain-access-group entitlement. Stub view controller; content rendering follows. --- Code.xcodeproj/project.pbxproj | 338 +++++++++++++++++- NotificationContent/Info.plist | 24 ++ .../NotificationContent.entitlements | 10 + .../NotificationViewController.swift | 20 ++ 4 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 NotificationContent/Info.plist create mode 100644 NotificationContent/NotificationContent.entitlements create mode 100644 NotificationContent/NotificationViewController.swift diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 64f72066..241042b3 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -7,11 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EC0A077F6E7EE1EB0853134B /* Foundation.framework */; }; 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; 4CB225762F77260D0075874E /* FirebaseInstallations in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225752F77260D0075874E /* FirebaseInstallations */; }; 4CB225782F7726500075874E /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225772F7726500075874E /* FirebaseMessaging */; }; 508AF9C6D67C4CD29DE10A16 /* TweetNacl in Frameworks */ = {isa = PBXBuildFile; productRef = 2B166ABF94584FEF9C368C81 /* TweetNacl */; }; + 56BF88A54E7F0C5DAB42710A /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8DFC9D975C89EBB9B4E50C4D /* FlipcashCore */; }; + 7531D04D33DB0C02B873BF36 /* NotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C402C27F9E020E000EBA2936 /* NotificationContent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 81E1A94ED8796D1522C2E2B7 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E679C9CB11F511DF4EE5B34 /* NotificationViewController.swift */; }; 88FB56E02EE0AB2F0014EC5F /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56DF2EE0AB2F0014EC5F /* CLAUDE.md */; }; 88FB56E52EE0AB810014EC5F /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E32EE0AB810014EC5F /* README.md */; }; 88FB56E62EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E12EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md */; }; @@ -25,9 +29,17 @@ 9AD1265E2DA98ADC0048141F /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 9AD1265D2DA98ADC0048141F /* SQLite */; }; 9ADEF1D72DD627C0001B260A /* Bugsnag in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D62DD627C0001B260A /* Bugsnag */; }; 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D82DD627C6001B260A /* Mixpanel */; }; + A3BE11B009F4E2EF7ED93F05 /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = EFFC929EC8849F357BE7F997 /* FlipcashUI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 04567E8F80F37BC6F81DEE61 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9AED10F8258BE1310088D902 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 554904D5CE93303B1F28329F; + remoteInfo = NotificationContent; + }; 4C7D6FFF2FC8892D0091C7A4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9AED10F8258BE1310088D902 /* Project object */; @@ -59,6 +71,7 @@ dstSubfolderSpec = 13; files = ( 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */, + 7531D04D33DB0C02B873BF36 /* NotificationContent.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -67,6 +80,7 @@ /* Begin PBXFileReference section */ 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4E679C9CB11F511DF4EE5B34 /* NotificationViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 88FB56DF2EE0AB2F0014EC5F /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 88FB56E12EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "2025-11-27-swap-rpc-analysis.md"; sourceTree = ""; }; 88FB56E32EE0AB810014EC5F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -80,24 +94,28 @@ 9AA197072CE51ABB00FE1703 /* CodeCurves */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CodeCurves; sourceTree = ""; }; 9ABDD1962D9D7C2E006B6CDA /* FlipcashAPI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FlipcashAPI; sourceTree = ""; }; 9AD5E64B25F2BF3E007388AE /* opencv2.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = opencv2.framework; sourceTree = ""; }; + A361ABDE10578BE8E8859E31 /* NotificationContent.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationContent.entitlements; sourceTree = ""; }; + B2ECA4ED4ED388070B77FCD7 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C402C27F9E020E000EBA2936 /* NotificationContent.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationContent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + EC0A077F6E7EE1EB0853134B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 4C025B362F69DC1F00914087 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 4C025B362F69DC1F00914087 /* Exceptions for "FlipcashUITests" folder in "FlipcashUITests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = 9A516F922D9B12EA00AF478B /* FlipcashUITests */; }; - 4C7D70082FC8892D0091C7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 4C7D70082FC8892D0091C7A4 /* Exceptions for "NotificationService" folder in "NotificationService" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = 4C7D6FF92FC8892D0091C7A4 /* NotificationService */; }; - 9AC0156E2DA579120030298E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 9AC0156E2DA579120030298E /* Exceptions for "Flipcash" folder in "Flipcash" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "Supporting Files/apple-app-site-association", @@ -108,13 +126,66 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4C7D70082FC8892D0091C7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationService; sourceTree = ""; }; - 9A516F7B2D9B12E900AF478B /* Flipcash */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9AC0156E2DA579120030298E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Flipcash; sourceTree = ""; }; - 9A516F8C2D9B12EA00AF478B /* FlipcashTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FlipcashTests; sourceTree = ""; }; - 9A516F962D9B12EA00AF478B /* FlipcashUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4C025B362F69DC1F00914087 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FlipcashUITests; sourceTree = ""; }; + 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4C7D70082FC8892D0091C7A4 /* Exceptions for "NotificationService" folder in "NotificationService" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = NotificationService; + sourceTree = ""; + }; + 9A516F7B2D9B12E900AF478B /* Flipcash */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9AC0156E2DA579120030298E /* Exceptions for "Flipcash" folder in "Flipcash" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Flipcash; + sourceTree = ""; + }; + 9A516F8C2D9B12EA00AF478B /* FlipcashTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = FlipcashTests; + sourceTree = ""; + }; + 9A516F962D9B12EA00AF478B /* FlipcashUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4C025B362F69DC1F00914087 /* Exceptions for "FlipcashUITests" folder in "FlipcashUITests" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = FlipcashUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 1B6F963A954302E324130214 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */, + 56BF88A54E7F0C5DAB42710A /* FlipcashCore in Frameworks */, + A3BE11B009F4E2EF7ED93F05 /* FlipcashUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4C7D6FF72FC8892D0091C7A4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -159,6 +230,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1DE68FD2D78ED8BA7ACC542F /* NotificationContent */ = { + isa = PBXGroup; + children = ( + 4E679C9CB11F511DF4EE5B34 /* NotificationViewController.swift */, + B2ECA4ED4ED388070B77FCD7 /* Info.plist */, + A361ABDE10578BE8E8859E31 /* NotificationContent.entitlements */, + ); + name = NotificationContent; + path = NotificationContent; + sourceTree = ""; + }; 88FB56E22EE0AB810014EC5F /* plans */ = { isa = PBXGroup; children = ( @@ -206,6 +288,8 @@ 9A516F962D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */, 9AED1101258BE1310088D902 /* Products */, + DE3C8C53AB74589FBE36B004 /* Frameworks */, + 1DE68FD2D78ED8BA7ACC542F /* NotificationContent */, ); sourceTree = ""; }; @@ -216,6 +300,7 @@ 9A516F892D9B12EA00AF478B /* FlipcashTests.xctest */, 9A516F932D9B12EA00AF478B /* FlipcashUITests.xctest */, 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */, + C402C27F9E020E000EBA2936 /* NotificationContent.appex */, ); name = Products; sourceTree = ""; @@ -231,6 +316,22 @@ name = Packages; sourceTree = ""; }; + A2C1D925AA58EE6DFD1C4022 /* iOS */ = { + isa = PBXGroup; + children = ( + EC0A077F6E7EE1EB0853134B /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + DE3C8C53AB74589FBE36B004 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A2C1D925AA58EE6DFD1C4022 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -257,6 +358,27 @@ productReference = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; + 554904D5CE93303B1F28329F /* NotificationContent */ = { + isa = PBXNativeTarget; + buildConfigurationList = 371A55E2554524D8CDBA32B3 /* Build configuration list for PBXNativeTarget "NotificationContent" */; + buildPhases = ( + 19FA9A33BC65E07F1E0C744A /* Sources */, + 1B6F963A954302E324130214 /* Frameworks */, + 0D1BAC92A8904A35DE905ADA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationContent; + packageProductDependencies = ( + 8DFC9D975C89EBB9B4E50C4D /* FlipcashCore */, + EFFC929EC8849F357BE7F997 /* FlipcashUI */, + ); + productName = NotificationContent; + productReference = C402C27F9E020E000EBA2936 /* NotificationContent.appex */; + productType = "com.apple.product-type.app-extension"; + }; 9A516F792D9B12E800AF478B /* Flipcash */ = { isa = PBXNativeTarget; buildConfigurationList = 9A516F9B2D9B12EA00AF478B /* Build configuration list for PBXNativeTarget "Flipcash" */; @@ -271,6 +393,7 @@ ); dependencies = ( 4C7D70002FC8892D0091C7A4 /* PBXTargetDependency */, + 47A27522041981A8EE511CE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9A516F7B2D9B12E900AF478B /* Flipcash */, @@ -412,7 +535,7 @@ 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */, 9A1A827528660A6F00A4979F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 9A476D482978884700C5B5CE /* XCRemoteSwiftPackageReference "bugsnag-cocoa" */, - 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */, + 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite.swift" */, 9A8D9A3D2D78C2E200E755B9 /* XCRemoteSwiftPackageReference "Kingfisher" */, 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */, 9A444EE52E8C414D002B1E39 /* XCRemoteSwiftPackageReference "BigDecimal" */, @@ -426,11 +549,19 @@ 9A516F882D9B12EA00AF478B /* FlipcashTests */, 9A516F922D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FF92FC8892D0091C7A4 /* NotificationService */, + 554904D5CE93303B1F28329F /* NotificationContent */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 0D1BAC92A8904A35DE905ADA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4C7D6FF82FC8892D0091C7A4 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -491,6 +622,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 19FA9A33BC65E07F1E0C744A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81E1A94ED8796D1522C2E2B7 /* NotificationViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4C7D6FF62FC8892D0091C7A4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -522,6 +661,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 47A27522041981A8EE511CE8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = NotificationContent; + target = 554904D5CE93303B1F28329F /* NotificationContent */; + targetProxy = 04567E8F80F37BC6F81DEE61 /* PBXContainerItemProxy */; + }; 4C7D70002FC8892D0091C7A4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4C7D6FF92FC8892D0091C7A4 /* NotificationService */; @@ -690,6 +835,47 @@ }; name = "Release Development"; }; + 4CBD160721BD7B5207D62D56 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationContent/NotificationContent.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationContent/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.12.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; 9A1654E7268381BB000A135E /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9A592D762B17AF5500735BB0 /* base.xcconfig */; @@ -1362,9 +1548,133 @@ }; name = Release; }; + BF8938E4E5820474A413F15F /* Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationContent/NotificationContent.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationContent/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.12.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Development; + }; + D8C9CB3DDBC0E7A87BC380D3 /* Release Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationContent/NotificationContent.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationContent/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.12.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = "Release Development"; + }; + EB7612F59762D71910310905 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationContent/NotificationContent.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationContent/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.12.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 371A55E2554524D8CDBA32B3 /* Build configuration list for PBXNativeTarget "NotificationContent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BF8938E4E5820474A413F15F /* Development */, + 4CBD160721BD7B5207D62D56 /* Debug */, + EB7612F59762D71910310905 /* Release */, + D8C9CB3DDBC0E7A87BC380D3 /* Release Development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 4C7D70072FC8892D0091C7A4 /* Build configuration list for PBXNativeTarget "NotificationService" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1478,7 +1788,7 @@ minimumVersion = 8.3.0; }; }; - 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */ = { + 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dbart01/SQLite.swift"; requirement = { @@ -1512,6 +1822,10 @@ package = 9A1A827528660A6F00A4979F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; + 8DFC9D975C89EBB9B4E50C4D /* FlipcashCore */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashCore; + }; 9A017D682EAC1DE200216925 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = 9A8D9A3D2D78C2E200E755B9 /* XCRemoteSwiftPackageReference "Kingfisher" */; @@ -1536,7 +1850,7 @@ }; 9AD1265D2DA98ADC0048141F /* SQLite */ = { isa = XCSwiftPackageProductDependency; - package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */; + package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite.swift" */; productName = SQLite; }; 9ADEF1D62DD627C0001B260A /* Bugsnag */ = { @@ -1549,6 +1863,10 @@ package = 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */; productName = Mixpanel; }; + EFFC929EC8849F357BE7F997 /* FlipcashUI */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 9AED10F8258BE1310088D902 /* Project object */; diff --git a/NotificationContent/Info.plist b/NotificationContent/Info.plist new file mode 100644 index 00000000..a7691408 --- /dev/null +++ b/NotificationContent/Info.plist @@ -0,0 +1,24 @@ + + + + + NSExtension + + NSExtensionAttributes + + UNNotificationExtensionCategory + CHAT_MESSAGE + UNNotificationExtensionInitialContentSizeRatio + 0.6 + UNNotificationExtensionUserInteractionEnabled + + UNNotificationExtensionDefaultContentHidden + + + NSExtensionPointIdentifier + com.apple.usernotifications.content-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationViewController + + + diff --git a/NotificationContent/NotificationContent.entitlements b/NotificationContent/NotificationContent.entitlements new file mode 100644 index 00000000..be54783c --- /dev/null +++ b/NotificationContent/NotificationContent.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.flipcash.shared + + + diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift new file mode 100644 index 00000000..9eb97845 --- /dev/null +++ b/NotificationContent/NotificationViewController.swift @@ -0,0 +1,20 @@ +// +// NotificationViewController.swift +// NotificationContent +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import UserNotifications +import UserNotificationsUI + +/// Rich content for a chat-message notification: a portion of the real chat +/// (the last few messages, rendered with the app's own bubbles), fed by a +/// server fetch. Wired up in the next task; this is the target's entry point. +final class NotificationViewController: UIViewController, UNNotificationContentExtension { + + func didReceive(_ notification: UNNotification) { + // Implemented in the content-rendering task. + } +} From 530622af1d33f4b93a8c481b56864d655ccffb53 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 15:24:52 -0400 Subject: [PATCH 11/19] feat: implement NotificationContent rich-chat VC - OwnerKeyStore.loadOwnerAccount() returns both KeyPair and UserID; loadOwnerKeyPair() delegates to it - NotificationPayload.chatConversationID(from:) extracts ConversationID without requiring FlipcashAPI import in the extension - NotificationViewController fetches last 3 messages on push receipt, renders ChatMessageCell bubbles via UICollectionView + compositional list layout, handles inline reply (sendMessage + append bubble) and Send Cash action forwarding - Bumped NotificationContent deployment target to 18.0 (matches FlipcashCore requirement) --- Code.xcodeproj/project.pbxproj | 8 +- .../Clients/Chat/OwnerKeyStore.swift | 19 +- .../Push/NotificationPayload.swift | 11 + FlipcashTests/OwnerKeyStoreTests.swift | 13 + .../NotificationViewController.swift | 232 +++++++++++++++++- 5 files changed, 269 insertions(+), 14 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 241042b3..1b4f706d 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -855,7 +855,7 @@ INFOPLIST_FILE = NotificationContent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1568,7 +1568,7 @@ INFOPLIST_FILE = NotificationContent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1605,7 +1605,7 @@ INFOPLIST_FILE = NotificationContent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1641,7 +1641,7 @@ INFOPLIST_FILE = NotificationContent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationContent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift index 42e53d67..63d784c9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift @@ -18,6 +18,18 @@ public enum OwnerKeyStore { /// main app target. public static let ownerAccountKey = "com.flipcash.account.userAccount" + /// Loads the signed-in `UserAccount` from the shared keychain access group, + /// returning both the owner `KeyPair` and the user's `UserID`. + /// + /// Returns `nil` under the same conditions as `loadOwnerKeyPair()`. + public static func loadOwnerAccount() -> UserAccount? { + guard + let group = Keychain.sharedAccessGroup, + let data = Keychain.data(for: ownerAccountKey, accessGroup: group) + else { return nil } + return try? JSONDecoder().decode(UserAccount.self, from: data) + } + /// Loads the owner `KeyPair` from the shared keychain access group. /// /// Returns `nil` when: @@ -28,11 +40,6 @@ public enum OwnerKeyStore { /// The notification content extension has no access to the app's /// legacy application-identifier group, so there is no fallback read. public static func loadOwnerKeyPair() -> KeyPair? { - guard - let group = Keychain.sharedAccessGroup, - let data = Keychain.data(for: ownerAccountKey, accessGroup: group), - let account = try? JSONDecoder().decode(UserAccount.self, from: data) - else { return nil } - return account.keyAccount.owner + loadOwnerAccount()?.keyAccount.owner } } diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index dcbc2456..0baf7dee 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -36,4 +36,15 @@ public enum NotificationPayload { guard case .chatID(let chatID) = payload.navigation.type else { return nil } return ConversationID(chatID) } + + /// Extracts the chat `ConversationID` from a push's navigation payload, if present. + /// Callers that can't import `FlipcashAPI` (e.g. the notification content extension) + /// use this instead of accessing `payload.navigation.type` directly. + public static func chatConversationID(from userInfo: [AnyHashable: Any]) -> ConversationID? { + guard + let payload = decode(userInfo), + case .chatID(let chatID) = payload.navigation.type + else { return nil } + return ConversationID(chatID) + } } diff --git a/FlipcashTests/OwnerKeyStoreTests.swift b/FlipcashTests/OwnerKeyStoreTests.swift index cd25e904..d92f6a48 100644 --- a/FlipcashTests/OwnerKeyStoreTests.swift +++ b/FlipcashTests/OwnerKeyStoreTests.swift @@ -29,6 +29,19 @@ struct OwnerKeyStoreTests { // MARK: - Round-trip - + @Test("loadOwnerAccount returns the correct UserAccount after a shared-group write") + func loadOwnerAccount_sharedGroupWrite_returnsCorrectAccount() throws { + let group = try sharedGroup() + defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } + + let data = try JSONEncoder().encode(Self.mockAccount) + #expect(Keychain.set(data, for: OwnerKeyStore.ownerAccountKey, accessGroup: group)) + + let loaded = OwnerKeyStore.loadOwnerAccount() + #expect(loaded?.userID == Self.mockAccount.userID) + #expect(loaded?.keyAccount.owner.publicKey == Self.mockAccount.keyAccount.owner.publicKey) + } + @Test("loadOwnerKeyPair returns the correct public key after a shared-group write") func loadOwnerKeyPair_sharedGroupWrite_returnsCorrectPublicKey() throws { let group = try sharedGroup() diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 9eb97845..de20147b 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -8,13 +8,237 @@ import UIKit import UserNotifications import UserNotificationsUI +import FlipcashCore +import FlipcashUI -/// Rich content for a chat-message notification: a portion of the real chat -/// (the last few messages, rendered with the app's own bubbles), fed by a -/// server fetch. Wired up in the next task; this is the target's entry point. final class NotificationViewController: UIViewController, UNNotificationContentExtension { + // MARK: - State - + + private enum ViewState { + case loading + case loaded([ChatItem]) + case empty + case error(String) + } + + // MARK: - Properties - + + private var collectionView: UICollectionView! + private var items: [ChatItem] = [] + + /// Preserved across didReceive calls so reply can reuse them. + private var conversationID: ConversationID? + private var ownerKeyPair: KeyPair? + private var selfUserID: UserID? + + // MARK: - Lifecycle - + + override func viewDidLoad() { + super.viewDidLoad() + FontBook.registerApplicationFonts() + setupCollectionView() + } + + // MARK: - UNNotificationContentExtension - + func didReceive(_ notification: UNNotification) { - // Implemented in the content-rendering task. + guard let conversationID = NotificationPayload.chatConversationID( + from: notification.request.content.userInfo + ) else { + // Not a chat push or no payload — leave the default banner. + return + } + guard let account = OwnerKeyStore.loadOwnerAccount() else { + // Not logged in — leave the default banner. + return + } + self.conversationID = conversationID + self.ownerKeyPair = account.keyAccount.owner + self.selfUserID = account.userID + + apply(.loading) + + Task { @MainActor in + do { + let client = ChatNotificationClient() + let messages = try await client.getMessages( + owner: account.keyAccount.owner, + conversationID: conversationID, + limit: 3 + ) + if messages.isEmpty { + apply(.empty) + } else { + let chatItems = ChatItem.preview(from: messages, selfUserID: account.userID, limit: 3) + apply(.loaded(chatItems)) + } + } catch { + apply(.error("Couldn't load messages")) + } + } + } + + func didReceive( + _ response: UNNotificationResponse, + completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void + ) { + switch response.actionIdentifier { + + case ChatNotificationCategory.replyActionID: + guard + let textResponse = response as? UNTextInputNotificationResponse, + let conversationID, + let ownerKeyPair + else { + completion(.dismiss) + return + } + + let text = textResponse.userText + Task { @MainActor in + do { + let client = ChatNotificationClient() + let sent = try await client.sendMessage( + owner: ownerKeyPair, + conversationID: conversationID, + text: text + ) + let sentItem = ChatItem.message(ChatMessage( + id: String(sent.id.value), + text: text, + sender: .me + )) + appendItem(sentItem) + completion(.doNotDismiss) + } catch { + showSendError() + completion(.doNotDismiss) + } + } + + case ChatNotificationCategory.sendCashActionID: + completion(.dismissAndForwardAction) + + default: + completion(.dismiss) + } + } + + // MARK: - Collection view setup - + + private func setupCollectionView() { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: config) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.dataSource = self + collectionView.register(ChatMessageCell.self, forCellWithReuseIdentifier: ChatMessageCell.reuseIdentifier) + + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + // MARK: - State application - + + @MainActor + private func apply(_ state: ViewState) { + switch state { + case .loading: + items = [] + collectionView.reloadData() + + case .loaded(let chatItems): + items = chatItems + collectionView.reloadData() + updatePreferredContentSize() + + case .empty: + items = [] + collectionView.reloadData() + updatePreferredContentSize() + + case .error(let message): + items = [] + collectionView.reloadData() + showStatusLabel(message) + } + } + + @MainActor + private func appendItem(_ item: ChatItem) { + items.append(item) + collectionView.reloadData() + updatePreferredContentSize() + } + + private func updatePreferredContentSize() { + collectionView.layoutIfNeeded() + let contentHeight = min(collectionView.contentSize.height, 320) + preferredContentSize = CGSize( + width: view.bounds.width, + height: max(contentHeight, 44) + ) + } + + // MARK: - Error UI - + + private func showStatusLabel(_ text: String) { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = text + label.textColor = .secondaryLabel + label.font = .preferredFont(forTextStyle: .footnote) + label.textAlignment = .center + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + preferredContentSize = CGSize(width: view.bounds.width, height: 44) + } + + private func showSendError() { + showStatusLabel("Couldn't send message") + } +} + +// MARK: - UICollectionViewDataSource - + +extension NotificationViewController: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ChatMessageCell.reuseIdentifier, + for: indexPath + ) as! ChatMessageCell // swiftlint:disable:this force_cast + + let item = items[indexPath.item] + switch item { + case .message(let message): + let maxWidth = collectionView.bounds.width * 0.78 + cell.configure(with: message, maxWidth: maxWidth) + case .dateSeparator, .receipt: + // Preview only shows message bubbles — separators not expected for ≤3 messages. + break + } + return cell } } From 4cc3accea5a34a72891c0bfb4995a0e46fe81e44 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 15:31:31 -0400 Subject: [PATCH 12/19] fix: prevent stacked status labels in notification content extension Track the status label and remove the prior one before showing a new one; clear it when content loads. A fetch error followed by a send error no longer stacks two overlapping labels. --- NotificationContent/NotificationViewController.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index de20147b..43b642ea 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -26,6 +26,7 @@ final class NotificationViewController: UIViewController, UNNotificationContentE private var collectionView: UICollectionView! private var items: [ChatItem] = [] + private var statusLabel: UILabel? /// Preserved across didReceive calls so reply can reuse them. private var conversationID: ConversationID? @@ -159,11 +160,13 @@ final class NotificationViewController: UIViewController, UNNotificationContentE collectionView.reloadData() case .loaded(let chatItems): + clearStatusLabel() items = chatItems collectionView.reloadData() updatePreferredContentSize() case .empty: + clearStatusLabel() items = [] collectionView.reloadData() updatePreferredContentSize() @@ -177,6 +180,7 @@ final class NotificationViewController: UIViewController, UNNotificationContentE @MainActor private func appendItem(_ item: ChatItem) { + clearStatusLabel() items.append(item) collectionView.reloadData() updatePreferredContentSize() @@ -194,6 +198,7 @@ final class NotificationViewController: UIViewController, UNNotificationContentE // MARK: - Error UI - private func showStatusLabel(_ text: String) { + clearStatusLabel() let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.text = text @@ -205,9 +210,15 @@ final class NotificationViewController: UIViewController, UNNotificationContentE label.centerXAnchor.constraint(equalTo: view.centerXAnchor), label.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) + statusLabel = label preferredContentSize = CGSize(width: view.bounds.width, height: 44) } + private func clearStatusLabel() { + statusLabel?.removeFromSuperview() + statusLabel = nil + } + private func showSendError() { showStatusLabel("Couldn't send message") } From 537daecf28acb5fef7187be9901df37895a11d45 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 15:43:09 -0400 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20address=20code=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20keychain=20lock-screen=20access,=20NIO=20leak,?= =?UTF-8?q?=20analytics=20gate,=20decode=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: Keychain.set with an accessGroup now inserts .accessible(.afterFirstUnlock) so the shared owner-account item is readable by the notification extension while the device is locked. The resolveTeamPrefix probe item gets the same accessibility so team-prefix resolution also works in that context. I1: NotificationViewController now holds a single lazy ChatNotificationClient instead of creating one in each didReceive call, preventing NIO event-loop-group leaks. ChatNotificationClient gains @unchecked Sendable to satisfy Swift 6 actor-isolation checks. Minor: Analytics.deeplinkRouted for .chatSendCash now fires only when the route proceeds (inside the canSend guard). PushController.didReceive now uses NotificationPayload.chatConversationID instead of inline decode. --- .../Core/Controllers/Deep Links/DeepLinkController.swift | 2 +- Flipcash/Core/Controllers/PushController.swift | 4 +--- .../FlipcashCore/Clients/Chat/ChatNotificationClient.swift | 2 +- FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift | 2 ++ NotificationContent/NotificationViewController.swift | 6 ++++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 0eaef183..00bcb363 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -235,13 +235,13 @@ struct DeepLinkAction { case .chatSendCash(let conversationID): if case .loggedIn(let container) = sessionAuthenticator.state { - Analytics.deeplinkRouted(kind: kind) let conversation = container.conversationController.conversation(withID: conversationID) if let target = ChatNotificationRouter.sendTarget( forChatID: conversationID, conversation: conversation, selfUserID: container.session.userID ), container.session.canSend { + Analytics.deeplinkRouted(kind: kind) container.appRouter.navigate(to: .sendAmount(contact: target)) } } diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 968bfc00..657a8f81 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -268,10 +268,8 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification postContactJoinIfNeeded(response.notification.request.content.userInfo) if response.actionIdentifier == ChatNotificationCategory.sendCashActionID, - let payload = NotificationPayload.decode(response.notification.request.content.userInfo), - case .chatID(let chatID) = payload.navigation.type + let conversationID = NotificationPayload.chatConversationID(from: response.notification.request.content.userInfo) { - let conversationID = ConversationID(chatID) let url = URL(string: "flipcash://chat/\(conversationID.base64URLEncoded)/send")! handleTargetUrlIfNeeded(url.absoluteString) } else { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index c03f895b..80a0d62d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -11,7 +11,7 @@ import GRPC /// A lean gRPC façade for fetching and sending chat messages from a /// notification extension. Constructs only the `ChatMessagingService` — /// no `FlipClient` or `Client` graph is required. -public final class ChatNotificationClient { +public final class ChatNotificationClient: @unchecked Sendable { private let messagingService: ChatMessagingService diff --git a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift index 3c7f0635..3e51c873 100644 --- a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift +++ b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift @@ -34,6 +34,7 @@ public class Keychain { ) if let accessGroup { query.insert(.accessGroup(accessGroup)) + query.insert(.accessible(.afterFirstUnlock)) } // Keychain will reject any insert queries for @@ -124,6 +125,7 @@ public class Keychain { .service("Flipcash (\(probeKey))"), .account(probeKey), .class(.genericPassword), + .accessible(.afterFirstUnlock), .value(Data([0x00])) ) diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 43b642ea..eaadacd9 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -28,6 +28,10 @@ final class NotificationViewController: UIViewController, UNNotificationContentE private var items: [ChatItem] = [] private var statusLabel: UILabel? + /// Single client shared across fetch and reply to avoid allocating multiple + /// NIO event-loop groups per notification. + private lazy var client = ChatNotificationClient() + /// Preserved across didReceive calls so reply can reuse them. private var conversationID: ConversationID? private var ownerKeyPair: KeyPair? @@ -62,7 +66,6 @@ final class NotificationViewController: UIViewController, UNNotificationContentE Task { @MainActor in do { - let client = ChatNotificationClient() let messages = try await client.getMessages( owner: account.keyAccount.owner, conversationID: conversationID, @@ -99,7 +102,6 @@ final class NotificationViewController: UIViewController, UNNotificationContentE let text = textResponse.userText Task { @MainActor in do { - let client = ChatNotificationClient() let sent = try await client.sendMessage( owner: ownerKeyPair, conversationID: conversationID, From 009175118c692d8ed01af62aee0ad55eb69ec58c Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 23 Jun 2026 16:37:50 -0400 Subject: [PATCH 14/19] fix: render rich chat notification content on device - Link UserNotificationsUI.framework so the content extension actually loads (it was being killed at launch: missing extension-point framework) - Force dark mode + paint the chat background so the white bubbles are visible - Render cash messages via ChatCashCardCell with currency + flag, not blank bubbles - Match the real chat's bubble spacing and width - Poll while expanded so the counterparty's new messages appear live --- Code.xcodeproj/project.pbxproj | 8 + FlipcashTests/ChatPreviewMappingTests.swift | 5 +- .../FlipcashUI/Chat/ChatPreviewMapping.swift | 8 +- .../NotificationViewController.swift | 150 +++++++++++++----- 4 files changed, 125 insertions(+), 46 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 1b4f706d..95c8b8b2 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 56BF88A54E7F0C5DAB42710A /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8DFC9D975C89EBB9B4E50C4D /* FlipcashCore */; }; 7531D04D33DB0C02B873BF36 /* NotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C402C27F9E020E000EBA2936 /* NotificationContent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 81E1A94ED8796D1522C2E2B7 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E679C9CB11F511DF4EE5B34 /* NotificationViewController.swift */; }; + 8778E6B10D2DD83ABE8E2969 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B4C2CDF828ABA73672DA87AB /* UserNotificationsUI.framework */; }; 88FB56E02EE0AB2F0014EC5F /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56DF2EE0AB2F0014EC5F /* CLAUDE.md */; }; 88FB56E52EE0AB810014EC5F /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E32EE0AB810014EC5F /* README.md */; }; 88FB56E62EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E12EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md */; }; @@ -30,6 +31,7 @@ 9ADEF1D72DD627C0001B260A /* Bugsnag in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D62DD627C0001B260A /* Bugsnag */; }; 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D82DD627C6001B260A /* Mixpanel */; }; A3BE11B009F4E2EF7ED93F05 /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = EFFC929EC8849F357BE7F997 /* FlipcashUI */; }; + B4D66444ACFD12F6C0FE8FA2 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,7 +98,9 @@ 9AD5E64B25F2BF3E007388AE /* opencv2.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = opencv2.framework; sourceTree = ""; }; A361ABDE10578BE8E8859E31 /* NotificationContent.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationContent.entitlements; sourceTree = ""; }; B2ECA4ED4ED388070B77FCD7 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B4C2CDF828ABA73672DA87AB /* UserNotificationsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotificationsUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/UserNotificationsUI.framework; sourceTree = DEVELOPER_DIR; }; C402C27F9E020E000EBA2936 /* NotificationContent.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationContent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/UserNotifications.framework; sourceTree = DEVELOPER_DIR; }; EC0A077F6E7EE1EB0853134B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ @@ -183,6 +187,8 @@ 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */, 56BF88A54E7F0C5DAB42710A /* FlipcashCore in Frameworks */, A3BE11B009F4E2EF7ED93F05 /* FlipcashUI in Frameworks */, + 8778E6B10D2DD83ABE8E2969 /* UserNotificationsUI.framework in Frameworks */, + B4D66444ACFD12F6C0FE8FA2 /* UserNotifications.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -320,6 +326,8 @@ isa = PBXGroup; children = ( EC0A077F6E7EE1EB0853134B /* Foundation.framework */, + B4C2CDF828ABA73672DA87AB /* UserNotificationsUI.framework */, + D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */, ); name = iOS; sourceTree = ""; diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift index 7a5df046..92dde995 100644 --- a/FlipcashTests/ChatPreviewMappingTests.swift +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -83,7 +83,7 @@ struct ChatPreviewMappingTests { #expect(msg.content == .text("see you soon")) } - @Test("Cash message maps to .cash content with formatted amount and 'Cash' token") + @Test("Cash message maps to .cash content with formatted amount, currency token, and flag") func cashContentMaps() { let messages = [cashMessage(id: 1, senderID: otherID, amount: 5.00)] let items = ChatItem.preview(from: messages, selfUserID: meID) @@ -96,7 +96,8 @@ struct ChatPreviewMappingTests { Issue.record("Expected .cash content") return } - #expect(cash.token == "Cash") + #expect(cash.token == "USD") + #expect(cash.flagImageName != nil) // Amount is a non-empty formatted string (locale-sensitive; just check non-empty) #expect(!cash.amount.isEmpty) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift index a777b712..a958fe1c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift @@ -36,9 +36,15 @@ extension ChatItem { case .text(let text): content = .text(text) case .cash(let fiat): + // Mirror the app's cash mapping: the flag comes from the currency's region and + // loads from the FlipcashUI bundle, so it resolves inside an extension. The token + // name normally comes from the app's mint-branding service, which the extension + // can't reach — fall back to the currency code. + let currency = fiat.nativeAmount.currency content = .cash(ChatCashContent( amount: fiat.nativeAmount.formatted(), - token: "Cash" + token: currency.rawValue.uppercased(), + flagImageName: currency.region?.rawValue ?? currency.rawValue.uppercased() )) } diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index eaadacd9..efb5a1f1 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -6,6 +6,7 @@ // import UIKit +import SwiftUI import UserNotifications import UserNotificationsUI import FlipcashCore @@ -28,19 +29,44 @@ final class NotificationViewController: UIViewController, UNNotificationContentE private var items: [ChatItem] = [] private var statusLabel: UILabel? + /// Mirrors the app's "background" color asset (display-P3 25,25,26). Hardcoded + /// because that asset lives in the app bundle, so `Color("background")` can't + /// resolve it from this extension's bundle. The chat bubbles are white-on-dark + /// by design, so the background must be this dark value or they render invisibly. + private static let chatBackground = UIColor( + displayP3Red: 25 / 255, green: 25 / 255, blue: 26 / 255, alpha: 1 + ) + + /// Matches the real chat (ChatViewController). + private static let maxBubbleWidthFraction: CGFloat = 0.78 + private static let interItemSpacing: CGFloat = 8 + + /// How many recent messages the preview shows, and how often it re-checks the + /// server for new ones while the notification stays expanded. + private static let previewLimit = 5 + private static let pollInterval: TimeInterval = 2.5 + /// Single client shared across fetch and reply to avoid allocating multiple /// NIO event-loop groups per notification. private lazy var client = ChatNotificationClient() - /// Preserved across didReceive calls so reply can reuse them. + /// Preserved across didReceive calls so reply and polling can reuse them. private var conversationID: ConversationID? private var ownerKeyPair: KeyPair? private var selfUserID: UserID? + /// Re-fetches recent messages while the notification is expanded so the + /// counterparty's new messages appear live. Cancelled when the view goes away. + private var pollTask: Task? + // MARK: - Lifecycle - override func viewDidLoad() { super.viewDidLoad() + // The chat bubbles are white-on-dark by design; the app forces dark mode, + // so the extension must too or the bubbles render invisibly on a light background. + overrideUserInterfaceStyle = .dark + view.backgroundColor = Self.chatBackground FontBook.registerApplicationFonts() setupCollectionView() } @@ -51,11 +77,11 @@ final class NotificationViewController: UIViewController, UNNotificationContentE guard let conversationID = NotificationPayload.chatConversationID( from: notification.request.content.userInfo ) else { - // Not a chat push or no payload — leave the default banner. + // Not a chat push — leave the default banner. return } guard let account = OwnerKeyStore.loadOwnerAccount() else { - // Not logged in — leave the default banner. + // Not signed in — leave the default banner. return } self.conversationID = conversationID @@ -63,26 +89,48 @@ final class NotificationViewController: UIViewController, UNNotificationContentE self.selfUserID = account.userID apply(.loading) + Task { @MainActor in await loadMessages() } + startPolling() + } - Task { @MainActor in - do { - let messages = try await client.getMessages( - owner: account.keyAccount.owner, - conversationID: conversationID, - limit: 3 - ) - if messages.isEmpty { - apply(.empty) - } else { - let chatItems = ChatItem.preview(from: messages, selfUserID: account.userID, limit: 3) - apply(.loaded(chatItems)) - } - } catch { - apply(.error("Couldn't load messages")) + /// Fetches the recent messages and renders them. Safe to call repeatedly (the poll + /// does); a transient failure keeps the current content rather than clobbering it. + @MainActor + private func loadMessages() async { + guard let conversationID, let ownerKeyPair, let selfUserID else { return } + do { + let messages = try await client.getMessages( + owner: ownerKeyPair, + conversationID: conversationID, + limit: Self.previewLimit + ) + if messages.isEmpty { + if items.isEmpty { apply(.empty) } + } else { + apply(.loaded(ChatItem.preview(from: messages, selfUserID: selfUserID, limit: Self.previewLimit))) + } + } catch { + if items.isEmpty { apply(.error("Couldn't load messages")) } + } + } + + private func startPolling() { + pollTask?.cancel() + pollTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Self.pollInterval)) + if Task.isCancelled { break } + await self?.loadMessages() } } } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + pollTask?.cancel() + pollTask = nil + } + func didReceive( _ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void @@ -115,7 +163,7 @@ final class NotificationViewController: UIViewController, UNNotificationContentE appendItem(sentItem) completion(.doNotDismiss) } catch { - showSendError() + showStatusLabel("Couldn't send message") completion(.doNotDismiss) } } @@ -131,17 +179,24 @@ final class NotificationViewController: UIViewController, UNNotificationContentE // MARK: - Collection view setup - private func setupCollectionView() { - var config = UICollectionLayoutListConfiguration(appearance: .plain) - config.backgroundColor = .clear - config.showsSeparators = false - let layout = UICollectionViewCompositionalLayout.list(using: config) + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(44) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = Self.interItemSpacing + section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0) + let layout = UICollectionViewCompositionalLayout(section: section) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = .clear + collectionView.backgroundColor = Self.chatBackground collectionView.isScrollEnabled = false collectionView.dataSource = self collectionView.register(ChatMessageCell.self, forCellWithReuseIdentifier: ChatMessageCell.reuseIdentifier) + collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) view.addSubview(collectionView) NSLayoutConstraint.activate([ @@ -160,6 +215,7 @@ final class NotificationViewController: UIViewController, UNNotificationContentE case .loading: items = [] collectionView.reloadData() + showStatusLabel("Loading…") case .loaded(let chatItems): clearStatusLabel() @@ -168,10 +224,9 @@ final class NotificationViewController: UIViewController, UNNotificationContentE updatePreferredContentSize() case .empty: - clearStatusLabel() items = [] collectionView.reloadData() - updatePreferredContentSize() + showStatusLabel("No messages") case .error(let message): items = [] @@ -204,26 +259,24 @@ final class NotificationViewController: UIViewController, UNNotificationContentE let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.text = text - label.textColor = .secondaryLabel + label.numberOfLines = 0 + label.textColor = UIColor(Color.textMain) label.font = .preferredFont(forTextStyle: .footnote) label.textAlignment = .center view.addSubview(label) NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), label.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) statusLabel = label - preferredContentSize = CGSize(width: view.bounds.width, height: 44) + preferredContentSize = CGSize(width: view.bounds.width, height: 120) } private func clearStatusLabel() { statusLabel?.removeFromSuperview() statusLabel = nil } - - private func showSendError() { - showStatusLabel("Couldn't send message") - } } // MARK: - UICollectionViewDataSource - @@ -238,20 +291,31 @@ extension NotificationViewController: UICollectionViewDataSource { _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ChatMessageCell.reuseIdentifier, - for: indexPath - ) as! ChatMessageCell // swiftlint:disable:this force_cast - let item = items[indexPath.item] switch item { case .message(let message): - let maxWidth = collectionView.bounds.width * 0.78 - cell.configure(with: message, maxWidth: maxWidth) + switch message.content { + case .text: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ChatMessageCell.reuseIdentifier, + for: indexPath + ) as! ChatMessageCell // swiftlint:disable:this force_cast + cell.configure(with: message, maxWidth: collectionView.bounds.width * Self.maxBubbleWidthFraction) + return cell + case .cash: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ChatCashCardCell.reuseIdentifier, + for: indexPath + ) as! ChatCashCardCell // swiftlint:disable:this force_cast + cell.configure(with: message) + return cell + } case .dateSeparator, .receipt: - // Preview only shows message bubbles — separators not expected for ≤3 messages. - break + // The preview mapping only emits .message rows. + return collectionView.dequeueReusableCell( + withReuseIdentifier: ChatMessageCell.reuseIdentifier, + for: indexPath + ) } - return cell } } From 3bca159d47aac8df49a060b6ede9530fbb8e4e00 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 23 Jun 2026 16:59:50 -0400 Subject: [PATCH 15/19] feat: route notification Send Cash through the chat conversation Open the chat as the root sheet then stack Send Cash on top (present(.conversation) + presentNested(.sendAmount)), matching #402, so it always lands back in the chat. Repoint callers to main's NotificationPayload.chatID(_:) (drop the duplicate helper) and update the preview switch for main's ChatItem (no .receipt). --- .../Controllers/Deep Links/DeepLinkController.swift | 5 ++++- Flipcash/Core/Controllers/PushController.swift | 2 +- .../FlipcashCore/Push/NotificationPayload.swift | 11 ----------- NotificationContent/NotificationViewController.swift | 6 +++--- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 00bcb363..b591ff2c 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -242,7 +242,10 @@ struct DeepLinkAction { selfUserID: container.session.userID ), container.session.canSend { Analytics.deeplinkRouted(kind: kind) - container.appRouter.navigate(to: .sendAmount(contact: target)) + // Open the chat as the root sheet, then stack Send Cash on top, so + // dismissing the amount entry lands back in the conversation. + container.appRouter.present(.conversation(.existing(conversationID))) + container.appRouter.presentNested(.sendAmount(target)) } } diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 657a8f81..471314a8 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -268,7 +268,7 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification postContactJoinIfNeeded(response.notification.request.content.userInfo) if response.actionIdentifier == ChatNotificationCategory.sendCashActionID, - let conversationID = NotificationPayload.chatConversationID(from: response.notification.request.content.userInfo) + let conversationID = NotificationPayload.chatID(response.notification.request.content.userInfo) { let url = URL(string: "flipcash://chat/\(conversationID.base64URLEncoded)/send")! handleTargetUrlIfNeeded(url.absoluteString) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 0baf7dee..dcbc2456 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -36,15 +36,4 @@ public enum NotificationPayload { guard case .chatID(let chatID) = payload.navigation.type else { return nil } return ConversationID(chatID) } - - /// Extracts the chat `ConversationID` from a push's navigation payload, if present. - /// Callers that can't import `FlipcashAPI` (e.g. the notification content extension) - /// use this instead of accessing `payload.navigation.type` directly. - public static func chatConversationID(from userInfo: [AnyHashable: Any]) -> ConversationID? { - guard - let payload = decode(userInfo), - case .chatID(let chatID) = payload.navigation.type - else { return nil } - return ConversationID(chatID) - } } diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index efb5a1f1..88e57ff4 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -74,8 +74,8 @@ final class NotificationViewController: UIViewController, UNNotificationContentE // MARK: - UNNotificationContentExtension - func didReceive(_ notification: UNNotification) { - guard let conversationID = NotificationPayload.chatConversationID( - from: notification.request.content.userInfo + guard let conversationID = NotificationPayload.chatID( + notification.request.content.userInfo ) else { // Not a chat push — leave the default banner. return @@ -310,7 +310,7 @@ extension NotificationViewController: UICollectionViewDataSource { cell.configure(with: message) return cell } - case .dateSeparator, .receipt: + case .dateSeparator: // The preview mapping only emits .message rows. return collectionView.dequeueReusableCell( withReuseIdentifier: ChatMessageCell.reuseIdentifier, From 6c5a838cff81d7eada93c26d31390be28a3a6f04 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 23 Jun 2026 17:28:58 -0400 Subject: [PATCH 16/19] fix: open Send Cash directly over the chat, no chat slide-up Add present(_:animated:) to AppRouter; the notification Send Cash deeplink and the App Intents send now mount the chat instantly (animated: false) and animate only the Send Cash amount sheet on top, so it opens directly with the chat already behind it instead of sliding the chat in first. --- Flipcash/Core/AppIntents/AppIntentContext.swift | 3 ++- .../Controllers/Deep Links/DeepLinkController.swift | 7 ++++--- Flipcash/Core/Navigation/AppRouter.swift | 12 +++++++++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/AppIntents/AppIntentContext.swift b/Flipcash/Core/AppIntents/AppIntentContext.swift index bfc5e16a..3f465634 100644 --- a/Flipcash/Core/AppIntents/AppIntentContext.swift +++ b/Flipcash/Core/AppIntents/AppIntentContext.swift @@ -48,7 +48,8 @@ enum AppIntentContext { /// router path the in-chat Send Cash button drives. static func openSendFlow(to contact: ResolvedContact) { guard let router = loggedInContainer?.appRouter else { return } - router.present(.conversation(.contact(contact))) + // Mount the chat instantly and animate only the Send Cash sheet on top. + router.present(.conversation(.contact(contact)), animated: false) router.presentNested(.sendAmount(contact)) } } diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index b591ff2c..37cd8b35 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -242,9 +242,10 @@ struct DeepLinkAction { selfUserID: container.session.userID ), container.session.canSend { Analytics.deeplinkRouted(kind: kind) - // Open the chat as the root sheet, then stack Send Cash on top, so - // dismissing the amount entry lands back in the conversation. - container.appRouter.present(.conversation(.existing(conversationID))) + // Mount the chat instantly (no slide-up) and animate only the Send + // Cash sheet on top, so it opens directly with the chat already + // behind it — dismissing the amount entry lands back in the chat. + container.appRouter.present(.conversation(.existing(conversationID)), animated: false) container.appRouter.presentNested(.sendAmount(target)) } } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 1800105e..e589b126 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -224,7 +224,17 @@ final class AppRouter { /// sheet mounts — so a re-open lands at root. A sheet swap (presenting a /// different sheet without going through `dismissSheet` first) leaves /// both paths intact, preserving the original "swap-and-return" behaviour. - func present(_ sheet: SheetPresentation) { + func present(_ sheet: SheetPresentation, animated: Bool = true) { + guard animated else { + // Mount this sheet without its slide-up so a sheet stacked on top + // (presentNested) is the only thing that animates — e.g. opening Send + // Cash over a chat from a deeplink lands on the amount sheet with the + // chat already behind it, instead of animating the chat in first. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { present(sheet) } + return + } if presentedSheets == [sheet] { return } let previousTop = presentedSheet From 4295e05c62471ab5680ba8517152fca33b8f6a2a Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Tue, 23 Jun 2026 17:58:52 -0400 Subject: [PATCH 17/19] fix: present Send Cash amount sheet directly from the deeplink (no chat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification Send Cash deeplink and the App Intents send now present .sendAmount as a root sheet — one animation, no chat slide-up behind it. Render .sendAmount at the ScanScreen root (was EmptyView, nested-only) and expose SendAmountSheetRoot. Reverts the no-op present(animated:) (disablesAnimations does not suppress a SwiftUI sheet's presentation animation). --- Flipcash/Core/AppIntents/AppIntentContext.swift | 10 +++------- .../Controllers/Deep Links/DeepLinkController.swift | 9 ++++----- Flipcash/Core/Navigation/AppRouter+NestedSheet.swift | 2 +- Flipcash/Core/Navigation/AppRouter.swift | 12 +----------- Flipcash/Core/Screens/Main/ScanScreen.swift | 10 +++++----- 5 files changed, 14 insertions(+), 29 deletions(-) diff --git a/Flipcash/Core/AppIntents/AppIntentContext.swift b/Flipcash/Core/AppIntents/AppIntentContext.swift index 3f465634..86898c0e 100644 --- a/Flipcash/Core/AppIntents/AppIntentContext.swift +++ b/Flipcash/Core/AppIntents/AppIntentContext.swift @@ -43,13 +43,9 @@ enum AppIntentContext { await sendableContacts().first { $0.id == id } } - /// Foregrounds the app on `contact`'s conversation with the Send Cash amount - /// entry stacked on top, so dismissing it lands back in the chat — the same - /// router path the in-chat Send Cash button drives. + /// Foregrounds the app on the Send Cash amount entry for `contact`, presented + /// directly as a sheet (one animation, no chat behind). static func openSendFlow(to contact: ResolvedContact) { - guard let router = loggedInContainer?.appRouter else { return } - // Mount the chat instantly and animate only the Send Cash sheet on top. - router.present(.conversation(.contact(contact)), animated: false) - router.presentNested(.sendAmount(contact)) + loggedInContainer?.appRouter.present(.sendAmount(contact)) } } diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 37cd8b35..3e033c32 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -242,11 +242,10 @@ struct DeepLinkAction { selfUserID: container.session.userID ), container.session.canSend { Analytics.deeplinkRouted(kind: kind) - // Mount the chat instantly (no slide-up) and animate only the Send - // Cash sheet on top, so it opens directly with the chat already - // behind it — dismissing the amount entry lands back in the chat. - container.appRouter.present(.conversation(.existing(conversationID)), animated: false) - container.appRouter.presentNested(.sendAmount(target)) + // Open the Send Cash amount entry directly as the sheet — one + // animation, no chat behind it. Dismissing returns to where the + // user was (the chat itself is reachable via the chat deeplink). + container.appRouter.present(.sendAmount(target)) } } diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 18a86d2d..cdb1da98 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -135,7 +135,7 @@ private struct BuySheetRoot: View { /// Root view for the `.sendAmount(contact)` nested sheet — Send Cash stacks it on /// top of the chat. The `NavigationStack` is unbound: nothing pushes onto this /// stack, so it exists only to render the screen's toolbar. -private struct SendAmountSheetRoot: View { +struct SendAmountSheetRoot: View { let contact: ResolvedContact let sessionContainer: SessionContainer diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index e589b126..1800105e 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -224,17 +224,7 @@ final class AppRouter { /// sheet mounts — so a re-open lands at root. A sheet swap (presenting a /// different sheet without going through `dismissSheet` first) leaves /// both paths intact, preserving the original "swap-and-return" behaviour. - func present(_ sheet: SheetPresentation, animated: Bool = true) { - guard animated else { - // Mount this sheet without its slide-up so a sheet stacked on top - // (presentNested) is the only thing that animates — e.g. opening Send - // Cash over a chat from a deeplink lands on the amount sheet with the - // chat already behind it, instead of animating the chat in first. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { present(sheet) } - return - } + func present(_ sheet: SheetPresentation) { if presentedSheets == [sheet] { return } let previousTop = presentedSheet diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 0547568f..d7ec953c 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -408,11 +408,11 @@ private struct RoutedSheet: View { // notification. The picker → chat flow pushes `.dmConversation` // onto the `.send` stack instead. ConversationSheetRoot(context: context) - case .sendAmount: - // Nested-only sheet — Send Cash enters it via - // `presentNested(.sendAmount)`. Rendering EmptyView at root is a - // defensive no-op, mirroring `.buy`. - EmptyView() + case .sendAmount(let contact): + // Send Cash entered directly as a root sheet — e.g. the notification + // Send Cash deeplink / App Intent opens the amount entry with no chat + // behind it. (In-chat Send Cash still enters it via presentNested.) + SendAmountSheetRoot(contact: contact, sessionContainer: sessionContainer) } } } From 125cd8ad130d4f30d24b25eb31efd6e9d76c492d Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 24 Jun 2026 13:22:14 -0400 Subject: [PATCH 18/19] feat: render the chat notification preview with the real chat transcript Embed ChatViewController so the expanded notification sizes to its content, scrolls, and renders every cell (including the cash card) like the in-app chat. Hide the default notification content so the message isn't shown twice, and match the extension version to the app so iOS loads the extension. --- Code.xcodeproj/project.pbxproj | 8 +- NotificationContent/Info.plist | 4 +- .../NotificationViewController.swift | 276 ++++++------------ 3 files changed, 91 insertions(+), 197 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 95c8b8b2..b5581620 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -870,7 +870,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1583,7 +1583,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1620,7 +1620,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1656,7 +1656,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.NotificationContent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/NotificationContent/Info.plist b/NotificationContent/Info.plist index a7691408..6a5701f7 100644 --- a/NotificationContent/Info.plist +++ b/NotificationContent/Info.plist @@ -9,11 +9,11 @@ UNNotificationExtensionCategory CHAT_MESSAGE UNNotificationExtensionInitialContentSizeRatio - 0.6 + 0.82 UNNotificationExtensionUserInteractionEnabled UNNotificationExtensionDefaultContentHidden - + NSExtensionPointIdentifier com.apple.usernotifications.content-extension diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 88e57ff4..38a59190 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -14,61 +14,69 @@ import FlipcashUI final class NotificationViewController: UIViewController, UNNotificationContentExtension { - // MARK: - State - - - private enum ViewState { - case loading - case loaded([ChatItem]) - case empty - case error(String) - } - // MARK: - Properties - - private var collectionView: UICollectionView! - private var items: [ChatItem] = [] + /// The app's real chat transcript, embedded so the preview renders, sizes, scrolls, + /// and opens at the newest message exactly like the in-app chat. + private let chat = ChatViewController() private var statusLabel: UILabel? - /// Mirrors the app's "background" color asset (display-P3 25,25,26). Hardcoded - /// because that asset lives in the app bundle, so `Color("background")` can't - /// resolve it from this extension's bundle. The chat bubbles are white-on-dark - /// by design, so the background must be this dark value or they render invisibly. + /// Mirrors the app's "background" color asset (display-P3 25,25,26). Hardcoded because + /// that asset lives in the app bundle, so `Color("background")` can't resolve it here. private static let chatBackground = UIColor( displayP3Red: 25 / 255, green: 25 / 255, blue: 26 / 255, alpha: 1 ) - /// Matches the real chat (ChatViewController). - private static let maxBubbleWidthFraction: CGFloat = 0.78 - private static let interItemSpacing: CGFloat = 8 - - /// How many recent messages the preview shows, and how often it re-checks the - /// server for new ones while the notification stays expanded. - private static let previewLimit = 5 + /// Recent messages to show, and how often to re-check the server while expanded. + private static let previewLimit = 3 private static let pollInterval: TimeInterval = 2.5 + /// The panel sizes to its content up to this; taller transcripts scroll (newest pinned + /// at the bottom) instead of clipping, like the chat screen. + private static let maxContentHeight: CGFloat = 440 - /// Single client shared across fetch and reply to avoid allocating multiple - /// NIO event-loop groups per notification. private lazy var client = ChatNotificationClient() - - /// Preserved across didReceive calls so reply and polling can reuse them. private var conversationID: ConversationID? private var ownerKeyPair: KeyPair? private var selfUserID: UserID? - - /// Re-fetches recent messages while the notification is expanded so the - /// counterparty's new messages appear live. Cancelled when the view goes away. private var pollTask: Task? + /// True once messages have been rendered, so polling/reply failures don't clobber them + /// and the panel sizing kicks in. + private var hasContent = false // MARK: - Lifecycle - override func viewDidLoad() { super.viewDidLoad() - // The chat bubbles are white-on-dark by design; the app forces dark mode, - // so the extension must too or the bubbles render invisibly on a light background. overrideUserInterfaceStyle = .dark view.backgroundColor = Self.chatBackground FontBook.registerApplicationFonts() - setupCollectionView() + + addChild(chat) + chat.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(chat.view) + NSLayoutConstraint.activate([ + chat.view.topAnchor.constraint(equalTo: view.topAnchor), + chat.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + chat.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + chat.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + chat.didMove(toParent: self) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard hasContent else { return } + updatePanelSize() + } + + /// Sizes the panel to the transcript's content height (capped) — short conversations + /// fit exactly, taller ones cap and scroll. Reads ChatLayout's `contentSize`, which is + /// the true content height (bottom-anchoring only offsets cells, it doesn't pad it). + private func updatePanelSize() { + let target = max(min(chat.collectionView.contentSize.height, Self.maxContentHeight), 44) + if abs(preferredContentSize.height - target) > 0.5 { + preferredContentSize = CGSize(width: view.bounds.width, height: target) + } } // MARK: - UNNotificationContentExtension - @@ -81,56 +89,18 @@ final class NotificationViewController: UIViewController, UNNotificationContentE return } guard let account = OwnerKeyStore.loadOwnerAccount() else { - // Not signed in — leave the default banner. + // Owner key unavailable (e.g. before first unlock) — hint rather than blank. + showStatusLabel("Open Flipcash to view this message") return } self.conversationID = conversationID self.ownerKeyPair = account.keyAccount.owner self.selfUserID = account.userID - apply(.loading) Task { @MainActor in await loadMessages() } startPolling() } - /// Fetches the recent messages and renders them. Safe to call repeatedly (the poll - /// does); a transient failure keeps the current content rather than clobbering it. - @MainActor - private func loadMessages() async { - guard let conversationID, let ownerKeyPair, let selfUserID else { return } - do { - let messages = try await client.getMessages( - owner: ownerKeyPair, - conversationID: conversationID, - limit: Self.previewLimit - ) - if messages.isEmpty { - if items.isEmpty { apply(.empty) } - } else { - apply(.loaded(ChatItem.preview(from: messages, selfUserID: selfUserID, limit: Self.previewLimit))) - } - } catch { - if items.isEmpty { apply(.error("Couldn't load messages")) } - } - } - - private func startPolling() { - pollTask?.cancel() - pollTask = Task { @MainActor [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(Self.pollInterval)) - if Task.isCancelled { break } - await self?.loadMessages() - } - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - pollTask?.cancel() - pollTask = nil - } - func didReceive( _ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void @@ -150,22 +120,17 @@ final class NotificationViewController: UIViewController, UNNotificationContentE let text = textResponse.userText Task { @MainActor in do { - let sent = try await client.sendMessage( + _ = try await client.sendMessage( owner: ownerKeyPair, conversationID: conversationID, text: text ) - let sentItem = ChatItem.message(ChatMessage( - id: String(sent.id.value), - text: text, - sender: .me - )) - appendItem(sentItem) - completion(.doNotDismiss) + // Re-fetch so the sent message appears in the transcript. + await loadMessages() } catch { - showStatusLabel("Couldn't send message") - completion(.doNotDismiss) + // Leave the transcript as-is; the message simply wasn't sent. } + completion(.doNotDismiss) } case ChatNotificationCategory.sendCashActionID: @@ -176,83 +141,53 @@ final class NotificationViewController: UIViewController, UNNotificationContentE } } - // MARK: - Collection view setup - - - private func setupCollectionView() { - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .estimated(44) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - section.interGroupSpacing = Self.interItemSpacing - section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0) - let layout = UICollectionViewCompositionalLayout(section: section) - - collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = Self.chatBackground - collectionView.isScrollEnabled = false - collectionView.dataSource = self - collectionView.register(ChatMessageCell.self, forCellWithReuseIdentifier: ChatMessageCell.reuseIdentifier) - collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) - - view.addSubview(collectionView) - NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - // MARK: - State application - + // MARK: - Loading - + /// Fetches recent messages and feeds them to the transcript. Safe to call repeatedly + /// (the poll does); a transient failure keeps whatever is already shown. @MainActor - private func apply(_ state: ViewState) { - switch state { - case .loading: - items = [] - collectionView.reloadData() - showStatusLabel("Loading…") - - case .loaded(let chatItems): - clearStatusLabel() - items = chatItems - collectionView.reloadData() - updatePreferredContentSize() - - case .empty: - items = [] - collectionView.reloadData() - showStatusLabel("No messages") - - case .error(let message): - items = [] - collectionView.reloadData() - showStatusLabel(message) + private func loadMessages() async { + guard let conversationID, let ownerKeyPair, let selfUserID else { return } + do { + let messages = try await client.getMessages( + owner: ownerKeyPair, + conversationID: conversationID, + limit: Self.previewLimit + ) + if messages.isEmpty { + if !hasContent { showStatusLabel("No messages") } + } else { + clearStatusLabel() + hasContent = true + chat.update(items: ChatItem.preview(from: messages, selfUserID: selfUserID, limit: Self.previewLimit)) + // The transcript lays out asynchronously and the parent won't re-lay out on + // its own, so force the layout now and size the panel to the real content. + chat.collectionView.layoutIfNeeded() + updatePanelSize() + } + } catch { + if !hasContent { showStatusLabel("Couldn't load messages") } } } - @MainActor - private func appendItem(_ item: ChatItem) { - clearStatusLabel() - items.append(item) - collectionView.reloadData() - updatePreferredContentSize() + private func startPolling() { + pollTask?.cancel() + pollTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Self.pollInterval)) + if Task.isCancelled { break } + await self?.loadMessages() + } + } } - private func updatePreferredContentSize() { - collectionView.layoutIfNeeded() - let contentHeight = min(collectionView.contentSize.height, 320) - preferredContentSize = CGSize( - width: view.bounds.width, - height: max(contentHeight, 44) - ) + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + pollTask?.cancel() + pollTask = nil } - // MARK: - Error UI - + // MARK: - Status UI - private func showStatusLabel(_ text: String) { clearStatusLabel() @@ -278,44 +213,3 @@ final class NotificationViewController: UIViewController, UNNotificationContentE statusLabel = nil } } - -// MARK: - UICollectionViewDataSource - - -extension NotificationViewController: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - items.count - } - - func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { - let item = items[indexPath.item] - switch item { - case .message(let message): - switch message.content { - case .text: - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ChatMessageCell.reuseIdentifier, - for: indexPath - ) as! ChatMessageCell // swiftlint:disable:this force_cast - cell.configure(with: message, maxWidth: collectionView.bounds.width * Self.maxBubbleWidthFraction) - return cell - case .cash: - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ChatCashCardCell.reuseIdentifier, - for: indexPath - ) as! ChatCashCardCell // swiftlint:disable:this force_cast - cell.configure(with: message) - return cell - } - case .dateSeparator: - // The preview mapping only emits .message rows. - return collectionView.dequeueReusableCell( - withReuseIdentifier: ChatMessageCell.reuseIdentifier, - for: indexPath - ) - } - } -} From be0bf3ba25e1a65800a719152ddc71b6d9c6b2e0 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 25 Jun 2026 14:22:42 -0400 Subject: [PATCH 19/19] feat: token name and icon on the chat notification cash card Resolve mint metadata over the network from the extension (lean CurrencyService on the payments host, cached per mint) and render it without the diff sliding it in. Plus rich-notification cleanup: dedupe the send-target construction, the keychain test helper, and the transcript reload path; tidy comments. --- .../Core/Controllers/ContactDirectory.swift | 7 +++ .../Deep Links/DeepLinkController.swift | 6 +- .../ChatNotificationRouter.swift | 12 ---- .../Conversation/ConversationScreen.swift | 12 ++-- .../Core/Session/SessionAuthenticator.swift | 2 - .../Session/SharedKeychainMigration.swift | 9 +-- Flipcash/Keychain/Secure.swift | 8 +-- .../Clients/Chat/ChatNotificationClient.swift | 29 +++++++--- .../Clients/Chat/OwnerKeyStore.swift | 2 - .../ChatNotificationCategory.swift | 7 +++ .../FlipcashCore/Utilities/Keychain.swift | 20 +++---- .../ChatNotificationRouterTests.swift | 34 ----------- FlipcashTests/ChatPreviewMappingTests.swift | 38 ++++++++++-- FlipcashTests/OwnerKeyStoreTests.swift | 13 +---- .../ResolvedContactCounterpartTests.swift | 34 +++++++++++ FlipcashTests/RouteTests.swift | 2 +- .../SharedKeychainMigrationTests.swift | 23 +++----- .../TestSupport/Keychain+TestSupport.swift | 17 ++++++ .../FlipcashUI/Chat/ChatPreviewMapping.swift | 19 +++--- .../FlipcashUI/Chat/ChatViewController.swift | 10 ++-- .../NotificationViewController.swift | 58 ++++++++++++++++--- 21 files changed, 223 insertions(+), 139 deletions(-) delete mode 100644 Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift delete mode 100644 FlipcashTests/ChatNotificationRouterTests.swift create mode 100644 FlipcashTests/TestSupport/Keychain+TestSupport.swift diff --git a/Flipcash/Core/Controllers/ContactDirectory.swift b/Flipcash/Core/Controllers/ContactDirectory.swift index e162fc33..24e9736c 100644 --- a/Flipcash/Core/Controllers/ContactDirectory.swift +++ b/Flipcash/Core/Controllers/ContactDirectory.swift @@ -60,6 +60,13 @@ extension ResolvedContact { dmChatID: dmChatID ) } + + /// The Send Cash target for a DM `conversation`: its counterpart resolved by phone number, + /// carrying `dmChatID`. `nil` when there's no counterpart or no number on file. + nonisolated static func sendTarget(in conversation: Conversation?, dmChatID: Data?, selfUserID: UserID) -> ResolvedContact? { + guard let counterpart = conversation?.counterpart(excluding: selfUserID) else { return nil } + return ResolvedContact(counterpart: counterpart, dmChatID: dmChatID) + } } nonisolated struct ResolvedContacts: Equatable, Sendable { diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 3e033c32..b853a0a8 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -236,9 +236,9 @@ struct DeepLinkAction { case .chatSendCash(let conversationID): if case .loggedIn(let container) = sessionAuthenticator.state { let conversation = container.conversationController.conversation(withID: conversationID) - if let target = ChatNotificationRouter.sendTarget( - forChatID: conversationID, - conversation: conversation, + if let target = ResolvedContact.sendTarget( + in: conversation, + dmChatID: conversationID.data, selfUserID: container.session.userID ), container.session.canSend { Analytics.deeplinkRouted(kind: kind) diff --git a/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift b/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift deleted file mode 100644 index ac66184f..00000000 --- a/Flipcash/Core/Controllers/Notifications/ChatNotificationRouter.swift +++ /dev/null @@ -1,12 +0,0 @@ -import FlipcashCore - -nonisolated enum ChatNotificationRouter { - /// The contact a Send Cash from the notification should pay: the synced - /// contact when present, else a target built from the counterpart's shared - /// phone number (mirrors `ConversationScreen.sendTarget`, #382). - static func sendTarget(forChatID id: ConversationID, conversation: Conversation?, selfUserID: UserID) -> ResolvedContact? { - guard let counterpart = conversation?.counterpart(excluding: selfUserID), - counterpart.phoneE164?.isEmpty == false else { return nil } - return ResolvedContact(counterpart: counterpart, dmChatID: id.data) - } -} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 66164ab4..a9bd5877 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -78,12 +78,12 @@ struct ConversationScreen: View { if let contact { return contact } - guard let conversationID, - let counterpart = conversationController.conversation(withID: conversationID)? - .counterpart(excluding: conversationController.selfUserID) else { - return nil - } - return ResolvedContact(counterpart: counterpart, dmChatID: conversationID.data) + guard let conversationID else { return nil } + return ResolvedContact.sendTarget( + in: conversationController.conversation(withID: conversationID), + dmChatID: conversationID.data, + selfUserID: conversationController.selfUserID + ) } /// Whether the DM chat actually exists server-side. Matched contacts carry diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index a0748d33..4c75645c 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -98,8 +98,6 @@ final class SessionAuthenticator { return } - // Copy the owner key into the shared access group so a notification - // extension can authenticate. Idempotent; preserves the legacy item. SharedKeychainMigration.migrateOwnerKeyIfNeeded() initializeState { userAccount in diff --git a/Flipcash/Core/Session/SharedKeychainMigration.swift b/Flipcash/Core/Session/SharedKeychainMigration.swift index cc9830cc..231e8d22 100644 --- a/Flipcash/Core/Session/SharedKeychainMigration.swift +++ b/Flipcash/Core/Session/SharedKeychainMigration.swift @@ -10,12 +10,9 @@ import FlipcashCore private nonisolated let logger = Logger(label: "flipcash.shared-keychain-migration") -/// One-time migration that copies the owner key into the shared keychain -/// access group so a notification extension in the same group can read it. -/// -/// The copy preserves the legacy groupless item, so an existing user is never -/// logged out: reads fall back to the legacy item until the migration lands, -/// and the migration only ever adds the shared-group copy. +/// One-time migration that copies the owner key into the shared keychain access group so a +/// notification extension in the same group can read it. Non-destructive — it only adds the +/// shared-group copy and leaves the legacy item, so existing users aren't logged out. enum SharedKeychainMigration { /// Copies the owner key from the legacy groupless location into the shared diff --git a/Flipcash/Keychain/Secure.swift b/Flipcash/Keychain/Secure.swift index c883d15f..ab23647a 100644 --- a/Flipcash/Keychain/Secure.swift +++ b/Flipcash/Keychain/Secure.swift @@ -64,10 +64,10 @@ struct SecureCodable where T: Codable { Keychain.set(newValue, for: key.rawValue, useSynchronization: sync, accessGroup: group) } else { Keychain.delete(key.rawValue, accessGroup: group) - if sharedGroup { - // Also clear any legacy copy. A groupless delete spans every - // accessible access group, so a stale pre-migration item - // can't survive logout and resurrect via the fallback read. + // When the delete above was group-scoped, also clear any legacy groupless copy so a + // stale pre-migration item can't survive logout and resurrect via the fallback read. + // (With no group the delete above is already groupless.) + if group != nil { Keychain.delete(key.rawValue) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 80a0d62d..ba1db3eb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -8,22 +8,24 @@ import Foundation import GRPC -/// A lean gRPC façade for fetching and sending chat messages from a -/// notification extension. Constructs only the `ChatMessagingService` — -/// no `FlipClient` or `Client` graph is required. +/// A lean gRPC façade for a notification extension: fetches and sends chat messages +/// and resolves mint metadata over its own channel — no `FlipClient`, `Client`, or +/// SQLite `Database` graph is required. public final class ChatNotificationClient: @unchecked Sendable { private let messagingService: ChatMessagingService + private let currencyService: CurrencyService // MARK: - Init - public init(network: Network = .mainNet) { let queue = DispatchQueue(label: "flipcash.chat-notification-client", qos: .userInitiated) - let channel = ClientConnection.appConnection( - host: network.hostForCore, - port: network.port - ) - self.messagingService = ChatMessagingService(channel: channel, queue: queue) + // Messaging is served by the core host, currency (mint metadata) by the payments host. + // Two channels share the one queue. + let coreChannel = ClientConnection.appConnection(host: network.hostForCore, port: network.port) + let paymentsChannel = ClientConnection.appConnection(host: network.hostForPayments, port: network.port) + self.messagingService = ChatMessagingService(channel: coreChannel, queue: queue) + self.currencyService = CurrencyService(channel: paymentsChannel, queue: queue) } // MARK: - Messages - @@ -59,4 +61,15 @@ public final class ChatNotificationClient: @unchecked Sendable { ) { continuation.resume(with: $0) } } } + + // MARK: - Mints - + + /// Resolves token metadata (name, symbol, decimals) for the given mints over the + /// network — the lean alternative to the app's SQLite mint cache, which an extension + /// can't reach. Unresolved mints are simply absent from the result. + public func fetchMintMetadata(for mints: [PublicKey]) async throws -> [PublicKey: MintMetadata] { + try await withCheckedThrowingContinuation { continuation in + currencyService.fetchMints(mints: mints) { continuation.resume(with: $0) } + } + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift index 63d784c9..20a9d3e0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift @@ -14,8 +14,6 @@ import Foundation public enum OwnerKeyStore { /// The keychain account key under which the owner `UserAccount` is stored. - /// Must stay in sync with `SecureKey.currentUserAccount.rawValue` in the - /// main app target. public static let ownerAccountKey = "com.flipcash.account.userAccount" /// Loads the signed-in `UserAccount` from the shared keychain access group, diff --git a/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift index b943707f..7cf79650 100644 --- a/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift +++ b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift @@ -1,3 +1,10 @@ +// +// ChatNotificationCategory.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + import Foundation /// Identifiers for the chat-message notification category. Shared by the app diff --git a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift index 3e51c873..b3cbb27e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift +++ b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift @@ -89,31 +89,25 @@ public class Keychain { /// The team-prefixed shared keychain access group /// (`".com.flipcash.shared"`), or `nil` if the team prefix - /// can't be resolved at runtime. Used to store the owner key where a - /// notification extension in the same access group can read it. - /// - /// The team prefix is derived at runtime — never hardcoded — by probing - /// the keychain for the app's default access group and taking its first - /// dot-separated component. + /// can't be resolved at runtime. Stores the owner key where a notification + /// extension in the same access group can read it. public static var sharedAccessGroup: String? { sharedAccessGroupLock.lock() defer { sharedAccessGroupLock.unlock() } if let cached = cachedSharedAccessGroup { - return cached.value + return cached } let resolved = resolveTeamPrefix().map { "\($0).com.flipcash.shared" } - cachedSharedAccessGroup = Cached(value: resolved) + cachedSharedAccessGroup = .some(resolved) return resolved } - private struct Cached { - let value: String? - } - private static let sharedAccessGroupLock = NSLock() - nonisolated(unsafe) private static var cachedSharedAccessGroup: Cached? + /// `nil` until first resolved; then `.some(value)`, where the inner value is the group or `nil` + /// when the team prefix couldn't be resolved. + nonisolated(unsafe) private static var cachedSharedAccessGroup: String?? /// Resolves the app's keychain access-group prefix (the team identifier /// prefix) by adding a throwaway generic-password item with no explicit diff --git a/FlipcashTests/ChatNotificationRouterTests.swift b/FlipcashTests/ChatNotificationRouterTests.swift deleted file mode 100644 index ab20119c..00000000 --- a/FlipcashTests/ChatNotificationRouterTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -@Suite("Chat notification Send Cash target") -struct ChatNotificationRouterTests { - @Test("Builds a send target from the conversation's counterpart phone") - func targetFromCounterpart() { - let id = ConversationID(data: Data(repeating: 0x01, count: 32)) - let me = UUID(); let them = UUID() - let convo = Conversation( - id: id, - members: [ - ConversationMember(userID: me, displayName: "Me"), - ConversationMember(userID: them, displayName: "", phoneE164: "+15551234567"), - ], - lastMessage: nil, - lastActivity: Date(timeIntervalSince1970: 0) - ) - let target = ChatNotificationRouter.sendTarget(forChatID: id, conversation: convo, selfUserID: me) - #expect(target?.phoneE164 == "+15551234567") - #expect(target?.dmChatID == id.data) - } - - @Test("No counterpart phone yields no target") - func noTarget() { - let id = ConversationID(data: Data(repeating: 0x02, count: 32)) - let me = UUID() - let convo = Conversation(id: id, members: [ConversationMember(userID: me, displayName: "Me")], - lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 0)) - #expect(ChatNotificationRouter.sendTarget(forChatID: id, conversation: convo, selfUserID: me) == nil) - } -} diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift index 92dde995..2922a34f 100644 --- a/FlipcashTests/ChatPreviewMappingTests.swift +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -28,9 +28,9 @@ struct ChatPreviewMappingTests { ) } - private func cashMessage(id: UInt64, senderID: UUID?, amount: Decimal) -> ConversationMessage { + private func cashMessage(id: UInt64, senderID: UUID?, amount: Decimal, mint: PublicKey = .usdf) -> ConversationMessage { let fiat = ExchangedFiat( - onChainAmount: TokenAmount(quarks: 0, mint: .usdf), + onChainAmount: TokenAmount(quarks: 0, mint: mint), nativeAmount: FiatAmount(value: amount, currency: .usd), currencyRate: Rate(fx: 1, currency: .usd) ) @@ -83,7 +83,7 @@ struct ChatPreviewMappingTests { #expect(msg.content == .text("see you soon")) } - @Test("Cash message maps to .cash content with formatted amount, currency token, and flag") + @Test("Cash message maps to .cash content with formatted amount and flag; unbranded by default") func cashContentMaps() { let messages = [cashMessage(id: 1, senderID: otherID, amount: 5.00)] let items = ChatItem.preview(from: messages, selfUserID: meID) @@ -96,12 +96,42 @@ struct ChatPreviewMappingTests { Issue.record("Expected .cash content") return } - #expect(cash.token == "USD") + // Without resolved branding the token label and icon are empty — no misleading fallback. + #expect(cash.token == "") + #expect(cash.iconURL == nil) #expect(cash.flagImageName != nil) // Amount is a non-empty formatted string (locale-sensitive; just check non-empty) #expect(!cash.amount.isEmpty) } + @Test("Cash row uses the resolved mint name and icon when provided") + func cashUsesResolvedBranding() { + let icon = URL(string: "https://example.com/jeffy.png")! + let messages = [cashMessage(id: 1, senderID: otherID, amount: 0.1, mint: .usdc)] + let items = ChatItem.preview(from: messages, selfUserID: meID, mintBranding: [.usdc: (name: "Jeffy", iconURL: icon)]) + + guard case .message(let msg) = items.first, case .cash(let cash) = msg.content else { + Issue.record("Expected .cash content") + return + } + #expect(cash.token == "Jeffy") + #expect(cash.iconURL == icon) + } + + @Test("Cash row shows no token label or icon when the mint isn't resolved") + func cashUnresolvedMintShowsNoBranding() { + let messages = [cashMessage(id: 1, senderID: otherID, amount: 0.1, mint: .usdc)] + // Branding keyed by a different mint must not apply. + let items = ChatItem.preview(from: messages, selfUserID: meID, mintBranding: [.usdf: (name: "Jeffy", iconURL: nil)]) + + guard case .message(let msg) = items.first, case .cash(let cash) = msg.content else { + Issue.record("Expected .cash content") + return + } + #expect(cash.token == "") + #expect(cash.iconURL == nil) + } + @Test("Message id is the string form of MessageID.value") func messageIDMapsToString() { let messages = [textMessage(id: 42, senderID: meID, text: "test")] diff --git a/FlipcashTests/OwnerKeyStoreTests.swift b/FlipcashTests/OwnerKeyStoreTests.swift index d92f6a48..a2717b88 100644 --- a/FlipcashTests/OwnerKeyStoreTests.swift +++ b/FlipcashTests/OwnerKeyStoreTests.swift @@ -20,18 +20,11 @@ struct OwnerKeyStoreTests { keyAccount: .mock ) - private func sharedGroup() throws -> String { - try #require( - Keychain.sharedAccessGroup, - "Shared access group must resolve at runtime" - ) - } - // MARK: - Round-trip - @Test("loadOwnerAccount returns the correct UserAccount after a shared-group write") func loadOwnerAccount_sharedGroupWrite_returnsCorrectAccount() throws { - let group = try sharedGroup() + let group = try Keychain.requireSharedAccessGroup() defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } let data = try JSONEncoder().encode(Self.mockAccount) @@ -44,7 +37,7 @@ struct OwnerKeyStoreTests { @Test("loadOwnerKeyPair returns the correct public key after a shared-group write") func loadOwnerKeyPair_sharedGroupWrite_returnsCorrectPublicKey() throws { - let group = try sharedGroup() + let group = try Keychain.requireSharedAccessGroup() defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } let data = try JSONEncoder().encode(Self.mockAccount) @@ -56,7 +49,7 @@ struct OwnerKeyStoreTests { @Test("loadOwnerKeyPair returns nil when no account is stored") func loadOwnerKeyPair_noAccount_returnsNil() throws { - let group = try sharedGroup() + let group = try Keychain.requireSharedAccessGroup() // Ensure the key is absent before the call. Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) defer { Keychain.delete(OwnerKeyStore.ownerAccountKey, accessGroup: group) } diff --git a/FlipcashTests/ResolvedContactCounterpartTests.swift b/FlipcashTests/ResolvedContactCounterpartTests.swift index 160edfcf..b868be47 100644 --- a/FlipcashTests/ResolvedContactCounterpartTests.swift +++ b/FlipcashTests/ResolvedContactCounterpartTests.swift @@ -42,4 +42,38 @@ struct ResolvedContactCounterpartTests { #expect(ResolvedContact(counterpart: member, dmChatID: chatID) == nil) } + + @Test("sendTarget builds the target from the conversation's counterpart phone") + func sendTargetFromCounterpart() { + let id = ConversationID(data: Data(repeating: 0x01, count: 32)) + let me = UUID() + let convo = Conversation( + id: id, + members: [ + ConversationMember(userID: me, displayName: "Me"), + ConversationMember(userID: UUID(), displayName: "", phoneE164: "+15551234567"), + ], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0) + ) + + let target = ResolvedContact.sendTarget(in: convo, dmChatID: id.data, selfUserID: me) + + #expect(target?.phoneE164 == "+15551234567") + #expect(target?.dmChatID == id.data) + } + + @Test("sendTarget yields nil when the conversation has no counterpart") + func sendTargetNoCounterpart() { + let id = ConversationID(data: Data(repeating: 0x02, count: 32)) + let me = UUID() + let convo = Conversation( + id: id, + members: [ConversationMember(userID: me, displayName: "Me")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0) + ) + + #expect(ResolvedContact.sendTarget(in: convo, dmChatID: id.data, selfUserID: me) == nil) + } } diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index 5dbe0513..58837ddb 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -2,7 +2,7 @@ // RouteTests.swift // FlipcashTests // -// Created by Claude on 2025-02-03. +// Copyright © 2026 Code Inc. All rights reserved. // import Foundation diff --git a/FlipcashTests/SharedKeychainMigrationTests.swift b/FlipcashTests/SharedKeychainMigrationTests.swift index 525245ee..6c6b04c8 100644 --- a/FlipcashTests/SharedKeychainMigrationTests.swift +++ b/FlipcashTests/SharedKeychainMigrationTests.swift @@ -28,21 +28,12 @@ struct SharedKeychainMigrationTests { "com.flipcash.tests.sharedKeychain.\(UUID().uuidString)" } - /// The shared access group, resolved at runtime. Skips the test cleanly if - /// the simulator keychain can't resolve it. - private func sharedGroup() throws -> String { - try #require( - Keychain.sharedAccessGroup, - "Shared access group must resolve at runtime" - ) - } - /// The legacy application-identifier access group an existing user's owner /// key lives in (`.`), derived at runtime — never /// hardcoded. This is the group groupless writes used *before* the /// `keychain-access-groups` entitlement existed. private func legacyGroup() throws -> String { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let prefix = try #require(shared.split(separator: ".").first.map(String.init)) let bundleId = try #require(Bundle.main.bundleIdentifier) return "\(prefix).\(bundleId)" @@ -59,7 +50,7 @@ struct SharedKeychainMigrationTests { @Test("A legacy item is recovered by the groupless fallback read") func legacyItemRecoveredByGrouplessFallback() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let legacy = try legacyGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared, legacy]) } @@ -80,7 +71,7 @@ struct SharedKeychainMigrationTests { @Test("Data written to the shared group reads back from the shared group") func sharedGroupRoundTrip() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared]) } @@ -93,7 +84,7 @@ struct SharedKeychainMigrationTests { @Test("Migration copies a legacy value into the shared group") func migrationCopiesLegacyIntoSharedGroup() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let legacy = try legacyGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared, legacy]) } @@ -113,7 +104,7 @@ struct SharedKeychainMigrationTests { @Test("Running the migration again is a no-op") func migrationIsIdempotent() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let legacy = try legacyGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared, legacy]) } @@ -131,7 +122,7 @@ struct SharedKeychainMigrationTests { @Test("Migration is a no-op when the legacy value is absent") func migrationNoOpWhenAbsent() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared]) } @@ -144,7 +135,7 @@ struct SharedKeychainMigrationTests { @Test("Logout teardown clears both the shared and legacy copies") func logoutClearsAllGroups() throws { - let shared = try sharedGroup() + let shared = try Keychain.requireSharedAccessGroup() let legacy = try legacyGroup() let key = Self.uniqueKey() defer { cleanup(key, groups: [shared, legacy]) } diff --git a/FlipcashTests/TestSupport/Keychain+TestSupport.swift b/FlipcashTests/TestSupport/Keychain+TestSupport.swift new file mode 100644 index 00000000..456c0054 --- /dev/null +++ b/FlipcashTests/TestSupport/Keychain+TestSupport.swift @@ -0,0 +1,17 @@ +// +// Keychain+TestSupport.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore + +extension Keychain { + /// The shared access group resolved at runtime; throws (skipping the test cleanly) when the + /// simulator keychain can't resolve it. + static func requireSharedAccessGroup() throws -> String { + try #require(Keychain.sharedAccessGroup, "Shared access group must resolve at runtime") + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift index a958fe1c..65489d5f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift @@ -20,10 +20,14 @@ extension ChatItem { /// the `.me` side. /// - limit: Maximum number of rows to return. Defaults to 3. The *most recent* messages /// (by `MessageID`) are kept, presented oldest-first. + /// - mintBranding: Resolved token branding (name + coin icon) keyed by mint, used to label + /// and illustrate cash rows (e.g. "Jeffy" with its icon). The caller resolves these over + /// the network; a cash row whose mint is absent shows no token label or icon. public static func preview( from messages: [ConversationMessage], selfUserID: UserID, - limit: Int = 3 + limit: Int = 3, + mintBranding: [PublicKey: (name: String, iconURL: URL?)] = [:] ) -> [ChatItem] { let sorted = messages.sorted { $0.id < $1.id } let slice = sorted.suffix(limit) @@ -36,15 +40,16 @@ extension ChatItem { case .text(let text): content = .text(text) case .cash(let fiat): - // Mirror the app's cash mapping: the flag comes from the currency's region and - // loads from the FlipcashUI bundle, so it resolves inside an extension. The token - // name normally comes from the app's mint-branding service, which the extension - // can't reach — fall back to the currency code. + // The flag loads from the FlipcashUI bundle, so it resolves inside an extension. The + // token name + coin icon come from `mintBranding`; an unresolved mint shows no token + // label or icon rather than a misleading fallback. let currency = fiat.nativeAmount.currency + let branding = mintBranding[fiat.mint] content = .cash(ChatCashContent( amount: fiat.nativeAmount.formatted(), - token: currency.rawValue.uppercased(), - flagImageName: currency.region?.rawValue ?? currency.rawValue.uppercased() + token: branding?.name ?? "", + flagImageName: currency.region?.rawValue ?? currency.rawValue.uppercased(), + iconURL: branding?.iconURL )) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6efecba4..e60e90da 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -124,7 +124,7 @@ public final class ChatViewController: UICollectionViewController { /// diff is computed by DifferenceKit and applied via `reload(using:)`, so /// `keepContentOffsetAtBottomOnBatchUpdates` keeps a new arrival pinned to the bottom (and a /// prepended older page anchored in place) with no hand-rolled scrolling. - public func update(items newItems: [ChatItem]) { + public func update(items newItems: [ChatItem], animated: Bool = true) { // While a context menu is lifted, hold pushed updates: reloading the transcript now (e.g. an // arriving message) would reflow the content out from under the lifted preview. The latest // push is applied when the menu closes. Mirrors ChatLayout deferring updates during a preview. @@ -144,9 +144,11 @@ public final class ChatViewController: UICollectionViewController { return } - // First load (or a clear): a plain reload, then open at the newest message rather than - // animating every row in. - if wasEmpty || newItems.isEmpty { + // First load, a clear, or a non-animated update: reload in place and open at the newest + // message rather than animating rows. A non-animated update (e.g. a late-resolving cash-card + // detail) re-arms the open so the detail appears without the diff sliding it in. + if wasEmpty || newItems.isEmpty || !animated { + if !animated { needsInitialScroll = true } items = newItems collectionView.reloadData() performInitialScrollIfNeeded() diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 38a59190..86033ac1 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -21,8 +21,8 @@ final class NotificationViewController: UIViewController, UNNotificationContentE private let chat = ChatViewController() private var statusLabel: UILabel? - /// Mirrors the app's "background" color asset (display-P3 25,25,26). Hardcoded because - /// that asset lives in the app bundle, so `Color("background")` can't resolve it here. + /// The dark "background" color (display-P3 25,25,26), hardcoded because the matching asset + /// lives in the app bundle and can't resolve from this extension. private static let chatBackground = UIColor( displayP3Red: 25 / 255, green: 25 / 255, blue: 26 / 255, alpha: 1 ) @@ -42,6 +42,13 @@ final class NotificationViewController: UIViewController, UNNotificationContentE /// True once messages have been rendered, so polling/reply failures don't clobber them /// and the panel sizing kicks in. private var hasContent = false + /// Resolved token branding (name + coin icon) keyed by mint, so cash bubbles read "Jeffy" + /// with its icon. Cached across polls so each mint is fetched at most once. + private var mintBranding: [PublicKey: (name: String, iconURL: URL?)] = [:] + /// Serializes `loadMessages`: the initial load and the 2.5s poll otherwise interleave their + /// `chat.update` calls when a fetch is slow, and overlapping transcript reloads corrupt the + /// diff and blank the panel. + private var isLoading = false // MARK: - Lifecycle - @@ -147,6 +154,9 @@ final class NotificationViewController: UIViewController, UNNotificationContentE /// (the poll does); a transient failure keeps whatever is already shown. @MainActor private func loadMessages() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } guard let conversationID, let ownerKeyPair, let selfUserID else { return } do { let messages = try await client.getMessages( @@ -159,17 +169,51 @@ final class NotificationViewController: UIViewController, UNNotificationContentE } else { clearStatusLabel() hasContent = true - chat.update(items: ChatItem.preview(from: messages, selfUserID: selfUserID, limit: Self.previewLimit)) - // The transcript lays out asynchronously and the parent won't re-lay out on - // its own, so force the layout now and size the panel to the real content. - chat.collectionView.layoutIfNeeded() - updatePanelSize() + // Render immediately with whatever names are cached (currency-code fallback for + // any new mint), then resolve missing token names over the network and re-render + // so they swap in — the bubble is never gated on that round-trip. + render(messages, selfUserID: selfUserID) + if await resolveMintBranding(in: messages) { + render(messages, selfUserID: selfUserID) + } } } catch { if !hasContent { showStatusLabel("Couldn't load messages") } } } + private func render(_ messages: [ConversationMessage], selfUserID: UserID) { + chat.update(items: ChatItem.preview( + from: messages, + selfUserID: selfUserID, + limit: Self.previewLimit, + mintBranding: mintBranding + ), animated: false) + // The transcript lays out asynchronously and the parent won't re-lay out on its + // own, so force the layout now and size the panel to the real content. + chat.collectionView.layoutIfNeeded() + updatePanelSize() + } + + /// Resolves token branding (name + icon) for cash mints not already cached, over the network — + /// the extension has no SQLite mint cache, so it asks the server directly. Returns true if the + /// cache changed (caller re-renders). Best-effort: a failed lookup leaves the row unbranded. + @MainActor + private func resolveMintBranding(in messages: [ConversationMessage]) async -> Bool { + let unresolved = Set(messages.compactMap { message -> PublicKey? in + guard case .cash(let fiat) = message.content, mintBranding[fiat.mint] == nil else { return nil } + return fiat.mint + }) + guard !unresolved.isEmpty else { return false } + guard let resolved = try? await client.fetchMintMetadata(for: Array(unresolved)), !resolved.isEmpty else { + return false + } + for (mint, metadata) in resolved { + mintBranding[mint] = (name: metadata.name, iconURL: metadata.imageURL) + } + return true + } + private func startPolling() { pollTask?.cancel() pollTask = Task { @MainActor [weak self] in