From 31b20a8d234cbce5f16033496820643e00f49ac6 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:14:50 -0500 Subject: [PATCH 1/3] Adding support for lightning addresses within contacts --- .../phoenix-ios.xcodeproj/project.pbxproj | 22 +- phoenix-ios/phoenix-ios/AppDelegate.swift | 2 - phoenix-ios/phoenix-ios/Localizable.xcstrings | 39 + .../phoenix-ios/extensions/String+Email.swift | 15 + .../kotlin/KotlinExtensions+Other.swift | 48 + .../kotlin/KotlinExtensions+Payments.swift | 22 + .../kotlin/KotlinIdentifiable.swift | 15 +- .../sync/SyncBackupManager+Contacts.swift | 61 +- .../configuration/ConfigurationView.swift | 2 +- .../contacts/AddContactOptionsSheet.swift | 79 + .../views/contacts/AddToContactsInfo.swift | 7 + .../views/contacts/ContactsList.swift | 47 +- .../views/contacts/ContactsListSheet.swift | 95 +- .../views/contacts/ManageContact.swift | 1320 +++++++++++++---- .../views/inspect/SummaryInfoGrid.swift | 41 +- .../views/inspect/SummaryView.swift | 134 +- .../views/send/PaymentDetails.swift | 97 +- .../phoenix-ios/views/send/SendView.swift | 259 +++- .../phoenix-ios/views/send/ValidateView.swift | 224 +-- .../fr.acinq.phoenix/data/ContactInfo.kt | 70 +- .../kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt | 16 +- .../db/cloud/contacts/CloudContact.kt | 119 +- .../db/notifications/ContactQueries.kt | 275 +++- .../managers/ContactsManager.kt | 112 +- .../fr.acinq.phoenix/managers/SendManager.kt | 26 +- .../acinq/phoenix/db/sqldelight/Contacts.sq | 97 +- .../phoenix/db/sqldelight/migrations/7.sqm | 23 + .../acinq/phoenix/utils/LightningExposure.kt | 29 +- 28 files changed, 2520 insertions(+), 776 deletions(-) create mode 100644 phoenix-ios/phoenix-ios/extensions/String+Email.swift create mode 100644 phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift create mode 100644 phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift create mode 100644 phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index b9314495a..8661a864c 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -60,6 +60,8 @@ DC18C418256FE22300A2D083 /* Prefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18C417256FE22300A2D083 /* Prefs.swift */; }; DC18C41D256FF91100A2D083 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18C41C256FF91100A2D083 /* Utils.swift */; }; DC1916B029CB6C1D00917F06 /* Text_CurrencyName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1916AF29CB6C1D00917F06 /* Text_CurrencyName.swift */; }; + DC1B71C52CCBE31900914D80 /* AddToContactsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1B71C42CCBE31900914D80 /* AddToContactsInfo.swift */; }; + DC1B71C62CCBE9A400914D80 /* AddToContactsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1B71C42CCBE31900914D80 /* AddToContactsInfo.swift */; }; DC1D2B4B2593EB860036AD38 /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1D2B4A2593EB850036AD38 /* Currency.swift */; }; DC1D2B502594CE900036AD38 /* FormattedAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1D2B4F2594CE900036AD38 /* FormattedAmount.swift */; }; DC1E75722B73DD500026F36E /* LogFileParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E75712B73DD500026F36E /* LogFileParser.swift */; }; @@ -188,7 +190,6 @@ DC6F04232C35EB9900627B4F /* SummaryInfoGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04222C35EB9900627B4F /* SummaryInfoGrid.swift */; }; DC6F04252C38807300627B4F /* PhotosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04242C38807300627B4F /* PhotosManager.swift */; }; DC6F04272C3895E300627B4F /* ContactPhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04262C3895E300627B4F /* ContactPhoto.swift */; }; - DC6F042B2C3DA7AD00627B4F /* ContactsListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */; }; DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */; }; DC6F19C12C470F70004EC469 /* PickerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19C02C470F70004EC469 /* PickerResult.swift */; }; DC70A99C2BBB6093002DBFF8 /* InboundFeeWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */; }; @@ -217,6 +218,8 @@ DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */; }; DC98D3962AF170AC005BD177 /* PaymentWarningPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */; }; DC98D3982AF2AE41005BD177 /* ReceiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3972AF2AE41005BD177 /* ReceiveView.swift */; }; + DC9933322CC03D7500EB3100 /* ContactsListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */; }; + DC9933342CC0426300EB3100 /* AddContactOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */; }; DC99E90925B78FA800FB20F7 /* EnabledSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99E90825B78FA800FB20F7 /* EnabledSecurity.swift */; }; DC99E94025BA141000FB20F7 /* LocalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99E93F25BA141000FB20F7 /* LocalWebView.swift */; }; DC99E94D25BA258C00FB20F7 /* about.html in Resources */ = {isa = PBXBuildFile; fileRef = DC99E94825BA258C00FB20F7 /* about.html */; }; @@ -291,6 +294,7 @@ DCC3E57F2D08A63900CCDA40 /* XPC+Foreground.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC3E57E2D08A63500CCDA40 /* XPC+Foreground.swift */; }; DCC3E5822D08A65400CCDA40 /* XPC+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC3E5812D08A65000CCDA40 /* XPC+Background.swift */; }; DCC46F1625C3521C005D32D9 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = DC72C31825A3CF87008A927A /* FirebaseMessaging */; }; + DCC753E32CB98221006F646B /* String+Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC753E22CB98221006F646B /* String+Email.swift */; }; DCC9D99A267BD28600EA36DD /* SyncBackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC9D999267BD28600EA36DD /* SyncBackupManager.swift */; }; DCC9D99C267BEB3D00EA36DD /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCC9D99B267BEB3D00EA36DD /* CloudKit.framework */; }; DCCC7FD526B0A006008ACD9B /* SquareSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCC7FD426B0A006008ACD9B /* SquareSize.swift */; }; @@ -491,6 +495,7 @@ DC18C417256FE22300A2D083 /* Prefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prefs.swift; sourceTree = ""; }; DC18C41C256FF91100A2D083 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; DC1916AF29CB6C1D00917F06 /* Text_CurrencyName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text_CurrencyName.swift; sourceTree = ""; }; + DC1B71C42CCBE31900914D80 /* AddToContactsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToContactsInfo.swift; sourceTree = ""; }; DC1D2B4A2593EB850036AD38 /* Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currency.swift; sourceTree = ""; }; DC1D2B4F2594CE900036AD38 /* FormattedAmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedAmount.swift; sourceTree = ""; }; DC1E75712B73DD500026F36E /* LogFileParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileParser.swift; sourceTree = ""; }; @@ -602,7 +607,6 @@ DC6F04222C35EB9900627B4F /* SummaryInfoGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryInfoGrid.swift; sourceTree = ""; }; DC6F04242C38807300627B4F /* PhotosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosManager.swift; sourceTree = ""; }; DC6F04262C3895E300627B4F /* ContactPhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPhoto.swift; sourceTree = ""; }; - DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListSheet.swift; sourceTree = ""; }; DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+Async.swift"; sourceTree = ""; }; DC6F19C02C470F70004EC469 /* PickerResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerResult.swift; sourceTree = ""; }; DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFeeWarning.swift; sourceTree = ""; }; @@ -631,6 +635,8 @@ DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityAdsHelp.swift; sourceTree = ""; }; DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentWarningPopover.swift; sourceTree = ""; }; DC98D3972AF2AE41005BD177 /* ReceiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveView.swift; sourceTree = ""; }; + DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListSheet.swift; sourceTree = ""; }; + DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactOptionsSheet.swift; sourceTree = ""; }; DC99E90825B78FA800FB20F7 /* EnabledSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnabledSecurity.swift; sourceTree = ""; }; DC99E93F25BA141000FB20F7 /* LocalWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalWebView.swift; sourceTree = ""; }; DC99E94925BA258C00FB20F7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/about.html; sourceTree = ""; }; @@ -696,6 +702,7 @@ DCBDB8822BE154840097F940 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = de; path = de.lproj/liquidity.html; sourceTree = ""; }; DCC3E57E2D08A63500CCDA40 /* XPC+Foreground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XPC+Foreground.swift"; sourceTree = ""; }; DCC3E5812D08A65000CCDA40 /* XPC+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XPC+Background.swift"; sourceTree = ""; }; + DCC753E22CB98221006F646B /* String+Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Email.swift"; sourceTree = ""; }; DCC9D999267BD28600EA36DD /* SyncBackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBackupManager.swift; sourceTree = ""; }; DCC9D99B267BEB3D00EA36DD /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; DCCC7FD426B0A006008ACD9B /* SquareSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareSize.swift; sourceTree = ""; }; @@ -1073,10 +1080,12 @@ DC3345CE2C2B4BED00EDD2D4 /* contacts */ = { isa = PBXGroup; children = ( + DC1B71C42CCBE31900914D80 /* AddToContactsInfo.swift */, DC3345CF2C2B4C1200EDD2D4 /* ManageContact.swift */, DC5567442C2F1A6900008E11 /* ContactsList.swift */, - DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */, + DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */, DC6F04262C3895E300627B4F /* ContactPhoto.swift */, + DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */, ); path = contacts; sourceTree = ""; @@ -1378,6 +1387,7 @@ DC422F3429392B0500E72253 /* Int+ToDate.swift */, DCB493CA269F3B05001B0F09 /* Result+Deugly.swift */, DC59377027516296003B4B53 /* Sequence+Sum.swift */, + DCC753E22CB98221006F646B /* String+Email.swift */, DC4CF3CB2BE93311003A957F /* String+PIN.swift */, DC7DA9F52AD84DF200F86B99 /* String+Substring.swift */, DC09085725B5E43900A46136 /* String+VersionComparison.swift */, @@ -1869,6 +1879,7 @@ DCACF6FE2566D0BA0009B01E /* GenericPasswordConvertible.swift in Sources */, DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */, DC33369826BAF721000E3F49 /* ShortSheet.swift in Sources */, + DC1B71C52CCBE31900914D80 /* AddToContactsInfo.swift in Sources */, DC3780412C077E0300937C8E /* PriorityBoxStyle.swift in Sources */, DC3FDCAF2C3306AB002C5931 /* LightningDualView.swift in Sources */, DC99E90925B78FA800FB20F7 /* EnabledSecurity.swift in Sources */, @@ -1879,6 +1890,7 @@ DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */, DCCFE6C22B7140FA002FFF11 /* LoggerFactory+Foreground.swift in Sources */, 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */, + DC9933342CC0426300EB3100 /* AddContactOptionsSheet.swift in Sources */, DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, DC9CF83D2D2C6D37003F3B0F /* ScrollView_18.swift in Sources */, @@ -2042,7 +2054,6 @@ DCB30E592A0C3F8200E7D7A2 /* LiquidityPolicyView.swift in Sources */, DCEAE5B72943CC7400320C46 /* RangeSheet.swift in Sources */, DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */, - DC6F042B2C3DA7AD00627B4F /* ContactsListSheet.swift in Sources */, DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */, DCDD9ECE28637474001800A3 /* Orientation.swift in Sources */, DCCFE6B02B64326F002FFF11 /* OSLogHandler.swift in Sources */, @@ -2092,6 +2103,7 @@ DC09FC3F2D5BADB900E6579A /* RestartPopover.swift in Sources */, DC5E288E2C62C3DF0037B3D3 /* NavigationStackDestination.swift in Sources */, DC784A112B31EA180018DC4A /* LiquidityAdsView.swift in Sources */, + DC9933322CC03D7500EB3100 /* ContactsListSheet.swift in Sources */, DC6D26E329E76557006A7814 /* AnimatedClock.swift in Sources */, DC72CEF52C99DCEB00C810A8 /* LnurlFlowErrorNotice.swift in Sources */, DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */, @@ -2106,6 +2118,7 @@ DC3780392C04D60400937C8E /* KotlinEnums.swift in Sources */, DCBA60CD2C909C7600878895 /* SendView.swift in Sources */, DC5E28CA2C62D37A0037B3D3 /* NavigationCoordinator.swift in Sources */, + DCC753E32CB98221006F646B /* String+Email.swift in Sources */, DCACF7092566D0F00009B01E /* AppAccessView.swift in Sources */, DC27E4D1279753EC00C777CC /* TextFieldNumberStyler.swift in Sources */, DC46CB1628D9F30500C4EAC7 /* LoadingView.swift in Sources */, @@ -2200,6 +2213,7 @@ DC641C78282171EA00862DCD /* KotlinAssociatedObject.swift in Sources */, DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DCCFE6B32B680DF5002FFF11 /* LoggerFactory.swift in Sources */, + DC1B71C62CCBE9A400914D80 /* AddToContactsInfo.swift in Sources */, DCA6DECA2829C31B0073C658 /* KeyStoreError.swift in Sources */, DC49FE9D2AC49E0800D8D2E2 /* KotlinExtensions+Bitcoin.swift in Sources */, DC422F3629392C0000E72253 /* Int+ToDate.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/AppDelegate.swift b/phoenix-ios/phoenix-ios/AppDelegate.swift index 0f083e5bb..11c74f65f 100644 --- a/phoenix-ios/phoenix-ios/AppDelegate.swift +++ b/phoenix-ios/phoenix-ios/AppDelegate.swift @@ -11,8 +11,6 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .trace) fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif -let CONTACTS_ENABLED = true - @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 49aed40dd..567cf00cf 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -323,6 +323,9 @@ } } } + }, + ": %@" : { + }, "? confirmations" : { @@ -5600,6 +5603,12 @@ } } } + }, + "Add to contacts" : { + + }, + "Add to existing contact" : { + }, "adding to existing channel" : { "comment" : "Transaction Info: Explanation", @@ -9223,6 +9232,7 @@ } }, "Bolt12 offer:" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9263,6 +9273,7 @@ } }, "Bolt12 offers" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9301,6 +9312,9 @@ } } } + }, + "Bolt12 offers:" : { + }, "broadcast" : { "localizations" : { @@ -13486,6 +13500,9 @@ } } } + }, + "Create new contact" : { + }, "Create new wallet" : { "localizations" : { @@ -16391,6 +16408,12 @@ } } } + }, + "Duplicate in %@" : { + + }, + "Duplicate within this contact" : { + }, "Duration" : { "localizations" : { @@ -23358,6 +23381,9 @@ } } } + }, + "Invalid format" : { + }, "Invalid Lightning Request" : { "comment" : "toast warning", @@ -23442,6 +23468,7 @@ } }, "Invalid offer" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -24046,6 +24073,9 @@ } } } + }, + "label (optional)" : { + }, "Last attempt failed" : { "localizations" : { @@ -24490,6 +24520,9 @@ } } } + }, + "Lightning addresses:" : { + }, "lightning fee" : { "extractionState" : "stale", @@ -25321,6 +25354,9 @@ } } } + }, + "lno1... (paste offer here)" : { + }, "lnurl-auth" : { "localizations" : { @@ -28300,6 +28336,9 @@ } } } + }, + "none" : { + }, "None" : { "comment" : "TextField placeholder", diff --git a/phoenix-ios/phoenix-ios/extensions/String+Email.swift b/phoenix-ios/phoenix-ios/extensions/String+Email.swift new file mode 100644 index 000000000..1ea4616d3 --- /dev/null +++ b/phoenix-ios/phoenix-ios/extensions/String+Email.swift @@ -0,0 +1,15 @@ +import Foundation + +extension String { + + func isValidEmailAddress() -> Bool { + let types: NSTextCheckingResult.CheckingType = [.link] + guard let linkDetector = try? NSDataDetector(types: types.rawValue) else { + return false + } + let range = NSRange(location: 0, length: self.count) + let result = linkDetector.firstMatch(in: self, options: .reportCompletion, range: range) + let scheme = result?.url?.scheme ?? "" + return (scheme == "mailto") && (result?.range.length == self.count) + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift index a5361fb6b..f7be81744 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift @@ -199,3 +199,51 @@ extension Array where Element == LocalChannelInfo { return LocalChannelInfo.companion.inFlightPaymentsCount(channels: self) } } + +extension Date { + + func toInstant() -> Kotlinx_datetimeInstant { + let millis = self.toMilliseconds() + return Kotlinx_datetimeInstant.companion.fromEpochMilliseconds(epochMilliseconds: millis) + } +} + +extension ContactAddress { + + var domain: String? { + + if let atRange = address.range(of: "@") { + + let domainRange = atRange.upperBound ..< address.endIndex + let domainText = String(address[domainRange]).lowercased() + + let sanitized = domainText.trimmingCharacters(in: .whitespacesAndNewlines) + if !sanitized.isEmpty { + return sanitized + } + } + + return nil + } + + class func wellKnownDomains(includeTestnet: Bool) -> Set { + + var result: Set = Set([ + "phoenixwallet.me", + "bitrefill.me", + "strike.me", + "coincorner.io", + "sparkwallet.me", + "ln.tips", + "getalby.com", + "walletofsatoshi.com", + "stacker.news" + ]) + + if includeTestnet { + result.insert("testnet.phoenixwallet.me") + } + + return result + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 11dd7130f..44afdaacb 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -118,6 +118,10 @@ extension WalletPaymentInfo { return String(localized: "No description", comment: "placeholder text") } + func hasAttachedMessage() -> Bool { + return attachedMessage() != nil + } + func attachedMessage() -> String? { var msg: String? = nil @@ -136,6 +140,24 @@ extension WalletPaymentInfo { return nil } } + + func canAddToContacts() -> Bool { + return addToContactsInfo() != nil + } + + func addToContactsInfo() -> AddToContactsInfo? { + + if payment is Lightning_kmpOutgoingPayment { + // Todo: check for lightning address (requires db change in metadata table) + + let invoiceRequest = payment.outgoingInvoiceRequest() + if let offer = invoiceRequest?.offer { + return AddToContactsInfo(offer: offer, address: nil) + } + } + + return nil + } } extension WalletPaymentMetadata { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift index ff3e257c5..f1614027a 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift @@ -42,6 +42,16 @@ extension FiatCurrency: @retroactive Identifiable { } } +extension ContactOffer: @retroactive Identifiable { + // Already defined: +// var id: Bitcoin_kmpByteVector32 { get } +} + +extension ContactAddress: @retroactive Identifiable { + // Already defined: +// var id: Bitcoin_kmpByteVector32 { get } +} + extension ContactInfo: @retroactive Identifiable { /// In kotlin the variable is called `id`, but that's a reserved property name in objective-c. @@ -51,9 +61,8 @@ extension ContactInfo: @retroactive Identifiable { return self.kotlinId() // defined in PhoenixExposure.kt } - public var id: String { - return self.uuid.description() - } + // Already defined: +// var id: Lightning_kmpUUID { get } } extension Lightning_kmpWalletState.Utxo: @retroactive Identifiable { diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift index b339903bf..5887d7bcc 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift @@ -205,7 +205,7 @@ extension SyncBackupManager { rows.append(item.contact) - let contactId = item.contact.uuid + let contactId = item.contact.id let creationDate = item.record.creationDate ?? Date() let creation = self.dateToMillis(creationDate) let metadata = self.metadataForRecord(item.record) @@ -686,7 +686,7 @@ extension SyncBackupManager { _ contact: ContactInfo ) -> Data? { - let wrapper = CloudContact(contact: contact) + let wrapper = CloudContact_V2(contact: contact) let cbor = wrapper.cborSerialize().toSwiftData() #if DEBUG @@ -720,34 +720,6 @@ extension SyncBackupManager { log.debug(" - recordID: \(record.recordID)") log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") - var wrapper: CloudContact? = nil - if let ciphertext = record[contacts_record_column_data] as? Data { - - var cleartext: Data? = nil - do { - let box = try ChaChaPoly.SealedBox(combined: ciphertext) - cleartext = try ChaChaPoly.open(box, using: self.cloudKey) - } catch { - log.error("Error decrypting record.data: skipping \(record.recordID)") - } - - if let cleartext { - do { - let cleartext_kotlin = cleartext.toKotlinByteArray() - wrapper = try CloudContact.companion.cborDeserialize(blob: cleartext_kotlin) - - // #if DEBUG - // let jsonData = wrapper.jsonSerialize().toSwiftData() - // let jsonStr = String(data: jsonData, encoding: .utf8) - // log.debug(" - raw JSON:\n\(jsonStr ?? "")") - // #endif - - } catch { - log.error("Error deserializing record.data: skipping \(record.recordID)") - } - } - } - var photoUri: String? = nil if let asset = record[contacts_record_column_photo] as? CKAsset { @@ -766,11 +738,34 @@ extension SyncBackupManager { } var contact: ContactInfo? = nil - if let wrapper { + if let ciphertext = record[contacts_record_column_data] as? Data { + + var cleartext: Data? = nil do { - contact = try wrapper.unwrap(photoUri: photoUri) + let box = try ChaChaPoly.SealedBox(combined: ciphertext) + cleartext = try ChaChaPoly.open(box, using: self.cloudKey) } catch { - log.error("Error unwrapping record.data: skipping \(record.recordID)") + log.error("Error decrypting record.data: skipping \(record.recordID)") + } + + if let cleartext { + do { + let cleartext_kotlin = cleartext.toKotlinByteArray() + contact = try CloudContact_V2.companion.cborDeserializeAndUnwrap( + blob: cleartext_kotlin, + photoUri: photoUri + ) + + } catch { + log.error("Error deserializing record.data: skipping \(record.recordID)") + } + } + } + + if contact == nil, let photoUri { + // Edge case cleanup + Task { + await PhotosManager.shared.deleteFromDisk(fileName: photoUri) } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 75d0685f1..a42c02a48 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -207,7 +207,7 @@ struct ConfigurationList: View { .id(linkID_PaymentOptions) } // - if hasWallet && CONTACTS_ENABLED { + if hasWallet { navLink_label(.ContactsList) { Label { Text("Contacts") } icon: { Image(systemName: "person.2") diff --git a/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift new file mode 100644 index 000000000..38f584201 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct AddContactOptionsSheet: View { + + let createNewContact: () -> Void + let addToExistingContact: () -> Void + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + title() + createNewContactRow() + addToExistingContactRow() + } + .padding(.all) + } + + @ViewBuilder + func title() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 4) + Text("Add contact") + Spacer(minLength: 4) + } + .font(.title2) + } + + @ViewBuilder + func createNewContactRow() -> some View { + + Button { + smartModalState.close { + createNewContact() + } + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text("Create new contact") + Spacer() + } + .padding([.top, .bottom], 8) + .padding([.leading, .trailing], 16) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle( + ScaleButtonStyle( + cornerRadius: 16, + borderStroke: Color.appAccent + ) + ) + } + + @ViewBuilder + func addToExistingContactRow() -> some View { + + Button { + smartModalState.close { + addToExistingContact() + } + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text("Add to existing contact") + Spacer() + } + .padding([.top, .bottom], 8) + .padding([.leading, .trailing], 16) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle( + ScaleButtonStyle( + cornerRadius: 16, + borderStroke: Color.appAccent + ) + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift b/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift new file mode 100644 index 000000000..bbf39dfa6 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift @@ -0,0 +1,7 @@ +import Foundation +import PhoenixShared + +struct AddToContactsInfo: Hashable { + let offer: Lightning_kmpOfferTypesOffer? + let address: String? +} diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift index 30d785748..515baaab3 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift @@ -18,7 +18,9 @@ struct ContactsList: View { let popTo: (PopToDestination) -> Void @State var sortedContacts: [ContactInfo] = [] - @State var offers: [String: [String]] = [:] + + @State var search_offers: [Lightning_kmpUUID: [String]] = [:] + @State var search_addresses: [Lightning_kmpUUID: [String]] = [:] @State var searchText = "" @State var filteredContacts: [ContactInfo]? = nil @@ -196,7 +198,7 @@ struct ContactsList: View { ManageContact( location: .embedded, popTo: popToWrapper, - offer: nil, + info: nil, contact: nil, contactUpdated: { _ in } ) @@ -206,7 +208,7 @@ struct ContactsList: View { ManageContact( location: .embedded, popTo: popToWrapper, - offer: nil, + info: nil, contact: selectedItem, contactUpdated: { _ in } ) @@ -281,15 +283,30 @@ struct ContactsList: View { sortedContacts = updatedList - var updatedOffers: [String: [String]] = [:] - for contact in sortedContacts { - let key: String = contact.id - let values: [String] = contact.offers.map { $0.encode().lowercased() } + do { + var offers: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.offers.map { + $0.offer.encode().lowercased() + } + offers[key] = values + } - updatedOffers[key] = values + search_offers = offers + } + do { + var addresses: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.addresses.map { + $0.address.trimmingCharacters(in: .whitespacesAndNewlines) + } + addresses[key] = values + } + + search_addresses = addresses } - - offers = updatedOffers } func searchTextChanged() { @@ -316,12 +333,18 @@ struct ContactsList: View { return true } - if let offers = offers[contact.id] { + if let offers = search_offers[contact.id] { if offers.contains(searchtext) { return true } } + if let addresses = search_addresses[contact.id] { + if addresses.contains(where: { $0.contains(searchtext) }) { + return true + } + } + return false } } @@ -369,7 +392,7 @@ struct ContactsList: View { let contactsManager = Biz.business.contactsManager do { - try await contactsManager.deleteContact(contactId: contact.uuid) + try await contactsManager.deleteContact(contactId: contact.id) } catch { log.error("contactsManager.deleteContact(): error: \(error)") } diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift index 490ed9b38..f3acb583d 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift @@ -13,7 +13,9 @@ struct ContactsListSheet: View { let didSelectContact: (ContactInfo) -> Void @State var sortedContacts: [ContactInfo] = [] - @State var offers: [String: [String]] = [:] + + @State var search_offers: [Lightning_kmpUUID: [String]] = [:] + @State var search_addresses: [Lightning_kmpUUID: [String]] = [:] @State var searchText = "" @State var filteredContacts: [ContactInfo]? = nil @@ -86,8 +88,9 @@ struct ContactsListSheet: View { } } if hasZeroMatchesForSearch { - zeroMatches() - .deleteDisabled(true) + zeroMatchesRow() + } else if hasZeroContacts { + zeroContactsRow() } } // .listStyle(.plain) @@ -136,10 +139,36 @@ struct ContactsListSheet: View { } @ViewBuilder - func zeroMatches() -> some View { + func zeroMatchesRow() -> some View { - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("No matches for search").foregroundStyle(.secondary) + HStack(alignment: VerticalAlignment.center, spacing: 8) { + Image(systemName: "person.crop.circle.badge.questionmark") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + Text("No matches for search") + Spacer() + } + .foregroundStyle(.secondary) + .padding(.all, 4) + } + + @ViewBuilder + func zeroContactsRow() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 8) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("No Contacts") + .font(.title3) + .foregroundColor(.primary) + Text("Add contacts for easy & quick payments") + .font(.subheadline) + .foregroundColor(.secondary) + } Spacer() } .padding(.all, 4) @@ -154,12 +183,20 @@ struct ContactsListSheet: View { } var hasZeroMatchesForSearch: Bool { - - guard let filteredContacts else { + if sortedContacts.isEmpty { + // User has zero contacts. + // This is different from zero search results. + return false + } else if let filteredContacts { + return filteredContacts.isEmpty + } else { + // Not searching return false } - - return filteredContacts.isEmpty && !sortedContacts.isEmpty + } + + var hasZeroContacts: Bool { + return sortedContacts.isEmpty } // -------------------------------------------------- @@ -171,15 +208,30 @@ struct ContactsListSheet: View { sortedContacts = updatedList - var updatedOffers: [String: [String]] = [:] - for contact in sortedContacts { - let key: String = contact.id - let values: [String] = contact.offers.map { $0.encode().lowercased() } + do { + var offers: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.offers.map { + $0.offer.encode().lowercased() + } + offers[key] = values + } - updatedOffers[key] = values + search_offers = offers + } + do { + var addresses: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.addresses.map { + $0.address.trimmingCharacters(in: .whitespacesAndNewlines) + } + addresses[key] = values + } + + search_addresses = addresses } - - offers = updatedOffers } func searchTextChanged() { @@ -206,12 +258,18 @@ struct ContactsListSheet: View { return true } - if let offers = offers[contact.id] { + if let offers = search_offers[contact.id] { if offers.contains(searchtext) { return true } } + if let addresses = search_addresses[contact.id] { + if addresses.contains(where: { $0.contains(searchtext) }) { + return true + } + } + return false } } @@ -250,3 +308,4 @@ struct ContactsListSheet: View { } } + diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift index d19ddda80..9bc6dcce6 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -8,17 +8,55 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .trace) fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif -struct OfferRow: Identifiable { - let offer: String - let isCurrentOffer: Bool +fileprivate struct OfferRow: Identifiable { + let raw: ContactOffer + let identifier: String + let label: String + let text: String + let isReadonly: Bool + + init(raw: ContactOffer, isReadonly: Bool) { + self.raw = raw + self.identifier = raw.id.toSwiftData().toHex() + self.label = raw.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.text = raw.offer.encode() + self.isReadonly = isReadonly + } + + var id: String { + return identifier + } +} + +fileprivate struct AddressRow: Identifiable { + let raw: ContactAddress + let identifier: String + let label: String + let text: String + let isReadonly: Bool + + init(raw: ContactAddress, isReadonly: Bool) { + self.raw = raw + self.identifier = raw.id.toSwiftData().toHex() + self.label = raw.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.text = raw.address.trimmingCharacters(in: .whitespacesAndNewlines) + self.isReadonly = isReadonly + } var id: String { - return offer + return identifier } } +fileprivate enum InvalidReason { + case invalidFormat + case localDuplicate + case databaseDuplicate(contact: ContactInfo) +} + fileprivate let IMG_SIZE: CGFloat = 150 fileprivate let DEFAULT_TRUSTED: Bool = true +fileprivate let ROW_VERTICAL_SPACING: CGFloat = 15 struct ManageContact: View { @@ -31,27 +69,34 @@ struct ManageContact: View { let location: Location let popTo: ((PopToDestination) -> Void)? - let offer: Lightning_kmpOfferTypesOffer? let contact: ContactInfo? let contactUpdated: (ContactInfo?) -> Void - let isNewContact: Bool - @State var name: String - @State var trustedContact: Bool - @State var showImageOptions: Bool = false - @State var pickerResult: PickerResult? - @State var doNotUseDiskImage: Bool = false + @State private var name: String + @State private var trustedContact: Bool + @State private var showImageOptions: Bool = false + @State private var pickerResult: PickerResult? + @State private var doNotUseDiskImage: Bool = false + + @State private var isSaving: Bool = false + @State private var showDiscardChangesConfirmationDialog: Bool = false + @State private var showDeleteContactConfirmationDialog: Bool = false - @State var isSaving: Bool = false - @State var showDiscardChangesConfirmationDialog: Bool = false - @State var showDeleteContactConfirmationDialog: Bool = false + @State private var offers: [OfferRow] + @State private var offers_hasChanges: Bool - @State var showingOffers: Bool = false - @State var chevronPosition: AnimatedChevron.Position = .pointingDown + @State private var editOffer_index: Int? = nil + @State private var editOffer_label: String = "" + @State private var editOffer_text: String = "" + @State private var editOffer_invalidReason: InvalidReason? = nil - @State var pastedOffer: String = "" - @State var pastedOfferIsInvalid: Bool = false - @State var parsedOffer: Lightning_kmpOfferTypesOffer? = nil + @State private var addresses: [AddressRow] + @State private var addresses_hasChanges: Bool + + @State private var editAddress_index: Int? = nil + @State private var editAddress_label: String = "" + @State private var editAddress_text: String = "" + @State private var editAddress_invalidReason: InvalidReason? = nil enum FooterType: Int { case expanded_standard = 1 @@ -102,19 +147,70 @@ struct ManageContact: View { init( location: Location, popTo: ((PopToDestination) -> Void)?, - offer: Lightning_kmpOfferTypesOffer?, + info: AddToContactsInfo?, contact: ContactInfo?, contactUpdated: @escaping (ContactInfo?) -> Void ) { self.location = location self.popTo = popTo - self.offer = offer self.contact = contact self.contactUpdated = contactUpdated - self.isNewContact = (contact == nil) self._name = State(initialValue: contact?.name ?? "") self._trustedContact = State(initialValue: contact?.useOfferKey ?? DEFAULT_TRUSTED) + + do { + var set = Set() + var rows = Array() + + if let contact { + for offer in contact.offers { + if !set.contains(offer.id) { + set.insert(offer.id) + rows.append(OfferRow(raw: offer, isReadonly: false)) + } + } + } + var hasNewOffer = false + if let newOffer = info?.offer { + let offer = ContactOffer(offer: newOffer, label: "", createdAt: Date.now.toInstant()) + if !set.contains(offer.id) { + set.insert(offer.id) + rows.append(OfferRow(raw: offer, isReadonly: true)) + hasNewOffer = true + } + } + + + self._offers = State(initialValue: rows) + self._offers_hasChanges = State(initialValue: (contact != nil && hasNewOffer)) + } + do { + var set = Set() + var rows = Array() + + if let contact { + for address in contact.addresses { + if !set.contains(address.id) { + set.insert(address.id) + rows.append(AddressRow(raw: address, isReadonly: false)) + } + } + } + var hasNewAddress = false + if let newAddress = info?.address { + let address = ContactAddress(address: newAddress, label: "", createdAt: Date.now.toInstant()) + if !set.contains(address.id) { + set.insert(address.id) + rows.append(AddressRow(raw: address, isReadonly: true)) + hasNewAddress = true + } + } + + + self._addresses = State(initialValue: rows) + self._addresses_hasChanges = State(initialValue: (contact != nil && hasNewAddress)) + } } // -------------------------------------------------- @@ -138,9 +234,9 @@ struct ManageContact: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .navigationBarItems(leading: header_cancelButton(), trailing: header_doneButton()) - .background( - Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) - ) + // .background( + // Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) + // ) } } @@ -158,6 +254,28 @@ struct ManageContact: View { .onAppear { onAppear() } + .onChange(of: offers_hasChanges) { _ in + paymentOptionsChanged() + } + .onChange(of: addresses_hasChanges) { _ in + paymentOptionsChanged() + } + .confirmationDialog("Discard changes?", + isPresented: $showDiscardChangesConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Discard changes", role: ButtonRole.destructive) { + close() + } + } + .confirmationDialog("Delete contact?", + isPresented: $showDeleteContactConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Delete contact", role: ButtonRole.destructive) { + deleteContact() + } + } .sheet(isPresented: activeSheetBinding()) { // SwiftUI only allows for 1 ".sheet" switch activeSheet! { case .camera: @@ -185,22 +303,6 @@ struct ManageContact: View { header_embedded() } } - .confirmationDialog("Discard changes?", - isPresented: $showDiscardChangesConfirmationDialog, - titleVisibility: Visibility.hidden - ) { - Button("Discard changes", role: ButtonRole.destructive) { - close() - } - } - .confirmationDialog("Delete contact?", - isPresented: $showDeleteContactConfirmationDialog, - titleVisibility: Visibility.hidden - ) { - Button("Delete contact", role: ButtonRole.destructive) { - deleteContact() - } - } } @ViewBuilder @@ -249,8 +351,8 @@ struct ManageContact: View { @ViewBuilder func header_embedded() -> some View { - Spacer() - .frame(height: 25) + // Spacer().frame(height: 25) + EmptyView() } @ViewBuilder @@ -282,16 +384,11 @@ struct ManageContact: View { ScrollView { VStack(alignment: HorizontalAlignment.center, spacing: 0) { - content_image() content_name() content_trusted() - if showOffers { - content_offers() - } - if showPasteOffer { - content_pasteOffer() - } + content_offers() + content_addresses() } // .padding() } // @@ -328,8 +425,6 @@ struct ManageContact: View { Spacer(minLength: 0) } .padding(.bottom) - .background(backgroundColor) - .zIndex(1) .confirmationDialog("Contact Image", isPresented: $showImageOptions, titleVisibility: .automatic @@ -381,8 +476,6 @@ struct ManageContact: View { .stroke(Color.textFieldBorder, lineWidth: 1) ) .padding(.bottom, 30) - .background(backgroundColor) - .zIndex(1) } @ViewBuilder @@ -394,27 +487,25 @@ struct ManageContact: View { } .disabled(isSaving) - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { - Text(verbatim: "•") - .font(.title2) + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() Text("**enabled**: they will be able to tell when payments are from you") .font(.subheadline) .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + .padding(.vertical, 8) - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { - Text(verbatim: "•") - .font(.title2) + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() Text("**disabled**: sent payments will be anonymous") .font(.subheadline) .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + } .padding(.bottom, 30) - .background(backgroundColor) - .zIndex(1) } @ViewBuilder @@ -423,53 +514,30 @@ struct ManageContact: View { VStack(alignment: HorizontalAlignment.leading, spacing: 0) { HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Bolt12 offers") + Text("Bolt12 offers:") Spacer(minLength: 0) - AnimatedChevron( - position: $chevronPosition, - color: Color(UIColor.systemGray2), - lineWidth: 20, - lineThickness: 2, - verticalOffset: 8 - ) + Button { + createNewOffer() + } label: { + Image(systemName: "plus") + } + .disabled(isSaving) } // - .background(backgroundColor) - .contentShape(Rectangle()) // make Spacer area tappable - .onTapGesture { - withAnimation { - if showingOffers { - showingOffers = false - chevronPosition = .pointingDown + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + ForEach(0 ..< offers.count, id: \.self) { idx in + if let editingIndex = editOffer_index, editingIndex == idx { + content_offer_editRow() } else { - showingOffers = true - chevronPosition = .pointingUp + content_offer_row(idx) } - } - } - .zIndex(1) + } // + } // - if showingOffers { - VStack(alignment: HorizontalAlignment.leading, spacing: 8) { - ForEach(offerRows()) { row in - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text(row.offer) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor(row.isCurrentOffer ? Color.appPositive : Color.primary) - Spacer(minLength: 8) - Button { - copyText(row.offer) - } label: { - Image(systemName: "square.on.square") - } - } - .font(.subheadline) - .padding(.vertical, 8) - .padding(.leading, 20) - } // - } // - .zIndex(0) - .transition(.move(edge: .top).combined(with: .opacity)) + if let index = editOffer_index, index == offers.count { + content_offer_editRow() + } else if offers.isEmpty { + content_offer_emptyRow() } } // @@ -477,14 +545,103 @@ struct ManageContact: View { } @ViewBuilder - func content_pasteOffer() -> some View { + func content_offer_row(_ index: Int) -> some View { - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - Text("Bolt12 offer:") - .padding(.bottom, 4) + let row: OfferRow = offers[index] + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + bullet() + + Group { + if row.label.isEmpty { + Text(row.text) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(row.isReadonly ? Color.appPositive : Color.primary) + } else { + Text(row.label) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(row.isReadonly ? Color.appPositive : Color.primary) + Text(": \(row.text)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + } + } // + .font(.callout) - TextEditor(text: $pastedOffer) - .frame(minHeight: 80, maxHeight: 80) + Spacer(minLength: 8) + + Menu { + Button { + copyText(row.text) + } label: { + Label("Copy", systemImage: "square.on.square") + } + Button { + sendPayment(offer: row.raw.offer) + } label: { + Label("Send payment", systemImage: "paperplane.fill") + } + Button { + editOffer(index: index) + } label: { + Label("Edit", systemImage: "pencil.line") + } + Button { + deleteOffer(index: index) + } label: { + Label("Delete", systemImage: "trash") + } + + } label: { + Image(systemName: "line.3.horizontal") + } + + } // + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_offer_emptyRow() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("none") + .lineLimit(1) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + .font(.callout) + } // + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_offer_editRow() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + bullet() + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + + HStack(alignment: VerticalAlignment.center, spacing: 2) { + TextField("label (optional)", text: $editOffer_label) + .textInputAutocapitalization(.never) + + // Clear button (appears when TextField's text is non-empty) + if !editOffer_label.isEmpty { + Button { + editOffer_label = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + } + } // .padding(.all, 8) .background( RoundedRectangle(cornerRadius: 8) @@ -494,23 +651,291 @@ struct ManageContact: View { RoundedRectangle(cornerRadius: 8) .stroke(Color.textFieldBorder, lineWidth: 1) ) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + TextField("lno1... (paste offer here)", text: $editOffer_text, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(4) + .disabled(editOffer_isReadonly) + + // Clear button (appears when TextField's text is non-empty) + if !editOffer_text.isEmpty && !editOffer_isReadonly { + Button { + editOffer_text = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + } + } // + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Button { + cancelEditedOffer() + } label: { + Text("Cancel").foregroundStyle(Color.appNegative) + } + + Spacer() + if let reason = editOffer_invalidReason { + switch reason { + case .invalidFormat: + Text("Invalid format").foregroundColor(.appNegative) + case .localDuplicate: + Text("Duplicate within this contact").foregroundColor(.appNegative) + case .databaseDuplicate(let contact): + Text("Duplicate in \(contact.name)").foregroundColor(.appNegative) + } + } + Spacer() + + Button { + processEditedOffer() + } label: { + Text("Done") + } + .disabled(isSaving) + } + + } // + .font(.subheadline) + + } // + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_addresses() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { HStack(alignment: VerticalAlignment.center, spacing: 0) { - Spacer() - if pastedOfferIsInvalid { - Text("Invalid offer") - .font(.subheadline) - .foregroundColor(.appNegative) - } else { - Text(verbatim: " ") + Text("Lightning addresses:") + Spacer(minLength: 0) + Button { + createNewAddress() + } label: { + Image(systemName: "plus") } + .disabled(isSaving) + } // + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + ForEach(0 ..< addresses.count, id: \.self) { idx in + if let editingIndex = editAddress_index, editingIndex == idx { + content_address_editRow() + } else { + content_address_row(idx) + } + } // + } // + + if let index = editAddress_index, index == addresses.count { + content_address_editRow() + } else if addresses.isEmpty { + content_address_emptyRow() } } // .padding(.bottom, 30) - .onChange(of: pastedOffer) { _ in - pastedOfferChanged() + } + + @ViewBuilder + func content_address_row(_ index: Int) -> some View { + + let row: AddressRow = addresses[index] + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + bullet() + + Group { + if row.label.isEmpty { + Text(row.text) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(row.isReadonly ? Color.appPositive : Color.primary) + } else { + Text(row.label) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(row.isReadonly ? Color.appPositive : Color.primary) + Text(": \(row.text)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + } + } + .font(.callout) + + Spacer(minLength: 8) + + Menu { + Button { + copyText(row.text) + } label: { + Label("Copy", systemImage: "square.on.square") + } + Button { + sendPayment(address: row.raw.address) + } label: { + Label("Send payment", systemImage: "paperplane.fill") + } + Button { + editAddress(index: index) + } label: { + Label("Edit", systemImage: "pencil.line") + } + Button { + deleteAddress(index: index) + } label: { + Label("Delete", systemImage: "trash") + } + + } label: { + Image(systemName: "line.3.horizontal") + } + + } + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_address_emptyRow() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("none") + .lineLimit(1) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + .font(.callout) } + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_address_editRow() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + bullet() + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + + HStack(alignment: VerticalAlignment.center, spacing: 2) { + TextField("label (optional)", text: $editAddress_label) + .textInputAutocapitalization(.never) + + // Clear button (appears when TextField's text is non-empty) + if !editAddress_label.isEmpty { + Button { + editAddress_label = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + TextField( + "", // ignored + text: $editAddress_text, + prompt: Text(verbatim: "user@domain.tld"), // verbatim used to disable email detection + axis: .vertical + ) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(2) + .disabled(editAddress_isReadonly) + + // Clear button (appears when TextField's text is non-empty) + if !editAddress_text.isEmpty && !editAddress_isReadonly { + Button { + editAddress_text = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Button { + cancelEditedAddress() + } label: { + Text("Cancel").foregroundStyle(Color.appNegative) + } + + Spacer() + if let reason = editAddress_invalidReason { + switch reason { + case .invalidFormat: + Text("Invalid format").foregroundColor(.appNegative) + case .localDuplicate: + Text("Duplicate within this contact").foregroundColor(.appNegative) + case .databaseDuplicate(let contact): + Text("Duplicate in \(contact.name)").foregroundColor(.appNegative) + } + } + Spacer() + + Button { + processEditedAddress() + } label: { + Text("Done") + } + .disabled(isSaving) + } + + } // + .font(.subheadline) + + } // + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) } @ViewBuilder @@ -569,19 +994,30 @@ struct ManageContact: View { func footer_navStack() -> some View { if !isNewContact { - let type = footerType[dynamicTypeSize] ?? FooterType.expanded_standard - switch type { - case .expanded_standard: - footer_navStack_standard(compact: false) - case .expanded_squeezed: - footer_navStack_squeezed(compact: false) - case .compact_standard: - footer_navStack_standard(compact: true) - case .compact_squeezed: - footer_navStack_squeezed(compact: true) - case .accessible: - footer_navStack_accessible() - } + Group { + let type = footerType[dynamicTypeSize] ?? FooterType.expanded_standard + switch type { + case .expanded_standard: + footer_navStack_standard(compact: false) + case .expanded_squeezed: + footer_navStack_squeezed(compact: false) + case .compact_standard: + footer_navStack_standard(compact: true) + case .compact_squeezed: + footer_navStack_squeezed(compact: true) + case .accessible: + footer_navStack_accessible() + } + } // + .frame(maxWidth: .infinity) + .background( + Color( + colorScheme == ColorScheme.light + ? UIColor.primaryBackground + : UIColor.secondarySystemGroupedBackground + ) + .edgesIgnoringSafeArea(.bottom) // background color should extend to bottom of screen + ) } } @@ -608,21 +1044,23 @@ struct ManageContact: View { .read(footerButtonWidthReader) .read(footerButtonHeightReader) - if let footerButtonHeight { - Divider().frame(height: footerButtonHeight) + if globalSendButtonVisible { + if let footerButtonHeight { + Divider().frame(height: footerButtonHeight) + } + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_sendPayment(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "pay") + } + .frame(minWidth: footerButtonWidth, alignment: Alignment.leading) + .read(footerButtonWidthReader) + .read(footerButtonHeightReader) } - TruncatableView(fixedHorizontal: true, fixedVertical: true) { - footer_button_sendPayment(compact: compact, lineLimit: 1) - } wasTruncated: { - footerTruncationDetected(type, "pay") - } - .frame(minWidth: footerButtonWidth, alignment: Alignment.leading) - .read(footerButtonWidthReader) - .read(footerButtonHeightReader) - } // - .padding([.leading, .trailing, .bottom]) + .padding(.all) .assignMaxPreference(for: footerButtonWidthReader.key, to: $footerButtonWidth) .assignMaxPreference(for: footerButtonHeightReader.key, to: $footerButtonHeight) } @@ -649,19 +1087,20 @@ struct ManageContact: View { } .read(footerButtonHeightReader) - if let footerButtonHeight { - Divider().frame(height: footerButtonHeight) - } - - TruncatableView(fixedHorizontal: true, fixedVertical: true) { - footer_button_sendPayment(compact: compact, lineLimit: 1) - } wasTruncated: { - footerTruncationDetected(type, "pay") + if globalSendButtonVisible { + if let footerButtonHeight { + Divider().frame(height: footerButtonHeight) + } + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_sendPayment(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "pay") + } + .read(footerButtonHeightReader) } - .read(footerButtonHeightReader) - } // - .padding([.leading, .trailing, .bottom]) + .padding(.all) .assignMaxPreference(for: footerButtonHeightReader.key, to: $footerButtonHeight) } @@ -680,10 +1119,12 @@ struct ManageContact: View { VStack(alignment: HorizontalAlignment.center, spacing: 16) { footer_button_deleteContact(compact: true, lineLimit: nil) - footer_button_sendPayment(compact: true, lineLimit: nil) + if globalSendButtonVisible { + footer_button_sendPayment(compact: true, lineLimit: nil) + } } .padding(.horizontal, 4) // allow content to be closer to edges - .padding(.bottom) + .padding(.vertical) } @ViewBuilder @@ -724,6 +1165,10 @@ struct ManageContact: View { ) } + var isNewContact: Bool { + return contact == nil + } + var title: String { if isNewContact { @@ -784,20 +1229,34 @@ struct ManageContact: View { } } - var showOffers: Bool { + var editOffer_isReadonly: Bool { - if offer != nil { - return true - } else if let contact { - return !contact.offers.isEmpty + if let index = editOffer_index, index < offers.count { + return offers[index].isReadonly + } else { + return false + } + } + + var editAddress_isReadonly: Bool { + + if let index = editAddress_index, index < addresses.count { + return addresses[index].isReadonly } else { return false } } - var showPasteOffer: Bool { + var globalSendButtonVisible: Bool { + + if hasChanges { + // Because tapping the button takes the user away from this screen. + // They may want to save those changes first. + return false + } - return (offer == nil) && (contact == nil) + let count = offers.count + addresses.count + return count == 1 } var hasChanges: Bool { @@ -806,21 +1265,29 @@ struct ManageContact: View { if name != contact.name { return true } - if pickerResult != nil { + if trustedContact != contact.useOfferKey { return true } - if doNotUseDiskImage { + } else { + if name != "" { return true } - if trustedContact != contact.useOfferKey { + if trustedContact != DEFAULT_TRUSTED { return true } - - return false - - } else { + } + + if pickerResult != nil { + return true + } + if doNotUseDiskImage { return true } + if offers_hasChanges || addresses_hasChanges { + return true + } + + return false } var canSave: Bool { @@ -828,38 +1295,13 @@ struct ManageContact: View { if !hasName { return false } - if contact == nil { - if offer == nil && parsedOffer == nil { - return false - } + if offers.isEmpty && addresses.isEmpty { + return false } return true } - func offerRows() -> [OfferRow] { - - var offers = Set() - var results = Array() - - if let offer { - let offerStr = offer.encode() - offers.insert(offerStr) - results.append(OfferRow(offer: offerStr, isCurrentOffer: true)) - } - if let contact { - for offer in contact.offers { - let offerStr = offer.encode() - if !offers.contains(offerStr) { - offers.insert(offerStr) - results.append(OfferRow(offer: offerStr, isCurrentOffer: false)) - } - } - } - - return results - } - // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- @@ -882,6 +1324,18 @@ struct ManageContact: View { } } + func paymentOptionsChanged() { + log.trace("paymentOptionsChanged()") + + // This method is called when `offers` or `addresses` changes. + // Which may affect the footer, because the sendButton might appear/disappear. + // So we reset any measurements/calculations we've done for the footer. + + footerType = [:] + footerButtonWidth = nil + footerButtonHeight = nil + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -942,25 +1396,6 @@ struct ManageContact: View { ) } - func pastedOfferChanged() { - log.trace("pastedOfferChanged()") - - let text = pastedOffer.trimmingCharacters(in: .whitespacesAndNewlines) - if text.isEmpty { - pastedOfferIsInvalid = true - } else { - let result: Bitcoin_kmpTry = - Lightning_kmpOfferTypesOffer.companion.decode(s: text) - - if result.isFailure { - pastedOfferIsInvalid = true - } else { - pastedOfferIsInvalid = false - parsedOffer = result.get() - } - } - } - func deleteButtonTapped() { log.trace("deleteButtonTapped()") @@ -970,36 +1405,20 @@ struct ManageContact: View { func payButtonTapped() { log.trace("payButtonTapped()") - if let contact, let offer = contact.mostRelevantOffer { - let offerString = offer.encode() - AppDelegate.get().externalLightningUrlPublisher.send(offerString) - - if #available(iOS 17, *) { - // Do not do this here. It interferes with navigation. - // navCoordinator.path.removeAll() - // Instead we allow the MainView_X to perform both - // `path.removeAll()` & `path.append(x)` at the same time. - // Doing it at the same time allows navigation to work properly. - - } else { // iOS 16 - - if let popTo { - popTo(.ConfigurationView(followedBy: nil)) - } - - if case .sheet(let closeAction) = location { - closeAction() - } else { - close() - } - } + // The payButton should only be visible if there is exactly one payment method. + // So we can just grab the first available here. + + if let offer = offers.first { + sendPayment(offer: offer.raw.offer) + } else if let address = addresses.first { + sendPayment(address: address.raw.address) } } func cancelButtonTapped() { log.trace("cancelButtonTapped()") - if hasChanges && canSave { + if hasChanges { showDiscardChangesConfirmationDialog = true } else { close() @@ -1016,19 +1435,34 @@ struct ManageContact: View { } } + func close() { + log.trace("close()") + + switch location { + case .smartModal: + smartModalState.close() + case .sheet: + presentationMode.wrappedValue.dismiss() + case .embedded: + presentationMode.wrappedValue.dismiss() + } + } + + // -------------------------------------------------- + // MARK: Actions: Database + // -------------------------------------------------- + func saveContact() { log.trace("saveContact()") isSaving = true Task { @MainActor in - - var updatedContact: ContactInfo? = nil - var success = false do { - let updatedContactName = trimmedName + let contactId = contact?.id ?? Lightning_kmpUUID.companion.randomUUID() + let updatedName = trimmedName let updatedUseOfferKey = trustedContact - var oldPhotoName: String? = contact?.photoUri + let oldPhotoName: String? = contact?.photoUri var newPhotoName: String? = nil if let pickerResult { @@ -1040,81 +1474,36 @@ struct ManageContact: View { log.debug("oldPhotoName: \(oldPhotoName ?? "")") log.debug("newPhotoName: \(newPhotoName ?? "")") - let contactsManager = Biz.business.contactsManager + let updatedContact = ContactInfo( + id: contactId, + name: updatedName, + photoUri: newPhotoName, + useOfferKey: updatedUseOfferKey, + offers: offers.map { $0.raw }, + addresses: addresses.map { $0.raw } + ) - // There are 3 ways the ManageContact view is initialized: - // - // 1. With a non-nil contact, and possibly a non-nil offer. - // In this case we're updating the contact. - // The given offer may be highlighted in the UI. - // - // 2. With a nil contact, and a non-nil offer. - // In this case the user wishes to create a new contact - // associated with the given offer. - // - // 3. With a nil contact, and a nil offer. - // In this case, the user must paste a Bolt12 offer. - // And we'll create the new contact with the pasted offer. - - if let existingContact = contact { - updatedContact = try await contactsManager.updateContact( - contactId: existingContact.uuid, - name: updatedContactName, - photoUri: newPhotoName, - useOfferKey: updatedUseOfferKey, - offers: existingContact.offers - ) - } else if let newOffer = offer ?? parsedOffer { - let existingContact = try await contactsManager.getContactForOffer(offer: newOffer) - if let existingContact { - // The newOffer is actually NOT new. - // It already exists in the database and is attached to a contact. - // For now, we will update the details of that contact. - // In the future, it would be better to display some kind of error message, - // and then update the UI with this existing contact. - updatedContact = try await contactsManager.updateContact( - contactId: existingContact.uuid, - name: updatedContactName, - photoUri: newPhotoName, - useOfferKey: updatedUseOfferKey, - offers: existingContact.offers - ) - oldPhotoName = existingContact.photoUri - } else { - updatedContact = try await contactsManager.saveNewContact( - name: updatedContactName, - photoUri: newPhotoName, - useOfferKey: updatedUseOfferKey, - offer: newOffer - ) - } - } + try await Biz.business.contactsManager.saveContact(contact: updatedContact) if let oldPhotoName, oldPhotoName != newPhotoName { log.debug("Deleting old photo from disk...") await PhotosManager.shared.deleteFromDisk(fileName: oldPhotoName) } - - success = true - } catch { - log.error("contactsManager: error: \(error)") - } - isSaving = false - if success { close() - } - if let updatedContact { contactUpdated(updatedContact) + } catch { + log.error("contactsManager.saveContact(): error: \(error)") } + isSaving = false } // } func deleteContact() { log.trace("deleteContact()") - guard let cid = contact?.uuid else { + guard let cid = contact?.id else { return } @@ -1136,16 +1525,317 @@ struct ManageContact: View { } // } - func close() { - log.trace("close()") + // -------------------------------------------------- + // MARK: Actions: Offers + // -------------------------------------------------- + + func createNewOffer() { + log.trace("createNewOffer()") - switch location { - case .smartModal: - smartModalState.close() - case .sheet: - presentationMode.wrappedValue.dismiss() - case .embedded: - presentationMode.wrappedValue.dismiss() + // If the user is currently editing an offer + if editOffer_index != nil { + // Then we try to auto-save the changes + guard processEditedOffer() else { + // Auto-save failed so we're aborting + return + } + } + + editOffer_index = offers.count + editOffer_label = "" + editOffer_text = "" + } + + func editOffer(index: Int) { + log.trace("editOffer(index: \(index))") + + // If the user is currently editing an offer + if editOffer_index != nil { + // Then we try to auto-save the changes + guard processEditedOffer() else { + // Auto-save failed so we're aborting + return + } + } + + editOffer_index = index + let offer = offers[index] + editOffer_label = offer.label + editOffer_text = offer.text + } + + func deleteOffer(index: Int) { + log.trace("deleteOffer(index: \(index))") + + guard index < offers.count else { + log.info("deleteOffer(index: \(index)): ignoring: index out of bounds") + return + } + + offers.remove(at: index) + offers_hasChanges = true + } + + @discardableResult + func processEditedOffer() -> Bool { + log.trace("processEditedOffer()") + + guard let index = editOffer_index else { + log.info("processEditedOffer(): ignoring: editOffer_index is nil") + return true + } + + let label = editOffer_label.trimmingCharacters(in: .whitespacesAndNewlines) + let text = editOffer_text.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !text.isEmpty else { + editOffer_invalidReason = .invalidFormat + return false + } + + let result: Bitcoin_kmpTry = + Lightning_kmpOfferTypesOffer.companion.decode(s: text) + + guard !result.isFailure else { + editOffer_invalidReason = .invalidFormat + return false + } + + let offer: Lightning_kmpOfferTypesOffer = result.get()! + let raw = ContactOffer(offer: offer, label: label, createdAt: Date.now.toInstant()) + let row = OfferRow(raw: raw, isReadonly: false) + + // Check for local duplicates + + var isLocalDuplicate = false + for (idx, existing) in offers.enumerated() { + if idx == index { + // ignore: this is the row we're editing + } else { + if existing.identifier == row.identifier { + isLocalDuplicate = true + } + } + } + + guard !isLocalDuplicate else { + editOffer_invalidReason = .localDuplicate + return false + } + + // Check for duplicates in the database + + var databaseDuplicate: ContactInfo? = nil + if let matchingContact = Biz.business.contactsManager.contactForOfferId(offerId: row.raw.id) { + if let currentContact = contact { + if currentContact.id != matchingContact.id { + databaseDuplicate = matchingContact + } + } else { + databaseDuplicate = matchingContact + } + } + + guard databaseDuplicate == nil else { + editOffer_invalidReason = .databaseDuplicate(contact: databaseDuplicate!) + return false + } + + // Looks good + + if index < offers.count { + offers[index] = row + } else { + offers.append(row) + } + offers_hasChanges = true + + editOffer_invalidReason = nil + editOffer_index = nil + return true + } + + func cancelEditedOffer() { + log.trace("cancelEditedOffer()") + + if editOffer_index != nil { + editOffer_index = nil + editOffer_invalidReason = nil + } + } + + // -------------------------------------------------- + // MARK: Actions: Addresses + // -------------------------------------------------- + + func createNewAddress() { + log.trace("createNewAddress()") + + // If the user is currently editing an address + if editAddress_index != nil { + // Then we try to auto-save the changes + guard processEditedAddress() else { + // Auto-save failed so we're aborting + return + } + } + + editAddress_index = addresses.count + editAddress_label = "" + editAddress_text = "" + } + + func editAddress(index: Int) { + log.trace("editAddress(index: \(index))") + + // If the user is currently editing an address + if editAddress_index != nil { + // Then we try to auto-save the changes + guard processEditedAddress() else { + // Auto-save failed so we're aborting + return + } + } + + editAddress_index = index + let address = addresses[index] + editAddress_label = address.label + editAddress_text = address.text + } + + func deleteAddress(index: Int) { + log.trace("deleteAddress(index: \(index))") + + guard index < addresses.count else { + log.info("deleteAddress(index: \(index)): ignoring: index out of bounds") + return + } + + addresses.remove(at: index) + addresses_hasChanges = true + } + + @discardableResult + func processEditedAddress() -> Bool { + log.trace("processEditedAddress()") + + guard let index = editAddress_index else { + log.info("processEditedAddress(): ignoring: editAddress_index is nil") + return true + } + + let label = editAddress_label.trimmingCharacters(in: .whitespacesAndNewlines) + let text = editAddress_text.trimmingCharacters(in: .whitespacesAndNewlines) + + guard text.isValidEmailAddress() else { + editAddress_invalidReason = nil + return false + } + + let raw = ContactAddress(address: text, label: label, createdAt: Date.now.toInstant()) + let row = AddressRow(raw: raw, isReadonly: false) + + // Check for local duplicates + + var isLocalDuplicate = false + for (idx, existing) in addresses.enumerated() { + if idx == index { + // ignore: this is the row we're editing + } else { + if existing.identifier == row.identifier { + isLocalDuplicate = true + } + } + } + + guard !isLocalDuplicate else { + editAddress_invalidReason = .localDuplicate + return false + } + + // Check for duplicates in the database + + var databaseDuplicate: ContactInfo? = nil + if let matchingContact = Biz.business.contactsManager.contactForLightningAddress(address: row.raw.address) { + if let currentContact = contact { + if currentContact.id != matchingContact.id { + databaseDuplicate = matchingContact + } + } else { + databaseDuplicate = matchingContact + } + } + + guard databaseDuplicate == nil else { + editAddress_invalidReason = .databaseDuplicate(contact: databaseDuplicate!) + return false + } + + // Looks good + + if index < addresses.count { + addresses[index] = row + } else { + addresses.append(row) + } + addresses_hasChanges = true + + editAddress_invalidReason = nil + editAddress_index = nil + return true + } + + func cancelEditedAddress() { + log.trace("cancelEditedAddress()") + + if editAddress_index != nil { + editAddress_index = nil + editAddress_invalidReason = nil + } + } + + // -------------------------------------------------- + // MARK: Actions: Payments + // -------------------------------------------------- + + func sendPayment(offer: Lightning_kmpOfferTypesOffer) { + log.trace("sendPayment(offer:)") + + let offerString = offer.encode() + AppDelegate.get().externalLightningUrlPublisher.send(offerString) + popViewAfterSendPayment() + } + + func sendPayment(address: String) { + log.trace("sendPayment(address:)") + + AppDelegate.get().externalLightningUrlPublisher.send(address) + popViewAfterSendPayment() + } + + func popViewAfterSendPayment() { + log.trace("popViewAfterSendPayment()") + + if #available(iOS 17, *) { + // navCoordinator.path.removeAll() + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // Do not do this here. It interferes with navigation. + // + // Instead we allow the MainView_X to perform both + // `path.removeAll()` & `path.append(x)` at the same time. + // Doing it at the same time allows navigation to work properly. + + } else { // iOS 16 + + if let popTo { + popTo(.ConfigurationView(followedBy: nil)) + } + + if case .sheet(let closeAction) = location { + closeAction() + } else { + close() + } } } } diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift index 314a4456d..c47b9e4e7 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift @@ -14,6 +14,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @Binding var relatedPaymentIds: [Lightning_kmpUUID] @Binding var showOriginalFiatValue: Bool + let addToContacts: () -> Void let showContactView: (_ contact: ContactInfo) -> Void let switchToPayment: (_ paymentId: Lightning_kmpUUID) -> Void @@ -327,7 +328,8 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc func sentByRow() -> some View { let identifier: String = #function - if paymentInfo.payment.isIncoming() { + if paymentInfo.payment.isIncoming() && + (paymentInfo.contact != nil || paymentInfo.canAddToContacts() || paymentInfo.hasAttachedMessage()) { InfoGridRow( identifier: identifier, @@ -355,7 +357,14 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc VStack(alignment: HorizontalAlignment.leading, spacing: 4) { Text("Unknown") - if paymentInfo.attachedMessage() != nil { + if paymentInfo.canAddToContacts() { + Button { + addToContacts() + } label: { + Text("Add to contacts") + } + } + if paymentInfo.hasAttachedMessage() { Text("Be careful with messages from unknown sources") .foregroundColor(.secondary) .font(.subheadline) @@ -371,7 +380,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc func recipientRow() -> some View { let identifier: String = #function - if paymentInfo.payment.isOutgoing(), let contact = paymentInfo.contact { + if paymentInfo.payment.isOutgoing() && (paymentInfo.contact != nil || paymentInfo.canAddToContacts()) { InfoGridRow( identifier: identifier, @@ -385,12 +394,26 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } valueColumn: { - HStack(alignment: VerticalAlignment.center, spacing: 4) { - ContactPhoto(fileName: contact.photoUri, size: 32) - Text(contact.name) - } // - .onTapGesture { - showContactView(contact) + if let contact = paymentInfo.contact { + + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ContactPhoto(fileName: contact.photoUri, size: 32) + Text(contact.name) + } // + .onTapGesture { + showContactView(contact) + } + + } else { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Unknown") + Button { + addToContacts() + } label: { + Text("Add to contacts") + } + } } } // diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index 1f4238e5d..9bd34db5d 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -16,14 +16,14 @@ struct SummaryView: View { case DetailsView case EditInfoView case CpfpView(onChainPayment: Lightning_kmpOnChainOutgoingPayment) - case ContactView(contact: ContactInfo) + case ContactView(contact: ContactInfo?, info: AddToContactsInfo?) var description: String { switch self { - case .DetailsView : return "DetailsView" - case .EditInfoView : return "EditInfoView" - case .CpfpView(_) : return "CpfpView" - case .ContactView(_) : return "ContactView" + case .DetailsView : return "DetailsView" + case .EditInfoView : return "EditInfoView" + case .CpfpView(_) : return "CpfpView" + case .ContactView(_, _) : return "ContactView" } } } @@ -153,6 +153,9 @@ struct SummaryView: View { .onChange(of: paymentInfo) { _ in paymentInfoChanged() } + .onReceive(Biz.business.contactsManager.contactsListPublisher()) { _ in + contactsChanged() + } .task { await monitorBlockchain() } @@ -505,6 +508,7 @@ struct SummaryView: View { paymentInfo: $paymentInfo, relatedPaymentIds: $relatedPaymentIds, showOriginalFiatValue: $showOriginalFiatValue, + addToContacts: addToContacts, showContactView: showContactView, switchToPayment: switchToPayment ) @@ -857,13 +861,13 @@ struct SummaryView: View { onChainPayment: onChainPayment ) - case .ContactView(let contact): + case .ContactView(let contact, let info): ManageContact( location: manageContactLocation(), popTo: nil, - offer: nil, + info: info, contact: contact, - contactUpdated: { _ in } + contactUpdated: contactUpdated ) } } @@ -1099,17 +1103,11 @@ struct SummaryView: View { } else { log.trace("subsequent appearance") - // We are returning from the DetailsView/EditInfoView (via the NavigationController) + // We are returning from a subview (e.g. DetailsView, EditInfoView) via the NavigationController + // The payment metadata may have changed (e.g. description/notes modified). // So we need to refresh the payment info. - - Biz.business.paymentsManager.getPayment(id: paymentInfo.payment.id) { - (result: WalletPaymentInfo?, _) in - - if let result { - paymentInfo = result - } - } + forceRefreshPaymentInfo() if let destination = popToDestination { log.debug("popToDestination: \(destination)") @@ -1129,6 +1127,44 @@ struct SummaryView: View { } } + func contactUpdated(_ updatedContact: ContactInfo?) { + log.trace("contactUpdated()") + + // Nothing to do here. + // It's better to wait for the `contactsChanged` notification. + // + // That's because after we receive the `contactsChanged` notification, + // we're sure that a re-fetch of the payment will contain the correct contact info. + // + // Whereas, if we fetch right now, there's a chance we're too early, + // and the contact info could be out-of-date. + } + + func contactsChanged() { + log.trace("contactsChanged()") + + if didAppear { + forceRefreshPaymentInfo() + } + } + + func forceRefreshPaymentInfo() { + log.trace("forceRefreshPaymentInfo()") + + Biz.business.paymentsManager.getPayment(id: paymentInfo.payment.id) { + (result: WalletPaymentInfo?, _) in + + if let result { + if let contact = result.contact { + log.debug("result.contact = \(contact.name)") + } else { + log.debug("result.contact = ") + } + paymentInfo = result + } + } + } + func paymentInfoChanged() { log.trace("paymentInfoChanged()") @@ -1201,10 +1237,72 @@ struct SummaryView: View { } } + func addToContacts() { + log.trace("addToContacts()") + + guard paymentInfo.contact == nil else { + log.info("addToContacts(): ignoring: contact already exists") + return + } + + guard let info = paymentInfo.addToContactsInfo() else { + log.info("addToContacts(): ignoring: missing required info") + return + } + + let count: Int = Biz.business.contactsManager.contactsListCurrentValue().count + if count == 0 { + // User doesn't have any contacts. + // No choice but to create a new contact. + addContact_createNew(info) + + } else { + + smartModalState.display(dismissable: true) { + AddContactOptionsSheet( + createNewContact: { addContact_createNew(info) }, + addToExistingContact: { addContact_selectExisting(info) } + ) + } + } + } + + private func addContact_createNew( + _ info: AddToContactsInfo + ) { + log.trace("addContact_createNew()") + + navigateTo(.ContactView(contact: nil, info: info)) + } + + private func addContact_selectExisting( + _ info: AddToContactsInfo + ) { + log.trace("addContact_selectExisting()") + + smartModalState.display(dismissable: true) { + ContactsListSheet(didSelectContact: { existingContact in + log.debug("didSelectContact") + smartModalState.onNextDidDisappear { + addContact_addToExisting(existingContact, info) + } + }) + } + } + + private func addContact_addToExisting( + _ existingContact: ContactInfo, + _ info: AddToContactsInfo + ) { + log.trace("addContact_addToExisting()") + + navigateTo(.ContactView(contact: existingContact, info: info)) + } + func showContactView(_ contact: ContactInfo) { log.trace("showContactView()") - navigateTo(.ContactView(contact: contact)) + navigateTo(.ContactView(contact: contact, info: nil)) } func switchToPayment(_ paymentId: Lightning_kmpUUID) { diff --git a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift index 4ed084157..af1acd0f8 100644 --- a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift +++ b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift @@ -50,9 +50,13 @@ struct PaymentDetails: View { } else if requestDescription() != nil { gridRows_description() } + if let model = parent.flow as? SendManager.ParseResult_Bolt12Offer { gridRows_offer(model) + } else if let model = parent.flow as? SendManager.ParseResult_Lnurl_Pay { + gridRows_lnurlpay(model) } + if let paymentSummary = parent.paymentStrings() { gridRows_paymentSummary(paymentSummary) } @@ -122,7 +126,12 @@ struct PaymentDetails: View { GridRowWrapper(gridWidth: gridWidth) { titleColumn("Send To") } valueColumn: { - valueColumn_offer_sendTo(model) + if let contact = parent.contact { + valueColumn_sendTo_contact(contact) + } else { + let dst: String = model.lightningAddress ?? model.offer.encode() + valueColumn_sendTo_dst(dst) + } } // GridRowWrapper(gridWidth: gridWidth) { @@ -132,6 +141,26 @@ struct PaymentDetails: View { } // } + @ViewBuilder + func gridRows_lnurlpay( + _ model: SendManager.ParseResult_Lnurl_Pay + ) -> some View { + + if let contact = parent.contact { + GridRowWrapper(gridWidth: gridWidth) { + titleColumn("Send To") + } valueColumn: { + valueColumn_sendTo_contact(contact) + } // + } else if let address = model.lightningAddress { + GridRowWrapper(gridWidth: gridWidth) { + titleColumn("Send To") + } valueColumn: { + valueColumn_sendTo_dst(address) + } // + } + } + @ViewBuilder func gridRows_paymentSummary( _ info: PaymentSummaryStrings @@ -230,46 +259,44 @@ struct PaymentDetails: View { } @ViewBuilder - func valueColumn_offer_sendTo( - _ model: SendManager.ParseResult_Bolt12Offer + func valueColumn_sendTo_contact( + _ contact: ContactInfo ) -> some View { - if CONTACTS_ENABLED, let contact = parent.contact { - - HStack(alignment: VerticalAlignment.center, spacing: 4) { - ContactPhoto(fileName: contact.photoUri, size: 32) - Text(contact.name) - } // - .onTapGesture { - parent.showManageContactSheet() - } - - } else { - - let offer: String = model.offer.encode() - VStack(alignment: HorizontalAlignment.leading, spacing: 4) { - Text(offer) - .lineLimit(2) - .truncationMode(.middle) - .contextMenu { - Button { - UIPasteboard.general.string = offer - } label: { - Text("Copy") - } - } - if CONTACTS_ENABLED { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ContactPhoto(fileName: contact.photoUri, size: 32) + Text(contact.name) + } // + .onTapGesture { + parent.manageExistingContact() + } + } + + @ViewBuilder + func valueColumn_sendTo_dst( + _ dst: String + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text(dst) + .lineLimit(2) + .truncationMode(.middle) + .contextMenu { Button { - parent.showManageContactSheet() + UIPasteboard.general.string = dst } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { - Image(systemName: "person") - Text("Add contact") - } + Text("Copy") } } - } // - } + Button { + parent.addContact() + } label: { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + Image(systemName: "person") + Text("Add contact") + } + } + } // } @ViewBuilder diff --git a/phoenix-ios/phoenix-ios/views/send/SendView.swift b/phoenix-ios/phoenix-ios/views/send/SendView.swift index 11a36b241..0d0903bf3 100644 --- a/phoenix-ios/phoenix-ios/views/send/SendView.swift +++ b/phoenix-ios/phoenix-ios/views/send/SendView.swift @@ -37,8 +37,10 @@ struct SendView: View { @State var sortedContacts: [ContactInfo] = [] @State var filteredContacts: [ContactInfo]? = nil - @State var search_offers: [String: [String]] = [:] - @State var search_lnid: [String: [String]] = [:] + @State var selectedContactId: Lightning_kmpUUID? = nil + + @State var search_offers: [Lightning_kmpUUID: [String]] = [:] + @State var search_addresses: [Lightning_kmpUUID: [String]] = [:] @State var search_domains: [String] = [] @State var isParsing: Bool = false @@ -356,10 +358,10 @@ struct SendView: View { Section { ForEach(visibleContacts) { item in - Button { - selectContact(item) - } label: { - contactRow(item) + if let selectedContactId, selectedContactId == item.id { + contactRow_selected(item) + } else { + contactRow_unselected(item) } } if hasZeroMatchesForSearch { @@ -373,18 +375,144 @@ struct SendView: View { } @ViewBuilder - func contactRow(_ item: ContactInfo) -> some View { + func contactRow_unselected(_ item: ContactInfo) -> some View { - HStack(alignment: VerticalAlignment.center, spacing: 8) { - ContactPhoto(fileName: item.photoUri, size: 32) - Text(item.name) - .font(.title3) - .foregroundColor(.primary) - Spacer() + Button { + selectContact(item) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 8) { + ContactPhoto(fileName: item.photoUri, size: 32) + Text(item.name) + .font(.title3) + .foregroundColor(.primary) + Spacer() + } + .padding(.all, 4) + } + } + + @ViewBuilder + func contactRow_selected(_ item: ContactInfo) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 12) { + + Button { + unselectContact() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 8) { + ContactPhoto(fileName: item.photoUri, size: 32) + Text(item.name) + .font(.title3) + .foregroundColor(.primary) + Spacer() + } + } + + ForEach(item.offers) { offer in + contactRow_offer(offer) + } + ForEach(item.addresses) { address in + contactRow_address(address) + } } .padding(.all, 4) } + @ViewBuilder + func contactRow_offer(_ offer: ContactOffer) -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + + Button { + payOffer(offer) + } label: { + + let label = offer.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let text = offer.offer.encode() + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Group { + if label.isEmpty { + Text(text) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text(label) + .lineLimit(1) + .truncationMode(.tail) + Text(": \(text)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + } + } // + .font(.callout) + + Spacer() + + Image(systemName: "paperplane.fill") + } + } // + .buttonStyle(.borderless) // prevents trigger when row tapped + + } // + } + + @ViewBuilder + func contactRow_address(_ address: ContactAddress) -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + + Button { + payAddress(address) + } label: { + + let label = address.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let text = address.address.trimmingCharacters(in: .whitespacesAndNewlines) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Group { + if label.isEmpty { + Text(text) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text(label) + .lineLimit(1) + .truncationMode(.tail) + Text(": \(text)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + } + } // + .font(.callout) + + Spacer() + + Image(systemName: "paperplane.fill") + } + } // + .buttonStyle(.borderless) // prevents trigger when row tapped + + } // + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) + } + @ViewBuilder func zeroMatchesRow() -> some View { @@ -499,6 +627,10 @@ struct SendView: View { popToDestination = nil presentationMode.wrappedValue.dismiss() } + + if selectedContactId != nil { + selectedContactId = nil + } } // -------------------------------------------------- @@ -510,37 +642,42 @@ struct SendView: View { sortedContacts = updatedList - var offers: [String: [String]] = [:] - for contact in sortedContacts { - let key: String = contact.id - let values: [String] = contact.offers.map { $0.encode().lowercased() } + do { + var offers: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.offers.map { + $0.offer.encode().lowercased() + } + offers[key] = values + } - offers[key] = values + search_offers = offers } - - search_offers = offers - - // Todo: - // - update search_lnid after we add support - // - update search_domains after we add support - // - // Temp: For now, search_domains will just contain the list of "well known" domains - - var domains: [String] = [] - if BusinessManager.isTestnet { - domains.append("testnet.phoenixwallet.me") + do { + var addresses: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.addresses.map { + $0.address.trimmingCharacters(in: .whitespacesAndNewlines) + } + addresses[key] = values + } + + search_addresses = addresses + } + do { + var domains = ContactAddress.wellKnownDomains(includeTestnet: BusinessManager.isTestnet) + for contact in sortedContacts { + contact.addresses.forEach { address in + if let domain = address.domain { + domains.insert(domain) + } + } + } + + search_domains = domains.sorted() } - domains.append("phoenixwallet.me") - domains.append("bitrefill.me") - domains.append("strike.me") - domains.append("coincorner.io") - domains.append("sparkwallet.me") - domains.append("ln.tips") - domains.append("getalby.com") - domains.append("walletofsatoshi.com") - domains.append("stacker.news") - - search_domains = domains } func inputFieldTextChanged() { @@ -574,6 +711,12 @@ struct SendView: View { } } + if let addresses = search_addresses[contact.id] { + if addresses.contains(where: { $0.contains(searchtext) }) { + return true + } + } + return false } @@ -719,11 +862,41 @@ struct SendView: View { func selectContact(_ contact: ContactInfo) { log.trace("selectContact: \(contact.id)") - if let offer = contact.mostRelevantOffer { - parseUserInput(offer.encode()) + let count = contact.offers.count + contact.addresses.count + if count == 1 { + // When there's exactly one payment option available, we just immediately use it. + if let offer = contact.offers.first { + payOffer(offer) + } else if let address = contact.addresses.first { + payAddress(address) + } + + } else { + // Otherwise we show the list of payment options to the user + withAnimation { + selectedContactId = contact.id + } + } + } + + func unselectContact() { + log.trace("unselectContact()") + + withAnimation { + selectedContactId = nil } } + func payOffer(_ offer: ContactOffer) { + log.trace("payOffer()") + parseUserInput(offer.offer.encode()) + } + + func payAddress(_ address: ContactAddress) { + log.trace("payAddress()") + parseUserInput(address.address) + } + func cancelParseRequest() { log.trace("cancelParseRequest()") diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 47325b113..caca4fd92 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -194,9 +194,6 @@ struct ValidateView: View { .onReceive(balancePublisher) { balanceDidChange($0) } - .task { - await fetchContact() - } .task { await fetchMempoolRecommendedFees() } @@ -767,19 +764,6 @@ struct ValidateView: View { // MARK: Tasks // -------------------------------------------------- - func fetchContact() async { - - guard let offer = bolt12Offer() else { - return - } - let contactsManager = Biz.business.contactsManager - do { - contact = try await contactsManager.getContactForOffer(offer: offer) - } catch { - log.error("contactsManager: error: \(error)") - } - } - func fetchMempoolRecommendedFees() async { for try await response in MempoolMonitor.shared.stream() { @@ -823,6 +807,18 @@ struct ValidateView: View { return nil } + func lightningAddress() -> String? { + + var result: String? = nil + if let model = flow as? SendManager.ParseResult_Bolt12Offer { + result = model.lightningAddress + } else if let model = flow as? SendManager.ParseResult_Lnurl_Pay { + result = model.lightningAddress + } + + return result?.trimmingCharacters(in: .whitespacesAndNewlines) + } + func bolt11Invoice() -> Lightning_kmpBolt11Invoice? { if let model = flow as? SendManager.ParseResult_Bolt11Invoice { @@ -1092,6 +1088,13 @@ struct ValidateView: View { presentationMode.wrappedValue.dismiss() } } + + if let address = lightningAddress() { + contact = Biz.business.contactsManager.contactForLightningAddress(address: address) + } + if contact == nil, let offer = bolt12Offer() { + contact = Biz.business.contactsManager.contactForOffer(offer: offer) + } } // -------------------------------------------------- @@ -1592,10 +1595,11 @@ struct ValidateView: View { } } - func showManageContactSheet() { - log.trace("showManageContactSheet()") + func manageExistingContact() { + log.trace("manageExistingContact()") - guard let offer = bolt12Offer() else { + guard let contact else { + log.info("manageExistingContact(): ignoring: no existing contact") return } @@ -1604,13 +1608,104 @@ struct ValidateView: View { ManageContact( location: .smartModal, popTo: nil, - offer: offer, + info: nil, contact: contact, contactUpdated: contactUpdated ) } } + func addContact() { + log.trace("addContact()") + + guard contact == nil else { + log.info("addContact(): ignoring: contact already exists") + return + } + + let address = lightningAddress() + + var offer: Lightning_kmpOfferTypesOffer? = nil + if address == nil { + offer = bolt12Offer() + } + + guard (address != nil) || (offer != nil) else { + log.info("addContact(): ignoring: missing address/offer") + return + } + + let info = AddToContactsInfo(offer: offer, address: address) + + let count: Int = Biz.business.contactsManager.contactsListCurrentValue().count + if count == 0 { + // User doesn't have any contacts. + // No choice but to create a new contact. + addContact_createNew(info) + + } else { + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: true) { + AddContactOptionsSheet( + createNewContact: { addContact_createNew(info) }, + addToExistingContact: { addContact_selectExisting(info) } + ) + } + } + } + + private func addContact_createNew( + _ info: AddToContactsInfo + ) { + log.trace("addContact_createNew()") + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: false) { + ManageContact( + location: .smartModal, + popTo: nil, + info: info, + contact: nil, + contactUpdated: contactUpdated + ) + } + } + + private func addContact_selectExisting( + _ info: AddToContactsInfo + ) { + log.trace("addContact_selectExisting()") + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: true) { + ContactsListSheet(didSelectContact: { existingContact in + log.debug("didSelectContact") + smartModalState.onNextDidDisappear { + addContact_addToExisting(existingContact, info) + } + }) + } + } + + private func addContact_addToExisting( + _ existingContact: ContactInfo, + _ info: AddToContactsInfo + ) { + log.trace("addContact_addToExisting()") + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: false) { + ManageContact( + location: .smartModal, + popTo: nil, + info: info, + contact: existingContact, + contactUpdated: contactUpdated + ) + } + } + func contactUpdated(_ updatedContact: ContactInfo?) { log.trace("contactUpdated()") @@ -1657,7 +1752,7 @@ struct ValidateView: View { sendPayment_bolt11Invoice(model, msat) } else if let model = flow as? SendManager.ParseResult_Bolt12Offer { - sendPayment_bolt12Offer_C(model, msat) + sendPayment_bolt12Offer(model, msat) } else if let model = flow as? SendManager.ParseResult_Uri { sendPayment_onChain(model, msat) @@ -1704,94 +1799,11 @@ struct ValidateView: View { } // } - func sendPayment_bolt12Offer_test( - _ model: SendManager.ParseResult_Bolt12Offer, - _ msat: Int64 - ) { - log.trace("sendPayment_bolt12Offer()_test") - - guard !paymentInProgress else { - log.warning("ignore: payment already in progress") - return - } - - paymentInProgress = true - DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { - paymentInProgress = false - payOfferProblem = .noResponse - } - } - - func sendPayment_bolt12Offer_B( - _ model: SendManager.ParseResult_Bolt12Offer, - _ msat: Int64 - ) { - log.trace("sendPayment_bolt12Offer_B()") - - guard !paymentInProgress else { - log.warning("ignore: payment already in progress") - return - } - guard let peer = Biz.business.peerManager.peerStateValue() else { - log.warning("ignore: peer == nil") - return - } - - paymentInProgress = true - payOfferProblem = nil - let payerNote = comment.isEmpty ? nil : comment - - saveTipPercentInPrefs() - Task { @MainActor in - do { - let paymentId = Lightning_kmpUUID.companion.randomUUID() - Biz.beginLongLivedTask(id: paymentId.description()) - - let payerKey: Bitcoin_kmpPrivateKey - if contact?.useOfferKey ?? false { - let offerData = try await Biz.business.nodeParamsManager.defaultOffer() - payerKey = offerData.payerKey - } else { - payerKey = Lightning_randomKey() - } - - let result: Lightning_kmpSendPaymentResult = try await peer.altPayOffer( - paymentId: paymentId, - amount: Lightning_kmpMilliSatoshi(msat: msat), - offer: model.offer, - payerKey: payerKey, - payerNote: payerNote, - fetchInvoiceTimeoutInSeconds: 30 - ) - - paymentInProgress = false - - switch onEnum(of: result) { - case .offerNotPaid(let offerNotPaid): - let problem = PayOfferProblem.fromResponse(offerNotPaid) - payOfferProblem = problem - Biz.endLongLivedTask(id: paymentId.description()) - - case .paymentNotSent(_): fallthrough - case .paymentSent(_): - payOfferProblem = nil - popToRootView() - } - - } catch { - log.error("peer.payOffer(): error: \(error)") - - paymentInProgress = false - payOfferProblem = .other - } - } // - } - - func sendPayment_bolt12Offer_C( + func sendPayment_bolt12Offer( _ model: SendManager.ParseResult_Bolt12Offer, _ msat: Int64 ) { - log.trace("sendPayment_bolt12Offer_C()") + log.trace("sendPayment_bolt12Offer()") guard !paymentInProgress else { log.warning("ignore: payment already in progress") diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt index 16d1a50a3..fbe83f9aa 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt @@ -16,27 +16,85 @@ package fr.acinq.phoenix.data +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.OfferTypes +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class ContactOffer( + val id: ByteVector32, + val offer: OfferTypes.Offer, + val label: String?, + val createdAt: Instant +) { + constructor(offer: OfferTypes.Offer, label: String?, createdAt: Instant? = null) : this( + id = offer.offerId, // see note below + offer = offer, + label = label, + createdAt = createdAt ?: Clock.System.now() + ) + + // We purposefully store the calculated `offer.offerId` as a property, + // because the `offerId` property itself is actually a result of hashing and + // other calculations. In other words it's not cheap to compute. + // And it's a value we reference regularly within the UI. +} + +data class ContactAddress( + val id: ByteVector32, + val address: String, + val label: String?, + val createdAt: Instant +) { + constructor(address: String, label: String?, createdAt: Instant? = null) : this( + id = ContactAddress.hash(address), // see note below + address = address, + label = label, + createdAt = createdAt ?: Clock.System.now() + ) + + // We purposefully store the calculated `hash(address)` as a property, + // because the value is a result of hashing. So it's not cheap to compute. + // And it's a value we reference regularly within the UI. + + companion object { + fun hash(address: String): ByteVector32 { + val input = address.lowercase().toByteArray(charset = Charsets.UTF_8) + return Crypto.sha256(input).byteVector32() + } + } +} data class ContactInfo( val id: UUID, val name: String, val photoUri: String?, val useOfferKey: Boolean, - val offers: List, + val offers: List, + val addresses: List, val publicKeys: List, ) { - constructor(id: UUID, name: String, photoUri: String?, useOfferKey: Boolean, offers: List) : this( + constructor( + id: UUID, + name: String, + photoUri: String?, + useOfferKey: Boolean, + offers: List, + addresses: List + ) : this( id = id, name = name, photoUri = photoUri, useOfferKey = useOfferKey, offers = offers, - publicKeys = offers.map { it.contactInfos.map { it.nodeId } }.flatten() + addresses = addresses, + publicKeys = offers.map { it.offer.contactInfos.map { it.nodeId } }.flatten() + // publicKeys = offers.map { it.offer.contactNodeIds }.flatten() ) - - // TODO: order the offers listed by the group_concat in the sql query, and take the most recently added one - val mostRelevantOffer: OfferTypes.Offer? by lazy { offers.firstOrNull() } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt index 21815869f..2d4a93b7b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt @@ -174,12 +174,8 @@ class SqliteAppDb(private val driver: SqlDriver) { contactQueries.getContact(contactId) } - suspend fun getContactForOffer(offerId: ByteVector32): ContactInfo? = withContext(Dispatchers.Default) { - contactQueries.getContactForOffer(offerId) - } - - suspend fun monitorContacts(): Flow> = withContext(Dispatchers.Default) { - contactQueries.monitorContactsFlow() + fun monitorContactsFlow(): Flow> { + return contactQueries.monitorContactsFlow(Dispatchers.Default) } suspend fun listContacts(): List = withContext(Dispatchers.Default) { @@ -190,18 +186,10 @@ class SqliteAppDb(private val driver: SqlDriver) { contactQueries.saveContact(contact) } - suspend fun updateContact(contact: ContactInfo) = withContext(Dispatchers.Default) { - contactQueries.updateContact(contact) - } - suspend fun deleteContact(contactId: UUID) = withContext(Dispatchers.Default) { contactQueries.deleteContact(contactId) } - suspend fun deleteOfferContactLink(offerId: ByteVector32) = withContext(Dispatchers.Default) { - contactQueries.deleteOfferContactLink(offerId) - } - fun close() { driver.close() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt index db0883581..c6cc71494 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt @@ -2,10 +2,14 @@ package fr.acinq.phoenix.db.cloud.contacts import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.ContactOffer import fr.acinq.phoenix.db.cloud.OfferSerializer import fr.acinq.phoenix.db.cloud.UUIDSerializer import fr.acinq.phoenix.db.cloud.cborSerializer +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -17,12 +21,19 @@ import kotlinx.serialization.json.Json enum class CloudContactVersion(val value: Int) { // Initial version - V0(0) + V0(0), + V1(1) // Future versions go here } @Serializable -data class CloudContact( +data class CloudContactVersionSwitch( + @SerialName("v") + val version: Int +) + +@Serializable +data class CloudContact_V1( @SerialName("v") val version: Int, @Serializable(with = UUIDSerializer::class) @@ -30,29 +41,108 @@ data class CloudContact( val name: String, val useOfferKey: Boolean, val offers: List<@Serializable(OfferSerializer::class) OfferTypes.Offer>, +) { + @Throws(Exception::class) + fun unwrap(photoUri: String?): ContactInfo { + val now = Clock.System.now() + val mappedOffers: List = this.offers.map { + ContactOffer(offer = it, label = "", createdAt = now) + } + return ContactInfo( + id = this.id, + name = this.name, + photoUri = photoUri, + useOfferKey = this.useOfferKey, + offers = mappedOffers, + addresses = listOf() + ) + } + + companion object +} + +@Serializable +data class CloudContact_V2( + @SerialName("v") + val version: Int, + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val name: String, + val useOfferKey: Boolean, + val offers: List, + val addresses: List ) { constructor(contact: ContactInfo) : this( version = CloudContactVersion.V0.value, id = contact.id, name = contact.name, useOfferKey = contact.useOfferKey, - offers = contact.offers + offers = contact.offers.map { ContactOfferWrapper(it) }, + addresses = contact.addresses.map { ContactAddressWrapper(it) } ) @Throws(Exception::class) - fun unwrap(photoUri: String?): ContactInfo? { + fun unwrap(photoUri: String?): ContactInfo { return ContactInfo( id = this.id, name = this.name, photoUri = photoUri, useOfferKey = this.useOfferKey, - offers = this.offers + offers = this.offers.map { it.unwrap() }, + addresses = this.addresses.map { it.unwrap() } ) } companion object + + @Serializable + data class ContactOfferWrapper( + @Serializable(with = OfferSerializer::class) + val offer: OfferTypes.Offer, + val label: String, + val createdAt: Long + ) { + constructor(offer: ContactOffer) : this( + offer = offer.offer, + label = offer.label ?: "", + createdAt = offer.createdAt.toEpochMilliseconds() + ) + + @Throws(Exception::class) + fun unwrap(): ContactOffer { + return ContactOffer( + offer = this.offer, + label = this.label, + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + } + + @Serializable + data class ContactAddressWrapper( + val address: String, + val label: String, + val createdAt: Long + ) { + constructor(address: ContactAddress) : this( + address = address.address, + label = address.label ?: "", + createdAt = address.createdAt.toEpochMilliseconds() + ) + + @Throws(Exception::class) + fun unwrap(): ContactAddress { + return ContactAddress( + address = this.address, + label = this.label, + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + } } +typealias CloudContact = CloudContact_V2 + @OptIn(ExperimentalSerializationApi::class) fun CloudContact.cborSerialize(): ByteArray { return Cbor.encodeToByteArray(this) @@ -60,10 +150,21 @@ fun CloudContact.cborSerialize(): ByteArray { @OptIn(ExperimentalSerializationApi::class) @Throws(Exception::class) -fun CloudContact.Companion.cborDeserialize( - blob: ByteArray -): CloudContact { - return cborSerializer().decodeFromByteArray(blob) +fun CloudContact_V2.Companion.cborDeserializeAndUnwrap( + blob: ByteArray, + photoUri: String? +): ContactInfo? { + val serializer = cborSerializer() + val header: CloudContactVersionSwitch = serializer.decodeFromByteArray(blob) + return when (header.version) { + CloudContactVersion.V0.value -> { + serializer.decodeFromByteArray(blob).unwrap(photoUri) + } + CloudContactVersion.V1.value -> { + serializer.decodeFromByteArray(blob).unwrap(photoUri) + } + else -> null + } } /** diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt index 5b67ec6bb..f7e251e64 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt @@ -17,26 +17,47 @@ package fr.acinq.phoenix.db.notifications import app.cash.sqldelight.coroutines.asFlow -import app.cash.sqldelight.coroutines.mapToList import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.byteVector32 import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.db.didDeleteContact import fr.acinq.phoenix.db.didSaveContact import fr.acinq.phoenix.db.sqldelight.AppDatabase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import fr.acinq.phoenix.data.ContactOffer +import fr.acinq.phoenix.db.sqldelight.Contact_addresses +import fr.acinq.phoenix.db.sqldelight.Contact_offers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext class ContactQueries(val database: AppDatabase) { val queries = database.contactsQueries fun saveContact(contact: ContactInfo, notify: Boolean = true) { + database.transaction { + val contactExists = queries.existsContact( + id = contact.id.toString() + ).executeAsOne() > 0 + if (contactExists) { + updateExistingContact(contact) + } else { + saveNewContact(contact) + } + if (notify) { + didSaveContact(contact.id, database) + } + } + } + + private fun saveNewContact(contact: ContactInfo) { database.transaction { queries.insertContact( id = contact.id.toString(), @@ -46,21 +67,28 @@ class ContactQueries(val database: AppDatabase) { createdAt = currentTimestampMillis(), updatedAt = null ) - contact.offers.forEach { offer -> + contact.offers.forEach { row -> queries.insertOfferForContact( - offerId = offer.offerId.toByteArray(), + offerId = row.offer.offerId.toByteArray(), contactId = contact.id.toString(), - offer = offer.encode(), - createdAt = currentTimestampMillis(), + offer = row.offer.encode(), + label = row.label, + createdAt = row.createdAt.toEpochMilliseconds() ) } - if (notify) { - didSaveContact(contact.id, database) + contact.addresses.forEach { row -> + queries.insertAddressForContact( + addressHash = row.id.toByteArray(), + contactId = contact.id.toString(), + address = row.address, + label = row.label, + createdAt = row.createdAt.toEpochMilliseconds() + ) } } } - fun updateContact(contact: ContactInfo) { + private fun updateExistingContact(contact: ContactInfo) { database.transaction { queries.updateContact( name = contact.name, @@ -69,7 +97,85 @@ class ContactQueries(val database: AppDatabase) { updatedAt = currentTimestampMillis(), contactId = contact.id.toString() ) - didSaveContact(contact.id, database) + + val existingOffers: MutableMap = + queries.listOffersForContact( + contactId = contact.id.toString() + ).executeAsList().mapNotNull { offerRow -> + parseOfferRow(offerRow)?.let { offer -> + offerRow.offer_id.byteVector32() to offer + } + }.toMap().toMutableMap() + contact.offers.forEach { row -> + val result: ComparisonResult = + existingOffers.remove(row.id)?.let { existingOffer -> + compareOffers(existingOffer, row) + } ?: ComparisonResult.IsNew + when (result) { + ComparisonResult.IsNew -> { + queries.insertOfferForContact( + offerId = row.id.toByteArray(), + contactId = contact.id.toString(), + offer = row.offer.encode(), + label = row.label, + createdAt = row.createdAt.toEpochMilliseconds() + ) + } + ComparisonResult.IsUpdated -> { + queries.updateContactOffer( + label = row.label, + offerId = row.id.toByteArray() + ) + } + ComparisonResult.NoChanges -> {} + } + } + // In the loop above we removed every matching offer. + // So any items leftover have been deleted from the contact. + existingOffers.forEach { (key, _) -> + queries.deleteContactOfferForOfferId( + offerId = key.toByteArray() + ) + } + + val existingAddresses: MutableMap = + queries.listAddressesForContact( + contactId = contact.id.toString() + ).executeAsList().map { addressRow -> + addressRow.address_hash.byteVector32() to parseAddressRow(addressRow) + }.toMap().toMutableMap() + contact.addresses.forEach { row -> + val result: ComparisonResult = + existingAddresses.remove(row.id)?.let { existing -> + compareAddresses(existing, row) + } ?: ComparisonResult.IsNew + when (result) { + ComparisonResult.IsNew -> { + queries.insertAddressForContact( + addressHash = row.id.toByteArray(), + contactId = contact.id.toString(), + address = row.address, + label = row.label, + createdAt = row.createdAt.toEpochMilliseconds() + ) + } + ComparisonResult.IsUpdated -> { + queries.updateContactAddress( + label = row.label, + address = row.address, + addressHash = row.id.toByteArray() + ) + } + ComparisonResult.NoChanges -> {} + } + } + // In the loop above we removed every matching address. + // So any items leftover have been deleted from the contact. + existingAddresses.forEach { (key, _) -> + queries.deleteContactAddressForAddressHash( + addressHash = key.toByteArray() + ) + } } } @@ -83,72 +189,135 @@ class ContactQueries(val database: AppDatabase) { fun getContact(contactId: UUID): ContactInfo? { return database.transactionWithResult { - queries.getContact(contactId = contactId.toString()).executeAsOneOrNull()?.let { - val offers = it.offers.split(",").map { - OfferTypes.Offer.decode(it) - }.filterIsInstance>().map { - it.get() + queries.getContact2( + contactId = contactId.toString() + ).executeAsOneOrNull()?.let { contactRow -> + val offers: List = queries.listOffersForContact( + contactId = contactId.toString() + ).executeAsList().mapNotNull { offerRow -> + parseOfferRow(offerRow) } - ContactInfo(contactId, it.name, it.photo_uri, it.use_offer_key, offers) + val addresses: List = queries.listAddressesForContact( + contactId = contactId.toString() + ).executeAsList().map { addressRow -> + parseAddressRow(addressRow) + } + ContactInfo( + id = contactId, + name = contactRow.name, + photoUri = contactRow.photo_uri, + useOfferKey = contactRow.use_offer_key, + offers = offers, + addresses = addresses + ) } } } - /** Retrieve a contact from a transaction ID - should be done in a transaction. */ - fun getContactForOffer(offerId: ByteVector32): ContactInfo? { + fun listContacts(): List { return database.transactionWithResult { - queries.getContactIdForOffer(offerId = offerId.toByteArray()).executeAsOneOrNull()?.let { - val contactId = it.contact_id - queries.getContact(contactId = contactId).executeAsOneOrNull() - }?.let { - val offers = it.offers.split(",").map { - OfferTypes.Offer.decode(it) - }.filterIsInstance>().map { - it.get() + + val offers: MutableMap> = mutableMapOf() + queries.listContactOffers().executeAsList().forEach { offerRow -> + parseOfferRow(offerRow)?.let { offer -> + val contactId = UUID.fromString(offerRow.contact_id) + offers[contactId]?.add(offer) ?: run { + offers[contactId] = mutableListOf(offer) + } } - if (offers.isEmpty()) { - null - } else { - ContactInfo(UUID.fromString(it.id), it.name, it.photo_uri, it.use_offer_key, offers) + } + + val addresses: MutableMap> = mutableMapOf() + queries.listContactAddresses().executeAsList().forEach { addressRow -> + parseAddressRow(addressRow).let { address -> + val contactId = UUID.fromString(addressRow.contact_id) + addresses[contactId]?.add(address) ?: run { + addresses[contactId] = mutableListOf(address) + } } } - } - } - fun listContacts(): List { - return queries.listContacts().executeAsList().map { - val offers = it.offers?.split(",")?.map { - OfferTypes.Offer.decode(it) - }?.filterIsInstance>()?.map { - it.get() - } ?: emptyList() - ContactInfo(UUID.fromString(it.id), it.name, it.photo_uri, it.use_offer_key, offers) + queries.listContacts2().executeAsList().map { contactRow -> + val contactId = UUID.fromString(contactRow.id) + ContactInfo( + id = contactId, + name = contactRow.name, + photoUri = contactRow.photo_uri, + useOfferKey = contactRow.use_offer_key, + offers = offers[contactId]?.toList() ?: listOf(), + addresses = addresses[contactId]?.toList() ?: listOf() + ) + } } } - fun monitorContactsFlow(): Flow> { - return queries.listContacts().asFlow().mapToList(Dispatchers.IO).map { list -> - list.map { - val offers = it.offers?.split(",")?.map { - OfferTypes.Offer.decode(it) - }?.filterIsInstance>()?.map { - it.get() - } ?: emptyList() - ContactInfo(UUID.fromString(it.id), it.name, it.photo_uri, it.use_offer_key, offers) + fun monitorContactsFlow(context: CoroutineContext): Flow> { + return queries.listContacts2().asFlow().map { + withContext(context) { + listContacts() } } } fun deleteContact(contactId: UUID) { database.transaction { - queries.deleteContactOfferForContactId(contactId = contactId.toString()) + queries.deleteContactOffersForContactId(contactId = contactId.toString()) + queries.deleteContactAddressesForContactId(contactId = contactId.toString()) queries.deleteContact(contactId = contactId.toString()) didDeleteContact(contactId, database) } } - fun deleteOfferContactLink(offerId: ByteVector32) { - queries.deleteContactOfferForOfferId(offerId.toByteArray()) + private fun parseOfferRow(row: Contact_offers): ContactOffer? { + return when (val result = OfferTypes.Offer.decode(row.offer)) { + is Try.Success -> ContactOffer( + id = row.offer_id.byteVector32(), + offer = result.get(), + label = row.label ?: "", + createdAt = Instant.fromEpochMilliseconds(row.created_at) + ) + is Try.Failure -> null + } + } + + private fun parseAddressRow(row: Contact_addresses): ContactAddress { + return ContactAddress( + id = row.address_hash.byteVector32(), + address = row.address, + label = row.label ?: "", + createdAt = Instant.fromEpochMilliseconds(row.created_at) + ) + } + + private enum class ComparisonResult { + IsNew, + IsUpdated, + NoChanges } + private fun compareOffers( + existing: ContactOffer, + current: ContactOffer + ): ComparisonResult { + return if (existing.label != current.label) { + ComparisonResult.IsUpdated + } else { + ComparisonResult.NoChanges + } + } + + private fun compareAddresses( + existing: ContactAddress, + current: ContactAddress + ): ComparisonResult { + // Note: The address can be changed without changing the hash. + // For example: old("johndoe@foobar.co") -> new("JohnDoe@foobar.co") + // Since the hash is case-insensitive it remains unchanged. + // In other words, this is just a requested formatting change by the user. + return if ((existing.label != current.label) || (existing.address != current.address)) { + ComparisonResult.IsUpdated + } else { + ComparisonResult.NoChanges + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt index 0230357c2..42239b19e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt @@ -24,6 +24,7 @@ import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata @@ -36,7 +37,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch - class ContactsManager( private val loggerFactory: LoggerFactory, private val appDb: SqliteAppDb, @@ -55,23 +55,25 @@ class ContactsManager( private val _contactsMap = MutableStateFlow>(emptyMap()) val contactsMap = _contactsMap.asStateFlow() - private val _offerMap = MutableStateFlow>(emptyMap()) + // Key(Offer.OfferId), Value(ContactId) + private val _offerMap = MutableStateFlow>(emptyMap()) val offerMap = _offerMap.asStateFlow() + // Key(Offer.contactNodeId), Value(ContactId) private val _publicKeyMap = MutableStateFlow>(emptyMap()) val publicKeyMap = _publicKeyMap.asStateFlow() - val contactsWithOfferList = _contactsList.map { contacts -> - contacts.filter { it.offers.isNotEmpty() } - } + // Key(lightningAddress.hash), Value(ContactId) + private val _addressMap = MutableStateFlow>(emptyMap()) + val addressMap = _addressMap.asStateFlow() init { launch { - appDb.monitorContacts().collect { list -> + appDb.monitorContactsFlow().collect { list -> val newMap = list.associateBy { it.id } val newOfferMap = list.flatMap { contact -> - contact.offers.map { offer -> - offer to contact.id + contact.offers.map { row -> + row.id to contact.id } }.toMap() val newPublicKeyMap = list.flatMap { contact -> @@ -79,55 +81,43 @@ class ContactsManager( pubKey to contact.id } }.toMap() + val newAddressMap = list.flatMap { contact -> + contact.addresses.map { row -> + row.id to contact.id + } + }.toMap() _contactsList.value = list _contactsMap.value = newMap _offerMap.value = newOfferMap _publicKeyMap.value = newPublicKeyMap + _addressMap.value = newAddressMap } } } - suspend fun getContactForOffer(offer: OfferTypes.Offer): ContactInfo? { - return appDb.getContactForOffer(offer.offerId) - } - - suspend fun saveNewContact( - name: String, - photoUri: String?, - useOfferKey: Boolean, - offer: OfferTypes.Offer - ): ContactInfo { - val contact = ContactInfo(id = UUID.randomUUID(), name = name, photoUri = photoUri, useOfferKey = useOfferKey, offers = listOf(offer)) + /** + * This method will: + * - insert or update the contact in the database (depending on whether it already exists) + * - insert any new offers + * - update any offers that have been changed (i.e. label changed) + * - delete offers that have been removed from the list + * - insert any new addresses + * - update any addresses that have been changed (i.e. label changed) + * - delete any addresses that have been removed + * + * In other words, the UI doesn't have to track which changes have been made. + * It can simply call this method, and the database will be properly updated. + */ + suspend fun saveContact(contact: ContactInfo) { appDb.saveContact(contact) - return contact - } - - suspend fun updateContact( - contactId: UUID, - name: String, - photoUri: String?, - useOfferKey: Boolean, - offers: List - ): ContactInfo { - val contact = ContactInfo(id = contactId, name = name, photoUri = photoUri, useOfferKey = useOfferKey, offers = offers) - appDb.updateContact(contact) - return contact - } - - suspend fun getContactForPayerPubkey(payerPubkey: PublicKey): ContactInfo? { - return appDb.listContacts().firstOrNull { it.publicKeys.contains(payerPubkey) } } suspend fun deleteContact(contactId: UUID) { appDb.deleteContact(contactId) } - suspend fun detachOfferFromContact(offerId: ByteVector32) { - appDb.deleteOfferContactLink(offerId) - } - /** - * In many cases there's no need to query the database since we have everything in memory. + * In most cases there's no need to query the database since we have everything in memory. */ fun contactForId(contactId: UUID): ContactInfo? { @@ -141,7 +131,7 @@ class ContactsManager( } } else { payment.outgoingInvoiceRequest()?.let {invoiceRequest -> - offerMap.value[invoiceRequest.offer] + offerMap.value[invoiceRequest.offer.offerId] } } } @@ -151,4 +141,42 @@ class ContactsManager( contactForId(contactId) } } + + fun contactIdForOfferId(offerId: ByteVector32): UUID? { + return offerMap.value[offerId] + } + + fun contactForOfferId(offerId: ByteVector32): ContactInfo? { + return contactIdForOfferId(offerId)?.let { contactId -> + contactForId(contactId) + } + } + + fun contactIdForOffer(offer: OfferTypes.Offer): UUID? { + return contactIdForOfferId(offer.offerId) + } + + fun contactForOffer(offer: OfferTypes.Offer): ContactInfo? { + return contactForOfferId(offer.offerId) + } + + fun contactIdForPayerPubKey(payerPubKey: PublicKey): UUID? { + return publicKeyMap.value[payerPubKey] + } + + fun contactForPayerPubKey(payerPubKey: PublicKey): ContactInfo? { + return contactIdForPayerPubKey(payerPubKey)?.let { contactId -> + contactForId(contactId) + } + } + + fun contactIdForLightningAddress(address: String): UUID? { + return addressMap.value[ContactAddress.hash(address)] + } + + fun contactForLightningAddress(address: String): ContactInfo? { + return contactIdForLightningAddress(address)?.let { contactId -> + contactForId(contactId) + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt index 94b8e441c..9a7fd8eff 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt @@ -118,7 +118,8 @@ class SendManager( ): Success() data class Bolt12Offer( - val offer: OfferTypes.Offer + val offer: OfferTypes.Offer, + val lightningAddress: String? ): Success() data class Uri( @@ -127,7 +128,8 @@ class SendManager( sealed class Lnurl: Success() { data class Pay( - val paymentIntent: LnurlPay.Intent + val paymentIntent: LnurlPay.Intent, + val lightningAddress: String? ): Lnurl() data class Withdraw( @@ -150,18 +152,18 @@ class SendManager( Parser.readBolt11Invoice(input)?.let { processBolt11Invoice(it) } ?: Parser.readOffer(input)?.let { - processOffer(it) + processOffer(it, null) } ?: readEmailLikeAddress(input, progress)?.let { when (it) { - is Either.Left -> processOffer(it.value) - is Either.Right -> processLnurl(it.value, progress) + is Either.Left -> processOffer(it.value, input) + is Either.Right -> processLnurl(it.value, input, progress) } } ?: readLnurl(input)?.let { - processLnurl(it, progress) + processLnurl(it, null, progress) } ?: readBitcoinAddress(input)?.let { processBitcoinAddress(input, it) } ?: readLNURLFallback(input)?.let { - processLnurl(it, progress) + processLnurl(it, null, progress) } ?: run { ParseResult.BadRequest( request = request, @@ -224,7 +226,8 @@ class SendManager( } private fun processOffer( - offer: OfferTypes.Offer + offer: OfferTypes.Offer, + lightningAddress: String? ): ParseResult { return if (!offer.chains.contains(chain.chainHash)) { @@ -233,7 +236,7 @@ class SendManager( reason = BadRequestReason.ChainMismatch(expected = chain) ) } else { - ParseResult.Bolt12Offer(offer = offer) + ParseResult.Bolt12Offer(offer, lightningAddress) } } @@ -323,6 +326,7 @@ class SendManager( private suspend fun processLnurl( lnurl: Lnurl, + lightningAddress: String?, progress: (p: ParseProgress) -> Unit ): ParseResult? { return when (lnurl) { @@ -339,7 +343,7 @@ class SendManager( try { when (val result: Lnurl = task.await()) { is LnurlPay.Intent -> { - ParseResult.Lnurl.Pay(paymentIntent = result) + ParseResult.Lnurl.Pay(paymentIntent = result, lightningAddress) } is LnurlWithdraw -> { ParseResult.Lnurl.Withdraw(lnurlWithdraw = result) @@ -402,7 +406,7 @@ class SendManager( when { address.isNotBlank() -> ParseResult.Uri(uri = result.value) bolt11 != null -> ParseResult.Bolt11Invoice(request = input, invoice = bolt11) - bolt12 != null -> ParseResult.Bolt12Offer(offer = bolt12) + bolt12 != null -> ParseResult.Bolt12Offer(offer = bolt12, lightningAddress = null) else -> ParseResult.BadRequest(request = input, reason = BadRequestReason.UnknownFormat) } } diff --git a/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq b/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq index a157e9feb..af36a8269 100644 --- a/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq +++ b/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq @@ -14,18 +14,36 @@ CREATE TABLE IF NOT EXISTS contact_offers ( contact_id TEXT NOT NULL, offer TEXT NOT NULL, created_at INTEGER NOT NULL, + label TEXT, FOREIGN KEY(contact_id) REFERENCES contacts(id) ); +CREATE TABLE IF NOT EXISTS contact_addresses ( + address_hash BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + address TEXT NOT NULL, + created_at INTEGER NOT NULL, + label TEXT, + + FOREIGN KEY(contact_id) REFERENCES contacts(id) +); + +CREATE INDEX contact_name_index ON contacts(name ASC); CREATE INDEX contact_id_index ON contact_offers(contact_id); +CREATE INDEX contact_id_index2 ON contact_addresses(contact_id); + +-- ########## table: contacts ########## -listContacts: -SELECT id, name, photo_uri, use_offer_key, contacts.created_at, updated_at, group_concat(offer, ',') AS offers -FROM contacts AS contacts -LEFT OUTER JOIN contact_offers AS contact_offers ON contact_id = id -GROUP BY id -ORDER BY contacts.name ASC; +listContacts2: +SELECT * +FROM contacts +ORDER BY name ASC; + +getContact2: +SELECT * +FROM contacts +WHERE id = :contactId; scanContacts: SELECT id, created_at FROM contacts; @@ -34,18 +52,6 @@ existsContact: SELECT COUNT(*) FROM contacts WHERE id = ?; -getContact: -SELECT id, name, photo_uri, use_offer_key, contacts.created_at, updated_at, group_concat(offer, ',') AS offers -FROM contacts AS contacts -JOIN contact_offers AS contact_offers ON contact_id = id -WHERE id = :contactId -ORDER BY contact_offers.created_at; - -getContactIdForOffer: -SELECT offer_id, contact_id, offer, created_at -FROM contact_offers -WHERE offer_id=:offerId; - insertContact: INSERT INTO contacts(id, name, photo_uri, use_offer_key, created_at, updated_at) VALUES (:id, :name, :photoUri, :useOfferKey, :createdAt, :updatedAt); @@ -54,15 +60,64 @@ updateContact: UPDATE contacts SET name=:name, photo_uri=:photoUri, use_offer_key=:useOfferKey, updated_at=:updatedAt WHERE id=:contactId; +-- `contact.updated_at` should be incremented when a corresponding change is made +-- in `contact_offers` or `contact_addresses` +markContactUpdated: +UPDATE contacts +SET updated_at=:updatedAt +WHERE id=:contactId; + deleteContact: DELETE FROM contacts WHERE id=:contactId; +-- ########## table: contact_offers ########## + +listContactOffers: +SELECT * +FROM contact_offers; + +listOffersForContact: +SELECT * +FROM contact_offers +WHERE contact_id=:contactId; + insertOfferForContact: -INSERT INTO contact_offers(offer_id, contact_id, offer, created_at) -VALUES (:offerId, :contactId, :offer, :createdAt); +INSERT INTO contact_offers(offer_id, contact_id, offer, label, created_at) +VALUES (:offerId, :contactId, :offer, :label, :createdAt); + +updateContactOffer: +UPDATE contact_offers +SET label=:label +WHERE offer_id=:offerId; deleteContactOfferForOfferId: DELETE FROM contact_offers WHERE offer_id=:offerId; -deleteContactOfferForContactId: +deleteContactOffersForContactId: DELETE FROM contact_offers WHERE contact_id=:contactId; + +-- ########## table: contact_addresses ########## + +listContactAddresses: +SELECT * +FROM contact_addresses; + +listAddressesForContact: +SELECT * +FROM contact_addresses +WHERE contact_id=:contactId; + +insertAddressForContact: +INSERT INTO contact_addresses(address_hash, contact_id, address, label, created_at) +VALUES (:addressHash, :contactId, :address, :label, :createdAt); + +updateContactAddress: +UPDATE contact_addresses +SET label=:label, address=:address +WHERE address_hash=:addressHash; + +deleteContactAddressForAddressHash: +DELETE FROM contact_addresses WHERE address_hash=:addressHash; + +deleteContactAddressesForContactId: +DELETE FROM contact_addresses WHERE contact_id=:contactId; diff --git a/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm b/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm new file mode 100644 index 000000000..49707f96a --- /dev/null +++ b/phoenix-shared/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm @@ -0,0 +1,23 @@ +-- Migration: v7 -> v8 +-- +-- Changes: +-- * Added table contact_addresses +-- * Added index on table: contact +-- * Added index on table: contact_addresses +-- + +ALTER TABLE contact_offers + ADD COLUMN label TEXT DEFAULT NULL; + +CREATE TABLE IF NOT EXISTS contact_addresses ( + address_hash BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + address TEXT NOT NULL, + created_at INTEGER NOT NULL, + label TEXT, + + FOREIGN KEY(contact_id) REFERENCES contacts(id) +); + +CREATE INDEX contact_name_index ON contacts(name ASC); +CREATE INDEX contact_id_index2 ON contact_addresses(contact_id); diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt index dc2b10393..860c90a3e 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt @@ -352,26 +352,6 @@ suspend fun Peer.fundingRate(amount: Satoshi): LiquidityAds.FundingRate? { return this.remoteFundingRates.filterNotNull().first().findRate(amount) } -suspend fun Peer.altPayOffer( - paymentId: UUID, - amount: MilliSatoshi, - offer: OfferTypes.Offer, - payerKey: PrivateKey, - payerNote: String?, - fetchInvoiceTimeoutInSeconds: Int -): SendPaymentResult { - val res = CompletableDeferred() - this.launch { - res.complete(eventsFlow - .filterIsInstance() - .filter { it.request.paymentId == paymentId } - .first() - ) - } - send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeoutInSeconds.seconds)) - return res.await() -} - suspend fun Peer.betterPayOffer( paymentId: UUID, amount: MilliSatoshi, @@ -392,6 +372,13 @@ suspend fun Peer.betterPayOffer( } } } - send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeoutInSeconds.seconds)) + send(PayOffer( + paymentId = paymentId, + payerKey = payerKey, + payerNote = payerNote, + amount = amount, + offer = offer, + fetchInvoiceTimeout = fetchInvoiceTimeoutInSeconds.seconds + )) return res.await() } From db31f8c350fe9610fbce7fb8e81dddf227a0acea Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:53:59 -0500 Subject: [PATCH 2/3] If payment is sent to a lightningAddress, this information is now properly stored in the payment_metadata table --- .../kotlin/KotlinExtensions+Payments.swift | 15 ++++- .../phoenix-ios/views/main/HomeView.swift | 2 +- .../phoenix-ios/views/send/ValidateView.swift | 26 ++++--- .../views/transactions/TransactionsView.swift | 4 +- .../fr.acinq.phoenix/data/WalletPayment.kt | 1 + .../fr.acinq.phoenix/db/SqlitePaymentsDb.kt | 53 ++++++++++----- .../db/payments/MetadataTypes.kt | 4 ++ .../db/payments/PaymentsMetadataQueries.kt | 10 ++- .../managers/ContactsManager.kt | 39 ++++++----- .../managers/PaymentsManager.kt | 11 +-- .../fr.acinq.phoenix/managers/SendManager.kt | 67 +++++++++++++++++-- .../phoenix/db/sqldelight/PaymentsMetadata.sq | 8 ++- .../sqldelight/paymentsdb/migrations/12.sqm | 7 ++ .../fr/acinq/phoenix/db/CloudKitPaymentsDb.kt | 3 +- .../acinq/phoenix/utils/LightningExposure.kt | 36 +++------- 15 files changed, 190 insertions(+), 96 deletions(-) create mode 100644 phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 44afdaacb..b8af70d07 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -148,7 +148,19 @@ extension WalletPaymentInfo { func addToContactsInfo() -> AddToContactsInfo? { if payment is Lightning_kmpOutgoingPayment { - // Todo: check for lightning address (requires db change in metadata table) + + // First check for a lightning address. + // Remember that an outgoing payment might have both an address & offer (i.e. BIP-353). + // But from the user's perspective, they sent a payment to the address. + // The fact that it used an offer under-the-hood is just a technicality. + // What they expect to save is the lightning address. + // + // Note: in the future we may support something like "offer pinning" for an LN address. + // But that's a different feature. The user's perspective remains the same. + // + if let address = self.metadata.lightningAddress { + return AddToContactsInfo(offer: nil, address: address) + } let invoiceRequest = payment.outgoingInvoiceRequest() if let offer = invoiceRequest?.offer { @@ -168,6 +180,7 @@ extension WalletPaymentMetadata { originalFiat: nil, userDescription: nil, userNotes: nil, + lightningAddress: nil, modifiedAt: nil ) } diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 371d62ac6..a5d6c4f34 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -854,7 +854,7 @@ struct HomeView : MVIView { let contactsManager = Biz.business.contactsManager let updatedRows = paymentsPage.rows.map { info in - let updatedContact = contactsManager.contactForPayment(payment: info.payment) + let updatedContact = contactsManager.contactForPaymentInfo(paymentInfo: info) return WalletPaymentInfo( payment : info.payment, metadata : info.metadata, diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index caca4fd92..c1ae5d6e2 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1809,10 +1809,6 @@ struct ValidateView: View { log.warning("ignore: payment already in progress") return } - guard let peer = Biz.business.peerManager.peerStateValue() else { - log.warning("ignore: peer == nil") - return - } paymentInProgress = true payOfferProblem = nil @@ -1832,14 +1828,16 @@ struct ValidateView: View { payerKey = Lightning_randomKey() } - let response: Lightning_kmpOfferNotPaid? = try await peer.betterPayOffer( - paymentId: paymentId, - amount: Lightning_kmpMilliSatoshi(msat: msat), - offer: model.offer, - payerKey: payerKey, - payerNote: payerNote, - fetchInvoiceTimeoutInSeconds: 30 - ) + let response: Lightning_kmpOfferNotPaid? = + try await Biz.business.sendManager._payBolt12Offer( + paymentId: paymentId, + amount: Lightning_kmpMilliSatoshi(msat: msat), + offer: model.offer, + lightningAddress: model.lightningAddress, + payerKey: payerKey, + payerNote: payerNote, + fetchInvoiceTimeoutInSeconds: 30 + ) paymentInProgress = false @@ -1992,7 +1990,7 @@ struct ValidateView: View { do { let result1: Bitcoin_kmpEither = try await Biz.business.sendManager.lnurlPay_requestInvoice( - paymentIntent: model.paymentIntent, + pay: model, amount: updatedMsat, comment: commentSnapshot ) @@ -2014,7 +2012,7 @@ struct ValidateView: View { let invoice: LnurlPay.Invoice = result1.right! try await Biz.business.sendManager.lnurlPay_payInvoice( - paymentIntent: model.paymentIntent, + pay: model, amount: updatedMsat, comment: commentSnapshot, invoice: invoice, diff --git a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift index ee37a4034..5b674fcad 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift @@ -526,7 +526,7 @@ struct TransactionsView: View { let contactsManager = Biz.business.contactsManager let updatedCachedRows = cachedRows.map { row in - let updatedContact = contactsManager.contactForPayment(payment: row.payment) + let updatedContact = contactsManager.contactForPaymentInfo(paymentInfo: row) return WalletPaymentInfo( payment : row.payment, metadata : row.metadata, @@ -535,7 +535,7 @@ struct TransactionsView: View { } let updatedPaymentsPageRows = paymentsPage.rows.map { row in - let updatedContact = contactsManager.contactForPayment(payment: row.payment) + let updatedContact = contactsManager.contactForPaymentInfo(paymentInfo: row) return WalletPaymentInfo( payment : row.payment, metadata : row.metadata, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt index f5220ced2..36f90af1b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt @@ -22,6 +22,7 @@ data class WalletPaymentMetadata( val originalFiat: ExchangeRate.BitcoinPriceRate? = null, val userDescription: String? = null, val userNotes: String? = null, + val lightningAddress: String? = null, val modifiedAt: Long? = null ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt index cc4712820..f2e9ec2ce 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt @@ -157,27 +157,50 @@ class SqlitePaymentsDb( * - fetch contact details for incoming/outgoing bolt12 payments. */ private fun List.postProcess(): List = this.map { paymentInfo -> - val payment = paymentInfo.payment - when { - payment is Bolt12IncomingPayment || (payment is LightningOutgoingPayment && payment.details is LightningOutgoingPayment.Details.Blinded) -> { - paymentInfo.copy(contact = contactsManager?.contactForPayment(payment)) - } - else -> paymentInfo - } + // There's no need to check payment types here - all those checks are already done in ContactsManager. + contactsManager?.contactForPaymentInfo(paymentInfo)?.let { + paymentInfo.copy(contact = it) + } ?: paymentInfo } @Suppress("UNUSED_PARAMETER") - private fun mapPaymentsAndMetadata(data_: ByteArray, payment_id: UUID?, - lnurl_base_type: LnurlBase.TypeVersion?, lnurl_base_blob: ByteArray?, lnurl_description: String?, lnurl_metadata_type: LnurlMetadata.TypeVersion?, lnurl_metadata_blob: ByteArray?, - lnurl_successAction_type: LnurlSuccessAction.TypeVersion?, lnurl_successAction_blob: ByteArray?, - user_description: String?, user_notes: String?, modified_at: Long?, original_fiat_type: String?, original_fiat_rate: Double?): WalletPaymentInfo { + private fun mapPaymentsAndMetadata( + data_: ByteArray, + payment_id: UUID?, + lnurl_base_type: LnurlBase.TypeVersion?, + lnurl_base_blob: ByteArray?, + lnurl_description: String?, + lnurl_metadata_type: LnurlMetadata.TypeVersion?, + lnurl_metadata_blob: ByteArray?, + lnurl_successAction_type: LnurlSuccessAction.TypeVersion?, + lnurl_successAction_blob: ByteArray?, + user_description: String?, + user_notes: String?, + modified_at: Long?, + original_fiat_type: String?, + original_fiat_rate: Double?, + lightning_address: String? + ): WalletPaymentInfo { val payment = WalletPaymentAdapter.decode(data_) + val metadata = PaymentsMetadataQueries.mapAll( + id = payment.id, + lnurl_base_type = lnurl_base_type, + lnurl_base_blob = lnurl_base_blob, + lnurl_description = lnurl_description, + lnurl_metadata_type = lnurl_metadata_type, + lnurl_metadata_blob = lnurl_metadata_blob, + lnurl_successAction_type = lnurl_successAction_type, + lnurl_successAction_blob = lnurl_successAction_blob, + user_description = user_description, + user_notes = user_notes, + modified_at = modified_at, + original_fiat_type = original_fiat_type, + original_fiat_rate = original_fiat_rate, + lightning_address = lightning_address + ) return WalletPaymentInfo( payment = payment, - metadata = PaymentsMetadataQueries.mapAll(payment.id, - lnurl_base_type, lnurl_base_blob, lnurl_description, lnurl_metadata_type, lnurl_metadata_blob, - lnurl_successAction_type, lnurl_successAction_blob, - user_description, user_notes, modified_at, original_fiat_type, original_fiat_rate), + metadata = metadata, contact = null ) } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt index 21166ab4c..6910b9cac 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt @@ -223,6 +223,7 @@ data class WalletPaymentMetadataRow( val original_fiat: Pair? = null, val user_description: String? = null, val user_notes: String? = null, + val lightning_address: String? = null, val modified_at: Long? = null ) { @@ -271,6 +272,7 @@ data class WalletPaymentMetadataRow( originalFiat = originalFiat, userDescription = user_description, userNotes = user_notes, + lightningAddress = lightning_address, modifiedAt = modified_at ) } @@ -286,6 +288,7 @@ data class WalletPaymentMetadataRow( && original_fiat == null && user_description == null && user_notes == null + && lightning_address == null } companion object { @@ -319,6 +322,7 @@ data class WalletPaymentMetadataRow( original_fiat = originalFiat, user_description = metadata.userDescription, user_notes = metadata.userNotes, + lightning_address = metadata.lightningAddress, modified_at = metadata.modifiedAt ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt index 3430ed08d..b477a7b75 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt @@ -30,7 +30,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { user_notes = data.user_notes, modified_at = data.modified_at, original_fiat_type = data.original_fiat?.first, - original_fiat_rate = data.original_fiat?.second + original_fiat_rate = data.original_fiat?.second, + lightning_address = data.lightning_address ) didUpdateWalletPaymentMetadata(id, database) } @@ -68,7 +69,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { user_notes = userNotes, modified_at = modifiedAt, original_fiat_type = null, - original_fiat_rate = null + original_fiat_rate = null, + lightning_address = null ) } didUpdateWalletPaymentMetadata(id, database) @@ -91,7 +93,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { user_notes: String?, modified_at: Long?, original_fiat_type: String?, - original_fiat_rate: Double? + original_fiat_rate: Double?, + lightning_address: String? ): WalletPaymentMetadata { val lnurlBase = if (lnurl_base_type != null && lnurl_base_blob != null) { @@ -121,6 +124,7 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { original_fiat = originalFiat, user_description = user_description, user_notes = user_notes, + lightning_address = lightning_address, modified_at = modified_at ).deserialize() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt index 42239b19e..3b95c8a48 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt @@ -26,6 +26,7 @@ import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.WalletPaymentInfo import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest @@ -124,24 +125,6 @@ class ContactsManager( return contactsMap.value[contactId] } - fun contactIdForPayment(payment: WalletPayment): UUID? { - return if (payment is IncomingPayment) { - payment.incomingOfferMetadata()?.let { offerMetadata -> - publicKeyMap.value[offerMetadata.payerKey] - } - } else { - payment.outgoingInvoiceRequest()?.let {invoiceRequest -> - offerMap.value[invoiceRequest.offer.offerId] - } - } - } - - fun contactForPayment(payment: WalletPayment): ContactInfo? { - return contactIdForPayment(payment)?.let { contactId -> - contactForId(contactId) - } - } - fun contactIdForOfferId(offerId: ByteVector32): UUID? { return offerMap.value[offerId] } @@ -179,4 +162,24 @@ class ContactsManager( contactForId(contactId) } } + + fun contactIdForPaymentInfo(paymentInfo: WalletPaymentInfo): UUID? { + return if (paymentInfo.payment is IncomingPayment) { + paymentInfo.payment.incomingOfferMetadata()?.let { offerMetadata -> + contactIdForPayerPubKey(offerMetadata.payerKey) + } + } else { + paymentInfo.metadata.lightningAddress?.let { address -> + contactIdForLightningAddress(address) + } ?: paymentInfo.payment.outgoingInvoiceRequest()?.let { invoiceRequest -> + contactIdForOfferId(invoiceRequest.offer.offerId) + } + } + } + + fun contactForPaymentInfo(paymentInfo: WalletPaymentInfo): ContactInfo? { + return contactIdForPaymentInfo(paymentInfo)?.let { contactId -> + contactForId(contactId) + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt index bdea68b91..6e8548172 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt @@ -126,13 +126,14 @@ class PaymentsManager( id: UUID ): WalletPaymentInfo? { return paymentsDb().getPayment(id)?.let { - val payment = it.first - val contact = contactsManager.contactForPayment(payment) - WalletPaymentInfo( - payment = payment, + val paymentInfo = WalletPaymentInfo( + payment = it.first, metadata = it.second ?: WalletPaymentMetadata(), - contact = contact + contact = null ) + contactsManager.contactForPaymentInfo(paymentInfo)?.let { + paymentInfo.copy(contact = it) + } ?: paymentInfo } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt index 9a7fd8eff..c3e1f207c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt @@ -2,12 +2,17 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.Lightning import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.io.OfferInvoiceReceived +import fr.acinq.lightning.io.OfferNotPaid import fr.acinq.lightning.io.PayInvoice +import fr.acinq.lightning.io.PayOffer +import fr.acinq.lightning.io.Peer import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.error @@ -30,12 +35,15 @@ import fr.acinq.phoenix.utils.DnsResolvers import fr.acinq.phoenix.utils.EmailLikeAddress import fr.acinq.phoenix.utils.Parser import io.ktor.http.Url +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull @@ -460,6 +468,50 @@ class SendManager( ) } + suspend fun payBolt12Offer( + paymentId: UUID, + amount: MilliSatoshi, + offer: OfferTypes.Offer, + lightningAddress: String?, + payerKey: PrivateKey, + payerNote: String?, + fetchInvoiceTimeout: Duration + ): OfferNotPaid? { + val peer = peerManager.getPeer() + + lightningAddress?.let { + val metadata = WalletPaymentMetadata(lightningAddress = it) + WalletPaymentMetadataRow.serialize(metadata)?.let { row -> + databaseManager.paymentsDb().enqueueMetadata( + row = row, + id = paymentId + ) + } + } + + val res = CompletableDeferred() + launch { + peer.eventsFlow.collect { + if (it is OfferNotPaid && it.request.paymentId == paymentId) { + res.complete(it) + cancel() + } else if (it is OfferInvoiceReceived && it.request.paymentId == paymentId) { + res.complete(null) + cancel() + } + } + } + peer.send(PayOffer( + paymentId = paymentId, + payerKey = payerKey, + payerNote = payerNote, + amount = amount, + offer = offer, + fetchInvoiceTimeout = fetchInvoiceTimeout + )) + return res.await() + } + /** * Step 1 of 2: * First call this function to convert the LnurlPay.Intent into a LnurlPay.Invoice. @@ -467,12 +519,12 @@ class SendManager( * Note: This step is cancellable. The UI can simply ignore the result. */ suspend fun lnurlPay_requestInvoice( - paymentIntent: LnurlPay.Intent, + pay: ParseResult.Lnurl.Pay, amount: MilliSatoshi, comment: String? ): Either { val task = lnurlManager.requestPayInvoice( - intent = paymentIntent, + intent = pay.paymentIntent, amount = amount, comment = comment ) @@ -491,7 +543,7 @@ class SendManager( else -> Either.Left( LnurlPayError.RemoteError( LnurlError.RemoteFailure.Unreadable( - origin = paymentIntent.callback.host + origin = pay.paymentIntent.callback.host ) ) ) @@ -506,7 +558,7 @@ class SendManager( * Note: This step is non-cancellable. */ suspend fun lnurlPay_payInvoice( - paymentIntent: LnurlPay.Intent, + pay: ParseResult.Lnurl.Pay, amount: MilliSatoshi, comment: String?, invoice: LnurlPay.Invoice, @@ -518,11 +570,12 @@ class SendManager( invoice = invoice.invoice, metadata = WalletPaymentMetadata( lnurl = LnurlPayMetadata( - pay = paymentIntent, - description = paymentIntent.metadata.plainText, + pay = pay.paymentIntent, + description = pay.paymentIntent.metadata.plainText, successAction = invoice.successAction ), - userNotes = comment + userNotes = comment, + lightningAddress = pay.lightningAddress ) ) } diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq index 562c9a55f..169e30adf 100644 --- a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq @@ -26,7 +26,8 @@ CREATE TABLE IF NOT EXISTS payments_metadata ( user_notes TEXT, modified_at INTEGER, original_fiat_type TEXT, - original_fiat_rate REAL + original_fiat_rate REAL, + lightning_address TEXT ); -- queries for payments_metadata table @@ -44,8 +45,9 @@ INSERT INTO payments_metadata ( lnurl_successAction_type, lnurl_successAction_blob, user_description, user_notes, modified_at, - original_fiat_type, original_fiat_rate) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + original_fiat_type, original_fiat_rate, + lightning_address) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); updateUserInfo: UPDATE payments_metadata diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm new file mode 100644 index 000000000..32387bd71 --- /dev/null +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm @@ -0,0 +1,7 @@ +-- Migration: v12 -> v13 +-- +-- Changes: +-- * Added a new column [lightning_address] in table [payments_metadata] + +ALTER TABLE payments_metadata + ADD COLUMN lightning_address TEXT; diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt index de4efe651..abc923b79 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt @@ -248,7 +248,8 @@ class CloudKitPaymentsDb( user_notes = row.user_notes, modified_at = row.modified_at, original_fiat_type = row.original_fiat?.first, - original_fiat_rate = row.original_fiat?.second + original_fiat_rate = row.original_fiat?.second, + lightning_address = row.lightning_address ) } } // diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt index 860c90a3e..2bb78b2f3 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt @@ -30,15 +30,12 @@ import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.NativeSocketException -import fr.acinq.lightning.io.OfferInvoiceReceived import fr.acinq.lightning.io.OfferNotPaid import fr.acinq.lightning.io.PaymentNotSent import fr.acinq.lightning.io.PaymentProgress import fr.acinq.lightning.io.PaymentSent -import fr.acinq.lightning.io.PayOffer import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.PeerEvent -import fr.acinq.lightning.io.SendPaymentResult import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.payment.LiquidityPolicy @@ -50,13 +47,9 @@ import fr.acinq.lightning.utils.toByteArray import fr.acinq.lightning.utils.toNSData import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance +import fr.acinq.phoenix.managers.SendManager import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import platform.Foundation.NSData import kotlin.time.Duration.Companion.seconds @@ -352,33 +345,24 @@ suspend fun Peer.fundingRate(amount: Satoshi): LiquidityAds.FundingRate? { return this.remoteFundingRates.filterNotNull().first().findRate(amount) } -suspend fun Peer.betterPayOffer( +// kotlinx.datetime.Duration isn't properly exposed to iOS. +// So we need this little workaround until that issue is fixed. +suspend fun SendManager._payBolt12Offer( paymentId: UUID, amount: MilliSatoshi, offer: OfferTypes.Offer, + lightningAddress: String?, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeoutInSeconds: Int ): OfferNotPaid? { - val res = CompletableDeferred() - launch { - eventsFlow.collect { - if (it is OfferNotPaid && it.request.paymentId == paymentId) { - res.complete(it) - cancel() - } else if (it is OfferInvoiceReceived && it.request.paymentId == paymentId) { - res.complete(null) - cancel() - } - } - } - send(PayOffer( + return payBolt12Offer( paymentId = paymentId, - payerKey = payerKey, - payerNote = payerNote, amount = amount, offer = offer, + lightningAddress = lightningAddress, + payerKey = payerKey, + payerNote = payerNote, fetchInvoiceTimeout = fetchInvoiceTimeoutInSeconds.seconds - )) - return res.await() + ) } From 6291988d9ec62d6b0626ddc85cf460e3159efdeb Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:54:19 -0500 Subject: [PATCH 3/3] Fixing CloudContact bug (incorrect version number), and refactoring to match common design pattern. --- .../sync/SyncBackupManager+Contacts.swift | 4 +- .../db/cloud/contacts/CloudContact.kt | 292 +++++++++--------- 2 files changed, 156 insertions(+), 140 deletions(-) diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift index 5887d7bcc..d6559118a 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift @@ -686,7 +686,7 @@ extension SyncBackupManager { _ contact: ContactInfo ) -> Data? { - let wrapper = CloudContact_V2(contact: contact) + let wrapper = CloudContact.V1(contact: contact) let cbor = wrapper.cborSerialize().toSwiftData() #if DEBUG @@ -751,7 +751,7 @@ extension SyncBackupManager { if let cleartext { do { let cleartext_kotlin = cleartext.toKotlinByteArray() - contact = try CloudContact_V2.companion.cborDeserializeAndUnwrap( + contact = try CloudContact.companion.cborDeserializeAndUnwrap( blob: cleartext_kotlin, photoUri: photoUri ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt index c6cc71494..de2de21ad 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt @@ -19,163 +19,179 @@ import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -enum class CloudContactVersion(val value: Int) { - // Initial version - V0(0), - V1(1) - // Future versions go here -} - -@Serializable -data class CloudContactVersionSwitch( - @SerialName("v") - val version: Int -) - -@Serializable -data class CloudContact_V1( - @SerialName("v") - val version: Int, - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val name: String, - val useOfferKey: Boolean, - val offers: List<@Serializable(OfferSerializer::class) OfferTypes.Offer>, -) { - @Throws(Exception::class) - fun unwrap(photoUri: String?): ContactInfo { - val now = Clock.System.now() - val mappedOffers: List = this.offers.map { - ContactOffer(offer = it, label = "", createdAt = now) - } - return ContactInfo( - id = this.id, - name = this.name, - photoUri = photoUri, - useOfferKey = this.useOfferKey, - offers = mappedOffers, - addresses = listOf() - ) - } +sealed class CloudContact { - companion object -} - -@Serializable -data class CloudContact_V2( - @SerialName("v") - val version: Int, - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val name: String, - val useOfferKey: Boolean, - val offers: List, - val addresses: List -) { - constructor(contact: ContactInfo) : this( - version = CloudContactVersion.V0.value, - id = contact.id, - name = contact.name, - useOfferKey = contact.useOfferKey, - offers = contact.offers.map { ContactOfferWrapper(it) }, - addresses = contact.addresses.map { ContactAddressWrapper(it) } - ) - - @Throws(Exception::class) - fun unwrap(photoUri: String?): ContactInfo { - return ContactInfo( - id = this.id, - name = this.name, - photoUri = photoUri, - useOfferKey = this.useOfferKey, - offers = this.offers.map { it.unwrap() }, - addresses = this.addresses.map { it.unwrap() } - ) + enum class Version(val value: Int) { + // Initial version + V0(0), + V1(1) + // Future versions go here } - companion object + @Serializable + data class VersionSwitch( + @SerialName("v") + val version: Int + ) @Serializable - data class ContactOfferWrapper( - @Serializable(with = OfferSerializer::class) - val offer: OfferTypes.Offer, - val label: String, - val createdAt: Long - ) { - constructor(offer: ContactOffer) : this( - offer = offer.offer, - label = offer.label ?: "", - createdAt = offer.createdAt.toEpochMilliseconds() - ) + data class V0( + @SerialName("v") + val version: Int, + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val name: String, + val useOfferKey: Boolean, + val offers: List<@Serializable(OfferSerializer::class) OfferTypes.Offer>, + ): CloudContact() { @Throws(Exception::class) - fun unwrap(): ContactOffer { - return ContactOffer( - offer = this.offer, - label = this.label, - createdAt = Instant.fromEpochMilliseconds(this.createdAt) + fun unwrap(photoUri: String?): ContactInfo { + val now = Clock.System.now() + val mappedOffers: List = this.offers.map { + ContactOffer(offer = it, label = "", createdAt = now) + } + return ContactInfo( + id = this.id, + name = this.name, + photoUri = photoUri, + useOfferKey = this.useOfferKey, + offers = mappedOffers, + addresses = listOf() ) } + + companion object } @Serializable - data class ContactAddressWrapper( - val address: String, - val label: String, - val createdAt: Long - ) { - constructor(address: ContactAddress) : this( - address = address.address, - label = address.label ?: "", - createdAt = address.createdAt.toEpochMilliseconds() + data class V1( + @SerialName("v") + val version: Int, + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val name: String, + val useOfferKey: Boolean, + val offers: List, + val addresses: List + ): CloudContact() { + + constructor(contact: ContactInfo) : this( + version = Version.V1.value, + id = contact.id, + name = contact.name, + useOfferKey = contact.useOfferKey, + offers = contact.offers.map { ContactOfferWrapper(it) }, + addresses = contact.addresses.map { ContactAddressWrapper(it) } ) + @OptIn(ExperimentalSerializationApi::class) + fun cborSerialize(): ByteArray { + return Cbor.encodeToByteArray(this) + } + + /** + * For DEBUGGING: + * + * You can use the jsonSerializer to see what the data looks like. + * Just keep in mind that the ByteArray's will be encoded super-inefficiently. + * That's because we're optimizing for Cbor. + * To optimize for JSON, you would use ByteVector's, + * and encode the data as Base64 via ByteVectorJsonSerializer. + */ + fun jsonSerialize(): ByteArray { + return Json.encodeToString(this).encodeToByteArray() + } + @Throws(Exception::class) - fun unwrap(): ContactAddress { - return ContactAddress( - address = this.address, - label = this.label, - createdAt = Instant.fromEpochMilliseconds(this.createdAt) + fun unwrap(photoUri: String?): ContactInfo { + return ContactInfo( + id = this.id, + name = this.name, + photoUri = photoUri, + useOfferKey = this.useOfferKey, + offers = this.offers.map { it.unwrap() }, + addresses = this.addresses.map { it.unwrap() } ) } + + companion object + + @Serializable + data class ContactOfferWrapper( + @Serializable(with = OfferSerializer::class) + val offer: OfferTypes.Offer, + val label: String, + val createdAt: Long + ) { + constructor(offer: ContactOffer) : this( + offer = offer.offer, + label = offer.label ?: "", + createdAt = offer.createdAt.toEpochMilliseconds() + ) + + @Throws(Exception::class) + fun unwrap(): ContactOffer { + return ContactOffer( + offer = this.offer, + label = this.label, + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + } + + @Serializable + data class ContactAddressWrapper( + val address: String, + val label: String, + val createdAt: Long + ) { + constructor(address: ContactAddress) : this( + address = address.address, + label = address.label ?: "", + createdAt = address.createdAt.toEpochMilliseconds() + ) + + @Throws(Exception::class) + fun unwrap(): ContactAddress { + return ContactAddress( + address = this.address, + label = this.label, + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + } } -} - -typealias CloudContact = CloudContact_V2 - -@OptIn(ExperimentalSerializationApi::class) -fun CloudContact.cborSerialize(): ByteArray { - return Cbor.encodeToByteArray(this) -} - -@OptIn(ExperimentalSerializationApi::class) -@Throws(Exception::class) -fun CloudContact_V2.Companion.cborDeserializeAndUnwrap( - blob: ByteArray, - photoUri: String? -): ContactInfo? { - val serializer = cborSerializer() - val header: CloudContactVersionSwitch = serializer.decodeFromByteArray(blob) - return when (header.version) { - CloudContactVersion.V0.value -> { - serializer.decodeFromByteArray(blob).unwrap(photoUri) + + companion object { + + @OptIn(ExperimentalSerializationApi::class) + @Throws(Exception::class) + fun cborDeserializeVersion( + blob: ByteArray + ): Int { + val serializer = cborSerializer() + val header: VersionSwitch = serializer.decodeFromByteArray(blob) + return header.version } - CloudContactVersion.V1.value -> { - serializer.decodeFromByteArray(blob).unwrap(photoUri) + + @OptIn(ExperimentalSerializationApi::class) + @Throws(Exception::class) + fun cborDeserializeAndUnwrap( + blob: ByteArray, + photoUri: String? + ): ContactInfo? { + val serializer = cborSerializer() + val header: VersionSwitch = serializer.decodeFromByteArray(blob) + return when (header.version) { + Version.V0.value -> { + serializer.decodeFromByteArray(blob).unwrap(photoUri) + } + Version.V1.value -> { + serializer.decodeFromByteArray(blob).unwrap(photoUri) + } + else -> null + } } - else -> null } -} - -/** - * For DEBUGGING: - * - * You can use the jsonSerializer to see what the data looks like. - * Just keep in mind that the ByteArray's will be encoded super-inefficiently. - * That's because we're optimizing for Cbor. - * To optimize for JSON, you would use ByteVector's, - * and encode the data as Base64 via ByteVectorJsonSerializer. - */ -fun CloudContact.jsonSerialize(): ByteArray { - return Json.encodeToString(this).encodeToByteArray() } \ No newline at end of file