From 6bce252f4d2e86e4be00a005ef56dbc035b4fb96 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Mon, 22 Jun 2026 13:20:10 -0400 Subject: [PATCH] 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 | 51 +++++ 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, 781 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 e05ac9109..15960fe37 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 000000000..096c25b8d --- /dev/null +++ b/Flipcash/Core/AppIntents/AppIntentContext.swift @@ -0,0 +1,51 @@ +// +// 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 at the send amount-entry screen for `contact`, + /// reusing the same router path the recipient picker drives. + static func openSendFlow(to contact: ResolvedContact) { + loggedInContainer?.appRouter.navigate(to: .sendAmount(contact: contact)) + } +} diff --git a/Flipcash/Core/AppIntents/ContactEntity.swift b/Flipcash/Core/AppIntents/ContactEntity.swift new file mode 100644 index 000000000..1ebb59d40 --- /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 000000000..fd2025673 --- /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 000000000..7dbe8d72c --- /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 cdf65b158..3986550db 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 a48ab453f..c63c74706 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -200,6 +200,19 @@ struct ConversationScreen: View { hasAppeared = true } } + // 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 + } } private func sendCash() { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 3716e48cb..52531a1b3 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, @@ -532,6 +534,13 @@ struct SessionContainer { ) conversationController.start() self.conversationController = conversationController + + 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 000000000..29f84057a --- /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 000000000..8de0213fc --- /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 000000000..22d6b2b2a --- /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 4b24ed28e..451b9a8d3 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 af4e8328e..e306b2c48 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 039cc7d64..37e6e1ad3 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 000000000..46f01861d --- /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 000000000..f436df858 --- /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) + } +}