diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index cdbf8ca04..69e74e96d 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -240,6 +240,8 @@ DCA6DECE282AB12B0073C658 /* SecurityFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6F82566D0BA0009B01E /* SecurityFile.swift */; }; DCA6DED0282AB7E20073C658 /* KeychainConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA6DECF282AB7E20073C658 /* KeychainConstants.swift */; }; DCA6DED1282ABA930073C658 /* KeychainConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA6DECF282AB7E20073C658 /* KeychainConstants.swift */; }; + DCA7263B2C80BA0E00600716 /* SyncBackupManager+Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA7263A2C80BA0E00600716 /* SyncBackupManager+Payments.swift */; }; + DCA7263D2C80BC0800600716 /* SyncBackupManager+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA7263C2C80BC0800600716 /* SyncBackupManager+Contacts.swift */; }; DCA849E02813311D000FADE1 /* aes256Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA849DF2813311D000FADE1 /* aes256Tests.swift */; }; DCA849E2281333EB000FADE1 /* currencyFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA849E1281333EB000FADE1 /* currencyFormattingTests.swift */; }; DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC5B6F27726FC80077BB98 /* DeepLink.swift */; }; @@ -266,8 +268,8 @@ DCB410892902D5BF00CE4FF9 /* PaymentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB410882902D5BF00CE4FF9 /* PaymentsSection.swift */; }; DCB493CB269F3B06001B0F09 /* Result+Deugly.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493CA269F3B05001B0F09 /* Result+Deugly.swift */; }; DCB493CD269F8531001B0F09 /* Int+TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493CC269F8531001B0F09 /* Int+TimeInterval.swift */; }; - DCB493CF269F859E001B0F09 /* SyncTxManager_State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493CE269F859E001B0F09 /* SyncTxManager_State.swift */; }; - DCB493D1269F890D001B0F09 /* SyncTxManager_PendingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493D0269F890D001B0F09 /* SyncTxManager_PendingSettings.swift */; }; + DCB493CF269F859E001B0F09 /* SyncBackupManager_State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493CE269F859E001B0F09 /* SyncBackupManager_State.swift */; }; + DCB493D1269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB493D0269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift */; }; DCB511CA281AED58001BC525 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB511C9281AED58001BC525 /* NotificationService.swift */; }; DCB511CE281AED58001BC525 /* phoenix-notifySrvExt.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCB5D2DF280879460020B8F5 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB5D2DE280879460020B8F5 /* DeviceInfo.swift */; }; @@ -277,7 +279,7 @@ DCB876322735AAB500657570 /* UserDefaults+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB876312735AAB500657570 /* UserDefaults+Codable.swift */; }; DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */; }; DCC46F1625C3521C005D32D9 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = DC72C31825A3CF87008A927A /* FirebaseMessaging */; }; - DCC9D99A267BD28600EA36DD /* SyncTxManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC9D999267BD28600EA36DD /* SyncTxManager.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 */; }; DCCCEAB528F6DA2A0047871A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCCEAB428F6DA2A0047871A /* RootView.swift */; }; @@ -320,7 +322,7 @@ DCE3C7AB2A6AD3CC00F4D385 /* MempoolMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE3C7AA2A6AD3CC00F4D385 /* MempoolMonitor.swift */; }; DCE443AC2B3482C800CABA96 /* LiquidityFeeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE443AB2B3482C800CABA96 /* LiquidityFeeInfo.swift */; }; DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE6FB8B28D0B5F200054511 /* ResetWalletView.swift */; }; - DCE7232E27AD68CD0017CF56 /* SyncTxManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232D27AD68CD0017CF56 /* SyncTxManager_Actor.swift */; }; + DCE7232E27AD68CD0017CF56 /* SyncBackupManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232D27AD68CD0017CF56 /* SyncBackupManager_Actor.swift */; }; DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */; }; DCE77A5627C5240500F0FA24 /* TLSConnectionCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE77A5527C5240500F0FA24 /* TLSConnectionCheck.swift */; }; DCE77A5827C671D600F0FA24 /* ElectrumAddressSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */; }; @@ -629,6 +631,8 @@ DCA6DEC52829BDEB0073C658 /* CrossProcessCommunication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossProcessCommunication.swift; sourceTree = ""; }; DCA6DECB282AAA740073C658 /* SharedSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedSecurity.swift; sourceTree = ""; }; DCA6DECF282AB7E20073C658 /* KeychainConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainConstants.swift; sourceTree = ""; }; + DCA7263A2C80BA0E00600716 /* SyncBackupManager+Payments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncBackupManager+Payments.swift"; sourceTree = ""; }; + DCA7263C2C80BC0800600716 /* SyncBackupManager+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncBackupManager+Contacts.swift"; sourceTree = ""; }; DCA849DF2813311D000FADE1 /* aes256Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = aes256Tests.swift; sourceTree = ""; }; DCA849E1281333EB000FADE1 /* currencyFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = currencyFormattingTests.swift; sourceTree = ""; }; DCAC5B6F27726FC80077BB98 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; @@ -655,8 +659,8 @@ DCB410882902D5BF00CE4FF9 /* PaymentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsSection.swift; sourceTree = ""; }; DCB493CA269F3B05001B0F09 /* Result+Deugly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Deugly.swift"; sourceTree = ""; }; DCB493CC269F8531001B0F09 /* Int+TimeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+TimeInterval.swift"; sourceTree = ""; }; - DCB493CE269F859E001B0F09 /* SyncTxManager_State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTxManager_State.swift; sourceTree = ""; }; - DCB493D0269F890D001B0F09 /* SyncTxManager_PendingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTxManager_PendingSettings.swift; sourceTree = ""; }; + DCB493CE269F859E001B0F09 /* SyncBackupManager_State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBackupManager_State.swift; sourceTree = ""; }; + DCB493D0269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBackupManager_PendingSettings.swift; sourceTree = ""; }; DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "phoenix-notifySrvExt.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; DCB511C9281AED58001BC525 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DCB511CB281AED58001BC525 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -668,7 +672,7 @@ DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager.swift; sourceTree = ""; }; DCBDB8812BE154840097F940 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = de; path = de.lproj/about.html; sourceTree = ""; }; DCBDB8822BE154840097F940 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = de; path = de.lproj/liquidity.html; sourceTree = ""; }; - DCC9D999267BD28600EA36DD /* SyncTxManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTxManager.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 = ""; }; DCCCEAB428F6DA2A0047871A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -702,7 +706,7 @@ DCE3C7AA2A6AD3CC00F4D385 /* MempoolMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolMonitor.swift; sourceTree = ""; }; DCE443AB2B3482C800CABA96 /* LiquidityFeeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityFeeInfo.swift; sourceTree = ""; }; DCE6FB8B28D0B5F200054511 /* ResetWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetWalletView.swift; sourceTree = ""; }; - DCE7232D27AD68CD0017CF56 /* SyncTxManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTxManager_Actor.swift; sourceTree = ""; }; + DCE7232D27AD68CD0017CF56 /* SyncBackupManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBackupManager_Actor.swift; sourceTree = ""; }; DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager_Actor.swift; sourceTree = ""; }; DCE77A5527C5240500F0FA24 /* TLSConnectionCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSConnectionCheck.swift; sourceTree = ""; }; DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElectrumAddressSheet.swift; sourceTree = ""; }; @@ -1392,10 +1396,12 @@ DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */, DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */, DCAEF8D8275E69B000015993 /* SyncSeedManager_State.swift */, - DCC9D999267BD28600EA36DD /* SyncTxManager.swift */, - DCE7232D27AD68CD0017CF56 /* SyncTxManager_Actor.swift */, - DCB493CE269F859E001B0F09 /* SyncTxManager_State.swift */, - DCB493D0269F890D001B0F09 /* SyncTxManager_PendingSettings.swift */, + DCC9D999267BD28600EA36DD /* SyncBackupManager.swift */, + DCA7263C2C80BC0800600716 /* SyncBackupManager+Contacts.swift */, + DCA7263A2C80BA0E00600716 /* SyncBackupManager+Payments.swift */, + DCE7232D27AD68CD0017CF56 /* SyncBackupManager_Actor.swift */, + DCB493CE269F859E001B0F09 /* SyncBackupManager_State.swift */, + DCB493D0269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift */, ); path = sync; sourceTree = ""; @@ -1804,7 +1810,7 @@ DC3780412C077E0300937C8E /* PriorityBoxStyle.swift in Sources */, DC3FDCAF2C3306AB002C5931 /* LightningDualView.swift in Sources */, DC99E90925B78FA800FB20F7 /* EnabledSecurity.swift in Sources */, - DCB493CF269F859E001B0F09 /* SyncTxManager_State.swift in Sources */, + DCB493CF269F859E001B0F09 /* SyncBackupManager_State.swift in Sources */, DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */, DC98D3982AF2AE41005BD177 /* ReceiveView.swift in Sources */, DC9E7EC32A12955300A5F1D0 /* LiquidityHTML.swift in Sources */, @@ -1826,11 +1832,12 @@ DC4309702A795F9900E28995 /* UtxoWrapper.swift in Sources */, DC355E1D2A4398A8008E8A8E /* NoticeBox.swift in Sources */, 7555FF81242A565900829871 /* SceneDelegate.swift in Sources */, - DCC9D99A267BD28600EA36DD /* SyncTxManager.swift in Sources */, + DCC9D99A267BD28600EA36DD /* SyncBackupManager.swift in Sources */, DCB876302735AA7300657570 /* UserDefaults+Serialization.swift in Sources */, DC5F1C4C28DDF702007A55ED /* DrainWalletView_Action.swift in Sources */, DCE1E5FA26418183005465B8 /* Toast.swift in Sources */, DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */, + DCA7263D2C80BC0800600716 /* SyncBackupManager+Contacts.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, DCFB8DF92A94112A00947698 /* Dictionary+MapKeys.swift in Sources */, DC37803B2C050F7000937C8E /* SpendExpiredSwapIns.swift in Sources */, @@ -1921,7 +1928,7 @@ DC72C33425A51AAC008A927A /* CurrencyPrefs.swift in Sources */, DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */, DC3345D02C2B4C1200EDD2D4 /* ManageContact.swift in Sources */, - DCE7232E27AD68CD0017CF56 /* SyncTxManager_Actor.swift in Sources */, + DCE7232E27AD68CD0017CF56 /* SyncBackupManager_Actor.swift in Sources */, DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */, DC65BBF22A58A40700EBA651 /* CpfpView.swift in Sources */, DC422F3329392ABD00E72253 /* Date+Format.swift in Sources */, @@ -1942,6 +1949,7 @@ DCCD046127EE045C007D57A5 /* DetailsView.swift in Sources */, DC6CF35B2938F32E001837EE /* ListBackgroundColor.swift in Sources */, DC641C82282188E700862DCD /* Utils+CurrencyPrefs.swift in Sources */, + DCA7263B2C80BA0E00600716 /* SyncBackupManager+Payments.swift in Sources */, DC5F1C4828DDA539007A55ED /* DrainWalletView_Confirm.swift in Sources */, DC641C802821767D00862DCD /* Prefs+BackupTransactions.swift in Sources */, DC46BAF426CACCF700E760A6 /* KotlinFlow.swift in Sources */, @@ -1987,7 +1995,7 @@ DCDD9ED628637FD7001800A3 /* AppStatusButton.swift in Sources */, DCCFE6A62B63028A002FFF11 /* UnfairLock.swift in Sources */, DC37803F2C06338000937C8E /* BtcAddressInput.swift in Sources */, - DCB493D1269F890D001B0F09 /* SyncTxManager_PendingSettings.swift in Sources */, + DCB493D1269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift in Sources */, DCAEF8F727628BEB00015993 /* Either.swift in Sources */, DCA6DECC282AAA740073C658 /* SharedSecurity.swift in Sources */, DC5CA4EF28F842F10048A737 /* MVI+Extensions.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 83ed8a507..5150e590c 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -14452,8 +14452,12 @@ } } } + }, + "Delete payment history and contacts from my iCloud account." : { + }, "Delete payment history from my iCloud account." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14692,8 +14696,12 @@ } } } + }, + "Deleting **payment history** and **contacts** from iCloud" : { + }, "Deleting **payment history** from iCloud" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -21793,8 +21801,12 @@ } } } + }, + "If you switch to a new device (or reinstall the app) then you'll lose this information." : { + }, "If you switch to a new device (or reinstall the app) then you'll lose your payment history." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -30280,8 +30292,12 @@ } } } + }, + "Payment history and contacts not stored in iCloud." : { + }, "Payment history not stored in iCloud." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -31050,6 +31066,7 @@ } }, "Payments backup" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -39018,8 +39035,12 @@ } } } + }, + "The **payment history** and **contacts** for this wallet will be deleted from your iCloud account." : { + }, "The **payment history** for this wallet will be deleted from your iCloud account." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -48543,8 +48564,15 @@ } } } + }, + "Your payment history and contacts are only stored on this device." : { + + }, + "Your payment history and contacts will be stored in iCloud." : { + }, "Your payment history is only stored on this device." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -48585,6 +48613,7 @@ } }, "Your payment history will be stored in iCloud." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift index 0ef14da51..ca77b246c 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift @@ -1,14 +1,54 @@ import Foundation import PhoenixShared +struct FetchContactsQueueBatchResult { + let rowids: [Int64] + let rowidMap: [Int64: Lightning_kmpUUID] + let rowMap: [Lightning_kmpUUID : ContactInfo] + let metadataMap: [Lightning_kmpUUID : KotlinByteArray] + + func uniqueContactIds() -> Set { + return Set(rowidMap.values) + } + + func rowidsMatching(_ query: Lightning_kmpUUID) -> [Int64] { + var results = [Int64]() + for (rowid, contactId) in rowidMap { + if contactId == query { + results.append(rowid) + } + } + return results + } + + func rowidsMatching(_ query: String) -> [Int64] { + var results = [Int64]() + for (rowid, contactId) in rowidMap { + if contactId.id == query { + results.append(rowid) + } + } + return results + } + + static func empty() -> FetchContactsQueueBatchResult { + + return FetchContactsQueueBatchResult( + rowids: [], + rowidMap: [:], + rowMap: [:], + metadataMap: [:] + ) + } +} -struct FetchQueueBatchResult { +struct FetchPaymentsQueueBatchResult { let rowids: [Int64] let rowidMap: [Int64: WalletPaymentId] let rowMap: [WalletPaymentId : WalletPaymentInfo] let metadataMap: [WalletPaymentId : KotlinByteArray] - let incomingStats: CloudKitDb.MetadataStats - let outgoingStats: CloudKitDb.MetadataStats + let incomingStats: CloudKitPaymentsDb.MetadataStats + let outgoingStats: CloudKitPaymentsDb.MetadataStats func uniquePaymentIds() -> Set { return Set(rowidMap.values) @@ -24,22 +64,69 @@ struct FetchQueueBatchResult { return results } - static func empty() -> FetchQueueBatchResult { + func rowidsMatching(_ query: String) -> [Int64] { + var results = [Int64]() + for (rowid, paymentRowId) in rowidMap { + if paymentRowId.id == query { + results.append(rowid) + } + } + return results + } + + static func empty() -> FetchPaymentsQueueBatchResult { - return FetchQueueBatchResult( + return FetchPaymentsQueueBatchResult( rowids: [], rowidMap: [:], rowMap: [:], metadataMap: [:], - incomingStats: CloudKitDb.MetadataStats(), - outgoingStats: CloudKitDb.MetadataStats() + incomingStats: CloudKitPaymentsDb.MetadataStats(), + outgoingStats: CloudKitPaymentsDb.MetadataStats() + ) + } +} + +extension CloudKitContactsDb.FetchQueueBatchResult { + + func convertToSwift() -> FetchContactsQueueBatchResult { + + // We are experiencing crashes like this: + // + // for (rowid, paymentRowId) in batch.rowidMap { + // ^^^^^ + // Crash: Could not cast value of type '__NSCFNumber' to 'PhoenixSharedLong'. + // + // This appears to be some kind of bug in Kotlin. + // So we're going to make a clean migration. + // And we need to do so without swift-style enumeration in order to avoid crashing. + + var _rowids = [Int64]() + var _rowidMap = [Int64: Lightning_kmpUUID]() + + for i in 0 ..< self.rowids.count { // cannot enumerate self.rowidMap + + let value_kotlin = rowids[i] + let value_swift = value_kotlin.int64Value + + _rowids.append(value_swift) + if let contactId = self.rowidMap[value_kotlin] { + _rowidMap[value_swift] = contactId + } + } + + return FetchContactsQueueBatchResult( + rowids: _rowids, + rowidMap: _rowidMap, + rowMap: self.rowMap, + metadataMap: self.metadataMap ) } } -extension CloudKitDb.FetchQueueBatchResult { +extension CloudKitPaymentsDb.FetchQueueBatchResult { - func convertToSwift() -> FetchQueueBatchResult { + func convertToSwift() -> FetchPaymentsQueueBatchResult { // We are experiencing crashes like this: // @@ -65,7 +152,7 @@ extension CloudKitDb.FetchQueueBatchResult { } } - return FetchQueueBatchResult( + return FetchPaymentsQueueBatchResult( rowids: _rowids, rowidMap: _rowidMap, rowMap: self.rowMap, diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift index cb650f34f..ac4fbc694 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift @@ -9,6 +9,20 @@ extension WalletBalance { } } +extension WalletManager.WalletInfo { + + /// All data from a user's wallet are stored in the user's privateCloudDatabase. + /// And within the privateCloudDatabase, we create a dedicated CKRecordZone for each wallet, + /// where recordZone.name == encryptedNodeId. + /// + var encryptedNodeId: String { + + // For historical reasons, this is the cloudKeyHash, and NOT the nodeIdHash. + // The cloudKeyHash is created via: Hash160(cloudKey) + return self.cloudKeyHash + } +} + extension PhoenixShared.Notification { var createdAtDate: Date { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift index 2847d5ed1..de9a1632a 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift @@ -1,6 +1,12 @@ import Foundation import PhoenixShared +extension Lightning_kmpUUID: Identifiable { + + public var id: String { + return self.description + } +} extension WalletPaymentId: Identifiable { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 81972f70a..5789dee92 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -396,15 +396,37 @@ extension PaymentsPageFetcher { // MARK: - -extension CloudKitDb { +extension CloudKitContactsDb { fileprivate struct _Key { - static var fetchQueueCountPublisher = 0 + static var queueCountPublisher = 0 } - func fetchQueueCountPublisher() -> AnyPublisher { + func queueCountPublisher() -> AnyPublisher { - self.getSetAssociatedObject(storageKey: &_Key.fetchQueueCountPublisher) { + self.getSetAssociatedObject(storageKey: &_Key.queueCountPublisher) { + + /// Transforming from Kotlin: + /// `queueCount: StateFlow` + /// + KotlinCurrentValueSubject( + self.queueCount + ) + .compactMap { $0?.int64Value } + .eraseToAnyPublisher() + } + } +} + +extension CloudKitPaymentsDb { + + fileprivate struct _Key { + static var queueCountPublisher = 0 + } + + func queueCountPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.queueCountPublisher) { /// Transforming from Kotlin: /// `queueCount: StateFlow` diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 44af7a8b1..7d9d04767 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -448,7 +448,6 @@ class BusinessManager { self.walletInfo = _walletInfo maybeRegisterFcmToken() - let cloudKey = _walletInfo.cloudKey let encryptedNodeId = _walletInfo.cloudKeyHash as String if let walletRestoreType = walletRestoreType { @@ -484,8 +483,7 @@ class BusinessManager { self.syncManager = SyncManager( chain: business.chain, recoveryPhrase: recoveryPhrase, - cloudKey: cloudKey, - encryptedNodeId: encryptedNodeId + walletInfo: _walletInfo ) if LockState.shared.walletExistence == .doesNotExist { diff --git a/phoenix-ios/phoenix-ios/officers/PhotosManager.swift b/phoenix-ios/phoenix-ios/officers/PhotosManager.swift index 62b53205f..01fc97487 100644 --- a/phoenix-ios/phoenix-ios/officers/PhotosManager.swift +++ b/phoenix-ios/phoenix-ios/officers/PhotosManager.swift @@ -75,13 +75,15 @@ class PhotosManager { return photosDir }() + func genFileName() -> String { + return UUID().uuidString.replacingOccurrences(of: "-", with: "") + } + func urlForPhoto(fileName: String) -> URL { - return photosDirectory.appendingPathComponent(fileName, isDirectory: false) } func filePathForPhoto(fileName: String) -> String { - return urlForPhoto(fileName: fileName).path } @@ -97,7 +99,7 @@ class PhotosManager { func writeToDisk(_ original: PickerResult) async throws -> String { - let fileName = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let fileName = genFileName() let fileUrl = self.urlForPhoto(fileName: fileName) let scaled = await original.downscale() diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift index 096fb1a33..b1e3a44ed 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift @@ -14,8 +14,9 @@ fileprivate enum Key: String { case backupTransactions_enabled case backupTransactions_useCellularData case backupTransactions_useUploadDelay - case hasCKRecordZone - case hasDownloadedCKRecords + case hasCKRecordZone_v2 + case hasDownloadedPayments = "hasDownloadedCKRecords" + case hasDownloadedContacts } /// Preferences pertaining to backing up payment history in the user's own iCloud account. @@ -77,16 +78,14 @@ class Prefs_BackupTransactions { } private func recordZoneCreatedKey(_ encryptedNodeId: String) -> String { - return "\(Key.hasCKRecordZone.rawValue)-\(encryptedNodeId)" + return "\(Key.hasCKRecordZone_v2.rawValue)-\(encryptedNodeId)" } - func recordZoneCreated(encryptedNodeId: String) -> Bool { - + func recordZoneCreated(_ encryptedNodeId: String) -> Bool { return defaults.bool(forKey: recordZoneCreatedKey(encryptedNodeId)) } - func setRecordZoneCreated(_ value: Bool, encryptedNodeId: String) { - + func setRecordZoneCreated(_ value: Bool, _ encryptedNodeId: String) { let key = recordZoneCreatedKey(encryptedNodeId) if value == true { defaults.setValue(value, forKey: key) @@ -95,30 +94,35 @@ class Prefs_BackupTransactions { } } - private func hasDownloadedRecordsKey(_ encryptedNodeId: String) -> String { - return "\(Key.hasDownloadedCKRecords.rawValue)-\(encryptedNodeId)" + private func hasDownloadedPaymentsKey(_ encryptedNodeId: String) -> String { + return "\(Key.hasDownloadedPayments.rawValue)-\(encryptedNodeId)" } - func hasDownloadedRecords(encryptedNodeId: String) -> Bool { - - return defaults.bool(forKey: hasDownloadedRecordsKey(encryptedNodeId)) + func hasDownloadedPayments(_ encryptedNodeId: String) -> Bool { + return defaults.bool(forKey: hasDownloadedPaymentsKey(encryptedNodeId)) } - func setHasDownloadedRecords(_ value: Bool, encryptedNodeId: String) { - - let key = hasDownloadedRecordsKey(encryptedNodeId) - if value == true { - defaults.setValue(value, forKey: key) - } else { - // Remove trace of account on disk - defaults.removeObject(forKey: key) - } + func markHasDownloadedPayments(_ encryptedNodeId: String) { + defaults.setValue(true, forKey: hasDownloadedPaymentsKey(encryptedNodeId)) + } + + private func hasDownloadedContactsKey(_ encryptedNodeId: String) -> String { + return "\(Key.hasDownloadedContacts.rawValue)-\(encryptedNodeId)" + } + + func hasDownloadedContacts(_ encryptedNodeId: String) -> Bool { + return defaults.bool(forKey: hasDownloadedContactsKey(encryptedNodeId)) + } + + func markHasDownloadedContacts(_ encryptedNodeId: String) { + defaults.setValue(true, forKey: hasDownloadedContactsKey(encryptedNodeId)) } func resetWallet(encryptedNodeId: String) { defaults.removeObject(forKey: recordZoneCreatedKey(encryptedNodeId)) - defaults.removeObject(forKey: hasDownloadedRecordsKey(encryptedNodeId)) + defaults.removeObject(forKey: hasDownloadedPaymentsKey(encryptedNodeId)) + defaults.removeObject(forKey: hasDownloadedContactsKey(encryptedNodeId)) defaults.removeObject(forKey: Key.backupTransactions_enabled.rawValue) defaults.removeObject(forKey: Key.backupTransactions_useCellularData.rawValue) defaults.removeObject(forKey: Key.backupTransactions_useUploadDelay.rawValue) diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift new file mode 100644 index 000000000..2d3102e71 --- /dev/null +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift @@ -0,0 +1,899 @@ +import Foundation +import CloudKit +import CryptoKit +import PhoenixShared + +fileprivate let filename = "SyncBackupManager+Contacts" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +fileprivate struct DownloadedContact { + let record: CKRecord + let contact: ContactInfo +} + +fileprivate struct UploadContactsOperationInfo { + let batch: FetchContactsQueueBatchResult + + let recordsToSave: [CKRecord] + let recordIDsToDelete: [CKRecord.ID] + + let reverseMap: [CKRecord.ID: Lightning_kmpUUID] + + var completedRowids: [Int64] = [] + + var partialFailures: [Lightning_kmpUUID: CKError?] = [:] + + var savedRecords: [CKRecord] = [] + var deletedRecordIds: [CKRecord.ID] = [] +} + +extension SyncBackupManager { + + func downloadContacts(_ downloadProgress: SyncBackupManager_State_Downloading) { + log.trace("downloadContacts()") + + Task { + + // Step 1 of 4: + // + // We are downloading payments from newest to oldest. + // So first we fetch the oldest payment date in the table (if there is one) + + let millis: KotlinLong? = try await Task { @MainActor in + return try await self.cloudKitDb.contacts.fetchOldestCreation() + }.value + + let oldestCreationDate = millis?.int64Value.toDate(from: .milliseconds) + downloadProgress.setContacts_oldestCompletedDownload(oldestCreationDate) + + /** + * NOTE: + * If we want to report proper progress (via `SyncBackupManager_State_Downloading`), + * then we need to know the total number of records to be downloaded from the cloud. + * + * However, there's a minor problem here: + * CloudKit doesn't support aggregate queries ! + * + * So we cannot simply say: SELECT COUNT(*) + * + * Our only option (as far as I'm aware of), + * is to fetch the metadata for every record in the cloud. + * we would have to do this via recursive batch fetching, + * and counting the downloaded records as they stream in. + * + * The big downfall of this approach is that we end up downloading + * the CKRecord metadata 2 times for every record :( + * + * - first just to count the number of records + * - and again when we fetch the full record (with encrypted blob) + * + * Given this bad situation (Bad Apple), + * our current choice is to sacrifice the progress details. + */ + + let privateCloudDatabase = CKContainer.default().privateCloudDatabase + let zoneID = self.recordZoneID() + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + do { + try await privateCloudDatabase.configuredWith(configuration: configuration) { database in + + // Step 2 of 4: + // + // Execute a CKQuery to download a batch of payments from the cloud. + // There may be multiple batches available for download. + + let predicate: NSPredicate + if let oldestCreationDate { + predicate = NSPredicate(format: "creationDate < %@", oldestCreationDate as CVarArg) + } else { + predicate = NSPredicate(format: "TRUEPREDICATE") + } + + let query = CKQuery( + recordType: contacts_record_table_name, + predicate: predicate + ) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + + var done = false + var batch = 0 + var cursor: CKQueryOperation.Cursor? = nil + + while !done { + + // For the first batch, we want to quickly fetch an item from the cloud, + // and add it to the database. The faster the better, this way the user + // knows the app is restoring his/her contacts. + // + // After that, we can slowly increase the batch size, + // as the user becomes aware of what's happening. + + let resultsLimit: Int + switch batch { + case 0 : resultsLimit = 1 + case 1 : resultsLimit = 2 + case 2 : resultsLimit = 3 + case 3 : resultsLimit = 4 + default : resultsLimit = 8 + } + + log.trace("downloadContacts(): batchFetch: requesting \(resultsLimit)") + + let results: [(CKRecord.ID, Result)] + if let prvCursor = cursor { + (results, cursor) = try await database.records( + continuingMatchFrom: prvCursor, + resultsLimit: resultsLimit + ) + } else { + (results, cursor) = try await database.records( + matching: query, + inZoneWith: zoneID, + resultsLimit: resultsLimit + ) + } + + var items: [DownloadedContact] = [] + for (_, result) in results { + if case .success(let record) = result { + let contact = self.decryptAndDeserializeContact(record) + if let contact { + items.append(DownloadedContact( + record: record, + contact: contact + )) + } + } + } + + log.trace("downloadContacts(): batchFetch: received \(items.count)") + + // Step 3 of 4: + // + // Save the downloaded results to the database. + + try await Task { @MainActor [items] in + + var oldest: Date? = nil + + var rows: [ContactInfo] = [] + var metadataMap: [Lightning_kmpUUID: CloudKitContactsDb.MetadataRow] = [:] + + for item in items { + + rows.append(item.contact) + + let contactId = item.contact.uuid + let creationDate = item.record.creationDate ?? Date() + let creation = self.dateToMillis(creationDate) + let metadata = self.metadataForRecord(item.record) + + metadataMap[contactId] = CloudKitContactsDb.MetadataRow( + recordCreation: creation, + recordBlob: metadata.toKotlinByteArray() + ) + + if let prv = oldest { + if creationDate < prv { + oldest = creationDate + } + } else { + oldest = creationDate + } + } + + log.trace("downloadPayments(): cloudKitDb.updateRows()...") + + try await self.cloudKitDb.contacts.updateRows( + downloadedContacts: rows, + updateMetadata: metadataMap + ) + + downloadProgress.contacts_finishBatch(completed: items.count, oldest: oldest) + + }.value + // + + if (cursor == nil) { + log.trace("downloadContacts(): moreInCloud = false") + done = true + } else { + log.trace("downloadContacts(): moreInCloud = true") + batch += 1 + } + + } // + } // + + log.trace("downloadContacts(): enqueueMissingItems()...") + + // Step 4 of 4: + // + // There may be payments that we've added to the database since we started the download process. + // So we enqueue these for upload now. + + try await Task { @MainActor in + try await self.cloudKitDb.contacts.enqueueMissingItems() + }.value + + log.trace("downloadContacts(): finish: success") + + Prefs.shared.backupTransactions.markHasDownloadedContacts(self.walletInfo.encryptedNodeId) + self.consecutiveErrorCount = 0 + + if let newState = await self.actor.didDownloadContacts() { + self.handleNewState(newState) + } + + } catch { + + log.error("downloadContacts(): error: \(error)") + self.handleError(error) + } + + } // + } + + /// The upload task performs the following tasks: + /// - extract rows from the database that need to be uploaded + /// - serialize & encrypt the data + /// - upload items to the user's private cloud database + /// - remove the uploaded items from the queue + /// - repeat as needed + /// + func uploadContacts(_ uploadProgress: SyncBackupManager_State_Uploading) { + log.trace("uploadContacts()") + + let prepareUpload = {( + batch: FetchContactsQueueBatchResult + ) -> UploadContactsOperationInfo in + + log.trace("uploadContacts(): prepareUpload()") + + var recordsToSave = [CKRecord]() + var recordIDsToDelete = [CKRecord.ID]() + + var reverseMap = [CKRecord.ID: Lightning_kmpUUID]() + + // NB: batch.rowidMap may contain the same contactId multiple times. + // And if we include the same record multiple times in the CKModifyRecordsOperation, + // then the operation will fail. + // + for contactId in batch.uniqueContactIds() { + + var existingRecord: CKRecord? = nil + if let metadata = batch.metadataMap[contactId] { + + let data = metadata.toSwiftData() + existingRecord = self.recordFromMetadata(data) + } + + if let contactInfo = batch.rowMap[contactId] { + + if let ciphertext = self.serializeAndEncryptContact(contactInfo) { + + let record = existingRecord ?? CKRecord( + recordType: contacts_record_table_name, + recordID: self.recordID(contactId: contactId) + ) + + record[contacts_record_column_data] = ciphertext + + if let fileName = contactInfo.photoUri { + let fileUrl = PhotosManager.shared.urlForPhoto(fileName: fileName) + record[contacts_record_column_photo] = CKAsset(fileURL: fileUrl) + } + + recordsToSave.append(record) + reverseMap[record.recordID] = contactId + } + + } else { + + // The contact has been deleted from the local database. + // So we're going to delete it from the cloud database (if it exists there). + + let recordID = existingRecord?.recordID ?? self.recordID(contactId: contactId) + + recordIDsToDelete.append(recordID) + reverseMap[recordID] = contactId + } + } + + var opInfo = UploadContactsOperationInfo( + batch: batch, + recordsToSave: recordsToSave, + recordIDsToDelete: recordIDsToDelete, + reverseMap: reverseMap + ) + + // Edge-case: A rowid wasn't able to be converted to a UUID. + // + // So the rowid is not represented in either `rowidMap` or `uniquePaymentIds()`. + // Nor is it reprensented in `recordsToSave` or `recordIDsToDelete`. + // + // The end result is that we have an empty operation. + // And we won't remove the rowid from the database either, creating an infinite loop. + // + // So we add a sanity check here. + + for rowid in batch.rowids { + if batch.rowidMap[rowid] == nil { + log.warning("Malformed UUID in contacts_queue") + opInfo.completedRowids.append(rowid) + } + } + + return opInfo + + } // + + let performUpload = {( + opInfo: UploadContactsOperationInfo + ) async throws -> UploadContactsOperationInfo in + + log.trace("uploadContacts(): performUpload()") + log.trace("opInfo.recordsToSave.count = \(opInfo.recordsToSave.count)") + log.trace("opInfo.recordIDsToDelete.count = \(opInfo.recordIDsToDelete.count)") + + if Task.isCancelled { + throw CKError(.operationCancelled) + } + + let container = CKContainer.default() + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = Prefs.shared.backupTransactions.useCellular + + return try await container.privateCloudDatabase.configuredWith(configuration: configuration) { database in + + let (saveResults, deleteResults) = try await database.modifyRecords( + saving: opInfo.recordsToSave, + deleting: opInfo.recordIDsToDelete, + savePolicy: CKModifyRecordsOperation.RecordSavePolicy.ifServerRecordUnchanged, + atomically: false + ) + + // saveResults: [CKRecord.ID : Result] + // deleteResults: [CKRecord.ID : Result] + + var nextOpInfo = opInfo + + var accountFailure: CKError? = nil + var recordIDsToFetch: [CKRecord.ID] = [] + + for (recordID, result) in saveResults { + + guard let contactId = opInfo.reverseMap[recordID] else { + continue + } + + switch result { + case .success(let record): + nextOpInfo.savedRecords.append(record) + + for rowid in nextOpInfo.batch.rowidsMatching(contactId) { + nextOpInfo.completedRowids.append(rowid) + } + + case .failure(let error): + if let recordError = error as? CKError { + + nextOpInfo.partialFailures[contactId] = recordError + + // If this is a standard your-changetag-was-out-of-date message from the server, + // then we just need to fetch the latest CKRecord metadata from the cloud, + // and then re-try our upload. + if recordError.errorCode == CKError.serverRecordChanged.rawValue { + recordIDsToFetch.append(recordID) + } else if recordError.errorCode == CKError.accountTemporarilyUnavailable.rawValue { + accountFailure = recordError + } + } + } // + } // + + for (recordID, result) in deleteResults { + + guard let paymentId = opInfo.reverseMap[recordID] else { + continue + } + + switch result { + case .success(_): + nextOpInfo.deletedRecordIds.append(recordID) + + for rowid in nextOpInfo.batch.rowidsMatching(paymentId) { + nextOpInfo.completedRowids.append(rowid) + } + + case .failure(let error): + if let recordError = error as? CKError { + + nextOpInfo.partialFailures[paymentId] = recordError + + if recordError.errorCode == CKError.accountTemporarilyUnavailable.rawValue { + accountFailure = recordError + } + } + } // + } // + + if let accountFailure { + // We received one or more `accountTemporarilyUnavailable` errors. + // We have special error handling code for this. + throw accountFailure + + } else if !recordIDsToFetch.isEmpty { + // One or more records was out-of-date (as compared with the server version). + // So we need to refetch those records from the server. + + if Task.isCancelled { + throw CKError(.operationCancelled) + } + + let results: [CKRecord.ID : Result] = try await database.records( + for: recordIDsToFetch, + desiredKeys: [] // fetch only basic CKRecord metadata + ) + + let fetchedRecords: [CKRecord] = results.values.compactMap { result in + return try? result.get() + } + + // We successfully fetched the latest CKRecord(s) from the server. + // We add to nextOpInfo.savedRecords, which will write the CKRecord to the database. + // So on the next upload attempt, we should have the latest version. + + nextOpInfo.savedRecords.append(contentsOf: fetchedRecords) + + } else { + // Every payment in the batch was successful + } + + return nextOpInfo + + } // + + } // + + let updateDatabase = {( + opInfo: UploadContactsOperationInfo + ) async throws -> Void in + + log.trace("uploadContacts(): updateDatabase()") + + var deleteFromQueue = [KotlinLong]() + var deleteFromMetadata = [Lightning_kmpUUID]() + var updateMetadata = [Lightning_kmpUUID: CloudKitContactsDb.MetadataRow]() + + for (rowid) in opInfo.completedRowids { + deleteFromQueue.append(KotlinLong(longLong: rowid)) + } + for recordId in opInfo.deletedRecordIds { + if let contactId = opInfo.reverseMap[recordId] { + deleteFromMetadata.append(contactId) + } + } + for record in opInfo.savedRecords { + if let contactId = opInfo.reverseMap[record.recordID] { + + let creation = self.dateToMillis(record.creationDate ?? Date()) + let metadata = self.metadataForRecord(record) + + updateMetadata[contactId] = CloudKitContactsDb.MetadataRow( + recordCreation: creation, + recordBlob: metadata.toKotlinByteArray() + ) + } + } + + // Handle partial failures + let partialFailures: [String: CKError?] = opInfo.partialFailures.mapKeys { $0.id } + + for contactIdStr in self.updateConsecutivePartialFailures(partialFailures) { + for rowid in opInfo.batch.rowidsMatching(contactIdStr) { + deleteFromQueue.append(KotlinLong(longLong: rowid)) + } + } + + log.debug("deleteFromQueue.count = \(deleteFromQueue.count)") + log.debug("deleteFromMetadata.count = \(deleteFromMetadata.count)") + log.debug("updateMetadata.count = \(updateMetadata.count)") + + try await Task { @MainActor [deleteFromQueue, deleteFromMetadata, updateMetadata] in + try await self.cloudKitDb.contacts.updateRows( + deleteFromQueue: deleteFromQueue, + deleteFromMetadata: deleteFromMetadata, + updateMetadata: updateMetadata + ) + }.value + + } // + + let finish = {( + result: Result + ) async -> Void in + + switch result { + case .success: + log.trace("uploadContacts(): finish(): success") + + self.consecutiveErrorCount = 0 + if let newState = await self.actor.didUploadItems() { + self.handleNewState(newState) + } + + case .failure(let error): + log.trace("uploadContacts(): finish(): failure") + self.handleError(error) + } + + } // + + Task { + log.trace("uploadContacts(): starting task...") + + do { + // Step 1 of 4: + // + // Check the `cloudkit_contacts_queue` table, + // to see if there's anything we need to upload. + + let result: CloudKitContactsDb.FetchQueueBatchResult = try await Task { @MainActor in + return try await self.cloudKitDb.contacts.fetchQueueBatch(limit: 1) + }.value + + let batch = result.convertToSwift() + + log.debug("uploadContacts(): batch.rowids.count = \(batch.rowids.count)") + log.debug("uploadContacts(): batch.rowidMap.count = \(batch.rowidMap.count)") + log.debug("uploadContacts(): batch.rowMap.count = \(batch.rowMap.count)") + log.debug("uploadContacts(): batch.metadataMap.count = \(batch.metadataMap.count)") + + if batch.rowids.isEmpty { + // There's nothing queued for upload, so we're done. + + // Bug Fix / Workaround: + // The queueCountPublisher isn't firing reliably, and I'm not sure why... + // + // This can lead to the following infinite loop: + // - queueCountPublisher fires and reports a non-zero count + // - the actor's corresponding queueCount is updated + // - the uploadTask is evetually triggered + // - the item(s) are properly uploaded, and the rows are deleted from the queue + // - queueCountPublisher does NOT properly fire + // - the uploadTask is triggered again + // - it finds zero rows to upload, but actor's queueCount remains unchanged + // - the uploadTask is triggered again + // - ... + // + if let newState = await self.actor.contactsQueueCountChanged(0, wait: nil) { + self.handleNewState(newState) + } + return await finish(.success) + } + + // Step 2 of 4: + // + // Serialize and encrypt the contact information. + // Then encapsulate the encrypted blob into a CKRecord. + // And prepare a full CKModifyRecordsOperation for upload. + + var opInfo = prepareUpload(batch) + + // Step 3 of 4: + // + // Perform the cloud operation. + + let inFlightCount = opInfo.recordsToSave.count + opInfo.recordIDsToDelete.count + if inFlightCount == 0 { + // Edge case: there are no cloud tasks to perform. + // We have to skip the upload, because it will fail if given an empty set of tasks. + } else { + opInfo = try await performUpload(opInfo) + } + + // Step 4 of 4: + // + // Process the upload results. + + try await updateDatabase(opInfo) + + // Done ! + + uploadProgress.completeContacts_inFlight(inFlightCount) + return await finish(.success) + + } catch { + return await finish(.failure(error)) + } + } // + } + + // ---------------------------------------- + // MARK: Record ID + // ---------------------------------------- + + private func recordID(contactId: Lightning_kmpUUID) -> CKRecord.ID { + + // The recordID is: + // - deterministic => by hashing the contactId + // - secure => by mixing in the nodeIdHash (nodeKey.publicKey.hash160) + + let prefix = walletInfo.nodeIdHash.data(using: .utf8)! + let suffix = contactId.description().data(using: .utf8)! + + let hashMe = prefix + suffix + let digest = SHA256.hash(data: hashMe) + let hash = digest.map { String(format: "%02hhx", $0) }.joined() + + return CKRecord.ID(recordName: hash, zoneID: recordZoneID()) + } + + // ---------------------------------------- + // MARK: Utilities + // ---------------------------------------- + + /// Performs all of the following: + /// - serializes incoming/outgoing payment into JSON + /// - adds randomized padding to obfuscate payment type + /// - encrypts the blob using the cloudKey + /// + private func serializeAndEncryptContact( + _ contact: ContactInfo + ) -> Data? { + + let wrapper = CloudContact(contact: contact) + let cbor = wrapper.cborSerialize().toSwiftData() + + #if DEBUG + let jsonData = wrapper.jsonSerialize().toSwiftData() + let jsonStr = String(data: jsonData, encoding: .utf8) + log.debug("Uploading record (JSON representation):\n\(jsonStr ?? "")") + #endif + + let cleartext: Data = cbor + var ciphertext: Data? = nil + do { + let box = try ChaChaPoly.seal(cleartext, using: self.cloudKey) + ciphertext = box.combined + + } catch { + log.error("Error encrypting row with ChaChaPoly: \(String(describing: error))") + } + + if let ciphertext { + return ciphertext + } else { + return nil + } + } + + private func decryptAndDeserializeContact( + _ record: CKRecord + ) -> ContactInfo? { + + log.debug("Received record:") + 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 { + + if let srcFileURL = asset.fileURL { + + let dstFileName = PhotosManager.shared.genFileName() + let dstFileURL = PhotosManager.shared.urlForPhoto(fileName: dstFileName) + + do { + try FileManager.default.copyItem(at: srcFileURL, to: dstFileURL) + photoUri = dstFileName + } catch { + log.error("Unable to copy photo: \(error)") + } + } + } + + var contact: ContactInfo? = nil + if let wrapper { + do { + contact = try wrapper.unwrap(photoUri: photoUri) + } catch { + log.error("Error unwrapping record.data: skipping \(record.recordID)") + } + } + + return contact + } + + // ---------------------------------------- + // MARK: Debugging + // ---------------------------------------- + #if DEBUG + + func listAllContacts() { + log.trace("listAllContacts()") + + let query = CKQuery( + recordType: contacts_record_table_name, + predicate: NSPredicate(format: "TRUEPREDICATE") + ) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + + let operation = CKQueryOperation(query: query) + operation.zoneID = recordZoneID() + + recursiveListContactsBatch(operation: operation) + } + + private func recursiveListContactsBatch(operation: CKQueryOperation) { + log.trace("recursiveListContactsBatch()") + + operation.recordMatchedBlock = {(recordID: CKRecord.ID, result: Result) in + + if let record = try? result.get() { + + log.debug("Received record:") + log.debug(" - recordID: \(record.recordID)") + log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") + + if let data = record[contacts_record_column_data] as? Data { + log.debug(" - data.count: \(data.count)") + } else { + log.debug(" - data: ?") + } + + if let blob = record[contacts_record_column_photo] as? CKAsset { + log.debug(" - photo: \(blob.fileURL?.path ?? "")") + } else { + log.debug(" - photo: nil") + } + } + } + + operation.queryResultBlock = {(result: Result) in + + switch result { + case .success(let cursor): + if let cursor = cursor { + log.debug("recursiveListContactsBatch: Continuing with cursor...") + self.recursiveListContactsBatch(operation: CKQueryOperation(cursor: cursor)) + + } else { + log.debug("recursiveListContactsBatch: Complete") + } + + case .failure(let error): + log.debug("recursiveListContactsBatch: error: \(String(describing: error))") + } + } + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + operation.configuration = configuration + + CKContainer.default().privateCloudDatabase.add(operation) + } + + func deleteAllContacts() { + log.trace("deleteAllContacts()") + + let query = CKQuery( + recordType: contacts_record_table_name, + predicate: NSPredicate(format: "TRUEPREDICATE") + ) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + + let operation = CKQueryOperation(query: query) + operation.zoneID = recordZoneID() + + findContactsBatch(operation: operation) + } + + private func findContactsBatch(operation: CKQueryOperation) { + log.trace("findContactsBatch()") + + var results: [CKRecord.ID] = [] + operation.recordMatchedBlock = {(recordID: CKRecord.ID, result: Result) in + + results.append(recordID) + } + + operation.queryResultBlock = {(result: Result) in + + switch result { + case .success(let cursor): + log.debug("findContactsBatch: found: \(results.count)") + if results.isEmpty { + log.debug("findContactsBatch: complete") + } else { + self.deleteContactsBatch(ids: results, cursor: cursor) + } + + case .failure(let error): + log.debug("findContactsBatch: error: \(String(describing: error))") + } + } + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + operation.configuration = configuration + + CKContainer.default().privateCloudDatabase.add(operation) + } + + private func deleteContactsBatch(ids: [CKRecord.ID], cursor: CKQueryOperation.Cursor?) { + log.trace("deleteContactsBatch()") + + let operation = CKModifyRecordsOperation(recordsToSave: [], recordIDsToDelete: ids) + + operation.modifyRecordsResultBlock = {(_ result: Result) in + + switch result { + case .success(): + log.debug("deleteContactsBatch: deleted: \(ids.count)") + if let cursor { + self.findContactsBatch(operation: CKQueryOperation(cursor: cursor)) + } else { + log.debug("deleteContactsBatch: complete") + } + + case .failure(let error): + log.debug("deleteContactsBatch: error: \(String(describing: error))") + } + } + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + operation.configuration = configuration + + CKContainer.default().privateCloudDatabase.add(operation) + } + + #endif +} diff --git a/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Payments.swift similarity index 54% rename from phoenix-ios/phoenix-ios/sync/SyncTxManager.swift rename to phoenix-ios/phoenix-ios/sync/SyncBackupManager+Payments.swift index 1321e0508..3ee82d14d 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Payments.swift @@ -1,42 +1,24 @@ import Foundation -import Combine import CloudKit import CryptoKit -import Network import PhoenixShared -fileprivate let filename = "SyncTxManager" +fileprivate let filename = "SyncBackupManager+Payments" #if DEBUG && true fileprivate var log = LoggerFactory.shared.logger(filename, .trace) #else fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif -/** - * CloudKit hard limits (from the docs): - * - * Maximum number of operations in a request : 200 - * Maximum number of records in a response : 200 - * Maximum number of tokens in a request : 200 - * Maximum record size (not including Asset fields) : 1 MB - * Maximum file size of an Asset field : 50 MB - * Maximum number of source references to a single : - * target where the action is delete self : 750 -*/ - -fileprivate let record_table_name = "payments" -fileprivate let record_column_data = "encryptedData" -fileprivate let record_column_meta = "encryptedMeta" - -fileprivate struct DownloadedItem { +fileprivate struct DownloadedPayment { let record: CKRecord let unpaddedSize: Int let payment: Lightning_kmpWalletPayment let metadata: WalletPaymentMetadataRow? } -fileprivate struct UploadOperationInfo { - let batch: FetchQueueBatchResult +fileprivate struct UploadPaymentsOperationInfo { + let batch: FetchPaymentsQueueBatchResult let recordsToSave: [CKRecord] let recordIDsToDelete: [CKRecord.ID] @@ -52,451 +34,9 @@ fileprivate struct UploadOperationInfo { var deletedRecordIds: [CKRecord.ID] = [] } -fileprivate struct ConsecutivePartialFailure { - var count: Int - var error: CKError? -} - -// -------------------------------------------------------------------------------- -// MARK: - -// -------------------------------------------------------------------------------- - -/// Encompasses the logic for syncing transactions with Apple's CloudKit database. -/// -class SyncTxManager { - - /// Access to parent for shared logic. - /// - weak var parent: SyncManager? = nil - - /// The cloudKey is derived from the user's seed. - /// It's used to encrypt data before uploading to the cloud. - /// The data stored in the cloud is an encrypted blob, and requires the cloudKey for decryption. - /// - private let cloudKey: SymmetricKey - - /// The encryptedNodeId is created via: Hash(cloudKey + nodeID) - /// - /// All data from a user's wallet is stored in the user's CKContainer.default().privateCloudDatabase. - /// Within the privateCloudDatabase, we create a dedicated CKRecordZone for each wallet, - /// where recordZone.name == encryptedNodeId - /// - private let encryptedNodeId: String - - /// Informs the user interface regarding the activities of the SyncTxManager. - /// This includes various errors & active upload progress. - /// - /// Changes to this publisher will always occur on the main thread. - /// - public let statePublisher: CurrentValueSubject - - /// Informs the user interface about a pending change to the SyncTxManager's global settings. - /// - /// Changes to this publisher will always occur on the main thread. - /// - public let pendingSettingsPublisher = CurrentValueSubject(nil) - - /// Implements the state machine in a thread-safe actor. - /// - private let actor: SyncTxManager_Actor - - private var consecutiveErrorCount = 0 - private var consecutivePartialFailures: [WalletPaymentId: ConsecutivePartialFailure] = [:] - - private var _paymentsDb: SqlitePaymentsDb? = nil // see getter method - private var _cloudKitDb: CloudKitDb? = nil // see getter method - - private var cancellables = Set() - - init(cloudKey: Bitcoin_kmpByteVector32, encryptedNodeId: String) { - log.trace("init()") - - self.cloudKey = SymmetricKey(data: cloudKey.toByteArray().toSwiftData()) - self.encryptedNodeId = encryptedNodeId - - self.actor = SyncTxManager_Actor( - isEnabled: Prefs.shared.backupTransactions.isEnabled, - recordZoneCreated: Prefs.shared.backupTransactions.recordZoneCreated(encryptedNodeId: encryptedNodeId), - hasDownloadedRecords: Prefs.shared.backupTransactions.hasDownloadedRecords(encryptedNodeId: encryptedNodeId) - ) - statePublisher = CurrentValueSubject(.initializing) - - waitForDatabases() - } - - private var paymentsDb: SqlitePaymentsDb { - get { return _paymentsDb! } - } - private var cloudKitDb: CloudKitDb { - get { return _cloudKitDb! } - } - - // ---------------------------------------- - // MARK: Monitors - // ---------------------------------------- - - private func startQueueCountMonitor() { - log.trace("startQueueCountMonitor()") - - // Kotlin suspend functions are currently only supported on the main thread - assert(Thread.isMainThread, "Kotlin ahead: background threads unsupported") - - self.cloudKitDb.fetchQueueCountPublisher().sink {[weak self] (queueCount: Int64) in - log.debug("fetchQueueCountPublisher().sink(): count = \(queueCount)") - - guard let self = self else { - return - } - - let count = Int(clamping: queueCount) - - let wait: SyncTxManager_State_Waiting? - if Prefs.shared.backupTransactions.useUploadDelay { - let delay = TimeInterval.random(in: 10 ..< 900) - wait = SyncTxManager_State_Waiting(kind: .randomizedUploadDelay, parent: self, delay: delay) - } else { - wait = nil - } - - Task { - if let newState = await self.actor.queueCountChanged(count, wait: wait) { - self.handleNewState(newState) - } - } - - }.store(in: &cancellables) - } - - private func startPreferencesMonitor() { - log.trace("startPreferencesMonitor()") - - var isFirstFire = true - Prefs.shared.backupTransactions.isEnabledPublisher.sink {[weak self](shouldEnable: Bool) in - - if isFirstFire { - isFirstFire = false - return - } - guard let self = self else { - return - } - - log.debug("Prefs.shared.backupTransactions_isEnabled = \(shouldEnable ? "true" : "false")") - - let delay = 30.seconds() - let pendingSettings = shouldEnable ? - SyncTxManager_PendingSettings(self, enableSyncing: delay) - : SyncTxManager_PendingSettings(self, disableSyncing: delay) - - Task { - if await self.actor.enqueuePendingSettings(pendingSettings) { - self.publishPendingSettings(pendingSettings) - } - } - - }.store(in: &cancellables) - } - - // ---------------------------------------- - // MARK: Publishers - // ---------------------------------------- - - private func publishNewState(_ state: SyncTxManager_State) { - log.trace("publishNewState()") - - // Contract: Changes to this publisher will always occur on the main thread. - let block = { - self.statePublisher.value = state - } - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async { block() } - } - } - - private func publishPendingSettings(_ pending: SyncTxManager_PendingSettings?) { - log.trace("publishPendingSettings()") - - // Contract: Changes to this publisher will always occur on the main thread. - let block = { - self.pendingSettingsPublisher.value = pending - } - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async { block() } - } - } - - // ---------------------------------------- - // MARK: External Control - // ---------------------------------------- - - /// Called from SyncManager; part of SyncManagerProtocol - /// - func networkStatusChanged(hasInternet: Bool) { - log.trace("networkStatusChanged(hasInternet: \(hasInternet))") - - Task { - if let newState = await self.actor.networkStatusChanged(hasInternet: hasInternet) { - self.handleNewState(newState) - } - } - } - - /// Called from SyncManager; part of SyncManagerProtocol - /// - func cloudCredentialsChanged(hasCloudCredentials: Bool) { - log.trace("cloudCredentialsChanged(hasCloudCredentials: \(hasCloudCredentials))") - - Task { - if let newState = await self.actor.cloudCredentialsChanged(hasCloudCredentials: hasCloudCredentials) { - self.handleNewState(newState) - } - } - } - - /// Called from `SyncTxManager_PendingSettings` - /// - func dequeuePendingSettings(_ pending: SyncTxManager_PendingSettings, approved: Bool) { - log.trace("dequeuePendingSettings(_, approved: \(approved ? "true" : "false"))") - - Task { - let (accepted, newState) = await self.actor.dequeuePendingSettings(pending, approved: approved) - if accepted { - self.publishPendingSettings(nil) - if !approved { - if pending.paymentSyncing == .willEnable { - // We were going to enable cloud syncing. - // But the user just changed their mind, and cancelled it. - // So now we need to disable it again. - Prefs.shared.backupTransactions.isEnabled = false - } else { - // We were going to disable cloud syncing. - // But the user just changed their mind, and cancelled it. - // So now we need to enable it again. - Prefs.shared.backupTransactions.isEnabled = true - } - } - } - if let newState = newState { - self.handleNewState(newState) - } - } - } - - /// Called from `SyncTxManager_State_Waiting` - /// - func finishWaiting(_ waiting: SyncTxManager_State_Waiting) { - log.trace("finishWaiting()") - - Task { - if let newState = await self.actor.finishWaiting(waiting) { - self.handleNewState(newState) - } - } - } - - /// Used when closing the corresponding wallet. - /// We transition to a terminal state. - /// - func shutdown() { - log.trace("shutdown()") - - Task { - if let newState = await self.actor.shutdown() { - self.handleNewState(newState) - } - } - - cancellables.removeAll() - } - - // ---------------------------------------- - // MARK: Flow - // ---------------------------------------- - - private func handleNewState(_ newState: SyncTxManager_State) { - - log.trace("state = \(newState)") - switch newState { - case .updatingCloud(let details): - switch details.kind { - case .creatingRecordZone: - createRecordZone(details) - case .deletingRecordZone: - deleteRecordZone(details) - } - case .downloading(let progress): - downloadPayments(progress) - case .uploading(let progress): - uploadPayments(progress) - default: - break - } - - publishNewState(newState) - } - - /// We have to wait until the databases are setup and ready. - /// This may take a moment if a migration is triggered. - /// - private func waitForDatabases() { - log.trace("waitForDatabases()") - - Task { @MainActor in - - let databaseManager = Biz.business.databaseManager - do { - let paymentsDb = try await databaseManager.paymentsDb() - let cloudKitDb = paymentsDb.getCloudKitDb() as! CloudKitDb - - self._paymentsDb = paymentsDb - self._cloudKitDb = cloudKitDb - - if let newState = await self.actor.markDatabasesReady() { - self.handleNewState(newState) - } - - DispatchQueue.main.async { - self.startQueueCountMonitor() - self.startPreferencesMonitor() - } - - } catch { - - assertionFailure("Unable to extract paymentsDb or cloudKitDb") - } - - } // - } - - /// We create a dedicated CKRecordZone for each wallet. - /// This allows us to properly segregate transactions between multiple wallets. - /// Before we can interact with the RecordZone we have to explicitly create it. - /// - private func createRecordZone(_ state: SyncTxManager_State_UpdatingCloud) { - log.trace("createRecordZone()") - - state.task = Task { - log.trace("createRecordZone(): starting task") - - let container = CKContainer.default() - - let configuration = CKOperation.Configuration() - configuration.allowsCellularAccess = true - - do { - try await container.privateCloudDatabase.configuredWith(configuration: configuration) { database in - - log.trace("createRecordZone(): configured") - - if state.isCancelled { - throw CKError(.operationCancelled) - } - - let recordZone = CKRecordZone(zoneName: encryptedNodeId) - - let (saveResults, _) = try await database.modifyRecordZones( - saving: [recordZone], - deleting: [] - ) - - // saveResults: [CKRecordZone.ID : Result] - - let result = saveResults[recordZone.zoneID]! - - if case let .failure(error) = result { - log.trace("createRecordZone(): perZoneResult: failure") - throw error - } - - log.trace("createRecordZone(): perZoneResult: success") - - Prefs.shared.backupTransactions.setRecordZoneCreated(true, encryptedNodeId: self.encryptedNodeId) - self.consecutiveErrorCount = 0 - - if let newState = await self.actor.didCreateRecordZone() { - self.handleNewState(newState) - } - - } // - - } catch { - - log.error("createRecordZone(): error = \(error)") - self.handleError(error) - } - } // - } - - private func deleteRecordZone(_ state: SyncTxManager_State_UpdatingCloud) { - log.trace("deleteRecordZone()") - - state.task = Task { - log.trace("deleteRecordZone(): starting task") - - let container = CKContainer.default() - - let configuration = CKOperation.Configuration() - configuration.allowsCellularAccess = true - - do { - try await container.privateCloudDatabase.configuredWith(configuration: configuration) { database in - - log.trace("deleteRecordZone(): configured") - - if state.isCancelled { - throw CKError(.operationCancelled) - } - - // Step 1 of 2: - - let recordZoneID = CKRecordZone(zoneName: self.encryptedNodeId).zoneID - - let (_, deleteResults) = try await database.modifyRecordZones( - saving: [], - deleting: [recordZoneID] - ) - - // deleteResults: [CKRecordZone.ID : Result] - - let result = deleteResults[recordZoneID]! - - if case let .failure(error) = result { - log.trace("deleteRecordZone(): perZoneResult: failure") - throw error - } - - log.trace("deleteRecordZone(): perZoneResult: success") - - // Step 2 of 2: - - try await Task { @MainActor in - try await self.cloudKitDb.clearDatabaseTables() - }.value - - // Done ! - - Prefs.shared.backupTransactions.setRecordZoneCreated(false, encryptedNodeId: self.encryptedNodeId) - self.consecutiveErrorCount = 0 - - if let newState = await self.actor.didDeleteRecordZone() { - self.handleNewState(newState) - } - - } // - - } catch { - - log.error("deleteRecordZone(): error = \(error)") - self.handleError(error) - } - } // - } +extension SyncBackupManager { - private func downloadPayments(_ downloadProgress: SyncTxManager_State_Downloading) { + func downloadPayments(_ downloadProgress: SyncBackupManager_State_Downloading) { log.trace("downloadPayments()") Task { @@ -507,15 +47,15 @@ class SyncTxManager { // So first we fetch the oldest payment date in the table (if there is one) let millis: KotlinLong? = try await Task { @MainActor in - return try await self.cloudKitDb.fetchOldestCreation() + return try await self.cloudKitDb.payments.fetchOldestCreation() }.value let oldestCreationDate = millis?.int64Value.toDate(from: .milliseconds) - downloadProgress.setOldestCompletedDownload(oldestCreationDate) + downloadProgress.setPayments_oldestCompletedDownload(oldestCreationDate) /** * NOTE: - * If we want to report proper progress (via `SyncTxManager_State_Downloading`), + * If we want to report proper progress (via `SyncBackupManager_State_Downloading`), * then we need to know the total number of records to be downloaded from the cloud. * * However, there's a minor problem here: @@ -538,14 +78,14 @@ class SyncTxManager { * our current choice is to sacrifice the progress details. */ - let container = CKContainer.default() + let privateCloudDatabase = CKContainer.default().privateCloudDatabase let zoneID = self.recordZoneID() let configuration = CKOperation.Configuration() configuration.allowsCellularAccess = true do { - try await container.privateCloudDatabase.configuredWith(configuration: configuration) { database in + try await privateCloudDatabase.configuredWith(configuration: configuration) { database in // Step 2 of 4: // @@ -560,7 +100,7 @@ class SyncTxManager { } let query = CKQuery( - recordType: record_table_name, + recordType: payments_record_table_name, predicate: predicate ) query.sortDescriptors = [ @@ -605,12 +145,12 @@ class SyncTxManager { ) } - var items: [DownloadedItem] = [] + var items: [DownloadedPayment] = [] for (_, result) in results { if case .success(let record) = result { let (payment, metadata, unpaddedSize) = self.decryptAndDeserializePayment(record) if let payment { - items.append(DownloadedItem( + items.append(DownloadedPayment( record: record, unpaddedSize: unpaddedSize, payment: payment, @@ -632,7 +172,7 @@ class SyncTxManager { var paymentRows: [Lightning_kmpWalletPayment] = [] var paymentMetadataRows: [WalletPaymentId: WalletPaymentMetadataRow] = [:] - var metadataMap: [WalletPaymentId: CloudKitDb.MetadataRow] = [:] + var metadataMap: [WalletPaymentId: CloudKitPaymentsDb.MetadataRow] = [:] for item in items { @@ -646,7 +186,7 @@ class SyncTxManager { let creation = self.dateToMillis(creationDate) let metadata = self.metadataForRecord(item.record) - metadataMap[paymentId] = CloudKitDb.MetadataRow( + metadataMap[paymentId] = CloudKitPaymentsDb.MetadataRow( unpaddedSize: Int64(item.unpaddedSize), recordCreation: creation, recordBlob: metadata.toKotlinByteArray() @@ -663,13 +203,13 @@ class SyncTxManager { log.trace("downloadPayments(): cloudKitDb.updateRows()...") - try await self.cloudKitDb.updateRows( + try await self.cloudKitDb.payments.updateRows( downloadedPayments: paymentRows, downloadedPaymentsMetadata: paymentMetadataRows, updateMetadata: metadataMap ) - downloadProgress.finishBatch(completed: items.count, oldest: oldest) + downloadProgress.payments_finishBatch(completed: items.count, oldest: oldest) }.value // @@ -683,7 +223,6 @@ class SyncTxManager { } } // - } // log.trace("downloadPayments(): enqueueMissingItems()...") @@ -694,12 +233,12 @@ class SyncTxManager { // So we enqueue these for upload now. try await Task { @MainActor in - try await self.cloudKitDb.enqueueMissingItems() + try await self.cloudKitDb.payments.enqueueMissingItems() }.value log.trace("downloadPayments(): finish: success") - Prefs.shared.backupTransactions.setHasDownloadedRecords(true, encryptedNodeId: self.encryptedNodeId) + Prefs.shared.backupTransactions.markHasDownloadedPayments(self.encryptedNodeId) self.consecutiveErrorCount = 0 if let newState = await self.actor.didDownloadPayments() { @@ -721,12 +260,12 @@ class SyncTxManager { /// - remove the uploaded items from the queue /// - repeat as needed /// - private func uploadPayments(_ uploadProgress: SyncTxManager_State_Uploading) { + func uploadPayments(_ uploadProgress: SyncBackupManager_State_Uploading) { log.trace("uploadPayments()") let prepareUpload = {( - batch: FetchQueueBatchResult - ) -> UploadOperationInfo in + batch: FetchPaymentsQueueBatchResult + ) -> UploadPaymentsOperationInfo in log.trace("uploadPayments(): prepareUpload()") @@ -754,14 +293,14 @@ class SyncTxManager { if let (ciphertext, unpaddedSize) = self.serializeAndEncryptPayment(row.payment, batch) { let record = existingRecord ?? CKRecord( - recordType: record_table_name, - recordID: self.recordID(for: paymentId) + recordType: payments_record_table_name, + recordID: self.recordID(paymentId: paymentId) ) - record[record_column_data] = ciphertext + record[payments_record_column_data] = ciphertext if let fileUrl = self.serializeAndEncryptMetadata(row.metadata) { - record[record_column_meta] = CKAsset(fileURL: fileUrl) + record[payments_record_column_meta] = CKAsset(fileURL: fileUrl) } recordsToSave.append(record) @@ -774,14 +313,14 @@ class SyncTxManager { // The payment has been deleted from the local database. // So we're going to delete it from the cloud database (if it exists there). - let recordID = existingRecord?.recordID ?? self.recordID(for: paymentId) + let recordID = existingRecord?.recordID ?? self.recordID(paymentId: paymentId) recordIDsToDelete.append(recordID) reverseMap[recordID] = paymentId } } - var opInfo = UploadOperationInfo( + var opInfo = UploadPaymentsOperationInfo( batch: batch, recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, @@ -813,8 +352,8 @@ class SyncTxManager { } // let performUpload = {( - opInfo: UploadOperationInfo - ) async throws -> UploadOperationInfo in + opInfo: UploadPaymentsOperationInfo + ) async throws -> UploadPaymentsOperationInfo in log.trace("uploadPayments(): performUpload()") log.trace("opInfo.recordsToSave.count = \(opInfo.recordsToSave.count)") @@ -944,14 +483,14 @@ class SyncTxManager { } // let updateDatabase = {( - opInfo: UploadOperationInfo + opInfo: UploadPaymentsOperationInfo ) async throws -> Void in log.trace("uploadPayments(): updateDatabase()") var deleteFromQueue = [KotlinLong]() var deleteFromMetadata = [WalletPaymentId]() - var updateMetadata = [WalletPaymentId: CloudKitDb.MetadataRow]() + var updateMetadata = [WalletPaymentId: CloudKitPaymentsDb.MetadataRow]() for (rowid) in opInfo.completedRowids { deleteFromQueue.append(KotlinLong(longLong: rowid)) @@ -968,7 +507,7 @@ class SyncTxManager { let creation = self.dateToMillis(record.creationDate ?? Date()) let metadata = self.metadataForRecord(record) - updateMetadata[paymentRowId] = CloudKitDb.MetadataRow( + updateMetadata[paymentRowId] = CloudKitPaymentsDb.MetadataRow( unpaddedSize: Int64(unpaddedSize), recordCreation: creation, recordBlob: metadata.toKotlinByteArray() @@ -977,8 +516,10 @@ class SyncTxManager { } // Handle partial failures - for paymentRowId in self.updateConsecutivePartialFailures(opInfo.partialFailures) { - for rowid in opInfo.batch.rowidsMatching(paymentRowId) { + let partialFailures: [String: CKError?] = opInfo.partialFailures.mapKeys { $0.id } + + for paymentRowIdStr in self.updateConsecutivePartialFailures(partialFailures) { + for rowid in opInfo.batch.rowidsMatching(paymentRowIdStr) { deleteFromQueue.append(KotlinLong(longLong: rowid)) } } @@ -988,7 +529,7 @@ class SyncTxManager { log.debug("updateMetadata.count = \(updateMetadata.count)") try await Task { @MainActor [deleteFromQueue, deleteFromMetadata, updateMetadata] in - try await self.cloudKitDb.updateRows( + try await self.cloudKitDb.payments.updateRows( deleteFromQueue: deleteFromQueue, deleteFromMetadata: deleteFromMetadata, updateMetadata: updateMetadata @@ -1006,7 +547,7 @@ class SyncTxManager { log.trace("uploadPayments(): finish(): success") self.consecutiveErrorCount = 0 - if let newState = await self.actor.didUploadPayments() { + if let newState = await self.actor.didUploadItems() { self.handleNewState(newState) } @@ -1028,8 +569,8 @@ class SyncTxManager { // - the corresponding payment information that needs to be uploaded // - the corresponding CKRecord metadata from previous upload for the payment (if present) - let result: CloudKitDb.FetchQueueBatchResult = try await Task { @MainActor in - return try await self.cloudKitDb.fetchQueueBatch(limit: 20) + let result: CloudKitPaymentsDb.FetchQueueBatchResult = try await Task { @MainActor in + return try await self.cloudKitDb.payments.fetchQueueBatch(limit: 20) }.value let batch = result.convertToSwift() @@ -1056,7 +597,7 @@ class SyncTxManager { // - the uploadTask is triggered again // - ... // - if let newState = await self.actor.queueCountChanged(0, wait: nil) { + if let newState = await self.actor.paymentsQueueCountChanged(0, wait: nil) { self.handleNewState(newState) } return await finish(.success) @@ -1079,7 +620,6 @@ class SyncTxManager { // Edge case: there are no cloud tasks to perform. // We have to skip the upload, because it will fail if given an empty set of tasks. } else { - // uploadProgress.setInFlight(count: inFlightCount, progress: parentProgress) opInfo = try await performUpload(opInfo) } @@ -1091,7 +631,7 @@ class SyncTxManager { // Done ! - uploadProgress.completeInFlight(completed: inFlightCount) + uploadProgress.completePayments_inFlight(inFlightCount) return await finish(.success) } catch { @@ -1101,72 +641,25 @@ class SyncTxManager { } // ---------------------------------------- - // MARK: Debugging + // MARK: Record ID // ---------------------------------------- - #if DEBUG - private func listAllItems() -> Void { - log.trace("listAllItems()") + private func recordID(paymentId: WalletPaymentId) -> CKRecord.ID { - let query = CKQuery( - recordType: record_table_name, - predicate: NSPredicate(format: "TRUEPREDICATE") - ) - query.sortDescriptors = [ - NSSortDescriptor(key: "creationDate", ascending: false) - ] - - let operation = CKQueryOperation(query: query) - operation.zoneID = recordZoneID() - - recursiveListBatch(operation: operation) - } - - private func recursiveListBatch(operation: CKQueryOperation) -> Void { - log.trace("recursiveListBatch()") - - operation.recordMatchedBlock = {(recordID: CKRecord.ID, result: Result) in - - if let record = try? result.get() { - - log.debug("Received record:") - log.debug(" - recordID: \(record.recordID)") - log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") - - if let data = record[record_column_data] as? Data { - log.debug(" - data.count: \(data.count)") - } else { - log.debug(" - data: ?") - } - } - } - - operation.queryResultBlock = {(result: Result) in - - switch result { - case .success(let cursor): - if let cursor = cursor { - log.debug("Fetch batch complete. Continuing with cursor...") - self.recursiveListBatch(operation: CKQueryOperation(cursor: cursor)) - - } else { - log.debug("Fetch batch complete.") - } - - case .failure(let error): - log.debug("Error fetching batch: \(String(describing: error))") - } - } + // The recordID is: + // - deterministic => by hashing the paymentId + // - secure => by mixing in the secret cloudKey (derived from seed) - let configuration = CKOperation.Configuration() - configuration.allowsCellularAccess = true + let prefix = SHA256.hash(data: cloudKey.rawRepresentation) + let suffix = paymentId.dbId.data(using: .utf8)! - operation.configuration = configuration + let hashMe = prefix + suffix + let digest = SHA256.hash(data: hashMe) + let hash = digest.map { String(format: "%02hhx", $0) }.joined() - CKContainer.default().privateCloudDatabase.add(operation) + return CKRecord.ID(recordName: hash, zoneID: recordZoneID()) } - #endif // ---------------------------------------- // MARK: Utilities // ---------------------------------------- @@ -1178,7 +671,7 @@ class SyncTxManager { /// private func serializeAndEncryptPayment( _ row: Lightning_kmpWalletPayment, - _ batch: FetchQueueBatchResult + _ batch: FetchPaymentsQueueBatchResult ) -> (Data, Int)? { var wrapper: CloudData? = nil @@ -1219,9 +712,9 @@ class SyncTxManager { // // This allows us to generate the {mean, standardDeviation} for each payment type. // Using this information, we generate a target range for the size of the encrypted blob. - // And then we and a random amount of padding the reach our target range. + // And then we and a random amount of padding to reach our target range. - let makeRange = { (stats: CloudKitDb.MetadataStats) -> (Int, Int) in + let makeRange = { (stats: CloudKitPaymentsDb.MetadataStats) -> (Int, Int) in var rangeMin: Int = 0 var rangeMax: Int = 0 @@ -1349,7 +842,7 @@ class SyncTxManager { var payment: Lightning_kmpWalletPayment? = nil var metadata: WalletPaymentMetadataRow? = nil - if let ciphertext = record[record_column_data] as? Data { + if let ciphertext = record[payments_record_column_data] as? Data { // log.debug(" - data.count: \(ciphertext.count)") var cleartext: Data? = nil @@ -1393,7 +886,7 @@ class SyncTxManager { } } - if let asset = record[record_column_meta] as? CKAsset { + if let asset = record[payments_record_column_meta] as? CKAsset { var ciphertext: Data? = nil if let fileURL = asset.fileURL { @@ -1440,262 +933,71 @@ class SyncTxManager { return (payment, metadata, unpaddedSize) } - private func genRandomBytes(_ count: Int) -> Data { - - var data = Data(count: count) - let _ = data.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in - SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!) - } - return data - } - - /// Incorporates failures from the last CKModifyRecordsOperation, - /// and returns a list of permanently failed items. - /// - private func updateConsecutivePartialFailures( - _ partialFailures: [WalletPaymentId: CKError?] - ) -> [WalletPaymentId] { - - // Handle partial failures. - // The rules are: - // - if an operation fails 2 times in a row with the same error, then we drop the operation - // - unless the failure was serverChangeError, - // which must fail 3 times in a row before being dropped - - var permanentFailures: [WalletPaymentId] = [] - - for (paymentId, ckerror) in partialFailures { - - guard var cpf = consecutivePartialFailures[paymentId] else { - consecutivePartialFailures[paymentId] = ConsecutivePartialFailure( - count: 1, - error: ckerror - ) - continue - } - - let isSameError: Bool - if let lastError = cpf.error { - if let thisError = ckerror { - isSameError = lastError.errorCode == thisError.errorCode - } else { - isSameError = false - } - } else { - isSameError = (ckerror == nil) - } - - if isSameError { - cpf.count += 1 - - var isPermanentFailure: Bool - if let ckerror = ckerror, - ckerror.errorCode == CKError.serverRecordChanged.rawValue { - isPermanentFailure = cpf.count >= 3 - } else { - isPermanentFailure = cpf.count >= 2 - } - - if isPermanentFailure { - log.debug( - """ - Permanent failure: \(paymentId), count=\(cpf.count): \ - \( ckerror == nil ? "" : String(describing: ckerror!) ) - """ - ) - - permanentFailures.append(paymentId) - self.consecutivePartialFailures[paymentId] = nil - } else { - self.consecutivePartialFailures[paymentId] = cpf - } - - } else { - self.consecutivePartialFailures[paymentId] = ConsecutivePartialFailure( - count: 1, - error: ckerror - ) - } - } - - return permanentFailures - } + // ---------------------------------------- + // MARK: Debugging + // ---------------------------------------- + #if DEBUG - private func recordZoneID() -> CKRecordZone.ID { + private func listAllPayments() { + log.trace("listAllPayments()") - return CKRecordZone.ID( - zoneName: self.encryptedNodeId, - ownerName: CKCurrentUserDefaultName + let query = CKQuery( + recordType: payments_record_table_name, + predicate: NSPredicate(format: "TRUEPREDICATE") ) - } - - private func recordID(for paymentId: WalletPaymentId) -> CKRecord.ID { - - // The recordID is: - // - deterministic => by hashing the paymentId - // - secure => by mixing in the secret cloudKey (derived from seed) - - let prefix = SHA256.hash(data: cloudKey.rawRepresentation) - let suffix = paymentId.dbId.data(using: .utf8)! - - let hashMe = prefix + suffix - let digest = SHA256.hash(data: hashMe) - let hash = digest.map { String(format: "%02hhx", $0) }.joined() - - return CKRecord.ID(recordName: hash, zoneID: recordZoneID()) - } - - private func metadataForRecord(_ record: CKRecord) -> Data { - - // Source: CloudKit Tips and Tricks - WWDC 2015 - - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - record.encodeSystemFields(with: archiver) - - return archiver.encodedData - } - - private func recordFromMetadata(_ data: Data) -> CKRecord? { - - var record: CKRecord? = nil - do { - let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) - unarchiver.requiresSecureCoding = true - record = CKRecord(coder: unarchiver) - - } catch { - log.error("Error decoding CKRecord: \(String(describing: error))") - } + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] - return record - } - - private func dateToMillis(_ date: Date) -> Int64 { + let operation = CKQueryOperation(query: query) + operation.zoneID = self.recordZoneID() - return Int64(date.timeIntervalSince1970 * 1_000) + recursiveListPaymentsBatch(operation: operation) } - // ---------------------------------------- - // MARK: Errors - // ---------------------------------------- - - /// Standardized error handling routine for various async operations. - /// - private func handleError(_ error: Error) { - log.trace("handleError()") - - var isOperationCancelled = false - var isNotAuthenticated = false - var isZoneNotFound = false - var minDelay: Double? = nil + private func recursiveListPaymentsBatch(operation: CKQueryOperation) -> Void { + log.trace("recursiveListPaymentsBatch()") - if let ckerror = error as? CKError { - - switch ckerror.errorCode { - case CKError.operationCancelled.rawValue: - isOperationCancelled = true - - case CKError.notAuthenticated.rawValue: - isNotAuthenticated = true + operation.recordMatchedBlock = {(recordID: CKRecord.ID, result: Result) in - case CKError.accountTemporarilyUnavailable.rawValue: - isNotAuthenticated = true - - case CKError.userDeletedZone.rawValue: fallthrough - case CKError.zoneNotFound.rawValue: - isZoneNotFound = true + if let record = try? result.get() { - default: break - } - - // Sometimes a `notAuthenticated` error is hidden in a partial error. - if let partialErrorsByZone = ckerror.partialErrorsByItemID { + log.debug("Received record:") + log.debug(" - recordID: \(record.recordID)") + log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") - for (_, perZoneError) in partialErrorsByZone { - let errCode = (perZoneError as NSError).code - - if errCode == CKError.notAuthenticated.rawValue { - isNotAuthenticated = true - } else if errCode == CKError.accountTemporarilyUnavailable.rawValue { - isNotAuthenticated = true - } + if let data = record[payments_record_column_data] as? Data { + log.debug(" - data.count: \(data.count)") + } else { + log.debug(" - data: ?") } } - - // If the error was `requestRateLimited`, then `retryAfterSeconds` may be non-nil. - // The value may also be set for other errors, such as `zoneBusy`. - // - minDelay = ckerror.retryAfterSeconds } - let useExponentialBackoff: Bool - if isOperationCancelled || isNotAuthenticated || isZoneNotFound { - // There are edge cases to consider. - // I've witnessed the following: - // - CKAccountStatus is consistently reported as `.available` - // - Attempt to create zone consistently fails with "Not Authenticated" - // - // This seems to be the case when, for example, - // the account needs to accept a new "terms of service". - // - // After several consecutive failures, the server starts sending us a minDelay value. - // We should interpret this as a signal to start using exponential backoff. - // - if let delay = minDelay, delay > 0.0 { - useExponentialBackoff = true - } else { - useExponentialBackoff = false + operation.queryResultBlock = {(result: Result) in + + switch result { + case .success(let cursor): + if let cursor = cursor { + log.debug("Fetch batch complete. Continuing with cursor...") + self.recursiveListPaymentsBatch(operation: CKQueryOperation(cursor: cursor)) + + } else { + log.debug("Fetch batch complete.") + } + + case .failure(let error): + log.debug("Error fetching batch: \(String(describing: error))") } - } else { - useExponentialBackoff = true } - let wait: SyncTxManager_State_Waiting? - if useExponentialBackoff { - self.consecutiveErrorCount += 1 - var delay = self.exponentialBackoff() - if let minDelay = minDelay, delay < minDelay { - delay = minDelay - } - wait = SyncTxManager_State_Waiting(kind: .exponentialBackoff(error), parent: self, delay: delay) - } else { - wait = nil - } + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true - Task { [isNotAuthenticated, isZoneNotFound] in - if let newState = await self.actor.handleError( - isNotAuthenticated: isNotAuthenticated, - isZoneNotFound: isZoneNotFound, - wait: wait - ) { - self.handleNewState(newState) - } - } + operation.configuration = configuration - if isNotAuthenticated { - DispatchQueue.main.async { - self.parent?.checkForCloudCredentials() - } - } + CKContainer.default().privateCloudDatabase.add(operation) } - private func exponentialBackoff() -> TimeInterval { - - assert(consecutiveErrorCount > 0, "Invalid state") - - switch consecutiveErrorCount { - case 1 : return 250.milliseconds() - case 2 : return 500.milliseconds() - case 3 : return 1.seconds() - case 4 : return 2.seconds() - case 5 : return 4.seconds() - case 6 : return 8.seconds() - case 7 : return 16.seconds() - case 8 : return 32.seconds() - case 9 : return 64.seconds() - case 10 : return 128.seconds() - case 11 : return 256.seconds() - default : return 512.seconds() - } - } + #endif } diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift new file mode 100644 index 000000000..a1ef8dc25 --- /dev/null +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift @@ -0,0 +1,775 @@ +import Foundation +import Combine +import CloudKit +import CryptoKit +import Network +import PhoenixShared + +fileprivate let filename = "SyncBackupManager" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +/** + * CloudKit hard limits (from the docs): + * + * Maximum number of operations in a request : 200 + * Maximum number of records in a response : 200 + * Maximum number of tokens in a request : 200 + * Maximum record size (not including Asset fields) : 1 MB + * Maximum file size of an Asset field : 50 MB + * Maximum number of source references to a single : + * target where the action is delete self : 750 +*/ + +let payments_record_table_name = "payments" +let payments_record_column_data = "encryptedData" +let payments_record_column_meta = "encryptedMeta" + +let contacts_record_table_name = "contacts" +let contacts_record_column_data = "encryptedData" +let contacts_record_column_photo = "photo" // CKAsset: automatically encrypted by CloudKit + +struct ConsecutivePartialFailure { + var count: Int + var error: CKError? +} + +// -------------------------------------------------------------------------------- +// MARK: - +// -------------------------------------------------------------------------------- + +/// Encompasses the logic for syncing data with Apple's CloudKit database. +/// +class SyncBackupManager { + + /// Access to parent for shared logic. + /// + weak var parent: SyncManager? = nil + + /// The wallet info, such as nodeID, cloudKey, etc. + /// + let walletInfo: WalletManager.WalletInfo + + /// Informs the user interface regarding the activities of the SyncBackupManager. + /// This includes various errors & active upload progress. + /// + /// Changes to this publisher will always occur on the main thread. + /// + let statePublisher = CurrentValueSubject(.initializing) + + /// Informs the user interface about a pending change to the SyncBackupManager's global settings. + /// + /// Changes to this publisher will always occur on the main thread. + /// + let pendingSettingsPublisher = CurrentValueSubject(nil) + + /// Implements the state machine in a thread-safe actor. + /// + let actor: SyncBackupManager_Actor + + var consecutiveErrorCount = 0 + var consecutivePartialFailures: [String: ConsecutivePartialFailure] = [:] + + private var _cloudKitDb: CloudKitDb? = nil // see getter method + + private var cancellables = Set() + + init(walletInfo: WalletManager.WalletInfo) { + log.trace("init()") + + self.walletInfo = walletInfo + + let encryptedNodeId = walletInfo.encryptedNodeId + self.actor = SyncBackupManager_Actor( + isEnabled: Prefs.shared.backupTransactions.isEnabled, + recordZoneCreated: Prefs.shared.backupTransactions.recordZoneCreated(encryptedNodeId), + hasDownloadedPayments: Prefs.shared.backupTransactions.hasDownloadedPayments(encryptedNodeId), + hasDownloadedContacts: Prefs.shared.backupTransactions.hasDownloadedContacts(encryptedNodeId) + ) + + waitForDatabases() + } + + var cloudKitDb: CloudKitDb { + get { return _cloudKitDb! } + } + + var cloudKey: SymmetricKey { + let cloudKeyData = walletInfo.cloudKey.toByteArray().toSwiftData() + return SymmetricKey(data: cloudKeyData) + } + + var encryptedNodeId: String { + return walletInfo.encryptedNodeId + } + + // ---------------------------------------- + // MARK: Monitors + // ---------------------------------------- + + private func startPaymentsQueueCountMonitor() { + log.trace("startPaymentsQueueCountMonitor()") + + // Kotlin suspend functions are currently only supported on the main thread + assert(Thread.isMainThread, "Kotlin ahead: background threads unsupported") + + self.cloudKitDb.payments.queueCountPublisher().sink {[weak self] (queueCount: Int64) in + log.debug("payments.queueCountPublisher().sink(): count = \(queueCount)") + + guard let self = self else { + return + } + + let count = Int(clamping: queueCount) + + let wait: SyncBackupManager_State_Waiting? + if Prefs.shared.backupTransactions.useUploadDelay { + let delay = TimeInterval.random(in: 10 ..< 900) + wait = SyncBackupManager_State_Waiting( + kind: .randomizedUploadDelay, + parent: self, + delay: delay + ) + } else { + wait = nil + } + + Task { + if let newState = await self.actor.paymentsQueueCountChanged(count, wait: wait) { + self.handleNewState(newState) + } + } + + }.store(in: &cancellables) + } + + private func startContactsQueueCountMonitor() { + log.trace("startContactsQueueCountMonitor()") + + // Kotlin suspend functions are currently only supported on the main thread + assert(Thread.isMainThread, "Kotlin ahead: background threads unsupported") + + self.cloudKitDb.contacts.queueCountPublisher().sink {[weak self] (queueCount: Int64) in + log.debug("contacts.queueCountPublisher().sink(): count = \(queueCount)") + + guard let self = self else { + return + } + + // Note: Upload delay doesn't apply to contacts. + + let count = Int(clamping: queueCount) + Task { + if let newState = await self.actor.contactsQueueCountChanged(count, wait: nil) { + self.handleNewState(newState) + } + } + + }.store(in: &cancellables) + } + + private func startPreferencesMonitor() { + log.trace("startPreferencesMonitor()") + + var isFirstFire = true + Prefs.shared.backupTransactions.isEnabledPublisher.sink {[weak self](shouldEnable: Bool) in + + if isFirstFire { + isFirstFire = false + return + } + guard let self = self else { + return + } + + log.debug("Prefs.shared.backupTransactions_isEnabled = \(shouldEnable ? "true" : "false")") + + let delay = 30.seconds() + let pendingSettings = shouldEnable ? + SyncBackupManager_PendingSettings(self, enableSyncing: delay) + : SyncBackupManager_PendingSettings(self, disableSyncing: delay) + + Task { + if await self.actor.enqueuePendingSettings(pendingSettings) { + self.publishPendingSettings(pendingSettings) + } + } + + }.store(in: &cancellables) + } + + // ---------------------------------------- + // MARK: Publishers + // ---------------------------------------- + + func publishNewState(_ state: SyncBackupManager_State) { + log.trace("publishNewState()") + + // Contract: Changes to this publisher will always occur on the main thread. + let block = { + self.statePublisher.value = state + } + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { block() } + } + } + + func publishPendingSettings(_ pending: SyncBackupManager_PendingSettings?) { + log.trace("publishPendingSettings()") + + // Contract: Changes to this publisher will always occur on the main thread. + let block = { + self.pendingSettingsPublisher.value = pending + } + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { block() } + } + } + + // ---------------------------------------- + // MARK: External Control + // ---------------------------------------- + + /// Called from SyncManager; part of SyncManagerProtocol + /// + func networkStatusChanged(hasInternet: Bool) { + log.trace("networkStatusChanged(hasInternet: \(hasInternet))") + + Task { + if let newState = await self.actor.networkStatusChanged(hasInternet: hasInternet) { + self.handleNewState(newState) + } + } + } + + /// Called from SyncManager; part of SyncManagerProtocol + /// + func cloudCredentialsChanged(hasCloudCredentials: Bool) { + log.trace("cloudCredentialsChanged(hasCloudCredentials: \(hasCloudCredentials))") + + Task { + if let newState = await self.actor.cloudCredentialsChanged(hasCloudCredentials: hasCloudCredentials) { + self.handleNewState(newState) + } + } + } + + /// Called from `SyncBackupManager_PendingSettings` + /// + func dequeuePendingSettings(_ pending: SyncBackupManager_PendingSettings, approved: Bool) { + log.trace("dequeuePendingSettings(_, approved: \(approved ? "true" : "false"))") + + Task { + let (accepted, newState) = await self.actor.dequeuePendingSettings(pending, approved: approved) + if accepted { + self.publishPendingSettings(nil) + if !approved { + if pending.paymentSyncing == .willEnable { + // We were going to enable cloud syncing. + // But the user just changed their mind, and cancelled it. + // So now we need to disable it again. + Prefs.shared.backupTransactions.isEnabled = false + } else { + // We were going to disable cloud syncing. + // But the user just changed their mind, and cancelled it. + // So now we need to enable it again. + Prefs.shared.backupTransactions.isEnabled = true + } + } + } + if let newState = newState { + self.handleNewState(newState) + } + } + } + + /// Called from `SyncBackupManager_State_Waiting` + /// + func finishWaiting(_ waiting: SyncBackupManager_State_Waiting) { + log.trace("finishWaiting()") + + Task { + if let newState = await self.actor.finishWaiting(waiting) { + self.handleNewState(newState) + } + } + } + + /// Used when closing the corresponding wallet. + /// We transition to a terminal state. + /// + func shutdown() { + log.trace("shutdown()") + + Task { + if let newState = await self.actor.shutdown() { + self.handleNewState(newState) + } + } + + cancellables.removeAll() + } + + // ---------------------------------------- + // MARK: Flow + // ---------------------------------------- + + func handleNewState(_ newState: SyncBackupManager_State) { + + log.trace("state = \(newState)") + switch newState { + case .updatingCloud(let details): + switch details.kind { + case .creatingRecordZone: + createRecordZone(details) + case .deletingRecordZone: + deleteRecordZone(details) + } + case .downloading(let details): + if details.needsDownloadPayments { + downloadPayments(details) + } + if details.needsDownloadContacts { + downloadContacts(details) + } + case .uploading(let details): + if details.payments_pendingCount > 0 { + uploadPayments(details) + } else { + uploadContacts(details) + } + default: + break + } + + publishNewState(newState) + } + + /// We have to wait until the databases are setup and ready. + /// This may take a moment if a migration is triggered. + /// + private func waitForDatabases() { + log.trace("waitForDatabases()") + + Task { @MainActor in + + let databaseManager = Biz.business.databaseManager + do { + let cloudKitDb = try await databaseManager.cloudKitDb() as! CloudKitDb + self._cloudKitDb = cloudKitDb + + if let newState = await self.actor.markDatabasesReady() { + self.handleNewState(newState) + } + + DispatchQueue.main.async { + self.startPaymentsQueueCountMonitor() + self.startContactsQueueCountMonitor() + self.startPreferencesMonitor() + } + + } catch { + + assertionFailure("Unable to extract cloudKitDb") + } + + } // + } + + /// We create a dedicated CKRecordZone for each wallet. + /// This allows us to properly segregate transactions between multiple wallets. + /// Before we can interact with the RecordZone we have to explicitly create it. + /// + private func createRecordZone(_ state: SyncBackupManager_State_UpdatingCloud) { + log.trace("createRecordZone()") + + state.task = Task { + log.trace("createRecordZone(): starting task") + + let privateCloudDatabase = CKContainer.default().privateCloudDatabase + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + do { + try await privateCloudDatabase.configuredWith(configuration: configuration) { database in + + log.trace("createRecordZone(): configured") + + if state.isCancelled { + throw CKError(.operationCancelled) + } + + let rzId = self.recordZoneID() + let recordZone = CKRecordZone(zoneID: rzId) + + let (saveResults, _) = try await database.modifyRecordZones( + saving: [recordZone], + deleting: [] + ) + + // saveResults: [CKRecordZone.ID : Result] + + if let result = saveResults[rzId] { + if case let .failure(error) = result { + log.warning("createRecordZone(): perZoneResult: failure") + throw error + } + } else { + log.error("createRecordZone(): result missing: recordZone") + throw CKError(CKError.zoneNotFound) + } + + log.trace("createRecordZone(): perZoneResult: success") + + Prefs.shared.backupTransactions.setRecordZoneCreated(true, self.encryptedNodeId) + self.consecutiveErrorCount = 0 + + if let newState = await self.actor.didCreateRecordZone() { + self.handleNewState(newState) + } + + } // + + } catch { + + log.error("createRecordZone(): error = \(error)") + self.handleError(error) + } + } // + } + + private func deleteRecordZone(_ state: SyncBackupManager_State_UpdatingCloud) { + log.trace("deleteRecordZone()") + + state.task = Task { + log.trace("deleteRecordZone(): starting task") + + let privateCloudDatabase = CKContainer.default().privateCloudDatabase + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + do { + try await privateCloudDatabase.configuredWith(configuration: configuration) { database in + + log.trace("deleteRecordZone(): configured") + + if state.isCancelled { + throw CKError(.operationCancelled) + } + + // Step 1 of 2: + + let rzId = recordZoneID() + + let (_, deleteResults) = try await database.modifyRecordZones( + saving: [], + deleting: [rzId] + ) + + // deleteResults: [CKRecordZone.ID : Result] + + if let result = deleteResults[rzId] { + if case let .failure(error) = result { + log.warning("deleteRecordZone(): perZoneResult: failure") + throw error + } + } else { + log.error("deleteRecordZone(): result missing: recordZone") + throw CKError(CKError.zoneNotFound) + } + + log.trace("deleteRecordZone(): perZoneResult: success") + + // Step 2 of 2: + + try await Task { @MainActor in + try await self.cloudKitDb.payments.clearDatabaseTables() + try await self.cloudKitDb.contacts.clearDatabaseTables() + }.value + + // Done ! + + Prefs.shared.backupTransactions.setRecordZoneCreated(false, self.encryptedNodeId) + self.consecutiveErrorCount = 0 + + if let newState = await self.actor.didDeleteRecordZone() { + self.handleNewState(newState) + } + + } // + + } catch { + + log.error("deleteRecordZone(): error = \(error)") + self.handleError(error) + } + } // + } + + // ---------------------------------------- + // MARK: Record Zones + // ---------------------------------------- + + func recordZoneName() -> String { + return self.encryptedNodeId + } + + func recordZoneID() -> CKRecordZone.ID { + + return CKRecordZone.ID( + zoneName: recordZoneName(), + ownerName: CKCurrentUserDefaultName + ) + } + + // ---------------------------------------- + // MARK: Utilities + // ---------------------------------------- + + func metadataForRecord(_ record: CKRecord) -> Data { + + // Source: CloudKit Tips and Tricks - WWDC 2015 + + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + record.encodeSystemFields(with: archiver) + + return archiver.encodedData + } + + func recordFromMetadata(_ data: Data) -> CKRecord? { + + var record: CKRecord? = nil + do { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + record = CKRecord(coder: unarchiver) + + } catch { + log.error("Error decoding CKRecord: \(String(describing: error))") + } + + return record + } + + func dateToMillis(_ date: Date) -> Int64 { + + return Int64(date.timeIntervalSince1970 * 1_000) + } + + func genRandomBytes(_ count: Int) -> Data { + + var data = Data(count: count) + let _ = data.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in + SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!) + } + return data + } + + // ---------------------------------------- + // MARK: Errors + // ---------------------------------------- + + /// Standardized error handling routine for various async operations. + /// + func handleError(_ error: Error) { + log.trace("handleError()") + + var isOperationCancelled = false + var isNotAuthenticated = false + var isZoneNotFound = false + var minDelay: Double? = nil + + if let ckerror = error as? CKError { + + switch ckerror.errorCode { + case CKError.operationCancelled.rawValue: + isOperationCancelled = true + + case CKError.notAuthenticated.rawValue: + isNotAuthenticated = true + + case CKError.accountTemporarilyUnavailable.rawValue: + isNotAuthenticated = true + + case CKError.userDeletedZone.rawValue: fallthrough + case CKError.zoneNotFound.rawValue: + isZoneNotFound = true + + default: break + } + + // Sometimes a `notAuthenticated` error is hidden in a partial error. + if let partialErrorsByZone = ckerror.partialErrorsByItemID { + + for (_, perZoneError) in partialErrorsByZone { + let errCode = (perZoneError as NSError).code + + if errCode == CKError.notAuthenticated.rawValue { + isNotAuthenticated = true + } else if errCode == CKError.accountTemporarilyUnavailable.rawValue { + isNotAuthenticated = true + } + } + } + + // If the error was `requestRateLimited`, then `retryAfterSeconds` may be non-nil. + // The value may also be set for other errors, such as `zoneBusy`. + // + minDelay = ckerror.retryAfterSeconds + } + + let useExponentialBackoff: Bool + if isOperationCancelled || isNotAuthenticated || isZoneNotFound { + // There are edge cases to consider. + // I've witnessed the following: + // - CKAccountStatus is consistently reported as `.available` + // - Attempt to create zone consistently fails with "Not Authenticated" + // + // This seems to be the case when, for example, + // the account needs to accept a new "terms of service". + // + // After several consecutive failures, the server starts sending us a minDelay value. + // We should interpret this as a signal to start using exponential backoff. + // + if let delay = minDelay, delay > 0.0 { + useExponentialBackoff = true + } else { + useExponentialBackoff = false + } + } else { + useExponentialBackoff = true + } + + let wait: SyncBackupManager_State_Waiting? + if useExponentialBackoff { + self.consecutiveErrorCount += 1 + var delay = self.exponentialBackoff() + if let minDelay = minDelay, delay < minDelay { + delay = minDelay + } + wait = SyncBackupManager_State_Waiting( + kind : .exponentialBackoff(error), + parent : self, + delay : delay + ) + } else { + wait = nil + } + + Task { [isNotAuthenticated, isZoneNotFound] in + if let newState = await self.actor.handleError( + isNotAuthenticated: isNotAuthenticated, + isZoneNotFound: isZoneNotFound, + wait: wait + ) { + self.handleNewState(newState) + } + } + + if isNotAuthenticated { + DispatchQueue.main.async { + self.parent?.checkForCloudCredentials() + } + } + } + + private func exponentialBackoff() -> TimeInterval { + + assert(consecutiveErrorCount > 0, "Invalid state") + + switch consecutiveErrorCount { + case 1 : return 250.milliseconds() + case 2 : return 500.milliseconds() + case 3 : return 1.seconds() + case 4 : return 2.seconds() + case 5 : return 4.seconds() + case 6 : return 8.seconds() + case 7 : return 16.seconds() + case 8 : return 32.seconds() + case 9 : return 64.seconds() + case 10 : return 128.seconds() + case 11 : return 256.seconds() + default : return 512.seconds() + } + } + + /// Incorporates failures from the last CKModifyRecordsOperation, + /// and returns a list of permanently failed items. + /// + func updateConsecutivePartialFailures( + _ partialFailures: [String: CKError?] + ) -> [String] { + + // The rules are: + // - if an operation fails 2 times in a row with the same error, then we drop the operation + // - unless the failure was serverChangeError, + // which must fail 3 times in a row before being dropped + + var permanentFailures: [String] = [] + + for (id, ckerror) in partialFailures { + + guard var cpf = consecutivePartialFailures[id] else { + consecutivePartialFailures[id] = ConsecutivePartialFailure( + count: 1, + error: ckerror + ) + continue + } + + let isSameError: Bool + if let lastError = cpf.error { + if let thisError = ckerror { + isSameError = lastError.errorCode == thisError.errorCode + } else { + isSameError = false + } + } else { + isSameError = (ckerror == nil) + } + + if isSameError { + cpf.count += 1 + + var isPermanentFailure: Bool + if let ckerror = ckerror, + ckerror.errorCode == CKError.serverRecordChanged.rawValue { + isPermanentFailure = cpf.count >= 3 + } else { + isPermanentFailure = cpf.count >= 2 + } + + if isPermanentFailure { + log.debug( + """ + Permanent failure: \(id), count=\(cpf.count): \ + \( ckerror == nil ? "" : String(describing: ckerror!) ) + """ + ) + + permanentFailures.append(id) + self.consecutivePartialFailures[id] = nil + } else { + self.consecutivePartialFailures[id] = cpf + } + + } else { + self.consecutivePartialFailures[id] = ConsecutivePartialFailure( + count: 1, + error: ckerror + ) + } + } + + return permanentFailures + } +} diff --git a/phoenix-ios/phoenix-ios/sync/SyncTxManager_Actor.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift similarity index 69% rename from phoenix-ios/phoenix-ios/sync/SyncTxManager_Actor.swift rename to phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift index aa03a9305..d14ccfea1 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncTxManager_Actor.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift @@ -1,6 +1,6 @@ import Foundation -fileprivate let filename = "SyncTxActor" +fileprivate let filename = "SyncBackupActor" #if DEBUG && true fileprivate var log = LoggerFactory.shared.logger(filename, .trace) #else @@ -8,9 +8,9 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif /// This class implements the state machine in a thread-safe actor. -/// See `SyncTxManager_State.swift` for state machine diagrams +/// See `SyncBackupManager_State.swift` for state machine diagrams /// -actor SyncTxManager_Actor { +actor SyncBackupManager_Actor { private var waitingForDatabases = true @@ -20,26 +20,35 @@ actor SyncTxManager_Actor { private var isEnabled: Bool private var needsCreateRecordZone: Bool private var needsDeleteRecordZone: Bool - private var needsDownloadExisting = false + private var needsDownloadPayments = false + private var needsDownloadContacts = false private var paymentsQueueCount: Int = 0 + private var contactsQueueCount: Int = 0 - private var state: SyncTxManager_State - private var pendingSettings: SyncTxManager_PendingSettings? = nil + private var state: SyncBackupManager_State + private var pendingSettings: SyncBackupManager_PendingSettings? = nil - var activeState: SyncTxManager_State { + var activeState: SyncBackupManager_State { return state } - init(isEnabled: Bool, recordZoneCreated: Bool, hasDownloadedRecords: Bool) { + init( + isEnabled: Bool, + recordZoneCreated: Bool, + hasDownloadedPayments: Bool, + hasDownloadedContacts: Bool + ) { self.isEnabled = isEnabled if isEnabled { needsCreateRecordZone = !recordZoneCreated - needsDownloadExisting = !hasDownloadedRecords + needsDownloadPayments = !hasDownloadedPayments + needsDownloadContacts = !hasDownloadedContacts needsDeleteRecordZone = false } else { needsCreateRecordZone = false - needsDownloadExisting = false + needsDownloadPayments = false + needsDownloadContacts = false needsDeleteRecordZone = recordZoneCreated } @@ -50,7 +59,7 @@ actor SyncTxManager_Actor { // MARK: Transition Logic // -------------------------------------------------- - func markDatabasesReady() -> SyncTxManager_State? { + func markDatabasesReady() -> SyncBackupManager_State? { waitingForDatabases = false switch state { @@ -61,7 +70,7 @@ actor SyncTxManager_Actor { } } - func networkStatusChanged(hasInternet: Bool) -> SyncTxManager_State? { + func networkStatusChanged(hasInternet: Bool) -> SyncBackupManager_State? { log.trace("networkStatusChanged(hasInternet: \(hasInternet))") if hasInternet { @@ -91,7 +100,7 @@ actor SyncTxManager_Actor { } } - func cloudCredentialsChanged(hasCloudCredentials: Bool) -> SyncTxManager_State? { + func cloudCredentialsChanged(hasCloudCredentials: Bool) -> SyncBackupManager_State? { log.trace("cloudCredentialsChanged(hasCloudCredentials: \(hasCloudCredentials))") if hasCloudCredentials { @@ -121,7 +130,12 @@ actor SyncTxManager_Actor { } } - func queueCountChanged(_ count: Int, wait: SyncTxManager_State_Waiting?) -> SyncTxManager_State? { + func paymentsQueueCountChanged( + _ count: Int, + wait: SyncBackupManager_State_Waiting? + ) -> SyncBackupManager_State? { + + log.trace("paymentsQueueCountChanged(\(count))") paymentsQueueCount = count guard count > 0 else { @@ -129,7 +143,7 @@ actor SyncTxManager_Actor { } switch state { case .uploading(let details): - details.setTotalCount(count) + details.setPayments_totalCount(count) return nil case .synced: if let wait = wait { @@ -144,7 +158,35 @@ actor SyncTxManager_Actor { } } - func didCreateRecordZone() -> SyncTxManager_State? { + func contactsQueueCountChanged( + _ count: Int, + wait: SyncBackupManager_State_Waiting? + ) -> SyncBackupManager_State? { + + log.trace("contactsQueueCountChanged(\(count))") + + contactsQueueCount = count + guard count > 0 else { + return nil + } + switch state { + case .uploading(let details): + details.setContacts_totalCount(count) + return nil + case .synced: + if let wait = wait { + log.debug("state = waiting(randomizedUploadDelay)") + state = .waiting(details: wait) + return state + } else { + return simplifiedStateFlow() + } + default: + return nil + } + } + + func didCreateRecordZone() -> SyncBackupManager_State? { log.trace("didCreateRecordZone()") needsCreateRecordZone = false @@ -161,7 +203,7 @@ actor SyncTxManager_Actor { } } - func didDeleteRecordZone() -> SyncTxManager_State? { + func didDeleteRecordZone() -> SyncBackupManager_State? { log.trace("didDeleteRecordZone()") needsDeleteRecordZone = false @@ -178,20 +220,40 @@ actor SyncTxManager_Actor { } } - func didDownloadPayments() -> SyncTxManager_State? { + func didDownloadPayments() -> SyncBackupManager_State? { log.trace("didDownloadPayments()") - needsDownloadExisting = false - switch state { - case .downloading: - return simplifiedStateFlow() - default: - return nil + needsDownloadPayments = false + if needsDownloadContacts { + return nil + } else { + switch state { + case .downloading: + return simplifiedStateFlow() + default: + return nil + } + } + } + + func didDownloadContacts() -> SyncBackupManager_State? { + log.trace("didDownloadContacts()") + + needsDownloadContacts = false + if needsDownloadPayments { + return nil + } else { + switch state { + case .downloading: + return simplifiedStateFlow() + default: + return nil + } } } - func didUploadPayments() -> SyncTxManager_State? { - log.trace("didUploadPayments()") + func didUploadItems() -> SyncBackupManager_State? { + log.trace("didUploadItems()") switch state { case .uploading: @@ -204,8 +266,8 @@ actor SyncTxManager_Actor { func handleError( isNotAuthenticated: Bool, isZoneNotFound: Bool, - wait: SyncTxManager_State_Waiting? - ) -> SyncTxManager_State? { + wait: SyncBackupManager_State_Waiting? + ) -> SyncBackupManager_State? { if isNotAuthenticated { waitingForCloudCredentials = true @@ -231,7 +293,7 @@ actor SyncTxManager_Actor { } } - func finishWaiting(_ sender: SyncTxManager_State_Waiting) -> SyncTxManager_State? { + func finishWaiting(_ sender: SyncBackupManager_State_Waiting) -> SyncBackupManager_State? { log.trace("finishWaiting()") guard case .waiting(let details) = state, details == sender else { @@ -250,7 +312,7 @@ actor SyncTxManager_Actor { } } - func enqueuePendingSettings(_ pending: SyncTxManager_PendingSettings) -> Bool { + func enqueuePendingSettings(_ pending: SyncBackupManager_PendingSettings) -> Bool { let willEnable = pending.paymentSyncing == .willEnable if willEnable { @@ -278,9 +340,9 @@ actor SyncTxManager_Actor { } func dequeuePendingSettings( - _ pending: SyncTxManager_PendingSettings, + _ pending: SyncBackupManager_PendingSettings, approved: Bool - ) -> (Bool, SyncTxManager_State?) { + ) -> (Bool, SyncBackupManager_State?) { if pendingSettings != pending { // Current state doesn't match parameter. @@ -303,7 +365,8 @@ actor SyncTxManager_Actor { isEnabled = true needsCreateRecordZone = true - needsDownloadExisting = true + needsDownloadPayments = true + needsDownloadContacts = true needsDeleteRecordZone = false switch state { @@ -337,7 +400,8 @@ actor SyncTxManager_Actor { isEnabled = false needsCreateRecordZone = false - needsDownloadExisting = false + needsDownloadPayments = false + needsDownloadContacts = false needsDeleteRecordZone = true switch state { @@ -374,7 +438,7 @@ actor SyncTxManager_Actor { } } - func shutdown() -> SyncTxManager_State? { + func shutdown() -> SyncBackupManager_State? { switch state { case .shutdown: return nil // already shutdown @@ -386,7 +450,7 @@ actor SyncTxManager_Actor { // MARK: Internal // -------------------------------------------------- - private func simplifiedStateFlow() -> SyncTxManager_State? { + private func simplifiedStateFlow() -> SyncBackupManager_State? { let prvState = state @@ -397,11 +461,15 @@ actor SyncTxManager_Actor { } else if isEnabled { if needsCreateRecordZone { state = .updatingCloud_creatingRecordZone() - } else if needsDownloadExisting { - state = .downloading(details: SyncTxManager_State_Downloading()) - } else if paymentsQueueCount > 0 { - state = .uploading(details: SyncTxManager_State_Uploading( - totalCount: paymentsQueueCount + } else if needsDownloadPayments || needsDownloadContacts { + state = .downloading(details: SyncBackupManager_State_Downloading( + needsDownloadPayments: needsDownloadPayments, + needsDownloadContacts: needsDownloadContacts + )) + } else if paymentsQueueCount > 0 || contactsQueueCount > 0 { + state = .uploading(details: SyncBackupManager_State_Uploading( + payments_totalCount: paymentsQueueCount, + contacts_totalCount: contactsQueueCount )) } else { state = .synced diff --git a/phoenix-ios/phoenix-ios/sync/SyncTxManager_PendingSettings.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_PendingSettings.swift similarity index 73% rename from phoenix-ios/phoenix-ios/sync/SyncTxManager_PendingSettings.swift rename to phoenix-ios/phoenix-ios/sync/SyncBackupManager_PendingSettings.swift index ba0fa9472..7e2018772 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncTxManager_PendingSettings.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_PendingSettings.swift @@ -1,27 +1,27 @@ import Foundation -fileprivate let filename = "SyncTxManager_PendingSettings" +fileprivate let filename = "SyncBackupManager_PendingSettings" #if DEBUG && true fileprivate var log = LoggerFactory.shared.logger(filename, .trace) #else fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif -class SyncTxManager_PendingSettings: Equatable, CustomStringConvertible { +class SyncBackupManager_PendingSettings: Equatable, CustomStringConvertible { enum EnableDisable{ case willEnable case willDisable } - private weak var parent: SyncTxManager? + private weak var parent: SyncBackupManager? let paymentSyncing: EnableDisable let delay: TimeInterval let startDate: Date let fireDate: Date - init(_ parent: SyncTxManager, enableSyncing delay: TimeInterval) { + init(_ parent: SyncBackupManager, enableSyncing delay: TimeInterval) { let now = Date() self.parent = parent self.paymentSyncing = .willEnable @@ -32,7 +32,7 @@ class SyncTxManager_PendingSettings: Equatable, CustomStringConvertible { startTimer() } - init(_ parent: SyncTxManager, disableSyncing delay: TimeInterval) { + init(_ parent: SyncBackupManager, disableSyncing delay: TimeInterval) { let now = Date() self.parent = parent self.paymentSyncing = .willDisable @@ -80,14 +80,15 @@ class SyncTxManager_PendingSettings: Equatable, CustomStringConvertible { let dateStr = fireDate.description(with: Locale.current) switch paymentSyncing { case .willEnable: - return "" + return "" case .willDisable: - return "" + return "" } } - static func == (lhs: SyncTxManager_PendingSettings, rhs: SyncTxManager_PendingSettings) -> Bool { - + static func == (lhs: SyncBackupManager_PendingSettings, + rhs: SyncBackupManager_PendingSettings + ) -> Bool { return (lhs.paymentSyncing == rhs.paymentSyncing) && (lhs.fireDate == rhs.fireDate) } } diff --git a/phoenix-ios/phoenix-ios/sync/SyncTxManager_State.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift similarity index 52% rename from phoenix-ios/phoenix-ios/sync/SyncTxManager_State.swift rename to phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift index 8f28abb4c..42942685f 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncTxManager_State.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift @@ -1,7 +1,7 @@ import Foundation import CloudKit -/* SyncTxManager State Machine: +/* SyncBackupManager State Machine: * * The state is always one of the following: * @@ -66,13 +66,13 @@ import CloudKit * as implemented by the actor's `simplifiedStateFlow` function. */ -enum SyncTxManager_State: Equatable, CustomStringConvertible { +enum SyncBackupManager_State: Equatable, CustomStringConvertible { case initializing - case updatingCloud(details: SyncTxManager_State_UpdatingCloud) - case downloading(details: SyncTxManager_State_Downloading) - case uploading(details: SyncTxManager_State_Uploading) - case waiting(details: SyncTxManager_State_Waiting) + case updatingCloud(details: SyncBackupManager_State_UpdatingCloud) + case downloading(details: SyncBackupManager_State_Downloading) + case uploading(details: SyncBackupManager_State_Uploading) + case waiting(details: SyncBackupManager_State_Waiting) case synced case disabled case shutdown @@ -114,28 +114,28 @@ enum SyncTxManager_State: Equatable, CustomStringConvertible { // Simplified initializers: - static func updatingCloud_creatingRecordZone() -> SyncTxManager_State { - return .updatingCloud(details: SyncTxManager_State_UpdatingCloud(kind: .creatingRecordZone)) + static func updatingCloud_creatingRecordZone() -> SyncBackupManager_State { + return .updatingCloud(details: SyncBackupManager_State_UpdatingCloud(kind: .creatingRecordZone)) } - static func updatingCloud_deletingRecordZone() -> SyncTxManager_State { - return .updatingCloud(details: SyncTxManager_State_UpdatingCloud(kind: .deletingRecordZone)) + static func updatingCloud_deletingRecordZone() -> SyncBackupManager_State { + return .updatingCloud(details: SyncBackupManager_State_UpdatingCloud(kind: .deletingRecordZone)) } - static func waiting_forInternet() -> SyncTxManager_State { - return .waiting(details: SyncTxManager_State_Waiting(kind: .forInternet)) + static func waiting_forInternet() -> SyncBackupManager_State { + return .waiting(details: SyncBackupManager_State_Waiting(kind: .forInternet)) } - static func waiting_forCloudCredentials() -> SyncTxManager_State { - return .waiting(details: SyncTxManager_State_Waiting(kind: .forCloudCredentials)) + static func waiting_forCloudCredentials() -> SyncBackupManager_State { + return .waiting(details: SyncBackupManager_State_Waiting(kind: .forCloudCredentials)) } static func waiting_exponentialBackoff( - _ parent: SyncTxManager, + _ parent: SyncBackupManager, delay: TimeInterval, error: Error - ) -> SyncTxManager_State { - return .waiting(details: SyncTxManager_State_Waiting( + ) -> SyncBackupManager_State { + return .waiting(details: SyncBackupManager_State_Waiting( kind: .exponentialBackoff(error), parent: parent, delay: delay @@ -143,10 +143,10 @@ enum SyncTxManager_State: Equatable, CustomStringConvertible { } static func waiting_randomizedUploadDelay( - _ parent: SyncTxManager, + _ parent: SyncBackupManager, delay: TimeInterval - ) -> SyncTxManager_State { - return .waiting(details: SyncTxManager_State_Waiting( + ) -> SyncBackupManager_State { + return .waiting(details: SyncBackupManager_State_Waiting( kind: .randomizedUploadDelay, parent: parent, delay: delay @@ -156,7 +156,7 @@ enum SyncTxManager_State: Equatable, CustomStringConvertible { /// Details concerning the type of changes being made to the CloudKit container(s). /// -class SyncTxManager_State_UpdatingCloud: Equatable { +class SyncBackupManager_State_UpdatingCloud: Equatable { enum Kind { case creatingRecordZone @@ -177,7 +177,9 @@ class SyncTxManager_State_UpdatingCloud: Equatable { task?.cancel() } - static func == (lhs: SyncTxManager_State_UpdatingCloud, rhs: SyncTxManager_State_UpdatingCloud) -> Bool { + static func == (lhs: SyncBackupManager_State_UpdatingCloud, + rhs: SyncBackupManager_State_UpdatingCloud + ) -> Bool { return lhs.kind == rhs.kind } } @@ -185,10 +187,25 @@ class SyncTxManager_State_UpdatingCloud: Equatable { /// Exposes an ObservableObject that can be used by the UI for various purposes. /// All changes to `@Published` properties will be made on the UI thread. /// -class SyncTxManager_State_Downloading: ObservableObject, Equatable { +class SyncBackupManager_State_Downloading: ObservableObject, Equatable { - @Published private(set) var completedCount: Int = 0 - @Published private(set) var oldestCompletedDownload: Date? = nil + let needsDownloadPayments: Bool + let needsDownloadContacts: Bool + + @Published private(set) var payments_completedCount: Int = 0 + @Published private(set) var payments_oldestCompletedDownload: Date? = nil + + @Published private(set) var contacts_completedCount: Int = 0 + @Published private(set) var contacts_oldestCompletedDownload: Date? = nil + + init(needsDownloadPayments: Bool, needsDownloadContacts: Bool) { + self.needsDownloadPayments = needsDownloadPayments + self.needsDownloadContacts = needsDownloadContacts + } + + var completedCount: Int { + return payments_completedCount + contacts_completedCount + } private func updateOnMainThread(_ block: @escaping () -> Void) { if Thread.isMainThread { @@ -198,29 +215,53 @@ class SyncTxManager_State_Downloading: ObservableObject, Equatable { } } - func setOldestCompletedDownload(_ date: Date?) { + func setPayments_oldestCompletedDownload(_ date: Date?) { + updateOnMainThread { + self.payments_oldestCompletedDownload = date + } + } + + func setContacts_oldestCompletedDownload(_ date: Date?) { + updateOnMainThread { + self.contacts_oldestCompletedDownload = date + } + } + + func payments_finishBatch(completed: Int, oldest: Date?) { updateOnMainThread { - self.oldestCompletedDownload = date + self.payments_completedCount += completed + + if let oldest = oldest { + if let prv = self.payments_oldestCompletedDownload { + if oldest < prv { + self.payments_oldestCompletedDownload = oldest + } + } else { + self.payments_oldestCompletedDownload = oldest + } + } } } - func finishBatch(completed: Int, oldest: Date?) { + func contacts_finishBatch(completed: Int, oldest: Date?) { updateOnMainThread { - self.completedCount += completed + self.contacts_completedCount += completed if let oldest = oldest { - if let prv = self.oldestCompletedDownload { + if let prv = self.contacts_oldestCompletedDownload { if oldest < prv { - self.oldestCompletedDownload = oldest + self.contacts_oldestCompletedDownload = oldest } } else { - self.oldestCompletedDownload = oldest + self.contacts_oldestCompletedDownload = oldest } } } } - static func == (lhs: SyncTxManager_State_Downloading, rhs: SyncTxManager_State_Downloading) -> Bool { + static func == (lhs: SyncBackupManager_State_Downloading, + rhs: SyncBackupManager_State_Downloading + ) -> Bool { // Equality for this class is is based on pointers return lhs === rhs } @@ -229,18 +270,49 @@ class SyncTxManager_State_Downloading: ObservableObject, Equatable { /// Exposes an ObservableObject that can be used by the UI to display progress information. /// All changes to `@Published` properties will be made on the UI thread. /// -class SyncTxManager_State_Uploading: ObservableObject, Equatable { +class SyncBackupManager_State_Uploading: ObservableObject, Equatable { + + @Published private(set) var payments_totalCount: Int + @Published private(set) var payments_completedCount: Int = 0 + @Published private(set) var payments_inFlightCount: Int = 0 + @Published private(set) var payments_inFlightProgress: Progress? = nil - @Published private(set) var totalCount: Int - @Published private(set) var completedCount: Int = 0 - @Published private(set) var inFlightCount: Int = 0 - @Published private(set) var inFlightProgress: Progress? = nil + @Published private(set) var contacts_totalCount: Int + @Published private(set) var contacts_completedCount: Int = 0 + @Published private(set) var contacts_inFlightCount: Int = 0 + @Published private(set) var contacts_inFlightProgress: Progress? = nil private(set) var isCancelled = false private(set) var operation: CKOperation? = nil - init(totalCount: Int) { - self.totalCount = totalCount + var totalCount: Int { + return payments_totalCount + contacts_totalCount + } + + var completedCount: Int { + return payments_completedCount + contacts_completedCount + } + + var inFlightCount: Int { + return payments_inFlightCount + contacts_inFlightCount + } + + var inFlightProgress: Progress? { + // Note: we only perform one upload at a time (either payments or contacts) + return payments_inFlightProgress ?? contacts_inFlightProgress + } + + var payments_pendingCount: Int { + return payments_totalCount - payments_completedCount + } + + var contacts_pendingCount: Int { + return contacts_totalCount - contacts_completedCount + } + + init(payments_totalCount: Int, contacts_totalCount: Int) { + self.payments_totalCount = payments_totalCount + self.contacts_totalCount = contacts_totalCount } private func updateOnMainThread(_ block: @escaping () -> Void) { @@ -251,24 +323,38 @@ class SyncTxManager_State_Uploading: ObservableObject, Equatable { } } - func setTotalCount(_ value: Int) { + func setPayments_totalCount(_ value: Int) { + updateOnMainThread { + self.payments_totalCount = value + } + } + + func setContacts_totalCount(_ value: Int) { updateOnMainThread { - self.totalCount = value + self.contacts_totalCount = value } } - func setInFlight(count: Int, progress: Progress) { + func setPayments_inFlight(count: Int, progress: Progress) { updateOnMainThread { - self.inFlightCount = count - self.inFlightProgress = progress + self.payments_inFlightCount = count + self.payments_inFlightProgress = progress } } - func completeInFlight(completed: Int) { + func completePayments_inFlight(_ completed: Int) { updateOnMainThread { - self.completedCount += completed - self.inFlightCount = 0 - self.inFlightProgress = nil + self.payments_completedCount += completed + self.payments_inFlightCount = 0 + self.payments_inFlightProgress = nil + } + } + + func completeContacts_inFlight(_ completed: Int) { + updateOnMainThread { + self.contacts_completedCount += completed + self.contacts_inFlightCount = 0 + self.contacts_inFlightProgress = nil } } @@ -286,16 +372,18 @@ class SyncTxManager_State_Uploading: ObservableObject, Equatable { } } - static func == (lhs: SyncTxManager_State_Uploading, rhs: SyncTxManager_State_Uploading) -> Bool { + static func == (lhs: SyncBackupManager_State_Uploading, + rhs: SyncBackupManager_State_Uploading + ) -> Bool { // Equality for this class is is based on pointers return lhs === rhs } } -/// Details concerning what/why the SyncTxManager is temporarily paused. +/// Details concerning what/why the SyncBackupManager is temporarily paused. /// Sometimes these delays can be manually cancelled by the user. /// -class SyncTxManager_State_Waiting: Equatable { +class SyncBackupManager_State_Waiting: Equatable { enum Kind: Equatable { case forInternet @@ -303,7 +391,9 @@ class SyncTxManager_State_Waiting: Equatable { case exponentialBackoff(Error) case randomizedUploadDelay - static func == (lhs: SyncTxManager_State_Waiting.Kind, rhs: SyncTxManager_State_Waiting.Kind) -> Bool { + static func == (lhs: SyncBackupManager_State_Waiting.Kind, + rhs: SyncBackupManager_State_Waiting.Kind + ) -> Bool { switch (lhs, rhs) { case (.forInternet, .forInternet): return true case (.forCloudCredentials, .forCloudCredentials): return true @@ -319,13 +409,13 @@ class SyncTxManager_State_Waiting: Equatable { let kind: Kind struct WaitingUntil: Equatable { - weak var parent: SyncTxManager? + weak var parent: SyncBackupManager? let delay: TimeInterval let startDate: Date let fireDate: Date - static func == (lhs: SyncTxManager_State_Waiting.WaitingUntil, - rhs: SyncTxManager_State_Waiting.WaitingUntil + static func == (lhs: SyncBackupManager_State_Waiting.WaitingUntil, + rhs: SyncBackupManager_State_Waiting.WaitingUntil ) -> Bool { return (lhs.parent === rhs.parent) && (lhs.delay == rhs.delay) && @@ -341,7 +431,7 @@ class SyncTxManager_State_Waiting: Equatable { self.until = nil } - init(kind: Kind, parent: SyncTxManager, delay: TimeInterval) { + init(kind: Kind, parent: SyncBackupManager, delay: TimeInterval) { self.kind = kind let now = Date() @@ -368,8 +458,9 @@ class SyncTxManager_State_Waiting: Equatable { timerFire() } - static func == (lhs: SyncTxManager_State_Waiting, rhs: SyncTxManager_State_Waiting) -> Bool { - + static func == (lhs: SyncBackupManager_State_Waiting, + rhs: SyncBackupManager_State_Waiting + ) -> Bool { return (lhs.kind == rhs.kind) && (lhs.until == rhs.until) } } diff --git a/phoenix-ios/phoenix-ios/sync/SyncManager.swift b/phoenix-ios/phoenix-ios/sync/SyncManager.swift index af471029a..617db0cab 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncManager.swift @@ -13,12 +13,12 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) /// Common code utilized by both: /// - SyncSeedManager -/// - SyncTxManager +/// - SyncBackupManager /// class SyncManager { let syncSeedManager: SyncSeedManager - let syncTxManager: SyncTxManager + let syncBackupManager: SyncBackupManager private let networkMonitor = NWPathMonitor() @@ -27,22 +27,20 @@ class SyncManager { init( chain: Bitcoin_kmpChain, recoveryPhrase: RecoveryPhrase, - cloudKey: Bitcoin_kmpByteVector32, - encryptedNodeId: String + walletInfo: WalletManager.WalletInfo ) { syncSeedManager = SyncSeedManager( chain: chain, recoveryPhrase: recoveryPhrase, - encryptedNodeId: encryptedNodeId + walletInfo: walletInfo ) - syncTxManager = SyncTxManager( - cloudKey: cloudKey, - encryptedNodeId: encryptedNodeId + syncBackupManager = SyncBackupManager( + walletInfo: walletInfo ) syncSeedManager.parent = self - syncTxManager.parent = self + syncBackupManager.parent = self startNetworkMonitor() startCloudStatusMonitor() @@ -79,7 +77,7 @@ class SyncManager { } self.syncSeedManager.networkStatusChanged(hasInternet: hasInternet) - self.syncTxManager.networkStatusChanged(hasInternet: hasInternet) + self.syncBackupManager.networkStatusChanged(hasInternet: hasInternet) } networkMonitor.start(queue: DispatchQueue.main) @@ -100,7 +98,7 @@ class SyncManager { }.store(in: &cancellables) } - /// May also be called by `SyncTxManager` or `SyncSeedManager` if they encounter + /// May also be called by `SyncBackupManager` or `SyncSeedManager` if they encounter /// errors related to iCloud credential problems. /// func checkForCloudCredentials() { @@ -144,7 +142,7 @@ class SyncManager { } self.syncSeedManager.cloudCredentialsChanged(hasCloudCredentials: hasCloudCredentials) - self.syncTxManager.cloudCredentialsChanged(hasCloudCredentials: hasCloudCredentials) + self.syncBackupManager.cloudCredentialsChanged(hasCloudCredentials: hasCloudCredentials) } } @@ -155,7 +153,7 @@ class SyncManager { cancellables.removeAll() syncSeedManager.shutdown() - syncTxManager.shutdown() + syncBackupManager.shutdown() } } diff --git a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift index 59711c407..911449e3d 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift @@ -49,15 +49,9 @@ class SyncSeedManager: SyncManagerProtcol { /// private let recoveryPhrase: RecoveryPhrase - /// The encryptedNodeId is created via: Hash(cloudKey + nodeID) + /// The wallet info, such as nodeID, cloudKey, etc. /// - /// All data from a user's wallet are stored in the user's CKContainer.default().privateCloudDatabase. - /// And within the privateCloudDatabase, we create a dedicated CKRecordZone for each wallet, - /// where recordZone.name == encryptedNodeId. All trasactions for the wallet are stored in this recordZone. - /// - /// For simplicity, the name of the uploaded Seed shared the encryptedNodeId name. - /// - private let encryptedNodeId: String + private let walletInfo: WalletManager.WalletInfo /// Informs the user interface regarding the activities of the SyncSeedManager. /// This includes various errors & active upload progress. @@ -75,16 +69,22 @@ class SyncSeedManager: SyncManagerProtcol { private var cancellables = Set() private var upgradeTask: Task? = nil - init(chain: Bitcoin_kmpChain, recoveryPhrase: RecoveryPhrase, encryptedNodeId: String) { + init( + chain: Bitcoin_kmpChain, + recoveryPhrase: RecoveryPhrase, + walletInfo: WalletManager.WalletInfo + ) { log.trace("init()") self.chain = chain self.recoveryPhrase = recoveryPhrase - self.encryptedNodeId = encryptedNodeId + self.walletInfo = walletInfo actor = SyncSeedManager_Actor( isEnabled: Prefs.shared.backupSeed.isEnabled, - hasUploadedSeed: Prefs.shared.backupSeed.hasUploadedSeed(encryptedNodeId: encryptedNodeId) + hasUploadedSeed: Prefs.shared.backupSeed.hasUploadedSeed( + encryptedNodeId: walletInfo.encryptedNodeId + ) ) statePublisher = CurrentValueSubject(actor.initialState) @@ -94,6 +94,10 @@ class SyncSeedManager: SyncManagerProtcol { startUpgradeTask() } + var encryptedNodeId: String { + walletInfo.encryptedNodeId + } + // ---------------------------------------- // MARK: Fetch Seeds // ---------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 8ca6162b8..37a1f7224 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -309,8 +309,8 @@ fileprivate struct ConfigurationList: View { if hasWallet { navLink(.PaymentsBackup) { - Label { Text("Payments backup") } icon: { - Image(systemName: "icloud.and.arrow.up") + Label { Text("Cloud backup") } icon: { + Image(systemName: "icloud") } } .id(linkID_PaymentsBackup) diff --git a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift index 2c106fad9..a17178ea1 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift @@ -197,14 +197,12 @@ struct ResetWalletView: MVIView { .foregroundColor(.secondary) } - if !backupSeed_enabled { - Label { - Text("Seed backup not stored in iCloud.") - .font(.footnote) - .foregroundColor(.primary) // Stands out to provide explanation - } icon: { - invisibleImage() - } + Label { + Text("Seed backup not stored in iCloud.") + .font(.footnote) + .foregroundColor(.primary) // Stands out to provide explanation + } icon: { + invisibleImage() } } else { @@ -226,7 +224,7 @@ struct ResetWalletView: MVIView { if !backupTransactions_enabled { Label { - Text("Delete payment history from my iCloud account.") + Text("Delete payment history and contacts from my iCloud account.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } icon: { @@ -234,20 +232,18 @@ struct ResetWalletView: MVIView { .foregroundColor(.secondary) } - if !backupTransactions_enabled { - Label { - Text("Payment history not stored in iCloud.") - .font(.footnote) - .foregroundColor(.primary) // Stands out to provide explanation - } icon: { - invisibleImage() - } + Label { + Text("Payment history and contacts not stored in iCloud.") + .font(.footnote) + .foregroundColor(.primary) // Stands out to provide explanation + } icon: { + invisibleImage() } } else { Toggle(isOn: $deleteTransactionHistory) { - Text("Delete payment history from my iCloud account.") + Text("Delete payment history and contacts from my iCloud account.") .foregroundColor(.primary) } .toggleStyle(CheckboxToggleStyle( diff --git a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift index 9331d8d6e..7e4e426fe 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift @@ -31,7 +31,7 @@ struct ResetWalletView_Action: View { @State var didAppear = false @State var syncSeedManager = Biz.syncManager!.syncSeedManager - @State var syncTxManager = Biz.syncManager!.syncTxManager + @State var syncBackupManager = Biz.syncManager!.syncBackupManager // -------------------------------------------------- // MARK: View Builders @@ -78,11 +78,11 @@ struct ResetWalletView_Action: View { list() .frame(maxWidth: DeviceInfo.textColumnMaxWidth) } - .onReceive(syncTxManager.pendingSettingsPublisher) { - syncTx_pendingSettingsChanged($0) + .onReceive(syncBackupManager.pendingSettingsPublisher) { + syncBackup_pendingSettingsChanged($0) } - .onReceive(syncTxManager.statePublisher) { - syncTx_stateChanged($0) + .onReceive(syncBackupManager.statePublisher) { + syncBackup_stateChanged($0) } .onReceive(syncSeedManager.statePublisher) { syncSeed_stateChanged($0) @@ -129,7 +129,7 @@ struct ResetWalletView_Action: View { Section { Label { - Text("Deleting **payment history** from iCloud") + Text("Deleting **payment history** and **contacts** from iCloud") } icon: { Image(systemName: "icloud.fill") } @@ -358,9 +358,9 @@ struct ResetWalletView_Action: View { } } - func syncTx_pendingSettingsChanged(_ pendingSettings: SyncTxManager_PendingSettings?) { - log.trace("syncTx_pendingSettingsChanged()") - assertMainThread() // SyncTxManager promises to always publish on the main thread + func syncBackup_pendingSettingsChanged(_ pendingSettings: SyncBackupManager_PendingSettings?) { + log.trace("syncBackup_pendingSettingsChanged()") + assertMainThread() // SyncBackupManager promises to always publish on the main thread guard let pendingSettings = pendingSettings else { return @@ -372,9 +372,9 @@ struct ResetWalletView_Action: View { } } - func syncTx_stateChanged(_ state: SyncTxManager_State) { - log.trace("syncTx_stateChanged(\(state.description))") - assertMainThread() // SyncTxManager promises to always publish on the main thread + func syncBackup_stateChanged(_ state: SyncBackupManager_State) { + log.trace("syncBackup_stateChanged(\(state.description))") + assertMainThread() // SyncBackupManager promises to always publish on the main thread guard deleteTransactionHistory else { log.debug("ignoring => !deleteTransactionHistory") @@ -401,7 +401,7 @@ struct ResetWalletView_Action: View { } func syncSeed_stateChanged(_ state: SyncSeedManager_State) { - log.trace("syncTx_stateChanged(\(state.description)") + log.trace("syncSeed_stateChanged(\(state.description)") assertMainThread() // SyncSeedManager promises to always publish on the main thread guard deleteSeedBackup else { diff --git a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift index b4ad60f49..f6b601736 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift @@ -98,7 +98,7 @@ struct ResetWalletView_Confirm: MVISubView { func section_transactionHistory(_ idx: Int) -> some View { Section { - Text("The **payment history** for this wallet will be deleted from your iCloud account.") + Text("The **payment history** and **contacts** for this wallet will be deleted from your iCloud account.") } header: { Text("Step #\(idx)") diff --git a/phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift index 7f6dea549..faf467c36 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift @@ -75,7 +75,7 @@ struct PaymentsBackupView: View { Label { VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - Text("Your payment history will be stored in iCloud.") + Text("Your payment history and contacts will be stored in iCloud.") Text( """ @@ -98,12 +98,12 @@ struct PaymentsBackupView: View { Label { VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - Text("Your payment history is only stored on this device.") + Text("Your payment history and contacts are only stored on this device.") Text( """ If you switch to a new device (or reinstall the app) \ - then you'll lose your payment history. + then you'll lose this information. """ ) .foregroundColor(Color.gray) diff --git a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift index 494845073..11ef57bc3 100644 --- a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift +++ b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift @@ -15,8 +15,8 @@ struct AppStatusButton: View { @State var dimStatus = false - @State var syncState: SyncTxManager_State = .initializing - @State var pendingSettings: SyncTxManager_PendingSettings? = nil + @State var syncState: SyncBackupManager_State = .initializing + @State var pendingSettings: SyncBackupManager_PendingSettings? = nil @State var timer: Timer? = nil @State var showText: Bool = false @@ -27,7 +27,7 @@ struct AppStatusButton: View { @EnvironmentObject var popoverState: PopoverState @EnvironmentObject var deviceInfo: DeviceInfo - let syncTxManager = Biz.syncManager!.syncTxManager + let syncBackupManager = Biz.syncManager!.syncBackupManager // -------------------------------------------------- // MARK: View Builders @@ -55,11 +55,11 @@ struct AppStatusButton: View { .onChange(of: connectionsMonitor.connectingAt) { _ in updateTimer() } - .onReceive(syncTxManager.statePublisher) { - syncTxManagerStateChanged($0) + .onReceive(syncBackupManager.statePublisher) { + syncBackupManagerStateChanged($0) } - .onReceive(syncTxManager.pendingSettingsPublisher) { - syncTxManagerPendingSettingsChanged($0) + .onReceive(syncBackupManager.pendingSettingsPublisher) { + syncBackupManagerPendingSettingsChanged($0) } } @@ -194,14 +194,14 @@ struct AppStatusButton: View { updateTimer() } - func syncTxManagerStateChanged(_ newState: SyncTxManager_State) { - log.trace("syncTxManagerStateChanged()") + func syncBackupManagerStateChanged(_ newState: SyncBackupManager_State) { + log.trace("syncBackupManagerStateChanged()") syncState = newState } - func syncTxManagerPendingSettingsChanged(_ newPendingSettings: SyncTxManager_PendingSettings?) { - log.trace("syncTxManagerPendingSettingsChanged()") + func syncBackupManagerPendingSettingsChanged(_ newPendingSettings: SyncBackupManager_PendingSettings?) { + log.trace("syncBackupManagerPendingSettingsChanged()") pendingSettings = newPendingSettings } diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 8ba109beb..759b9302a 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -1248,15 +1248,15 @@ class DownloadMonitor: ObservableObject { init() { let syncManager = Biz.syncManager! - let syncStatePublisher = syncManager.syncTxManager.statePublisher + let syncStatePublisher = syncManager.syncBackupManager.statePublisher - syncStatePublisher.sink {[weak self](state: SyncTxManager_State) in + syncStatePublisher.sink {[weak self](state: SyncBackupManager_State) in self?.update(state) } .store(in: &cancellables) } - private func update(_ state: SyncTxManager_State) { + private func update(_ state: SyncBackupManager_State) { log.trace("[DownloadMonitor] update()") if case .downloading(let details) = state { @@ -1270,10 +1270,10 @@ class DownloadMonitor: ObservableObject { } } - private func subscribe(_ details: SyncTxManager_State_Downloading) { + private func subscribe(_ details: SyncBackupManager_State_Downloading) { log.trace("[DownloadMonitor] subscribe()") - details.$oldestCompletedDownload.sink {[weak self](date: Date?) in + details.$payments_oldestCompletedDownload.sink {[weak self](date: Date?) in log.trace("[DownloadMonitor] oldestCompletedDownload = \(date?.description ?? "nil")") self?.oldestCompletedDownload = date } diff --git a/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift b/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift index 66b283324..db9862ca8 100644 --- a/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift +++ b/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift @@ -16,12 +16,12 @@ struct AppStatusPopover: View { @State var srvExtConnectedToPeer = Biz.srvExtConnectedToPeer.value - @State var syncState: SyncTxManager_State = .initializing - @State var pendingSettings: SyncTxManager_PendingSettings? = nil + @State var syncState: SyncBackupManager_State = .initializing + @State var pendingSettings: SyncBackupManager_PendingSettings? = nil @EnvironmentObject var popoverState: PopoverState - let syncManager = Biz.syncManager!.syncTxManager + let syncManager = Biz.syncManager!.syncBackupManager enum TitleIconWidth: Preference {} let titleIconWidthReader = GeometryPreferenceReader( @@ -154,7 +154,7 @@ struct AppStatusPopover: View { } @ViewBuilder - func syncStatusSection_pending(_ value: SyncTxManager_PendingSettings) -> some View { + func syncStatusSection_pending(_ value: SyncBackupManager_PendingSettings) -> some View { VStack(alignment: .leading) { @@ -360,13 +360,13 @@ struct AppStatusPopover: View { srvExtConnectedToPeer = newValue } - func syncStateChanged(_ newSyncState: SyncTxManager_State) { + func syncStateChanged(_ newSyncState: SyncBackupManager_State) { log.trace("syncStateChanged()") syncState = newSyncState } - func pendingSettingsChanged(_ newPendingSettings: SyncTxManager_PendingSettings?) { + func pendingSettingsChanged(_ newPendingSettings: SyncBackupManager_PendingSettings?) { log.trace("pendingSettingsChanged()") pendingSettings = newPendingSettings @@ -441,7 +441,7 @@ fileprivate struct ConnectionCell: View { fileprivate struct DownloadProgressDetails: View { - @StateObject var details: SyncTxManager_State_Downloading + @StateObject var details: SyncBackupManager_State_Downloading @ViewBuilder var body: some View { @@ -462,7 +462,7 @@ fileprivate struct DownloadProgressDetails: View { fileprivate struct UploadProgressDetails: View { - @StateObject var details: SyncTxManager_State_Uploading + @StateObject var details: SyncBackupManager_State_Uploading @ViewBuilder var body: some View { @@ -514,7 +514,7 @@ fileprivate struct UploadProgressDetails: View { fileprivate struct SyncWaitingDetails: View, ViewName { - let waiting: SyncTxManager_State_Waiting + let waiting: SyncBackupManager_State_Waiting let timer = Timer.publish(every: 0.5, on: .current, in: .common).autoconnect() @State var currentDate = Date() @@ -640,7 +640,7 @@ fileprivate struct SyncWaitingDetails: View, ViewName { fileprivate struct PendingSettingsDetails: View, ViewName { - let pendingSettings: SyncTxManager_PendingSettings + let pendingSettings: SyncBackupManager_PendingSettings @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift index 29c0afec7..6ddd2762c 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift @@ -30,7 +30,7 @@ struct TransactionsView: View { @State var selectedItem: WalletPaymentInfo? = nil @State var historyExporterOpen: Bool = false - let syncStatePublisher = Biz.syncManager!.syncTxManager.statePublisher + let syncStatePublisher = Biz.syncManager!.syncBackupManager.statePublisher @State var isDownloadingTxs: Bool = false @State var didAppear = false @@ -523,7 +523,7 @@ struct TransactionsView: View { visibleRows.remove(visibleRow) } - func syncStateChanged(_ state: SyncTxManager_State) { + func syncStateChanged(_ state: SyncBackupManager_State) { log.trace("syncStateChanged()") if case .downloading(_) = state { diff --git a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index 89b6fc53c..92cfeab29 100644 --- a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -1,5 +1,6 @@ package fr.acinq.phoenix.db +import fr.acinq.lightning.utils.UUID import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.payments.CloudKitInterface @@ -7,6 +8,9 @@ actual fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) actual fun didDeleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) {} actual fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: PaymentsDatabase) {} -actual fun makeCloudKitDb(database: PaymentsDatabase): CloudKitInterface? { +actual fun didSaveContact(contactId: UUID, database: AppDatabase) {} +actual fun didDeleteContact(contactId: UUID, database: AppDatabase) {} + +actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { return null } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/CloudKitContacts.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/CloudKitContacts.sq new file mode 100644 index 000000000..4d689f7ab --- /dev/null +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/CloudKitContacts.sq @@ -0,0 +1,85 @@ + + +-- This table stores the CKRecord metadata corresponding to a synced contact. +-- * id => stores the primary key of the contact row +-- +CREATE TABLE IF NOT EXISTS cloudkit_contacts_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +-- When resuming the download process (e.g. after app relaunch), +-- we need to fetch the earliest creationDate. +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_contacts_metadata(record_creation); + +-- This table stores the queue of items that need to be pushed to the cloud. +-- * rowid => because we might store the same `id` multiple times +-- * id => stores the primary key of the contact row +-- +CREATE TABLE IF NOT EXISTS cloudkit_contacts_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); + +-- ########## cloudkit_contacts_metadata ########## + +addMetadata: +INSERT INTO cloudkit_contacts_metadata ( + id, + record_creation, + record_blob) +VALUES (?, ?, ?); + +updateMetadata: +UPDATE cloudkit_contacts_metadata +SET record_blob = ? +WHERE id = ?; + +existsMetadata: +SELECT COUNT(*) FROM cloudkit_contacts_metadata +WHERE id = ?; + +fetchMetadata: +SELECT * FROM cloudkit_contacts_metadata +WHERE id = ?; + +scanMetadata: +SELECT id FROM cloudkit_contacts_metadata; + +fetchOldestCreation_Contacts: +SELECT id, record_creation FROM cloudkit_contacts_metadata +ORDER BY record_creation ASC +LIMIT 1; + +deleteMetadata: +DELETE FROM cloudkit_contacts_metadata +WHERE id = ?; + +deleteAllFromMetadata: +DELETE FROM cloudkit_contacts_metadata; + +-- ########## cloudkit_contacts_queue ########## + +addToQueue: +INSERT INTO cloudkit_contacts_queue ( + id, + date_added) +VALUES (?, ?); + +fetchQueueBatch: +SELECT * FROM cloudkit_contacts_queue +ORDER BY date_added ASC +LIMIT :limit; + +fetchQueueCount: +SELECT COUNT(*) FROM cloudkit_contacts_queue; + +deleteFromQueue: +DELETE FROM cloudkit_contacts_queue +WHERE rowid = ?; + +deleteAllFromQueue: +DELETE FROM cloudkit_contacts_queue; \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq index 1fb9ef558..a157e9feb 100644 --- a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq @@ -27,6 +27,13 @@ LEFT OUTER JOIN contact_offers AS contact_offers ON contact_id = id GROUP BY id ORDER BY contacts.name ASC; +scanContacts: +SELECT id, created_at FROM contacts; + +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 diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/6.sqm b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/6.sqm new file mode 100644 index 000000000..67973b55c --- /dev/null +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/6.sqm @@ -0,0 +1,23 @@ +-- Migration: v6 -> v7 +-- +-- Changes: +-- * Added table cloudkit_contacts_metadata +-- * Added index on table cloudkit_contacts_metadata +-- * Added table cloudkit_contacts_queue +-- +-- See CloudKitContacts.sq for more details. + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_contacts_metadata(record_creation); + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt new file mode 100644 index 000000000..005f9e31e --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt @@ -0,0 +1,52 @@ +package fr.acinq.phoenix.db + +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.db.payments.CloudKitInterface + +/** + * Implement this function to execute platform specific code when a payment is saved to the database. + * For example, on iOS this is used to enqueue the (encrypted) payment for upload to CloudKit. + * + * This function is invoked inside the same transaction used to add/modify the row. + * This means any database operations performed in this function are atomic, + * with respect to the referenced row. + */ +expect fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) + +/** + * Implement this function to execute platform specific code when a payment is deleted. + * For example, on iOS this is used to enqueue an operation to delete the payment from CloudKit. + */ +expect fun didDeleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) + +/** + * Implement this function to execute platform specific code when a payment's metadata is updated. + * For example: the user modifies the payment description. + * + * This function is invoked inside the same transaction used to add/modify the row. + * This means any database operations performed in this function are atomic, + * with respect to the referenced row. + */ +expect fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: PaymentsDatabase) + +/** + * Implement this function to execute platform specific code when a contact is saved to the database. + * For example, on iOS this is used to enqueue the (encrypted) contact for upload to CloudKit. + * + * This function is invoked inside the same transaction used to add/modify the row. + * This means any database operations performed in this function are atomic, + * with respect to the referenced row. + */ +expect fun didSaveContact(contactId: UUID, database: AppDatabase) + +/** + * Implement this function to execute platform specific code when a contact is deleted. + * For example, on iOS this is used to enqueue an operation to delete the contact from CloudKit. + */ +expect fun didDeleteContact(contactId: UUID, database: AppDatabase) + +/** + * Implemented on Apple platforms with support for CloudKit. + */ +expect fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? 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 6b84efe29..89cecfc58 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 @@ -21,7 +21,7 @@ import kotlinx.coroutines.withContext class SqliteAppDb(private val driver: SqlDriver) { - private val database = AppDatabase( + internal val database = AppDatabase( driver = driver, exchange_ratesAdapter = Exchange_rates.Adapter( typeAdapter = EnumColumnAdapter() @@ -34,7 +34,7 @@ class SqliteAppDb(private val driver: SqlDriver) { private val priceQueries = database.exchangeRatesQueries private val keyValueStoreQueries = database.keyValueStoreQueries private val notificationsQueries = NotificationsQueries(database) - private val contactQueries = ContactQueries(database) + internal val contactQueries = ContactQueries(database) /** * Save a list of [ExchangeRate] items to the database. @@ -172,6 +172,10 @@ class SqliteAppDb(private val driver: SqlDriver) { notificationsQueries.listUnread() } + suspend fun getContact(contactId: UUID): ContactInfo? = withContext(Dispatchers.Default) { + contactQueries.getContact(contactId) + } + suspend fun getContactForOffer(offerId: ByteVector32): ContactInfo? = withContext(Dispatchers.Default) { contactQueries.getContactForOffer(offerId) } @@ -203,4 +207,4 @@ class SqliteAppDb(private val driver: SqlDriver) { fun close() { driver.close() } -} \ No newline at end of file +} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteChannelsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteChannelsDb.kt index 4f410ea7a..5c36b9653 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteChannelsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteChannelsDb.kt @@ -25,7 +25,7 @@ import fr.acinq.lightning.serialization.Serialization import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -internal class SqliteChannelsDb(private val driver: SqlDriver) : ChannelsDb { +class SqliteChannelsDb(private val driver: SqlDriver) : ChannelsDb { private val database = ChannelsDatabase(driver) private val queries = database.channelsDatabaseQueries 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 7d8a5922e..dd327bda9 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 @@ -51,7 +51,7 @@ class SqlitePaymentsDb( private val log = loggerFactory.newLogger(this::class) - private val database = PaymentsDatabase( + internal val database = PaymentsDatabase( driver = driver, outgoing_payment_partsAdapter = Outgoing_payment_parts.Adapter( part_routeAdapter = OutgoingQueries.hopDescAdapter, @@ -83,22 +83,16 @@ class SqlitePaymentsDb( internal val inQueries = IncomingQueries(database) internal val outQueries = OutgoingQueries(database) - private val spliceOutQueries = SpliceOutgoingQueries(database) - private val channelCloseQueries = ChannelCloseOutgoingQueries(database) - private val cpfpQueries = SpliceCpfpOutgoingQueries(database) + internal val spliceOutQueries = SpliceOutgoingQueries(database) + internal val channelCloseQueries = ChannelCloseOutgoingQueries(database) + internal val cpfpQueries = SpliceCpfpOutgoingQueries(database) private val aggrQueries = database.aggregatedQueriesQueries - private val metaQueries = MetadataQueries(database) + internal val metaQueries = MetadataQueries(database) private val linkTxToPaymentQueries = LinkTxToPaymentQueries(database) - private val inboundLiquidityQueries = InboundLiquidityQueries(database) - - private val cloudKitDb = makeCloudKitDb(database) + internal val inboundLiquidityQueries = InboundLiquidityQueries(database) private var metadataQueue = MutableStateFlow(mapOf()) - fun getCloudKitDb(): CloudKitInterface? { - return cloudKitDb - } - override suspend fun addOutgoingLightningParts( parentId: UUID, parts: List @@ -715,34 +709,3 @@ data class WalletPaymentOrderRow( return "${id.identifier}|${createdAt}|" } } - -/** - * Implement this function to execute platform specific code when a payment is saved to the database. - * For example, on iOS this is used to enqueue the (encrypted) payment for upload to CloudKit. - * - * This function is invoked inside the same transaction used to add/modify the row. - * This means any database operations performed in this function are atomic, - * with respect to the referenced row. - */ -expect fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a payment is deleted. - * For example, on iOS this is used to enqueue an operation to delete the payment from CloudKit. - */ -expect fun didDeleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a payment's metadata is updated. - * For example: the user modifies the payment description. - * - * This function is invoked inside the same transaction used to add/modify the row. - * This means any database operations performed in this function are atomic, - * with respect to the referenced row. - */ -expect fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: PaymentsDatabase) - -/** - * Implemented on Apple platforms with support for CloudKit. - */ -expect fun makeCloudKitDb(database: PaymentsDatabase): CloudKitInterface? diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudSerializers.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudSerializers.kt index 69e5f234b..1b7948daf 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudSerializers.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudSerializers.kt @@ -2,9 +2,12 @@ package fr.acinq.phoenix.db.cloud import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.wire.OfferTypes import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -126,3 +129,20 @@ object UUIDSerializer : KSerializer { return UUID.fromString(decoder.decodeString()) } } + +object OfferSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Offer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: OfferTypes.Offer) { + return encoder.encodeString(value.encode()) + } + + override fun deserialize(decoder: Decoder): OfferTypes.Offer { + val offerStr = decoder.decodeString() + return when (val result = OfferTypes.Offer.decode(offerStr)) { + is Try.Success -> result.result + is Try.Failure -> throw SerializationException(message = "invalid offer") + } + } +} 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 new file mode 100644 index 000000000..159fe7a9f --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt @@ -0,0 +1,85 @@ +package fr.acinq.phoenix.db.cloud.contacts + +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.db.cloud.CloudData +import fr.acinq.phoenix.db.cloud.CloudDataVersion +import fr.acinq.phoenix.db.cloud.IncomingPaymentWrapper +import fr.acinq.phoenix.db.cloud.OfferSerializer +import fr.acinq.phoenix.db.cloud.UUIDSerializer +import fr.acinq.phoenix.db.cloud.cborSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class CloudContactVersion(val value: Int) { + // Initial version + V0(0) + // Future versions go here +} + +@Serializable +data class CloudContact( + @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>, +) { + constructor(contact: ContactInfo) : this( + version = CloudContactVersion.V0.value, + id = contact.id, + name = contact.name, + useOfferKey = contact.useOfferKey, + offers = contact.offers + ) + + @Throws(Exception::class) + fun unwrap(photoUri: String?): ContactInfo? { + return ContactInfo( + id = this.id, + name = this.name, + photoUri = photoUri, + useOfferKey = this.useOfferKey, + offers = this.offers + ) + } + + companion object +} + +@OptIn(ExperimentalSerializationApi::class) +fun CloudContact.cborSerialize(): ByteArray { + return Cbor.encodeToByteArray(this) +} + +@OptIn(ExperimentalSerializationApi::class) +@Throws(Exception::class) +fun CloudContact.Companion.cborDeserialize( + blob: ByteArray +): CloudContact { + return cborSerializer().decodeFromByteArray(blob) +} + +/** + * 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 diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/ChannelCloseType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/ChannelCloseType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/ChannelCloseType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/ChannelCloseType.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudAsset.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudAsset.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudAsset.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudAsset.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/InboundLiquidityPaymentWrapper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/InboundLiquidityPaymentWrapper.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/IncomingType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/IncomingType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/IncomingType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/IncomingType.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingPartType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/LightningOutgoingPartType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingPartType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/LightningOutgoingPartType.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/LightningOutgoingType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/LightningOutgoingType.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceCpfpPaymentType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceCpfpPaymentType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceOutgoingType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/SpliceOutgoingType.kt similarity index 100% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceOutgoingType.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/SpliceOutgoingType.kt 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 5306eaf49..c7194fa9d 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 @@ -25,6 +25,8 @@ import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.db.AppDatabase +import fr.acinq.phoenix.db.didDeleteContact +import fr.acinq.phoenix.db.didSaveContact import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -34,7 +36,7 @@ class ContactQueries(val database: AppDatabase) { val queries = database.contactsQueries - fun saveContact(contact: ContactInfo) { + fun saveContact(contact: ContactInfo, notify: Boolean = true) { database.transaction { queries.insertContact( id = contact.id.toString(), @@ -52,17 +54,36 @@ class ContactQueries(val database: AppDatabase) { createdAt = currentTimestampMillis(), ) } + if (notify) { + didSaveContact(contact.id, database) + } } } fun updateContact(contact: ContactInfo) { - queries.updateContact( - name = contact.name, - photoUri = contact.photoUri, - useOfferKey = contact.useOfferKey, - updatedAt = currentTimestampMillis(), - contactId = contact.id.toString() - ) + database.transaction { + queries.updateContact( + name = contact.name, + photoUri = contact.photoUri, + useOfferKey = contact.useOfferKey, + updatedAt = currentTimestampMillis(), + contactId = contact.id.toString() + ) + didSaveContact(contact.id, database) + } + } + + 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() + } + ContactInfo(contactId, it.name, it.photo_uri, it.use_offer_key, offers) + } + } } /** Retrieve a contact from a transaction ID - should be done in a transaction. */ @@ -114,6 +135,7 @@ class ContactQueries(val database: AppDatabase) { database.transaction { queries.deleteContactOfferForContactId(contactId = contactId.toString()) queries.deleteContact(contactId = contactId.toString()) + didDeleteContact(contactId, database) } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt index 1a0cee4b8..b3bdbf8af 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt @@ -2,9 +2,7 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.db.ChannelsDb import fr.acinq.lightning.db.Databases -import fr.acinq.lightning.db.PaymentsDb import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.db.SqliteChannelsDb @@ -13,6 +11,9 @@ import fr.acinq.phoenix.db.createChannelsDbDriver import fr.acinq.phoenix.db.createPaymentsDbDriver import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.lightning.logging.debug +import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.db.makeCloudKitDb +import fr.acinq.phoenix.db.payments.CloudKitInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.* @@ -22,6 +23,7 @@ class DatabaseManager( loggerFactory: LoggerFactory, private val ctx: PlatformContext, private val chain: Chain, + private val appDb: SqliteAppDb, private val nodeParamsManager: NodeParamsManager, private val currencyManager: CurrencyManager ) : CoroutineScope by MainScope() { @@ -29,14 +31,16 @@ class DatabaseManager( constructor(business: PhoenixBusiness): this( loggerFactory = business.loggerFactory, ctx = business.ctx, + appDb = business.appDb, chain = business.chain, nodeParamsManager = business.nodeParamsManager, currencyManager = business.currencyManager ) private val log = loggerFactory.newLogger(this::class) - private val _databases = MutableStateFlow(null) - val databases: StateFlow = _databases + + private val _databases = MutableStateFlow(null) + val databases: StateFlow = _databases.asStateFlow() init { launch { @@ -53,11 +57,13 @@ class DatabaseManager( driver = createPaymentsDbDriver(ctx, chain, nodeIdHash), currencyManager = currencyManager ) + val cloudKitDb = makeCloudKitDb(appDb, paymentsDb) log.debug { "databases object created" } - _databases.value = object : Databases { - override val channels: ChannelsDb get() = channelsDb - override val payments: PaymentsDb get() = paymentsDb - } + _databases.value = PhoenixDatabases( + channels = channelsDb, + payments = paymentsDb, + cloudKit = cloudKitDb + ) } } } @@ -65,13 +71,24 @@ class DatabaseManager( fun close() { val db = databases.value if (db != null) { - (db.channels as SqliteChannelsDb).close() - (db.payments as SqlitePaymentsDb).close() + db.channels.close() + db.payments.close() } } suspend fun paymentsDb(): SqlitePaymentsDb { val db = databases.filterNotNull().first() - return db.payments as SqlitePaymentsDb + return db.payments + } + + suspend fun cloudKitDb(): CloudKitInterface? { + val db = databases.filterNotNull().first() + return db.cloudKit } -} \ No newline at end of file +} + +data class PhoenixDatabases( + override val channels: SqliteChannelsDb, + override val payments: SqlitePaymentsDb, + val cloudKit: CloudKitInterface? +): Databases diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt new file mode 100644 index 000000000..fa6a25848 --- /dev/null +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt @@ -0,0 +1,313 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.bitcoin.utils.Try +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.db.CloudKitPaymentsDb.MetadataRow +import fr.acinq.phoenix.db.payments.IncomingQueries +import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow +import fr.acinq.phoenix.db.payments.mapToDb +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class CloudKitContactsDb( + private val appDb: SqliteAppDb +): CoroutineScope by MainScope() { + + private val db: Transacter = appDb.database + private val queries = appDb.database.cloudKitContactsQueries + + /** + * Provides a flow of the count of items within the cloudkit_contacts_queue table. + */ + private val _queueCount = MutableStateFlow(0) + val queueCount: StateFlow = _queueCount.asStateFlow() + + data class MetadataRow( + val recordCreation: Long, + val recordBlob: ByteArray + ) + + data class MissingItem( + val contactId: UUID, + val timestamp: Long + ) + + data class FetchQueueBatchResult( + + // The fetched rowid values from the `cloudkit_contacts_queue` table + val rowids: List, + + // Maps `cloudkit_contacts_queue.rowid` to the corresponding ContactId. + // If missing from the map, then the `cloudkit_contacts_queue` row was + // malformed or unrecognized. + val rowidMap: Map, + + // Maps to the contact information in the database. + // If missing from the map, then the contacts has been deleted from the database. + val rowMap: Map, + + // Maps to `cloudkit_contacts_metadata.ckrecord_info`. + // If missing from the map, then then record doesn't exist in the database. + val metadataMap: Map, + ) + + init { + // N.B.: There appears to be a subtle bug in SQLDelight's + // `.asFlow().mapToX()`, as described here: + // https://github.com/ACINQ/phoenix/pull/415 + launch { + queries.fetchQueueCount() + .asFlow() + .map { + withContext(Dispatchers.Default) { + db.transactionWithResult { + it.executeAsOne() + } + } + } + .collect { count -> + _queueCount.value = count + } + } + } + + suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { + return withContext(Dispatchers.Default) { + + val rowids = mutableListOf() + val rowidMap = mutableMapOf() + val rowMap = mutableMapOf() + val metadataMap = mutableMapOf() + + db.transaction { + + // Step 1 of 3: + // Fetch the rows from the `cloudkit_contacts_queue` batch. + // We are fetching the next/oldest X rows from the queue. + + val batch = queries.fetchQueueBatch(limit).executeAsList() + + // Step 2 of 3: + // Process the batch, and fill out the `rowids` & `rowidMap` variable. + + batch.forEach { row -> + rowids.add(row.rowid) + try { + val contactId = UUID.fromString(row.id) + rowidMap[row.rowid] = contactId + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } // + + // Remember: there could be duplicates + val uniqueContactIds = rowidMap.values.toSet() + + // Step 3 of 3: + // Fetch the corresponding contact info from the database. + + uniqueContactIds.forEach { contactId -> + appDb.contactQueries.getContact(contactId)?.let { + rowMap[contactId] = it + } + } + + // Step 4 of 5: + // Fetch the corresponding `cloudkit_contacts_metadata.ckrecord_info` + + uniqueContactIds.forEach { contactId -> + queries.fetchMetadata( + id = contactId.toString() + ).executeAsOneOrNull()?.let { row -> + metadataMap[contactId] = row.record_blob + } + } + + } // + + FetchQueueBatchResult( + rowids = rowids, + rowidMap = rowidMap, + rowMap = rowMap, + metadataMap = metadataMap + ) + } + } + + suspend fun updateRows( + deleteFromQueue: List, + deleteFromMetadata: List, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + db.transaction { + + deleteFromQueue.forEach { rowid -> + queries.deleteFromQueue(rowid) + } + + deleteFromMetadata.forEach { contactId -> + queries.deleteMetadata( + id = contactId.toString() + ) + } + + updateMetadata.forEach { (contactId, row) -> + val rowExists = queries.existsMetadata( + id = contactId.toString() + ).executeAsOne() > 0 + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = contactId.toString() + ) + } else { + queries.addMetadata( + id = contactId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } + } + } + } + + suspend fun fetchOldestCreation(): Long? { + return withContext(Dispatchers.Default) { + val row = queries.fetchOldestCreation_Contacts().executeAsOneOrNull() + row?.record_creation + } + } + + suspend fun updateRows( + downloadedContacts: List, + updateMetadata: Map + ) { + // We are seeing crashes when accessing the values within the List. + // Perhaps because the List was created in Swift ? + // The workaround seems to be to copy the list here, + // or otherwise process it outside of the `withContext` below. + val contacts = downloadedContacts.map { it.copy() } + + withContext(Dispatchers.Default) { + val contactQueries = appDb.contactQueries + + db.transaction { + for (contact in contacts) { + + val rowExists = queries.existsMetadata( + id = contact.id.toString() + ).executeAsOne() > 0 + if (!rowExists) { + contactQueries.saveContact(contact, notify = false) + } + } + + for ((contactId, row) in updateMetadata) { + val rowExists = queries.existsMetadata( + id = contactId.toString() + ).executeAsOne() > 0 + + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = contactId.toString() + ) + } else { + queries.addMetadata( + id = contactId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } // + } + } + } + + suspend fun enqueueMissingItems() { + withContext(Dispatchers.Default) { + val rawContactQueries = appDb.database.contactsQueries + + db.transaction { + + // Step 1 of 3: + // Fetch list of contact ID's that are already represented in the cloud. + + val cloudContactIds = mutableSetOf() + queries.scanMetadata().executeAsList().forEach { id -> + try { + val contactId = UUID.fromString(id) + cloudContactIds.add(contactId) + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 2 of 3: + // Scan local contact ID's, looking to see if any are missing from the cloud. + + val missing = mutableListOf() + rawContactQueries.scanContacts().executeAsList().forEach { row -> + try { + val contactId = UUID.fromString(row.id) + if (!cloudContactIds.contains(contactId)) { + missing.add(MissingItem( + contactId = contactId, + timestamp = row.created_at + )) + } + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 3 of 3: + // Add any missing items to the queue. + // + // But in what order do we want to upload them to the cloud ? + // + // We will choose to upload the newest item first. + // Since items are uploaded in FIFO order, + // we just need to make the newest item have the + // smallest `date_added` value. + + missing.sortBy { it.timestamp } + + val now = currentTimestampMillis() + missing.forEachIndexed { idx, item -> + queries.addToQueue( + id = item.contactId.toString(), + date_added = now - idx + ) + } + } + } + } + + suspend fun clearDatabaseTables() { + withContext(Dispatchers.Default) { + db.transaction { + queries.deleteAllFromMetadata() + queries.deleteAllFromQueue() + } + } + } +} diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt index e598286f3..b55ba8470 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt @@ -1,742 +1,13 @@ package fr.acinq.phoenix.db -import app.cash.sqldelight.coroutines.asFlow -import fr.acinq.lightning.db.* -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.data.WalletPaymentFetchOptions -import fr.acinq.phoenix.data.WalletPaymentId -import fr.acinq.phoenix.data.WalletPaymentInfo -import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.db.payments.* import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlin.math.pow -import kotlin.math.sqrt class CloudKitDb( - private val database: PaymentsDatabase + appDb: SqliteAppDb, + paymentsDb: SqlitePaymentsDb ): CloudKitInterface, CoroutineScope by MainScope() { - /** - * Provides a flow of the count of items within the cloudkit_payments_queue table. - */ - private val _queueCount = MutableStateFlow(0) - val queueCount: StateFlow = _queueCount - - init { - launch { - database.cloudKitPaymentsQueries.fetchQueueCount() - .asFlow() - .map { - withContext(Dispatchers.Default) { - database.transactionWithResult { - it.executeAsOne() - } - } - } - .collect { count -> - _queueCount.value = count - } - } - } - - data class MetadataRow( - val unpaddedSize: Long, - val recordCreation: Long, - val recordBlob: ByteArray - ) - - data class MetadataStats( - val mean: Double, - val standardDeviation: Double - ) { - constructor(): this(mean = 0.0, standardDeviation = 0.0) - } - - data class FetchQueueBatchResult( - - // The fetched rowid values from the `cloudkit_payments_queue` table - val rowids: List, - - // Maps `cloudkit_payments_queue.rowid` to the corresponding PaymentRowId. - // If missing from the map, then the `cloudkit_payments_queue` row was - // malformed or unrecognized. - val rowidMap: Map, - - // Maps to the fetch payment information in the database. - // If missing from the map, then the payment has been deleted from the database. - val rowMap: Map, - - // Maps to `cloudkit_payments_metadata.ckrecord_info`. - // If missing from the map, then then record doesn't exist in the database. - val metadataMap: Map, - - // The `cloudkit_payments_metadata` stores the size of the non-padded record. - // Statistics about these values are returned, rowMap is non-empty. - val incomingStats: MetadataStats, - val outgoingStats: MetadataStats - ) - - suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { - return withContext(Dispatchers.Default) { - - val ckQueries = database.cloudKitPaymentsQueries - val inQueries = IncomingQueries(database) - val outQueries = OutgoingQueries(database) - val spliceOutgoingQueries = SpliceOutgoingQueries(database) - val channelCloseOutgoingQueries = ChannelCloseOutgoingQueries(database) - val spliceCpfpOutgoingQueries = SpliceCpfpOutgoingQueries(database) - val inboundLiquidityQueries = InboundLiquidityQueries(database) - val metaQueries = MetadataQueries(database) - - val rowids = mutableListOf() - val rowidMap = mutableMapOf() - val rowMap = mutableMapOf() - val metadataMap = mutableMapOf() - var incomingStats = MetadataStats() - var outgoingStats = MetadataStats() - - database.transaction { - - // Step 1 of 5: - // Fetch the rows from the `cloudkit_payments_queue` batch. - // We are fetching the next/oldest X rows from the queue. - - val batch = ckQueries.fetchQueueBatch(limit).executeAsList() - - // Step 2 of 5: - // Process the batch, and fill out the `rowids` & `rowidMap` variable. - - batch.forEach { row -> - rowids.add(row.rowid) - try { - val paymentId: WalletPaymentId? = when (row.type) { - WalletPaymentId.DbType.INCOMING.value -> { - WalletPaymentId.IncomingPaymentId.fromString(row.id) - } - WalletPaymentId.DbType.OUTGOING.value -> { - WalletPaymentId.LightningOutgoingPaymentId.fromString(row.id) - } - WalletPaymentId.DbType.SPLICE_OUTGOING.value -> { - WalletPaymentId.SpliceOutgoingPaymentId.fromString(row.id) - } - WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> { - WalletPaymentId.ChannelCloseOutgoingPaymentId.fromString(row.id) - } - WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> { - WalletPaymentId.SpliceCpfpOutgoingPaymentId.fromString(row.id) - } - WalletPaymentId.DbType.INBOUND_LIQUIDITY_OUTGOING.value -> { - WalletPaymentId.InboundLiquidityOutgoingPaymentId.fromString(row.id) - } - else -> null - } - paymentId?.let { - rowidMap[row.rowid] = it - } - } catch (e: Exception) {} - } // - - // Remember: there could be duplicates - val uniquePaymentIds = rowidMap.values.toSet() - - // Step 3 of 5: - // Fetch the corresponding payment info from the database. - // In order to optimize disk access, we fetch from 1 table at a time. - - val metadataPlaceholder = WalletPaymentMetadata() - val emptyOptions = WalletPaymentFetchOptions.None - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - inQueries.getIncomingPayment( - paymentHash = paymentId.paymentHash - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - outQueries.getPaymentRelaxed( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - spliceOutgoingQueries.getSpliceOutPayment( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - channelCloseOutgoingQueries.getChannelCloseOutgoingPayment( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - spliceCpfpOutgoingQueries.getCpfp( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds - .filterIsInstance() - .forEach { paymentId -> - inboundLiquidityQueries.get( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - contact = null, - fetchOptions = emptyOptions - ) - } - } // - - val fetchOptions = WalletPaymentFetchOptions.All - WalletPaymentFetchOptions.Contact - uniquePaymentIds.forEach { paymentId -> - metaQueries.getMetadata(paymentId, fetchOptions)?.let { metadata -> - rowMap[paymentId]?.let { - rowMap[paymentId] = it.copy( - metadata = metadata, - contact = null, - fetchOptions = fetchOptions - ) - } - } - } // - - // Step 4 of 5: - // Fetch the corresponding `cloudkit_payments_metadata.ckrecord_info` - - uniquePaymentIds.forEach { paymentId -> - ckQueries.fetchMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId - ).executeAsOneOrNull()?.let { row -> - metadataMap[paymentId] = row.record_blob - } - } - - // Step 5 of 5: - // Fetch the metadata statistics (if needed). - - if (rowMap.isNotEmpty()) { - - val process = { list: List -> - val mean = list.sum().toDouble() / list.size.toDouble() - val variance = list.sumOf { - val diff = it.toDouble() - mean - diff.pow(2) - } - val standardDeviation = sqrt(variance) - - MetadataStats( - mean = mean, - standardDeviation = standardDeviation - ) - } - - val incoming = mutableListOf() - val outgoing = mutableListOf() - - ckQueries.scanSizes().executeAsList().forEach { row -> - if (row.unpadded_size > 0) { - when (row.type) { - WalletPaymentId.DbType.INCOMING.value -> - incoming.add(row.unpadded_size) - WalletPaymentId.DbType.OUTGOING.value -> - outgoing.add(row.unpadded_size) - WalletPaymentId.DbType.SPLICE_OUTGOING.value -> - outgoing.add(row.unpadded_size) - WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> - outgoing.add(row.unpadded_size) - WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> - outgoing.add(row.unpadded_size) - WalletPaymentId.DbType.INBOUND_LIQUIDITY_OUTGOING.value -> - outgoing.add(row.unpadded_size) - } - } - } - - incomingStats = process(incoming) - outgoingStats = process(outgoing) - } - - } // - - FetchQueueBatchResult( - rowids = rowids, - rowidMap = rowidMap, - rowMap = rowMap, - metadataMap = metadataMap, - incomingStats = incomingStats, - outgoingStats = outgoingStats - ) - } - } - - suspend fun updateRows( - deleteFromQueue: List, - deleteFromMetadata: List, - updateMetadata: Map - ) { - withContext(Dispatchers.Default) { - val ckQueries = database.cloudKitPaymentsQueries - - database.transaction { - - deleteFromQueue.forEach { rowid -> - ckQueries.deleteFromQueue(rowid) - } - - deleteFromMetadata.forEach { paymentId -> - ckQueries.deleteMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId - ) - } - - updateMetadata.forEach { (paymentId, row) -> - val rowExists = ckQueries.existsMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId - ).executeAsOne() > 0 - if (rowExists) { - ckQueries.updateMetadata( - unpadded_size = row.unpaddedSize, - record_blob = row.recordBlob, - type = paymentId.dbType.value, - id = paymentId.dbId - ) - } else { - ckQueries.addMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId, - unpadded_size = row.unpaddedSize, - record_creation = row.recordCreation, - record_blob = row.recordBlob - ) - } - } - } - } - } - - suspend fun fetchMetadata( - type: Long, - id: String - ): ByteArray? { - - return withContext(Dispatchers.Default) { - val ckQueries = database.cloudKitPaymentsQueries - - val row = ckQueries.fetchMetadata(type = type, id = id).executeAsOneOrNull() - row?.record_blob - } - } - - suspend fun fetchOldestCreation(): Long? { - - return withContext(Dispatchers.Default) { - val ckQueries = database.cloudKitPaymentsQueries - - val row = ckQueries.fetchOldestCreation().executeAsOneOrNull() - row?.record_creation - } - } - - suspend fun updateRows( - downloadedPayments: List, - downloadedPaymentsMetadata: Map, - updateMetadata: Map - ) { - // We are seeing crashes when accessing the values within the List. - // Perhaps because the List was created in Swift ? - // The workaround seems to be to copy the list here, - // or otherwise process it outside of the `withContext` below. - val incomingList = downloadedPayments.filterIsInstance() - val outgoingList = downloadedPayments.filterIsInstance() - val spliceOutList = downloadedPayments.filterIsInstance() - val channelCloseList = downloadedPayments.filterIsInstance() - val spliceCpfpList = downloadedPayments.filterIsInstance() - val inboundLiquidityList = downloadedPayments.filterIsInstance() - - withContext(Dispatchers.Default) { - - val ckQueries = database.cloudKitPaymentsQueries - val inQueries = database.incomingPaymentsQueries - val outQueries = database.outgoingPaymentsQueries - val spliceOutQueries = SpliceOutgoingQueries(database) - val channelCloseOutQueries = ChannelCloseOutgoingQueries(database) - val spliceCpfpQueries = SpliceCpfpOutgoingQueries(database) - val inboundLiquidityQueries = InboundLiquidityQueries(database) - val metaQueries = database.paymentsMetadataQueries - - database.transaction { - - for (incomingPayment in incomingList) { - - val existing = inQueries.get( - payment_hash = incomingPayment.paymentHash.toByteArray(), - mapper = IncomingQueries.Companion::mapIncomingPayment - ).executeAsOneOrNull() - - if (existing == null) { - val (originType, originData) = incomingPayment.origin.mapToDb() - inQueries.insert( - payment_hash = incomingPayment.paymentHash.toByteArray(), - preimage = incomingPayment.preimage.toByteArray(), - origin_type = originType, - origin_blob = originData, - created_at = incomingPayment.createdAt - ) - } - - val oldReceived = existing?.received - val received = incomingPayment.received - - if (oldReceived == null && received != null) { - val (type, blob) = received.receivedWith.mapToDb() ?: (null to null) - inQueries.updateReceived( - received_at = received.receivedAt, - received_with_type = type, - received_with_blob = blob, - payment_hash = incomingPayment.paymentHash.toByteArray() - ) - } - } // - - for (outgoingPayment in outgoingList) { - - val existing = outQueries.getPaymentWithoutParts( - id = outgoingPayment.id.toString(), - mapper = OutgoingQueries.Companion::mapLightningOutgoingPaymentWithoutParts - ).executeAsOneOrNull() - - if (existing == null) { - val (detailsTypeVersion, detailsData) = outgoingPayment.details.mapToDb() - database.outgoingPaymentsQueries.insertPayment( - id = outgoingPayment.id.toString(), - recipient_amount_msat = outgoingPayment.recipientAmount.msat, - recipient_node_id = outgoingPayment.recipient.toString(), - payment_hash = outgoingPayment.details.paymentHash.toByteArray(), - created_at = outgoingPayment.createdAt, - details_type = detailsTypeVersion, - details_blob = detailsData - ) - } - - for (part in outgoingPayment.parts) { - when { - outQueries.countLightningPart(part_id = part.id.toString()).executeAsOne() == 0L -> { - outQueries.insertLightningPart( - part_id = part.id.toString(), - part_parent_id = outgoingPayment.id.toString(), - part_amount_msat = part.amount.msat, - part_route = part.route, - part_created_at = part.createdAt - ) - val statusInfo = when (val status = part.status) { - is LightningOutgoingPayment.Part.Status.Failed -> status.completedAt to status.mapToDb() - is LightningOutgoingPayment.Part.Status.Succeeded -> status.completedAt to status.mapToDb() - else -> null - } - if (statusInfo != null) { - val completedAt = statusInfo.first - val (type, blob) = statusInfo.second - outQueries.updateLightningPart( - part_id = part.id.toString(), - part_status_type = type, - part_status_blob = blob, - part_completed_at = completedAt - ) - } - } - } - } - - val oldCompleted = existing?.status as? LightningOutgoingPayment.Status.Completed - val completed = outgoingPayment.status as? LightningOutgoingPayment.Status.Completed - - if (oldCompleted == null && completed != null) { - val (statusType, statusBlob) = completed.mapToDb() - outQueries.updatePayment( - id = outgoingPayment.id.toString(), - completed_at = completed.completedAt, - status_type = statusType, - status_blob = statusBlob - ) - } - } // - - spliceOutList.forEach { payment -> - - val existing = spliceOutQueries.getSpliceOutPayment(id = payment.id) - if (existing == null) { - spliceOutQueries.addSpliceOutgoingPayment(payment = payment) - - } else { - val confirmedAt = payment.confirmedAt - if (existing.confirmedAt == null && confirmedAt != null) { - spliceOutQueries.setConfirmed( - id = payment.id, - confirmedAt = confirmedAt - ) - } - - val lockedAt = payment.lockedAt - if (existing.lockedAt == null && lockedAt != null) { - spliceOutQueries.setLocked( - id = payment.id, - lockedAt = lockedAt - ) - } - } - } - - channelCloseList.forEach { payment -> - - val existing = channelCloseOutQueries.getChannelCloseOutgoingPayment(id = payment.id) - if (existing == null) { - channelCloseOutQueries.addChannelCloseOutgoingPayment(payment = payment) - - } else { - val confirmedAt = payment.confirmedAt - if (existing.confirmedAt == null && confirmedAt != null) { - channelCloseOutQueries.setConfirmed( - id = payment.id, - confirmedAt = confirmedAt - ) - } - - val lockedAt = payment.lockedAt - if (existing.lockedAt == null && lockedAt != null) { - channelCloseOutQueries.setLocked( - id = payment.id, - lockedAt = lockedAt - ) - } - } - } - - spliceCpfpList.forEach { payment -> - - val existing = spliceCpfpQueries.getCpfp(id = payment.id) - if (existing == null) { - spliceCpfpQueries.addCpfpPayment(payment = payment) - - } else { - val confirmedAt = payment.confirmedAt - if (existing.confirmedAt == null && confirmedAt != null) { - spliceCpfpQueries.setConfirmed( - id = payment.id, - confirmedAt = confirmedAt - ) - } - - val lockedAt = payment.lockedAt - if (existing.lockedAt == null && lockedAt != null) { - spliceCpfpQueries.setLocked( - id = payment.id, - lockedAt = lockedAt - ) - } - } - } - - inboundLiquidityList.forEach { payment -> - - val existing = inboundLiquidityQueries.get(id = payment.id) - if (existing == null) { - inboundLiquidityQueries.add(payment = payment) - - } else { - val confirmedAt = payment.confirmedAt - if (existing.confirmedAt == null && confirmedAt != null) { - inboundLiquidityQueries.setConfirmed( - id = payment.id, - confirmedAt = confirmedAt - ) - } - - val lockedAt = payment.lockedAt - if (existing.lockedAt == null && lockedAt != null) { - inboundLiquidityQueries.setLocked( - id = payment.id, - lockedAt = lockedAt - ) - } - } - } - - downloadedPaymentsMetadata.forEach { (paymentId, row) -> - val rowExists = metaQueries.hasMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId - ).executeAsOne() > 0 - if (!rowExists) { - metaQueries.addMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId, - lnurl_base_type = row.lnurl_base?.first, - lnurl_base_blob = row.lnurl_base?.second, - lnurl_metadata_type = row.lnurl_metadata?.first, - lnurl_metadata_blob = row.lnurl_metadata?.second, - lnurl_successAction_type = row.lnurl_successAction?.first, - lnurl_successAction_blob = row.lnurl_successAction?.second, - lnurl_description = row.lnurl_description, - user_description = row.user_description, - user_notes = row.user_notes, - modified_at = row.modified_at, - original_fiat_type = row.original_fiat?.first, - original_fiat_rate = row.original_fiat?.second - ) - } - } // - - updateMetadata.forEach { (paymentId, row) -> - val rowExists = ckQueries.existsMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId - ).executeAsOne() > 0 - if (rowExists) { - ckQueries.updateMetadata( - unpadded_size = row.unpaddedSize, - record_blob = row.recordBlob, - type = paymentId.dbType.value, - id = paymentId.dbId - ) - } else { - ckQueries.addMetadata( - type = paymentId.dbType.value, - id = paymentId.dbId, - unpadded_size = row.unpaddedSize, - record_creation = row.recordCreation, - record_blob = row.recordBlob - ) - } - } // - } - } - } - - data class MissingItem( - val paymentId: WalletPaymentId, - val timestamp: Long - ) - - suspend fun enqueueMissingItems() { - withContext(Dispatchers.Default) { - - val ckQueries = database.cloudKitPaymentsQueries - val inQueries = database.incomingPaymentsQueries - val outQueries = database.outgoingPaymentsQueries - - database.transaction { - - val existing = mutableSetOf() - ckQueries.scanMetadata().executeAsList().forEach { row -> - WalletPaymentId.create(row.type, row.id)?.let { - existing.add(it) - } - } - - val missing = mutableListOf() - - inQueries.scanCompleted().executeAsList().forEach { row -> - val rowId = WalletPaymentId.IncomingPaymentId.fromByteArray(row.payment_hash) - if (!existing.contains(rowId)) { - missing.add(MissingItem(rowId, row.received_at)) - } - } - - outQueries.scanCompleted().executeAsList().forEach { row -> - val rowId = WalletPaymentId.LightningOutgoingPaymentId.fromString(row.id) - if (!existing.contains(rowId)) { - missing.add(MissingItem(rowId, row.completed_at)) - } - } - - // Now we're going to add them to the database. - // But in what order do we want to upload them to the cloud ? - // - // We will choose to upload the newest item first. - // Since items are uploaded in FIFO order, - // we just need to make the newest item have the - // smallest `date_added` value. - - missing.sortBy { it.timestamp } - - // The list is now sorted in ascending order. - // Which means the oldest payment is at index 0, - // and the newest payment is at index . - - val now = currentTimestampMillis() - missing.forEachIndexed { idx, item -> - ckQueries.addToQueue( - type = item.paymentId.dbType.value, - id = item.paymentId.dbId, - date_added = now - idx - ) - } - } - } - } - - suspend fun clearDatabaseTables() { - withContext(Dispatchers.Default) { - - val ckQueries = database.cloudKitPaymentsQueries - - database.transaction { - ckQueries.deleteAllFromMetadata() - ckQueries.deleteAllFromQueue() - } - } - } + val contacts = CloudKitContactsDb(appDb) + val payments = CloudKitPaymentsDb(paymentsDb) } 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 new file mode 100644 index 000000000..0bbf8c17d --- /dev/null +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt @@ -0,0 +1,740 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.db.* +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.WalletPaymentFetchOptions +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.db.payments.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.math.pow +import kotlin.math.sqrt + +class CloudKitPaymentsDb( + private val paymentsDb: SqlitePaymentsDb +): CoroutineScope by MainScope() { + + private val db: Transacter = paymentsDb.database + private val queries = paymentsDb.database.cloudKitPaymentsQueries + + /** + * Provides a flow of the count of items within the cloudkit_payments_queue table. + */ + private val _queueCount = MutableStateFlow(0) + val queueCount: StateFlow = _queueCount.asStateFlow() + + data class MetadataRow( + val unpaddedSize: Long, + val recordCreation: Long, + val recordBlob: ByteArray + ) + + data class MetadataStats( + val mean: Double, + val standardDeviation: Double + ) { + constructor(): this(mean = 0.0, standardDeviation = 0.0) + } + + data class FetchQueueBatchResult( + + // The fetched rowid values from the `cloudkit_payments_queue` table + val rowids: List, + + // Maps `cloudkit_payments_queue.rowid` to the corresponding PaymentRowId. + // If missing from the map, then the `cloudkit_payments_queue` row was + // malformed or unrecognized. + val rowidMap: Map, + + // Maps to the fetch payment information in the database. + // If missing from the map, then the payment has been deleted from the database. + val rowMap: Map, + + // Maps to `cloudkit_payments_metadata.ckrecord_info`. + // If missing from the map, then then record doesn't exist in the database. + val metadataMap: Map, + + // The `cloudkit_payments_metadata` stores the size of the non-padded record. + // Statistics about these values are returned, rowMap is non-empty. + val incomingStats: MetadataStats, + val outgoingStats: MetadataStats + ) + + init { + // N.B.: There appears to be a subtle bug in SQLDelight's + // `.asFlow().mapToX()`, as described here: + // https://github.com/ACINQ/phoenix/pull/415 + launch { + queries.fetchQueueCount() + .asFlow() + .map { + withContext(Dispatchers.Default) { + db.transactionWithResult { + it.executeAsOne() + } + } + } + .collect { count -> + _queueCount.value = count + } + } + } + + suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { + return withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + val inQueries = paymentsDb.inQueries + val outQueries = paymentsDb.outQueries + val spliceOutgoingQueries = paymentsDb.spliceOutQueries + val channelCloseOutgoingQueries = paymentsDb.channelCloseQueries + val spliceCpfpOutgoingQueries = paymentsDb.cpfpQueries + val inboundLiquidityQueries = paymentsDb.inboundLiquidityQueries + val metaQueries = paymentsDb.metaQueries + + val rowids = mutableListOf() + val rowidMap = mutableMapOf() + val rowMap = mutableMapOf() + val metadataMap = mutableMapOf() + var incomingStats = MetadataStats() + var outgoingStats = MetadataStats() + + db.transaction { + + // Step 1 of 5: + // Fetch the rows from the `cloudkit_payments_queue` batch. + // We are fetching the next/oldest X rows from the queue. + + val batch = ckQueries.fetchQueueBatch(limit).executeAsList() + + // Step 2 of 5: + // Process the batch, and fill out the `rowids` & `rowidMap` variable. + + batch.forEach { row -> + rowids.add(row.rowid) + try { + val paymentId: WalletPaymentId? = when (row.type) { + WalletPaymentId.DbType.INCOMING.value -> { + WalletPaymentId.IncomingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.OUTGOING.value -> { + WalletPaymentId.LightningOutgoingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.SPLICE_OUTGOING.value -> { + WalletPaymentId.SpliceOutgoingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> { + WalletPaymentId.ChannelCloseOutgoingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> { + WalletPaymentId.SpliceCpfpOutgoingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.INBOUND_LIQUIDITY_OUTGOING.value -> { + WalletPaymentId.InboundLiquidityOutgoingPaymentId.fromString(row.id) + } + else -> null + } + paymentId?.let { + rowidMap[row.rowid] = it + } + } catch (e: Exception) {} + } // + + // Remember: there could be duplicates + val uniquePaymentIds = rowidMap.values.toSet() + + // Step 3 of 5: + // Fetch the corresponding payment info from the database. + // In order to optimize disk access, we fetch from 1 table at a time. + + val metadataPlaceholder = WalletPaymentMetadata() + val emptyOptions = WalletPaymentFetchOptions.None + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + inQueries.getIncomingPayment( + paymentHash = paymentId.paymentHash + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + outQueries.getPaymentRelaxed( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + spliceOutgoingQueries.getSpliceOutPayment( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + channelCloseOutgoingQueries.getChannelCloseOutgoingPayment( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + spliceCpfpOutgoingQueries.getCpfp( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + inboundLiquidityQueries.get( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + contact = null, + fetchOptions = emptyOptions + ) + } + } // + + val fetchOptions = WalletPaymentFetchOptions.All - WalletPaymentFetchOptions.Contact + uniquePaymentIds.forEach { paymentId -> + metaQueries.getMetadata(paymentId, fetchOptions)?.let { metadata -> + rowMap[paymentId]?.let { + rowMap[paymentId] = it.copy( + metadata = metadata, + contact = null, + fetchOptions = fetchOptions + ) + } + } + } // + + // Step 4 of 5: + // Fetch the corresponding `cloudkit_payments_metadata.ckrecord_info` + + uniquePaymentIds.forEach { paymentId -> + ckQueries.fetchMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId + ).executeAsOneOrNull()?.let { row -> + metadataMap[paymentId] = row.record_blob + } + } + + // Step 5 of 5: + // Fetch the metadata statistics (if needed). + + if (rowMap.isNotEmpty()) { + + val process = { list: List -> + val mean = list.sum().toDouble() / list.size.toDouble() + val variance = list.sumOf { + val diff = it.toDouble() - mean + diff.pow(2) + } + val standardDeviation = sqrt(variance) + + MetadataStats( + mean = mean, + standardDeviation = standardDeviation + ) + } + + val incoming = mutableListOf() + val outgoing = mutableListOf() + + ckQueries.scanSizes().executeAsList().forEach { row -> + if (row.unpadded_size > 0) { + when (row.type) { + WalletPaymentId.DbType.INCOMING.value -> + incoming.add(row.unpadded_size) + WalletPaymentId.DbType.OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.SPLICE_OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.INBOUND_LIQUIDITY_OUTGOING.value -> + outgoing.add(row.unpadded_size) + } + } + } + + incomingStats = process(incoming) + outgoingStats = process(outgoing) + } + + } // + + FetchQueueBatchResult( + rowids = rowids, + rowidMap = rowidMap, + rowMap = rowMap, + metadataMap = metadataMap, + incomingStats = incomingStats, + outgoingStats = outgoingStats + ) + } + } + + suspend fun updateRows( + deleteFromQueue: List, + deleteFromMetadata: List, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + db.transaction { + + deleteFromQueue.forEach { rowid -> + queries.deleteFromQueue(rowid) + } + + deleteFromMetadata.forEach { paymentId -> + queries.deleteMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId + ) + } + + updateMetadata.forEach { (paymentId, row) -> + val rowExists = queries.existsMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId + ).executeAsOne() > 0 + if (rowExists) { + queries.updateMetadata( + unpadded_size = row.unpaddedSize, + record_blob = row.recordBlob, + type = paymentId.dbType.value, + id = paymentId.dbId + ) + } else { + queries.addMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId, + unpadded_size = row.unpaddedSize, + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } + } + } + } + + suspend fun fetchMetadata( + type: Long, + id: String + ): ByteArray? { + + return withContext(Dispatchers.Default) { + val row = queries.fetchMetadata(type = type, id = id).executeAsOneOrNull() + row?.record_blob + } + } + + suspend fun fetchOldestCreation(): Long? { + + return withContext(Dispatchers.Default) { + val row = queries.fetchOldestCreation().executeAsOneOrNull() + row?.record_creation + } + } + + suspend fun updateRows( + downloadedPayments: List, + downloadedPaymentsMetadata: Map, + updateMetadata: Map + ) { + // We are seeing crashes when accessing the values within the List. + // Perhaps because the List was created in Swift ? + // The workaround seems to be to copy the list here, + // or otherwise process it outside of the `withContext` below. + val incomingList = downloadedPayments.filterIsInstance() + val outgoingList = downloadedPayments.filterIsInstance() + val spliceOutList = downloadedPayments.filterIsInstance() + val channelCloseList = downloadedPayments.filterIsInstance() + val spliceCpfpList = downloadedPayments.filterIsInstance() + val inboundLiquidityList = downloadedPayments.filterIsInstance() + + withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + val inQueries = paymentsDb.database.incomingPaymentsQueries + val outQueries = paymentsDb.database.outgoingPaymentsQueries + val spliceOutQueries = paymentsDb.spliceOutQueries + val channelCloseOutQueries = paymentsDb.channelCloseQueries + val spliceCpfpQueries = paymentsDb.cpfpQueries + val inboundLiquidityQueries = paymentsDb.inboundLiquidityQueries + val metaQueries = paymentsDb.database.paymentsMetadataQueries + + db.transaction { + + for (incomingPayment in incomingList) { + + val existing = inQueries.get( + payment_hash = incomingPayment.paymentHash.toByteArray(), + mapper = IncomingQueries.Companion::mapIncomingPayment + ).executeAsOneOrNull() + + if (existing == null) { + val (originType, originData) = incomingPayment.origin.mapToDb() + inQueries.insert( + payment_hash = incomingPayment.paymentHash.toByteArray(), + preimage = incomingPayment.preimage.toByteArray(), + origin_type = originType, + origin_blob = originData, + created_at = incomingPayment.createdAt + ) + } + + val oldReceived = existing?.received + val received = incomingPayment.received + + if (oldReceived == null && received != null) { + val (type, blob) = received.receivedWith.mapToDb() ?: (null to null) + inQueries.updateReceived( + received_at = received.receivedAt, + received_with_type = type, + received_with_blob = blob, + payment_hash = incomingPayment.paymentHash.toByteArray() + ) + } + } // + + for (outgoingPayment in outgoingList) { + + val existing = outQueries.getPaymentWithoutParts( + id = outgoingPayment.id.toString(), + mapper = OutgoingQueries.Companion::mapLightningOutgoingPaymentWithoutParts + ).executeAsOneOrNull() + + if (existing == null) { + val (detailsTypeVersion, detailsData) = outgoingPayment.details.mapToDb() + outQueries.insertPayment( + id = outgoingPayment.id.toString(), + recipient_amount_msat = outgoingPayment.recipientAmount.msat, + recipient_node_id = outgoingPayment.recipient.toString(), + payment_hash = outgoingPayment.details.paymentHash.toByteArray(), + created_at = outgoingPayment.createdAt, + details_type = detailsTypeVersion, + details_blob = detailsData + ) + } + + for (part in outgoingPayment.parts) { + when { + outQueries.countLightningPart(part_id = part.id.toString()).executeAsOne() == 0L -> { + outQueries.insertLightningPart( + part_id = part.id.toString(), + part_parent_id = outgoingPayment.id.toString(), + part_amount_msat = part.amount.msat, + part_route = part.route, + part_created_at = part.createdAt + ) + val statusInfo = when (val status = part.status) { + is LightningOutgoingPayment.Part.Status.Failed -> status.completedAt to status.mapToDb() + is LightningOutgoingPayment.Part.Status.Succeeded -> status.completedAt to status.mapToDb() + else -> null + } + if (statusInfo != null) { + val completedAt = statusInfo.first + val (type, blob) = statusInfo.second + outQueries.updateLightningPart( + part_id = part.id.toString(), + part_status_type = type, + part_status_blob = blob, + part_completed_at = completedAt + ) + } + } + } + } + + val oldCompleted = existing?.status as? LightningOutgoingPayment.Status.Completed + val completed = outgoingPayment.status as? LightningOutgoingPayment.Status.Completed + + if (oldCompleted == null && completed != null) { + val (statusType, statusBlob) = completed.mapToDb() + outQueries.updatePayment( + id = outgoingPayment.id.toString(), + completed_at = completed.completedAt, + status_type = statusType, + status_blob = statusBlob + ) + } + } // + + spliceOutList.forEach { payment -> + + val existing = spliceOutQueries.getSpliceOutPayment(id = payment.id) + if (existing == null) { + spliceOutQueries.addSpliceOutgoingPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + spliceOutQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + spliceOutQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } + } + + channelCloseList.forEach { payment -> + + val existing = channelCloseOutQueries.getChannelCloseOutgoingPayment(id = payment.id) + if (existing == null) { + channelCloseOutQueries.addChannelCloseOutgoingPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + channelCloseOutQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + channelCloseOutQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } + } + + spliceCpfpList.forEach { payment -> + + val existing = spliceCpfpQueries.getCpfp(id = payment.id) + if (existing == null) { + spliceCpfpQueries.addCpfpPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + spliceCpfpQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + spliceCpfpQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } + } + + inboundLiquidityList.forEach { payment -> + + val existing = inboundLiquidityQueries.get(id = payment.id) + if (existing == null) { + inboundLiquidityQueries.add(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + inboundLiquidityQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + inboundLiquidityQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } + } + + downloadedPaymentsMetadata.forEach { (paymentId, row) -> + val rowExists = metaQueries.hasMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId + ).executeAsOne() > 0 + if (!rowExists) { + metaQueries.addMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId, + lnurl_base_type = row.lnurl_base?.first, + lnurl_base_blob = row.lnurl_base?.second, + lnurl_metadata_type = row.lnurl_metadata?.first, + lnurl_metadata_blob = row.lnurl_metadata?.second, + lnurl_successAction_type = row.lnurl_successAction?.first, + lnurl_successAction_blob = row.lnurl_successAction?.second, + lnurl_description = row.lnurl_description, + user_description = row.user_description, + user_notes = row.user_notes, + modified_at = row.modified_at, + original_fiat_type = row.original_fiat?.first, + original_fiat_rate = row.original_fiat?.second + ) + } + } // + + updateMetadata.forEach { (paymentId, row) -> + val rowExists = ckQueries.existsMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId + ).executeAsOne() > 0 + if (rowExists) { + ckQueries.updateMetadata( + unpadded_size = row.unpaddedSize, + record_blob = row.recordBlob, + type = paymentId.dbType.value, + id = paymentId.dbId + ) + } else { + ckQueries.addMetadata( + type = paymentId.dbType.value, + id = paymentId.dbId, + unpadded_size = row.unpaddedSize, + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } // + } + } + } + + data class MissingItem( + val paymentId: WalletPaymentId, + val timestamp: Long + ) + + suspend fun enqueueMissingItems() { + withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + val inQueries = paymentsDb.database.incomingPaymentsQueries + val outQueries = paymentsDb.database.outgoingPaymentsQueries + + db.transaction { + + val existing = mutableSetOf() + ckQueries.scanMetadata().executeAsList().forEach { row -> + WalletPaymentId.create(row.type, row.id)?.let { + existing.add(it) + } + } + + val missing = mutableListOf() + + inQueries.scanCompleted().executeAsList().forEach { row -> + val rowId = WalletPaymentId.IncomingPaymentId.fromByteArray(row.payment_hash) + if (!existing.contains(rowId)) { + missing.add(MissingItem(rowId, row.received_at)) + } + } + + outQueries.scanCompleted().executeAsList().forEach { row -> + val rowId = WalletPaymentId.LightningOutgoingPaymentId.fromString(row.id) + if (!existing.contains(rowId)) { + missing.add(MissingItem(rowId, row.completed_at)) + } + } + + // Now we're going to add them to the database. + // But in what order do we want to upload them to the cloud ? + // + // We will choose to upload the newest item first. + // Since items are uploaded in FIFO order, + // we just need to make the newest item have the + // smallest `date_added` value. + + missing.sortBy { it.timestamp } + + // The list is now sorted in ascending order. + // Which means the oldest payment is at index 0, + // and the newest payment is at index . + + val now = currentTimestampMillis() + missing.forEachIndexed { idx, item -> + ckQueries.addToQueue( + type = item.paymentId.dbType.value, + id = item.paymentId.dbId, + date_added = now - idx + ) + } + } + } + } + + suspend fun clearDatabaseTables() { + withContext(Dispatchers.Default) { + db.transaction { + queries.deleteAllFromMetadata() + queries.deleteAllFromQueue() + } + } + } +} diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index 320ac6587..8b098b5f9 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -1,9 +1,9 @@ package fr.acinq.phoenix.db +import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.payments.CloudKitInterface -import fracinqphoenixdb.Cloudkit_payments_queue actual fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) { @@ -30,6 +30,20 @@ actual fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: Payment ) } -actual fun makeCloudKitDb(database: PaymentsDatabase): CloudKitInterface? { - return CloudKitDb(database) +actual fun didSaveContact(contactId: UUID, database: AppDatabase) { + database.cloudKitContactsQueries.addToQueue( + id = contactId.toString(), + date_added = currentTimestampMillis() + ) +} + +actual fun didDeleteContact(contactId: UUID, database: AppDatabase) { + database.cloudKitContactsQueries.addToQueue( + id = contactId.toString(), + date_added = currentTimestampMillis() + ) +} + +actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { + return CloudKitDb(appDb, paymentsDb) } \ No newline at end of file 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 a928b1507..eb8553520 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 @@ -3,11 +3,13 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.Transaction import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.byteVector import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.DefaultSwapInParams @@ -29,6 +31,7 @@ import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.crypto.KeyManager +import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment @@ -48,11 +51,14 @@ import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.OutgoingPaymentFailure import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.concat import fr.acinq.lightning.utils.copyTo 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 fr.acinq.phoenix.managers.cloudKey +import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.filter @@ -656,3 +662,12 @@ suspend fun Peer.betterPayOffer( send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeoutInSeconds.seconds)) return res.await() } + +fun LocalKeyManager.cloudHash(name: String): String { + val nid: ByteArray = this.nodeKeys.nodeKey.publicKey.value.toByteArray() + val ck: ByteArray = this.cloudKey().toByteArray() + val nm: ByteArray = name.toByteArray() + + val input = nid.concat(ck).concat(nm) + return Crypto.hash160(input).byteVector().toHex() +}