diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 64f720663..b55816201 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -7,11 +7,16 @@ 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 */; }; + 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 */; }; @@ -25,9 +30,18 @@ 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 */; }; + B4D66444ACFD12F6C0FE8FA2 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */; }; /* 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 +73,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 +82,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 +96,30 @@ 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 = ""; }; + 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 */ /* 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 +130,68 @@ /* 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 */, + 8778E6B10D2DD83ABE8E2969 /* UserNotificationsUI.framework in Frameworks */, + B4D66444ACFD12F6C0FE8FA2 /* UserNotifications.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4C7D6FF72FC8892D0091C7A4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -159,6 +236,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 +294,8 @@ 9A516F962D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */, 9AED1101258BE1310088D902 /* Products */, + DE3C8C53AB74589FBE36B004 /* Frameworks */, + 1DE68FD2D78ED8BA7ACC542F /* NotificationContent */, ); sourceTree = ""; }; @@ -216,6 +306,7 @@ 9A516F892D9B12EA00AF478B /* FlipcashTests.xctest */, 9A516F932D9B12EA00AF478B /* FlipcashUITests.xctest */, 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */, + C402C27F9E020E000EBA2936 /* NotificationContent.appex */, ); name = Products; sourceTree = ""; @@ -231,6 +322,24 @@ name = Packages; sourceTree = ""; }; + A2C1D925AA58EE6DFD1C4022 /* iOS */ = { + isa = PBXGroup; + children = ( + EC0A077F6E7EE1EB0853134B /* Foundation.framework */, + B4C2CDF828ABA73672DA87AB /* UserNotificationsUI.framework */, + D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */, + ); + name = iOS; + sourceTree = ""; + }; + DE3C8C53AB74589FBE36B004 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A2C1D925AA58EE6DFD1C4022 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -257,6 +366,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 +401,7 @@ ); dependencies = ( 4C7D70002FC8892D0091C7A4 /* PBXTargetDependency */, + 47A27522041981A8EE511CE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9A516F7B2D9B12E900AF478B /* Flipcash */, @@ -412,7 +543,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 +557,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 +630,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 +669,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 +843,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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.13.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 +1556,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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.13.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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.13.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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.13.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 +1796,7 @@ minimumVersion = 8.3.0; }; }; - 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */ = { + 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dbart01/SQLite.swift"; requirement = { @@ -1512,6 +1830,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 +1858,7 @@ }; 9AD1265D2DA98ADC0048141F /* SQLite */ = { isa = XCSwiftPackageProductDependency; - package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */; + package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite.swift" */; productName = SQLite; }; 9ADEF1D62DD627C0001B260A /* Bugsnag */ = { @@ -1549,6 +1871,10 @@ package = 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */; productName = Mixpanel; }; + EFFC929EC8849F357BE7F997 /* FlipcashUI */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 9AED10F8258BE1310088D902 /* Project object */; 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..86898c0ee --- /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 on the Send Cash amount entry for `contact`, presented + /// directly as a sheet (one animation, no chat behind). + static func openSendFlow(to contact: ResolvedContact) { + loggedInContainer?.appRouter.present(.sendAmount(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/Controllers/ContactDirectory.swift b/Flipcash/Core/Controllers/ContactDirectory.swift index 2509de355..24e9736c5 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( @@ -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 a43004a36..b853a0a84 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,22 @@ struct DeepLinkAction { container.appRouter.present(.conversation(.existing(conversationID))) } + case .chatSendCash(let conversationID): + if case .loggedIn(let container) = sessionAuthenticator.state { + let conversation = container.conversationController.conversation(withID: conversationID) + if let target = ResolvedContact.sendTarget( + in: conversation, + dmChatID: conversationID.data, + selfUserID: container.session.userID + ), container.session.canSend { + Analytics.deeplinkRouted(kind: kind) + // 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)) + } + } + case .openSheet(let sheet): if case .loggedIn(let container) = sessionAuthenticator.state { Analytics.deeplinkRouted(kind: kind) @@ -253,6 +279,7 @@ extension DeepLinkAction { case verifyEmail(VerificationDescription) case currencyInfo(PublicKey) case chat(ConversationID) + case chatSendCash(ConversationID) case openSheet(AppRouter.SheetPresentation) } } @@ -260,11 +287,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 4034d7fae..e10ecea33 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 c4bce4cdf..471314a8d 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,21 @@ 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 conversationID = NotificationPayload.chatID(response.notification.request.content.userInfo) + { + 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/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index dbefc39d3..268b5ba84 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/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 18a86d2de..cdb1da989 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/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 33ab8c82f..a9bd58775 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 @@ -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/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 0547568f5..d7ec953cf 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) } } } diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index b273761dc..4695b19fa 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/Flipcash/Core/Session/AccountManager.swift b/Flipcash/Core/Session/AccountManager.swift index bddaf2a39..0a30bb303 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 4e8894dae..4c75645c9 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -98,6 +98,8 @@ final class SessionAuthenticator { return } + SharedKeychainMigration.migrateOwnerKeyIfNeeded() + initializeState { userAccount in let initializedAccount = InitializedAccount( keyAccount: userAccount.keyAccount, @@ -427,6 +429,7 @@ final class SessionAuthenticator { container.pushController.prepareForLogout() container.usdcSweepOperation.cancel() container.conversationController.stop() + container.chatSpotlightIndexer.stop() QuickActionsController.clear() } @@ -459,6 +462,7 @@ struct SessionContainer { let usdcSweepOperation: UsdcSweepOperation let quickActionsController: QuickActionsController let conversationController: ConversationController + let chatSpotlightIndexer: ChatSpotlightIndexer init( session: Session, @@ -538,6 +542,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/Session/SharedKeychainMigration.swift b/Flipcash/Core/Session/SharedKeychainMigration.swift new file mode 100644 index 000000000..231e8d221 --- /dev/null +++ b/Flipcash/Core/Session/SharedKeychainMigration.swift @@ -0,0 +1,48 @@ +// +// 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. 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 + /// 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/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/Keychain/Secure.swift b/Flipcash/Keychain/Secure.swift index 73fb0c4c8..ab23647a4 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) + // 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) + } } } } - + 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 22874021e..807f30a4b 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/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/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift new file mode 100644 index 000000000..ba1db3eb2 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -0,0 +1,75 @@ +// +// ChatNotificationClient.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import GRPC + +/// 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) + // 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 - + + /// 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) } + } + } + + // 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 new file mode 100644 index 000000000..20a9d3e08 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/OwnerKeyStore.swift @@ -0,0 +1,43 @@ +// +// 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. + 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: + /// - 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? { + loadOwnerAccount()?.keyAccount.owner + } +} 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/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift new file mode 100644 index 000000000..7cf796506 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Notifications/ChatNotificationCategory.swift @@ -0,0 +1,17 @@ +// +// ChatNotificationCategory.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +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" +} diff --git a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift index 59bb134e0..b3cbb27e5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift +++ b/FlipcashCore/Sources/FlipcashCore/Utilities/Keychain.swift @@ -24,59 +24,138 @@ 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)) + query.insert(.accessible(.afterFirstUnlock)) + } + // 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. 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 + } + + let resolved = resolveTeamPrefix().map { "\($0).com.flipcash.shared" } + cachedSharedAccessGroup = .some(resolved) + return resolved + } + + private static let sharedAccessGroupLock = NSLock() + /// `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 + /// 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), + .accessible(.afterFirstUnlock), + .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/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/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift new file mode 100644 index 000000000..2922a34f3 --- /dev/null +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -0,0 +1,208 @@ +// +// 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, mint: PublicKey = .usdf) -> ConversationMessage { + let fiat = ExchangedFiat( + onChainAmount: TokenAmount(quarks: 0, mint: mint), + 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 flag; unbranded by default") + 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 + } + // 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")] + 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/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) + } +} diff --git a/FlipcashTests/OwnerKeyStoreTests.swift b/FlipcashTests/OwnerKeyStoreTests.swift new file mode 100644 index 000000000..a2717b88b --- /dev/null +++ b/FlipcashTests/OwnerKeyStoreTests.swift @@ -0,0 +1,66 @@ +// +// 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 + ) + + // MARK: - Round-trip - + + @Test("loadOwnerAccount returns the correct UserAccount after a shared-group write") + func loadOwnerAccount_sharedGroupWrite_returnsCorrectAccount() throws { + let group = try Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + // 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) + } +} diff --git a/FlipcashTests/ResolvedContactCounterpartTests.swift b/FlipcashTests/ResolvedContactCounterpartTests.swift index 160edfcf6..b868be47e 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 de816164f..58837ddba 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 @@ -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 diff --git a/FlipcashTests/SharedKeychainMigrationTests.swift b/FlipcashTests/SharedKeychainMigrationTests.swift new file mode 100644 index 000000000..6c6b04c8b --- /dev/null +++ b/FlipcashTests/SharedKeychainMigrationTests.swift @@ -0,0 +1,158 @@ +// +// 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 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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 Keychain.requireSharedAccessGroup() + 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) + } +} diff --git a/FlipcashTests/TestSupport/Keychain+TestSupport.swift b/FlipcashTests/TestSupport/Keychain+TestSupport.swift new file mode 100644 index 000000000..456c00540 --- /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 new file mode 100644 index 000000000..65489d5f8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPreviewMapping.swift @@ -0,0 +1,65 @@ +// +// 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. + /// - 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, + mintBranding: [PublicKey: (name: String, iconURL: URL?)] = [:] + ) -> [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): + // 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: branding?.name ?? "", + flagImageName: currency.region?.rawValue ?? currency.rawValue.uppercased(), + iconURL: branding?.iconURL + )) + } + + return .message(ChatMessage( + id: String(message.id.value), + content: content, + sender: sender, + isContinuationFromPrevious: false, + isContinuedByNext: false + )) + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6efecba4e..e60e90daa 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/Info.plist b/NotificationContent/Info.plist new file mode 100644 index 000000000..6a5701f70 --- /dev/null +++ b/NotificationContent/Info.plist @@ -0,0 +1,24 @@ + + + + + NSExtension + + NSExtensionAttributes + + UNNotificationExtensionCategory + CHAT_MESSAGE + UNNotificationExtensionInitialContentSizeRatio + 0.82 + 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 000000000..be54783c8 --- /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 000000000..86033ac13 --- /dev/null +++ b/NotificationContent/NotificationViewController.swift @@ -0,0 +1,259 @@ +// +// NotificationViewController.swift +// NotificationContent +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import SwiftUI +import UserNotifications +import UserNotificationsUI +import FlipcashCore +import FlipcashUI + +final class NotificationViewController: UIViewController, UNNotificationContentExtension { + + // MARK: - Properties - + + /// 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? + + /// 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 + ) + + /// 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 + + private lazy var client = ChatNotificationClient() + private var conversationID: ConversationID? + private var ownerKeyPair: KeyPair? + private var selfUserID: UserID? + 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 + /// 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 - + + override func viewDidLoad() { + super.viewDidLoad() + overrideUserInterfaceStyle = .dark + view.backgroundColor = Self.chatBackground + FontBook.registerApplicationFonts() + + 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 - + + func didReceive(_ notification: UNNotification) { + guard let conversationID = NotificationPayload.chatID( + notification.request.content.userInfo + ) else { + // Not a chat push — leave the default banner. + return + } + guard let account = OwnerKeyStore.loadOwnerAccount() else { + // 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 + + Task { @MainActor in await loadMessages() } + startPolling() + } + + 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 { + _ = try await client.sendMessage( + owner: ownerKeyPair, + conversationID: conversationID, + text: text + ) + // Re-fetch so the sent message appears in the transcript. + await loadMessages() + } catch { + // Leave the transcript as-is; the message simply wasn't sent. + } + completion(.doNotDismiss) + } + + case ChatNotificationCategory.sendCashActionID: + completion(.dismissAndForwardAction) + + default: + completion(.dismiss) + } + } + + // 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 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( + owner: ownerKeyPair, + conversationID: conversationID, + limit: Self.previewLimit + ) + if messages.isEmpty { + if !hasContent { showStatusLabel("No messages") } + } else { + clearStatusLabel() + hasContent = true + // 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 + 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 + } + + // MARK: - Status UI - + + private func showStatusLabel(_ text: String) { + clearStatusLabel() + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = text + label.numberOfLines = 0 + label.textColor = UIColor(Color.textMain) + label.font = .preferredFont(forTextStyle: .footnote) + label.textAlignment = .center + view.addSubview(label) + NSLayoutConstraint.activate([ + 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: 120) + } + + private func clearStatusLabel() { + statusLabel?.removeFromSuperview() + statusLabel = nil + } +} diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index d172d93cd..94510abe4 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.