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..096c25b8 --- /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 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 cdf65b15..3986550d 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 a48ab453..c63c7470 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 3716e48c..52531a1b 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 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) + } +}