diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index b538ec580..68ace4657 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,7 +1,6 @@ object Versions { const val lightningKmp = "1.8.4" const val secp256k1 = "0.14.0" - const val torMobile = "0.2.0" const val kotlin = "1.9.22" diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 23045ebf2..5c4514ec3 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ C8D7ABB189B14D3104ABB50D /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7A2327BC90150A3E1493D /* AboutView.swift */; }; C8D7ABC95B979B59AF5A7CA7 /* InitializationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7A209301A31C14A982ECD /* InitializationView.swift */; }; C8D7AFF5BC5754DBBEEB2688 /* ElectrumConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7A1F8A123C59199C182C2 /* ElectrumConfigurationView.swift */; }; + DC01DD6A2CF764B5009D2B78 /* CommunicationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC01DD692CF764B1009D2B78 /* CommunicationMode.swift */; }; + DC01DD6C2CF764CF009D2B78 /* FileSpecifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC01DD6B2CF764CC009D2B78 /* FileSpecifier.swift */; }; + DC01DD6E2CF764F1009D2B78 /* KeySpecifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC01DD6D2CF764EE009D2B78 /* KeySpecifier.swift */; }; DC04C2632A9009890021F2E8 /* MergeChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC04C2622A9009890021F2E8 /* MergeChannelsView.swift */; }; DC0732EC263CA6C3004CB88D /* PaymentOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0732EB263CA6C3004CB88D /* PaymentOptionsView.swift */; }; DC08A51827FB39530041603B /* AnimatedChevron.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08A51727FB39530041603B /* AnimatedChevron.swift */; }; @@ -48,12 +51,20 @@ DC118C0C27B561210080BBAC /* CurrencyAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC118C0B27B561210080BBAC /* CurrencyAmount.swift */; }; DC142135261E72320075857A /* AboutHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC142134261E72320075857A /* AboutHTML.swift */; }; DC142140261E72E40075857A /* AnyHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC14213F261E72E40075857A /* AnyHTML.swift */; }; + DC1530032D0C6B29001D6BA6 /* ArchiveCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1530022D0C6B21001D6BA6 /* ArchiveCardSheet.swift */; }; + DC1530052D0C8896001D6BA6 /* DeleteCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1530042D0C8891001D6BA6 /* DeleteCardSheet.swift */; }; + DC1530072D0C8E90001D6BA6 /* ResetCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1530062D0C8E8B001D6BA6 /* ResetCardSheet.swift */; }; DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */; }; DC1706D926A71D8E00BAFCD0 /* UnlockErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1706D826A71D8E00BAFCD0 /* UnlockErrorView.swift */; }; DC1718A72C20BF8A000CCAF5 /* PayOfferProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1718A62C20BF8A000CCAF5 /* PayOfferProblem.swift */; }; DC175C1C28F008AE0086B9A6 /* WalletReset.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC175C1B28F008AE0086B9A6 /* WalletReset.swift */; }; DC1771B42ABC99CE00B286C7 /* WebsiteLinkPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1771B32ABC99CE00B286C7 /* WebsiteLinkPopover.swift */; }; DC1844032A2690BB004D9578 /* MinerFeeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1844022A2690BB004D9578 /* MinerFeeSheet.swift */; }; + DC1850E02D10C07E0076BFF6 /* WriteErrorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1850DF2D10C07A0076BFF6 /* WriteErrorSheet.swift */; }; + DC1850E42D11C3D40076BFF6 /* ResetSuccessSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1850E32D11C3CF0076BFF6 /* ResetSuccessSheet.swift */; }; + DC1850E62D11CD8C0076BFF6 /* LnurlwRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1850E52D11CD840076BFF6 /* LnurlwRegistration.swift */; }; + DC1850E82D11D3190076BFF6 /* NFCReaderError+Ignore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1850E72D11D3110076BFF6 /* NFCReaderError+Ignore.swift */; }; + DC1850EA2D11D6900076BFF6 /* ReadCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1850E92D11D68B0076BFF6 /* ReadCardSheet.swift */; }; DC18C418256FE22300A2D083 /* Prefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18C417256FE22300A2D083 /* Prefs.swift */; }; DC18C41D256FF91100A2D083 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18C41C256FF91100A2D083 /* Utils.swift */; }; DC1916B029CB6C1D00917F06 /* Text_CurrencyName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1916AF29CB6C1D00917F06 /* Text_CurrencyName.swift */; }; @@ -189,6 +200,26 @@ DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */; }; DC6F19C12C470F70004EC469 /* PickerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19C02C470F70004EC469 /* PickerResult.swift */; }; DC70A99C2BBB6093002DBFF8 /* InboundFeeWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */; }; + DC71CA822CEFABF800EBCBD6 /* BoltCardsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CA812CEFABEA00EBCBD6 /* BoltCardsList.swift */; }; + DC71CAC32CF0AC5A00EBCBD6 /* ManageBoltCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CAC22CF0AC5300EBCBD6 /* ManageBoltCard.swift */; }; + DC71CAC52CF0C75700EBCBD6 /* BoltCardsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CAC42CF0C75400EBCBD6 /* BoltCardsHelp.swift */; }; + DC71CB522CF63BF300EBCBD6 /* DnaCommunicator+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB472CF63BF300EBCBD6 /* DnaCommunicator+Authentication.swift */; }; + DC71CB532CF63BF300EBCBD6 /* CapabilitiesContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB412CF63BF300EBCBD6 /* CapabilitiesContainer.swift */; }; + DC71CB542CF63BF300EBCBD6 /* AESEncryptionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB4C2CF63BF300EBCBD6 /* AESEncryptionMode.swift */; }; + DC71CB552CF63BF300EBCBD6 /* DnaCommunicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB462CF63BF300EBCBD6 /* DnaCommunicator.swift */; }; + DC71CB562CF63BF300EBCBD6 /* Ndef.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB442CF63BF300EBCBD6 /* Ndef.swift */; }; + DC71CB572CF63BF300EBCBD6 /* DnaCommunicator+ChipInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB482CF63BF300EBCBD6 /* DnaCommunicator+ChipInfo.swift */; }; + DC71CB582CF63BF300EBCBD6 /* EncryptionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB4D2CF63BF300EBCBD6 /* EncryptionMode.swift */; }; + DC71CB592CF63BF300EBCBD6 /* Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB512CF63BF300EBCBD6 /* Helper.swift */; }; + DC71CB5A2CF63BF300EBCBD6 /* DnaCommunicator+Iso.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB4A2CF63BF300EBCBD6 /* DnaCommunicator+Iso.swift */; }; + DC71CB5B2CF63BF300EBCBD6 /* Array+Read.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB4F2CF63BF300EBCBD6 /* Array+Read.swift */; }; + DC71CB5C2CF63BF300EBCBD6 /* DnaCommunicator+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB4B2CF63BF300EBCBD6 /* DnaCommunicator+KeyCommands.swift */; }; + DC71CB5D2CF63BF300EBCBD6 /* FileSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB432CF63BF300EBCBD6 /* FileSettings.swift */; }; + DC71CB5E2CF63BF300EBCBD6 /* DNACommunicator+FileCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB492CF63BF300EBCBD6 /* DNACommunicator+FileCommands.swift */; }; + DC71CB5F2CF63BF300EBCBD6 /* Permission.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB422CF63BF300EBCBD6 /* Permission.swift */; }; + DC71CB632CF63F3500EBCBD6 /* SwCrypt in Frameworks */ = {isa = PBXBuildFile; productRef = DC71CB622CF63F3500EBCBD6 /* SwCrypt */; }; + DC71CB652CF63FAB00EBCBD6 /* NfcWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB642CF63FAB00EBCBD6 /* NfcWriter.swift */; }; + DC71CB692CF75AB000EBCBD6 /* KotlinExtensions+Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB682CF75AAA00EBCBD6 /* KotlinExtensions+Cards.swift */; }; DC71E7302723240E0063613D /* KotlinObservables.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71E72F2723240E0063613D /* KotlinObservables.swift */; }; DC71E7332728645B0063613D /* CurrencyConverterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71E7322728645B0063613D /* CurrencyConverterView.swift */; }; DC71E7352728A5720063613D /* KotlinIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71E7342728A5720063613D /* KotlinIdentifiable.swift */; }; @@ -200,13 +231,22 @@ DC72CEFF2C9B2B3100C810A8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC72CEFE2C9B2B3100C810A8 /* LoginView.swift */; }; DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC74174A270F332700F7E3E3 /* KotlinTypes.swift */; }; DC74174D270F455D00F7E3E3 /* AES256.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC74174C270F455D00F7E3E3 /* AES256.swift */; }; + DC75C0E12CFDE0DC00ABEF93 /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC75C0E02CFDE0D600ABEF93 /* PushManager.swift */; }; + DC75C1202CFDE62B00ABEF93 /* Ntag424.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC75C11F2CFDE62700ABEF93 /* Ntag424.swift */; }; DC784A112B31EA180018DC4A /* LiquidityAdsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC784A102B31EA180018DC4A /* LiquidityAdsView.swift */; }; + DC7AFF0D2CFA49BD00A2F62A /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7AFF0C2CFA49B800A2F62A /* PushNotification.swift */; }; + DC7AFF0E2CFA4AC600A2F62A /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7AFF0C2CFA49B800A2F62A /* PushNotification.swift */; }; DC7BAA002CADAAE70074B568 /* WalletIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7BA9FF2CADAAE70074B568 /* WalletIdentifier.swift */; }; DC7DA9F62AD84DF200F86B99 /* String+Substring.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7DA9F52AD84DF200F86B99 /* String+Substring.swift */; }; DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC81B79E25BF2AA200F5A52C /* MVI.swift */; }; DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82EED529789853007A5853 /* TxHistoryExporter.swift */; }; DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */; }; DC8D94142CA7015F00EE844E /* ChannelFundingProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */; }; + DC90A9572D02181C00E145CB /* Ntag424.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC75C11F2CFDE62700ABEF93 /* Ntag424.swift */; }; + DC90A9582D02194200E145CB /* KotlinExtensions+Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB682CF75AAA00EBCBD6 /* KotlinExtensions+Cards.swift */; }; + DC90A9592D023FCA00E145CB /* Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71CB512CF63BF300EBCBD6 /* Helper.swift */; }; + DC90A95B2D02403900E145CB /* SwCrypt in Frameworks */ = {isa = PBXBuildFile; productRef = DC90A95A2D02403900E145CB /* SwCrypt */; }; + DC90A95C2D03564700E145CB /* KotlinEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3780382C04D60400937C8E /* KotlinEnums.swift */; }; DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9473F9261270B4008D7242 /* MVI+Mock.swift */; }; DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */; }; @@ -227,6 +267,8 @@ DCA02BA02BD1A5FC0080520F /* ChannelSizeImpactWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA02B9F2BD1A5FC0080520F /* ChannelSizeImpactWarning.swift */; }; DCA125752A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA125742A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift */; }; DCA3B41F2A5471C900E6B231 /* MinerFeeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA3B41E2A5471C900E6B231 /* MinerFeeInfo.swift */; }; + DCA43A292D22FBA200AA8FB1 /* SimulatorPasteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA43A282D22FB9D00AA8FB1 /* SimulatorPasteSheet.swift */; }; + DCA43A2B2D2336DC00AA8FB1 /* SimulatorWriteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA43A2A2D2336D600AA8FB1 /* SimulatorWriteSheet.swift */; }; DCA4DA322C6674A20010363C /* NavigationPath+RemoveAll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA4DA312C6674A20010363C /* NavigationPath+RemoveAll.swift */; }; DCA4DA352C669FDF0010363C /* CurrencyConverterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA4DA342C669FDF0010363C /* CurrencyConverterRow.swift */; }; DCA4DA372C66A0960010363C /* CurrencySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA4DA362C66A0960010363C /* CurrencySelector.swift */; }; @@ -246,6 +288,9 @@ 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 */; }; + DCA8972E2D009B27009F1304 /* WithdrawRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA8972D2D009B21009F1304 /* WithdrawRequest.swift */; }; + DCA8972F2D009B27009F1304 /* WithdrawRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA8972D2D009B21009F1304 /* WithdrawRequest.swift */; }; + DCAA6EC62D148DA900B1E102 /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */; }; DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC5B6F27726FC80077BB98 /* DeepLink.swift */; }; DCACF6F02566D0A60009B01E /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */; }; DCACF6FA2566D0BA0009B01E /* KeyStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6F52566D0BA0009B01E /* KeyStoreError.swift */; }; @@ -311,11 +356,13 @@ DCCFE6C22B7140FA002FFF11 /* LoggerFactory+Foreground.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCFE6C12B7140FA002FFF11 /* LoggerFactory+Foreground.swift */; }; DCCFE6C52B714226002FFF11 /* LoggerFactory+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCFE6C32B714171002FFF11 /* LoggerFactory+Background.swift */; }; DCD1208728663F4A00EB39C5 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1208628663F4A00EB39C5 /* TransactionsView.swift */; }; + DCD1BFD62CE775E900C2B811 /* NfcReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1BFD52CE775E500C2B811 /* NfcReader.swift */; }; DCD5FF4326A0D34B009CC666 /* EqualSizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD5FF4226A0D34B009CC666 /* EqualSizes.swift */; }; DCD777D226DE9FE800979A12 /* DelayedSave.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD777D126DE9FE800979A12 /* DelayedSave.swift */; }; DCD7E0AC28EC32CB009C30E5 /* BusinessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD7E0AB28EC32CB009C30E5 /* BusinessManager.swift */; }; DCD7E0AF28EC3C0D009C30E5 /* WatchTower.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD7E0AE28EC3C0D009C30E5 /* WatchTower.swift */; }; DCD7E0F128ED89A0009C30E5 /* GlobalEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD7E0F028ED89A0009C30E5 /* GlobalEnvironment.swift */; }; + DCDA42572CF8B4EA005EE003 /* NewCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDA42562CF8B4DF005EE003 /* NewCardSheet.swift */; }; DCDAA7402971C29700B406A8 /* RecentPaymentsSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDAA73F2971C29700B406A8 /* RecentPaymentsSelector.swift */; }; DCDD9ECB28637242001800A3 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDD9ECA28637242001800A3 /* MainView.swift */; }; DCDD9ECE28637474001800A3 /* Orientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDD9ECD28637474001800A3 /* Orientation.swift */; }; @@ -452,6 +499,9 @@ C8D7A607F036B3184C3D6EED /* QRCodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRCodeScanner.swift; sourceTree = ""; }; C8D7A986A61CCD64FA661B88 /* DisplayConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayConfigurationView.swift; sourceTree = ""; }; C8D7AFF1A7C09789C6CF2D06 /* ConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationView.swift; sourceTree = ""; }; + DC01DD692CF764B1009D2B78 /* CommunicationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicationMode.swift; sourceTree = ""; }; + DC01DD6B2CF764CC009D2B78 /* FileSpecifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSpecifier.swift; sourceTree = ""; }; + DC01DD6D2CF764EE009D2B78 /* KeySpecifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeySpecifier.swift; sourceTree = ""; }; DC04C2622A9009890021F2E8 /* MergeChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeChannelsView.swift; sourceTree = ""; }; DC0732EB263CA6C3004CB88D /* PaymentOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentOptionsView.swift; sourceTree = ""; }; DC08A51727FB39530041603B /* AnimatedChevron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedChevron.swift; sourceTree = ""; }; @@ -472,12 +522,20 @@ DC118C0B27B561210080BBAC /* CurrencyAmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyAmount.swift; sourceTree = ""; }; DC142134261E72320075857A /* AboutHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutHTML.swift; sourceTree = ""; }; DC14213F261E72E40075857A /* AnyHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyHTML.swift; sourceTree = ""; }; + DC1530022D0C6B21001D6BA6 /* ArchiveCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveCardSheet.swift; sourceTree = ""; }; + DC1530042D0C8891001D6BA6 /* DeleteCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteCardSheet.swift; sourceTree = ""; }; + DC1530062D0C8E8B001D6BA6 /* ResetCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetCardSheet.swift; sourceTree = ""; }; DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Currency.swift"; sourceTree = ""; }; DC1706D826A71D8E00BAFCD0 /* UnlockErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockErrorView.swift; sourceTree = ""; }; DC1718A62C20BF8A000CCAF5 /* PayOfferProblem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayOfferProblem.swift; sourceTree = ""; }; DC175C1B28F008AE0086B9A6 /* WalletReset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletReset.swift; sourceTree = ""; }; DC1771B32ABC99CE00B286C7 /* WebsiteLinkPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteLinkPopover.swift; sourceTree = ""; }; DC1844022A2690BB004D9578 /* MinerFeeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinerFeeSheet.swift; sourceTree = ""; }; + DC1850DF2D10C07A0076BFF6 /* WriteErrorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteErrorSheet.swift; sourceTree = ""; }; + DC1850E32D11C3CF0076BFF6 /* ResetSuccessSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetSuccessSheet.swift; sourceTree = ""; }; + DC1850E52D11CD840076BFF6 /* LnurlwRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnurlwRegistration.swift; sourceTree = ""; }; + DC1850E72D11D3110076BFF6 /* NFCReaderError+Ignore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NFCReaderError+Ignore.swift"; sourceTree = ""; }; + DC1850E92D11D68B0076BFF6 /* ReadCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadCardSheet.swift; sourceTree = ""; }; DC18C417256FE22300A2D083 /* Prefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Prefs.swift; sourceTree = ""; }; DC18C41C256FF91100A2D083 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; DC1916AF29CB6C1D00917F06 /* Text_CurrencyName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text_CurrencyName.swift; sourceTree = ""; }; @@ -596,6 +654,25 @@ DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+Async.swift"; sourceTree = ""; }; DC6F19C02C470F70004EC469 /* PickerResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerResult.swift; sourceTree = ""; }; DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFeeWarning.swift; sourceTree = ""; }; + DC71CA812CEFABEA00EBCBD6 /* BoltCardsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoltCardsList.swift; sourceTree = ""; }; + DC71CAC22CF0AC5300EBCBD6 /* ManageBoltCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageBoltCard.swift; sourceTree = ""; }; + DC71CAC42CF0C75400EBCBD6 /* BoltCardsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoltCardsHelp.swift; sourceTree = ""; }; + DC71CB412CF63BF300EBCBD6 /* CapabilitiesContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesContainer.swift; sourceTree = ""; }; + DC71CB422CF63BF300EBCBD6 /* Permission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permission.swift; sourceTree = ""; }; + DC71CB432CF63BF300EBCBD6 /* FileSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSettings.swift; sourceTree = ""; }; + DC71CB442CF63BF300EBCBD6 /* Ndef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ndef.swift; sourceTree = ""; }; + DC71CB462CF63BF300EBCBD6 /* DnaCommunicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DnaCommunicator.swift; sourceTree = ""; }; + DC71CB472CF63BF300EBCBD6 /* DnaCommunicator+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DnaCommunicator+Authentication.swift"; sourceTree = ""; }; + DC71CB482CF63BF300EBCBD6 /* DnaCommunicator+ChipInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DnaCommunicator+ChipInfo.swift"; sourceTree = ""; }; + DC71CB492CF63BF300EBCBD6 /* DNACommunicator+FileCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DNACommunicator+FileCommands.swift"; sourceTree = ""; }; + DC71CB4A2CF63BF300EBCBD6 /* DnaCommunicator+Iso.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DnaCommunicator+Iso.swift"; sourceTree = ""; }; + DC71CB4B2CF63BF300EBCBD6 /* DnaCommunicator+KeyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DnaCommunicator+KeyCommands.swift"; sourceTree = ""; }; + DC71CB4C2CF63BF300EBCBD6 /* AESEncryptionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AESEncryptionMode.swift; sourceTree = ""; }; + DC71CB4D2CF63BF300EBCBD6 /* EncryptionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionMode.swift; sourceTree = ""; }; + DC71CB4F2CF63BF300EBCBD6 /* Array+Read.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Read.swift"; sourceTree = ""; }; + DC71CB512CF63BF300EBCBD6 /* Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helper.swift; sourceTree = ""; }; + DC71CB642CF63FAB00EBCBD6 /* NfcWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NfcWriter.swift; sourceTree = ""; }; + DC71CB682CF75AAA00EBCBD6 /* KotlinExtensions+Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Cards.swift"; sourceTree = ""; }; DC71E72F2723240E0063613D /* KotlinObservables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinObservables.swift; sourceTree = ""; }; DC71E7322728645B0063613D /* CurrencyConverterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyConverterView.swift; sourceTree = ""; }; DC71E7342728A5720063613D /* KotlinIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinIdentifiable.swift; sourceTree = ""; }; @@ -608,7 +685,10 @@ DC72CEFE2C9B2B3100C810A8 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; DC74174A270F332700F7E3E3 /* KotlinTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinTypes.swift; sourceTree = ""; }; DC74174C270F455D00F7E3E3 /* AES256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AES256.swift; sourceTree = ""; }; + DC75C0E02CFDE0D600ABEF93 /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; + DC75C11F2CFDE62700ABEF93 /* Ntag424.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ntag424.swift; sourceTree = ""; }; DC784A102B31EA180018DC4A /* LiquidityAdsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityAdsView.swift; sourceTree = ""; }; + DC7AFF0C2CFA49B800A2F62A /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = ""; }; DC7BA9FF2CADAAE70074B568 /* WalletIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletIdentifier.swift; sourceTree = ""; }; DC7DA9F52AD84DF200F86B99 /* String+Substring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Substring.swift"; sourceTree = ""; }; DC81B79E25BF2AA200F5A52C /* MVI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVI.swift; sourceTree = ""; }; @@ -632,6 +712,8 @@ DCA02B9F2BD1A5FC0080520F /* ChannelSizeImpactWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSizeImpactWarning.swift; sourceTree = ""; }; DCA125742A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolRecommendedResponse.swift; sourceTree = ""; }; DCA3B41E2A5471C900E6B231 /* MinerFeeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinerFeeInfo.swift; sourceTree = ""; }; + DCA43A282D22FB9D00AA8FB1 /* SimulatorPasteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorPasteSheet.swift; sourceTree = ""; }; + DCA43A2A2D2336D600AA8FB1 /* SimulatorWriteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorWriteSheet.swift; sourceTree = ""; }; DCA4DA312C6674A20010363C /* NavigationPath+RemoveAll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationPath+RemoveAll.swift"; sourceTree = ""; }; DCA4DA342C669FDF0010363C /* CurrencyConverterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyConverterRow.swift; sourceTree = ""; }; DCA4DA362C66A0960010363C /* CurrencySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencySelector.swift; sourceTree = ""; }; @@ -643,6 +725,7 @@ 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 = ""; }; + DCA8972D2D009B21009F1304 /* WithdrawRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawRequest.swift; sourceTree = ""; }; DCAC5B6F27726FC80077BB98 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Hexadecimal.swift"; sourceTree = ""; }; DCACF6F52566D0BA0009B01E /* KeyStoreError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyStoreError.swift; sourceTree = ""; }; @@ -700,11 +783,13 @@ DCCFE6C12B7140FA002FFF11 /* LoggerFactory+Foreground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoggerFactory+Foreground.swift"; sourceTree = ""; }; DCCFE6C32B714171002FFF11 /* LoggerFactory+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoggerFactory+Background.swift"; sourceTree = ""; }; DCD1208628663F4A00EB39C5 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = ""; }; + DCD1BFD52CE775E500C2B811 /* NfcReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NfcReader.swift; sourceTree = ""; }; DCD5FF4226A0D34B009CC666 /* EqualSizes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualSizes.swift; sourceTree = ""; }; DCD777D126DE9FE800979A12 /* DelayedSave.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedSave.swift; sourceTree = ""; }; DCD7E0AB28EC32CB009C30E5 /* BusinessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessManager.swift; sourceTree = ""; }; DCD7E0AE28EC3C0D009C30E5 /* WatchTower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTower.swift; sourceTree = ""; }; DCD7E0F028ED89A0009C30E5 /* GlobalEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEnvironment.swift; sourceTree = ""; }; + DCDA42562CF8B4DF005EE003 /* NewCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCardSheet.swift; sourceTree = ""; }; DCDAA73F2971C29700B406A8 /* RecentPaymentsSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPaymentsSelector.swift; sourceTree = ""; }; DCDD9ECA28637242001800A3 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; DCDD9ECD28637474001800A3 /* Orientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Orientation.swift; sourceTree = ""; }; @@ -754,6 +839,7 @@ DCC9D99C267BEB3D00EA36DD /* CloudKit.framework in Frameworks */, DC39D4E5286B4A7E0030F18D /* Popovers in Frameworks */, DCC46F1625C3521C005D32D9 /* FirebaseMessaging in Frameworks */, + DC71CB632CF63F3500EBCBD6 /* SwCrypt in Frameworks */, DCCFE6AB2B6430AB002FFF11 /* Logging in Frameworks */, DCB0DB8A255AE42F005B29C8 /* PhoenixShared.framework in Frameworks */, ); @@ -778,6 +864,7 @@ buildActionMask = 2147483647; files = ( DCCFE6B72B682E22002FFF11 /* Logging in Frameworks */, + DC90A95B2D02403900E145CB /* SwCrypt in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -929,6 +1016,8 @@ 53BEF1337AFCFF0AE82A46BD /* utils */, DCACF6EE2566D0A60009B01E /* extensions */, DCA6DEC42829BD060073C658 /* xpc */, + DCD1BFD42CE775CE00C2B811 /* nfc */, + DC7AFF0B2CFA49AC00A2F62A /* notifications */, DCE3C7A92A6AD39E00F4D385 /* mempool */, DCCFE6AC2B6430BD002FFF11 /* logging */, ); @@ -1062,23 +1151,24 @@ isa = PBXGroup; children = ( DC46BAF226CACCF700E760A6 /* KotlinAssociatedObject.swift */, + DC3780382C04D60400937C8E /* KotlinEnums.swift */, + DC49FE982AC49C6300D8D2E2 /* KotlinExtensions+Bitcoin.swift */, + DC71CB682CF75AAA00EBCBD6 /* KotlinExtensions+Cards.swift */, DC0D2EA42939269800284608 /* KotlinExtensions+CloudKit.swift */, DC46BAF126CACCF700E760A6 /* KotlinExtensions+Conversion.swift */, DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */, - DC0D2EA62939273B00284608 /* KotlinExtensions+Payments.swift */, + DC49FE9A2AC49CB500D8D2E2 /* KotlinExtensions+Lightning.swift */, DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */, DC46BAED26CACCF700E760A6 /* KotlinExtensions+Other.swift */, - DC49FE9A2AC49CB500D8D2E2 /* KotlinExtensions+Lightning.swift */, - DC49FE982AC49C6300D8D2E2 /* KotlinExtensions+Bitcoin.swift */, + DC0D2EA62939273B00284608 /* KotlinExtensions+Payments.swift */, DC46BAEE26CACCF700E760A6 /* KotlinFlow.swift */, DC46BAF026CACCF700E760A6 /* KotlinFutures.swift */, DC71E7342728A5720063613D /* KotlinIdentifiable.swift */, + DCCFE6A32B63021E002FFF11 /* KotlinLogger.swift */, DC71E72F2723240E0063613D /* KotlinObservables.swift */, - DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */, DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */, + DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */, DC74174A270F332700F7E3E3 /* KotlinTypes.swift */, - DCCFE6A32B63021E002FFF11 /* KotlinLogger.swift */, - DC3780382C04D60400937C8E /* KotlinEnums.swift */, ); path = kotlin; sourceTree = ""; @@ -1155,9 +1245,10 @@ DC641C6C2820826000862DCD /* AppMigration.swift */, DCD7E0AB28EC32CB009C30E5 /* BusinessManager.swift */, DC63BDF329AE44380067A361 /* NotificationsManager.swift */, - DCD7E0AE28EC3C0D009C30E5 /* WatchTower.swift */, - DC175C1B28F008AE0086B9A6 /* WalletReset.swift */, DC6F04242C38807300627B4F /* PhotosManager.swift */, + DC75C0E02CFDE0D600ABEF93 /* PushManager.swift */, + DC175C1B28F008AE0086B9A6 /* WalletReset.swift */, + DCD7E0AE28EC3C0D009C30E5 /* WatchTower.swift */, ); path = officers; sourceTree = ""; @@ -1175,6 +1266,83 @@ path = prefs; sourceTree = ""; }; + DC71CA802CEFABD100EBCBD6 /* bolt card */ = { + isa = PBXGroup; + children = ( + DC1530022D0C6B21001D6BA6 /* ArchiveCardSheet.swift */, + DC71CAC42CF0C75400EBCBD6 /* BoltCardsHelp.swift */, + DC71CA812CEFABEA00EBCBD6 /* BoltCardsList.swift */, + DC1530042D0C8891001D6BA6 /* DeleteCardSheet.swift */, + DC1850E52D11CD840076BFF6 /* LnurlwRegistration.swift */, + DC71CAC22CF0AC5300EBCBD6 /* ManageBoltCard.swift */, + DCDA42562CF8B4DF005EE003 /* NewCardSheet.swift */, + DC1850E72D11D3110076BFF6 /* NFCReaderError+Ignore.swift */, + DC1850E92D11D68B0076BFF6 /* ReadCardSheet.swift */, + DC1530062D0C8E8B001D6BA6 /* ResetCardSheet.swift */, + DC1850E32D11C3CF0076BFF6 /* ResetSuccessSheet.swift */, + DCA43A282D22FB9D00AA8FB1 /* SimulatorPasteSheet.swift */, + DCA43A2A2D2336D600AA8FB1 /* SimulatorWriteSheet.swift */, + DC1850DF2D10C07A0076BFF6 /* WriteErrorSheet.swift */, + ); + path = "bolt card"; + sourceTree = ""; + }; + DC71CB402CF63B3A00EBCBD6 /* DnaCommunicator */ = { + isa = PBXGroup; + children = ( + DC71CB602CF63CD700EBCBD6 /* Communicator */, + DC71CB452CF63BF300EBCBD6 /* Configuration */, + DC71CB4E2CF63BF300EBCBD6 /* EncryptionModes */, + DC71CB502CF63BF300EBCBD6 /* Extensions */, + DC71CB512CF63BF300EBCBD6 /* Helper.swift */, + ); + path = DnaCommunicator; + sourceTree = ""; + }; + DC71CB452CF63BF300EBCBD6 /* Configuration */ = { + isa = PBXGroup; + children = ( + DC71CB412CF63BF300EBCBD6 /* CapabilitiesContainer.swift */, + DC01DD692CF764B1009D2B78 /* CommunicationMode.swift */, + DC71CB432CF63BF300EBCBD6 /* FileSettings.swift */, + DC01DD6B2CF764CC009D2B78 /* FileSpecifier.swift */, + DC01DD6D2CF764EE009D2B78 /* KeySpecifier.swift */, + DC71CB442CF63BF300EBCBD6 /* Ndef.swift */, + DC71CB422CF63BF300EBCBD6 /* Permission.swift */, + ); + path = Configuration; + sourceTree = ""; + }; + DC71CB4E2CF63BF300EBCBD6 /* EncryptionModes */ = { + isa = PBXGroup; + children = ( + DC71CB4C2CF63BF300EBCBD6 /* AESEncryptionMode.swift */, + DC71CB4D2CF63BF300EBCBD6 /* EncryptionMode.swift */, + ); + path = EncryptionModes; + sourceTree = ""; + }; + DC71CB502CF63BF300EBCBD6 /* Extensions */ = { + isa = PBXGroup; + children = ( + DC71CB4F2CF63BF300EBCBD6 /* Array+Read.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + DC71CB602CF63CD700EBCBD6 /* Communicator */ = { + isa = PBXGroup; + children = ( + DC71CB462CF63BF300EBCBD6 /* DnaCommunicator.swift */, + DC71CB472CF63BF300EBCBD6 /* DnaCommunicator+Authentication.swift */, + DC71CB482CF63BF300EBCBD6 /* DnaCommunicator+ChipInfo.swift */, + DC71CB492CF63BF300EBCBD6 /* DNACommunicator+FileCommands.swift */, + DC71CB4A2CF63BF300EBCBD6 /* DnaCommunicator+Iso.swift */, + DC71CB4B2CF63BF300EBCBD6 /* DnaCommunicator+KeyCommands.swift */, + ); + path = Communicator; + sourceTree = ""; + }; DC71E7312728643C0063613D /* tools */ = { isa = PBXGroup; children = ( @@ -1203,6 +1371,15 @@ path = "liquidity management"; sourceTree = ""; }; + DC7AFF0B2CFA49AC00A2F62A /* notifications */ = { + isa = PBXGroup; + children = ( + DCA8972D2D009B21009F1304 /* WithdrawRequest.swift */, + DC7AFF0C2CFA49B800A2F62A /* PushNotification.swift */, + ); + path = notifications; + sourceTree = ""; + }; DC949E6B2B45FC5E00E80BB5 /* fees */ = { isa = PBXGroup; children = ( @@ -1303,6 +1480,7 @@ DCFAEFC72A72F46D00330088 /* wallet */, DCFFAADC2900218B004E3C11 /* channels */, 53BEF0A8669F9379E4E4596F /* logs */, + DC71CA802CEFABD100EBCBD6 /* bolt card */, DC5631C42C541E5C00DCB5BF /* Experimental.swift */, ); path = advanced; @@ -1477,6 +1655,17 @@ path = transactions; sourceTree = ""; }; + DCD1BFD42CE775CE00C2B811 /* nfc */ = { + isa = PBXGroup; + children = ( + DC75C11F2CFDE62700ABEF93 /* Ntag424.swift */, + DCD1BFD52CE775E500C2B811 /* NfcReader.swift */, + DC71CB642CF63FAB00EBCBD6 /* NfcWriter.swift */, + DC71CB402CF63B3A00EBCBD6 /* DnaCommunicator */, + ); + path = nfc; + sourceTree = ""; + }; DCDD9ECC28637390001800A3 /* main */ = { isa = PBXGroup; children = ( @@ -1576,6 +1765,7 @@ DC26D0BA2A93BD0F006763B3 /* EffectsLibrary */, DCCFE6AA2B6430AB002FFF11 /* Logging */, DC9B15432B7D0DCB0023743B /* AsyncAlgorithms */, + DC71CB622CF63F3500EBCBD6 /* SwCrypt */, ); productName = "phoenix-ios"; productReference = 7555FF7B242A565900829871 /* Phoenix.app */; @@ -1653,6 +1843,7 @@ name = "phoenix-notifySrvExt"; packageProductDependencies = ( DCCFE6B62B682E22002FFF11 /* Logging */, + DC90A95A2D02403900E145CB /* SwCrypt */, ); productName = "phoenix-notifySrvExt"; productReference = DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */; @@ -1710,6 +1901,7 @@ DC26D0B82A93BA8C006763B3 /* XCRemoteSwiftPackageReference "effects-library" */, DCCFE6A92B6430AB002FFF11 /* XCRemoteSwiftPackageReference "swift-log" */, DC9B15422B7D0DCB0023743B /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + DC71CB612CF63E8E00EBCBD6 /* XCRemoteSwiftPackageReference "SwCrypt" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -1845,8 +2037,10 @@ DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */, + DC1530072D0C8E90001D6BA6 /* ResetCardSheet.swift in Sources */, DC6F19C12C470F70004EC469 /* PickerResult.swift in Sources */, DCEE8998288605FD00FE42DD /* PaymentCell.swift in Sources */, + DC71CAC32CF0AC5A00EBCBD6 /* ManageBoltCard.swift in Sources */, DCDD9ED428637EBB001800A3 /* ToolsButton.swift in Sources */, DCDD9ED0286377B7001800A3 /* MainView_Big.swift in Sources */, DC355E1F2A44A235008E8A8E /* NotificationCell.swift in Sources */, @@ -1858,11 +2052,13 @@ DC355E1D2A4398A8008E8A8E /* NoticeBox.swift in Sources */, 7555FF81242A565900829871 /* SceneDelegate.swift in Sources */, DCC9D99A267BD28600EA36DD /* SyncBackupManager.swift in Sources */, + DC01DD6C2CF764CF009D2B78 /* FileSpecifier.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 */, + DC01DD6E2CF764F1009D2B78 /* KeySpecifier.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, DCFB8DF92A94112A00947698 /* Dictionary+MapKeys.swift in Sources */, DC37803B2C050F7000937C8E /* SpendOnChainFunds.swift in Sources */, @@ -1877,6 +2073,7 @@ DC682FE8258175CE00CA1114 /* Popover.swift in Sources */, DC1844032A2690BB004D9578 /* MinerFeeSheet.swift in Sources */, DC39A2662A12C04D00F59E39 /* LiquidityPolicyHelp.swift in Sources */, + DC71CAC52CF0C75700EBCBD6 /* BoltCardsHelp.swift in Sources */, DC49FE9B2AC49CB500D8D2E2 /* KotlinExtensions+Lightning.swift in Sources */, DCACF6FA2566D0BA0009B01E /* KeyStoreError.swift in Sources */, DC0E31BB26EFDED4002071C6 /* VSlider.swift in Sources */, @@ -1885,6 +2082,7 @@ DCB410892902D5BF00CE4FF9 /* PaymentsSection.swift in Sources */, DCA6DED0282AB7E20073C658 /* KeychainConstants.swift in Sources */, DCACF6FC2566D0BA0009B01E /* AppSecurity.swift in Sources */, + DC01DD6A2CF764B5009D2B78 /* CommunicationMode.swift in Sources */, DCCD045D27EE0173007D57A5 /* EditInfoView.swift in Sources */, DC2DC86A2906AC620079E570 /* FiatCurrencySelector.swift in Sources */, DC1D2B4B2593EB860036AD38 /* Currency.swift in Sources */, @@ -1899,7 +2097,9 @@ DC0C52662BF3C31700143831 /* WhichPinSheet.swift in Sources */, DCD1208728663F4A00EB39C5 /* TransactionsView.swift in Sources */, DC70A99C2BBB6093002DBFF8 /* InboundFeeWarning.swift in Sources */, + DC75C0E12CFDE0DC00ABEF93 /* PushManager.swift in Sources */, DC8D94142CA7015F00EE844E /* ChannelFundingProblem.swift in Sources */, + DC75C1202CFDE62B00ABEF93 /* Ntag424.swift in Sources */, DC46BAF326CACCF700E760A6 /* KotlinExtensions+Other.swift in Sources */, DCB5D2DF280879460020B8F5 /* DeviceInfo.swift in Sources */, DCACF6FB2566D0BA0009B01E /* GenericPasswordStore.swift in Sources */, @@ -1918,6 +2118,7 @@ DC49FE992AC49C6300D8D2E2 /* KotlinExtensions+Bitcoin.swift in Sources */, DC142140261E72E40075857A /* AnyHTML.swift in Sources */, DC2CE3AF29AFEB0500BA0B00 /* Bundle+Icon.swift in Sources */, + DC1530052D0C8896001D6BA6 /* DeleteCardSheet.swift in Sources */, DC3345D22C2C761800EDD2D4 /* CameraPicker.swift in Sources */, DCE443AC2B3482C800CABA96 /* LiquidityFeeInfo.swift in Sources */, DC1916B029CB6C1D00917F06 /* Text_CurrencyName.swift in Sources */, @@ -1938,13 +2139,30 @@ DC23F80F2B55923F00389A10 /* WalletCreationOptions.swift in Sources */, DC4CF3D02BEA8C13003A957F /* EditPinView.swift in Sources */, DC1D2B502594CE900036AD38 /* FormattedAmount.swift in Sources */, + DC7AFF0D2CFA49BD00A2F62A /* PushNotification.swift in Sources */, DC18C418256FE22300A2D083 /* Prefs.swift in Sources */, DCAEF8F5276131A600015993 /* CheckboxToggleStyle.swift in Sources */, + DC1850E02D10C07E0076BFF6 /* WriteErrorSheet.swift in Sources */, DC641C712820889C00862DCD /* GroupPrefs.swift in Sources */, DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DC63BDF429AE44380067A361 /* NotificationsManager.swift in Sources */, DC118BFA27B44F840080BBAC /* TipSliderSheet.swift in Sources */, DC6F04252C38807300627B4F /* PhotosManager.swift in Sources */, + DC71CB522CF63BF300EBCBD6 /* DnaCommunicator+Authentication.swift in Sources */, + DC71CB532CF63BF300EBCBD6 /* CapabilitiesContainer.swift in Sources */, + DC71CB542CF63BF300EBCBD6 /* AESEncryptionMode.swift in Sources */, + DC71CB552CF63BF300EBCBD6 /* DnaCommunicator.swift in Sources */, + DC71CB562CF63BF300EBCBD6 /* Ndef.swift in Sources */, + DC71CB572CF63BF300EBCBD6 /* DnaCommunicator+ChipInfo.swift in Sources */, + DC71CB582CF63BF300EBCBD6 /* EncryptionMode.swift in Sources */, + DC71CB592CF63BF300EBCBD6 /* Helper.swift in Sources */, + DC71CB5A2CF63BF300EBCBD6 /* DnaCommunicator+Iso.swift in Sources */, + DC71CB5B2CF63BF300EBCBD6 /* Array+Read.swift in Sources */, + DC71CB5C2CF63BF300EBCBD6 /* DnaCommunicator+KeyCommands.swift in Sources */, + DC71CB5D2CF63BF300EBCBD6 /* FileSettings.swift in Sources */, + DC71CB5E2CF63BF300EBCBD6 /* DNACommunicator+FileCommands.swift in Sources */, + DC71CB5F2CF63BF300EBCBD6 /* Permission.swift in Sources */, + DCA8972E2D009B27009F1304 /* WithdrawRequest.swift in Sources */, DCA4DA322C6674A20010363C /* NavigationPath+RemoveAll.swift in Sources */, DC72C33425A51AAC008A927A /* CurrencyPrefs.swift in Sources */, DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */, @@ -1953,6 +2171,7 @@ DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */, DC65BBF22A58A40700EBA651 /* CpfpView.swift in Sources */, DC422F3329392ABD00E72253 /* Date+Format.swift in Sources */, + DCA43A2B2D2336DC00AA8FB1 /* SimulatorWriteSheet.swift in Sources */, DC9AD1F12C9B81450019BEE9 /* PaymentRequestedView.swift in Sources */, DCEFD922276A796800001767 /* SyncManager.swift in Sources */, DC5567452C2F1A6900008E11 /* ContactsList.swift in Sources */, @@ -1996,8 +2215,11 @@ C8D7AA4B09B32AD99C88BB5E /* DisplayConfigurationView.swift in Sources */, DCE77A5827C671D600F0FA24 /* ElectrumAddressSheet.swift in Sources */, DC384D81265C12B700131772 /* Cache.swift in Sources */, + DCD1BFD62CE775E900C2B811 /* NfcReader.swift in Sources */, DC4CF3CC2BE93311003A957F /* String+PIN.swift in Sources */, DCB30E592A0C3F8200E7D7A2 /* LiquidityPolicyView.swift in Sources */, + DC1530032D0C6B29001D6BA6 /* ArchiveCardSheet.swift in Sources */, + DCDA42572CF8B4EA005EE003 /* NewCardSheet.swift in Sources */, DCEAE5B72943CC7400320C46 /* RangeSheet.swift in Sources */, DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */, DC6F042B2C3DA7AD00627B4F /* ContactsListSheet.swift in Sources */, @@ -2007,13 +2229,16 @@ DCDD9ECB28637242001800A3 /* MainView.swift in Sources */, DCDD9ED2286377C5001800A3 /* MainView_Small.swift in Sources */, C8D7AFF5BC5754DBBEEB2688 /* ElectrumConfigurationView.swift in Sources */, + DC1850E42D11C3D40076BFF6 /* ResetSuccessSheet.swift in Sources */, 53BEFBECABE13063AB28A4D6 /* publishers.swift in Sources */, DCC3E57F2D08A63900CCDA40 /* XPC+Foreground.swift in Sources */, DC99E94025BA141000FB20F7 /* LocalWebView.swift in Sources */, 53BEFA633D95514CA5C0422A /* ChannelsConfigurationView.swift in Sources */, DCED09D42625DBC4005D5EE2 /* AnimationCompletion.swift in Sources */, + DC1850E62D11CD8C0076BFF6 /* LnurlwRegistration.swift in Sources */, DCE77A5627C5240500F0FA24 /* TLSConnectionCheck.swift in Sources */, DCCC7FD526B0A006008ACD9B /* SquareSize.swift in Sources */, + DC71CB652CF63FAB00EBCBD6 /* NfcWriter.swift in Sources */, DC2CE54D28A3D2F50070A2E1 /* TruncatableView.swift in Sources */, DCDD9ED628637FD7001800A3 /* AppStatusButton.swift in Sources */, DCCFE6A62B63028A002FFF11 /* UnfairLock.swift in Sources */, @@ -2022,6 +2247,7 @@ DCAEF8F727628BEB00015993 /* Either.swift in Sources */, DCA6DECC282AAA740073C658 /* SharedSecurity.swift in Sources */, DC5CA4EF28F842F10048A737 /* MVI+Extensions.swift in Sources */, + DC71CA822CEFABF800EBCBD6 /* BoltCardsList.swift in Sources */, DC6ACC582B10F0220079179B /* RecoveryPhrase.swift in Sources */, DCD7E0F128ED89A0009C30E5 /* GlobalEnvironment.swift in Sources */, DC09086325B626B300A46136 /* AppStatusPopover.swift in Sources */, @@ -2030,10 +2256,12 @@ DC1E75722B73DD500026F36E /* LogFileParser.swift in Sources */, DC49DA8E258BB882005BC4BC /* ScaledButtonStyle.swift in Sources */, DCAEF8D9275E69B000015993 /* SyncSeedManager_State.swift in Sources */, + DC1850E82D11D3190076BFF6 /* NFCReaderError+Ignore.swift in Sources */, DCB04685260D162C007FDA37 /* ViewName.swift in Sources */, DC4CF3CE2BE96C36003A957F /* DisablePinView.swift in Sources */, DCB30E522A0A948000E7D7A2 /* WalletInfoView.swift in Sources */, DCFA876D260E91E600AE8953 /* IntroContainer.swift in Sources */, + DC71CB692CF75AB000EBCBD6 /* KotlinExtensions+Cards.swift in Sources */, DCA5391C29F7202F001BD3D5 /* ChannelInfoPopup.swift in Sources */, DCB4108729028EF900CE4FF9 /* Prefs+BackupSeed.swift in Sources */, DCEC6A1827A82A98002C20BA /* ImagePicker.swift in Sources */, @@ -2058,6 +2286,7 @@ DCA3B41F2A5471C900E6B231 /* MinerFeeInfo.swift in Sources */, DC6F04232C35EB9900627B4F /* SummaryInfoGrid.swift in Sources */, DCACF6F02566D0A60009B01E /* Data+Hexadecimal.swift in Sources */, + DC1850EA2D11D6900076BFF6 /* ReadCardSheet.swift in Sources */, DCE3C7AB2A6AD3CC00F4D385 /* MempoolMonitor.swift in Sources */, DC3780392C04D60400937C8E /* KotlinEnums.swift in Sources */, DCBA60CD2C909C7600878895 /* SendView.swift in Sources */, @@ -2065,6 +2294,7 @@ DCACF7092566D0F00009B01E /* AppAccessView.swift in Sources */, DC27E4D1279753EC00C777CC /* TextFieldNumberStyler.swift in Sources */, DC46CB1628D9F30500C4EAC7 /* LoadingView.swift in Sources */, + DCA43A292D22FBA200AA8FB1 /* SimulatorPasteSheet.swift in Sources */, DC2ABAD92BED142900C11C9C /* ShakeEffect.swift in Sources */, DC72CEFD2C9B25CC00C810A8 /* PaymentDetails.swift in Sources */, DCFBC55B2AEAC2B000E3A418 /* ImportChannelsView.swift in Sources */, @@ -2125,8 +2355,10 @@ DCEB2798282D7B070096B87E /* KotlinExtensions+Other.swift in Sources */, DC641C7328208B7F00862DCD /* UserDefaults+Codable.swift in Sources */, DCA6DED1282ABA930073C658 /* KeychainConstants.swift in Sources */, + DCAA6EC62D148DA900B1E102 /* Data+Hexadecimal.swift in Sources */, DCEB2796282D7AAB0096B87E /* KotlinTypes.swift in Sources */, DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */, + DC7AFF0E2CFA4AC600A2F62A /* PushNotification.swift in Sources */, DCCFE6C02B713FBE002FFF11 /* LogFileHandler.swift in Sources */, DCCFE6B42B680DF9002FFF11 /* OSLogHandler.swift in Sources */, DCB511CA281AED58001BC525 /* NotificationService.swift in Sources */, @@ -2137,11 +2369,14 @@ DCC3E5822D08A65400CCDA40 /* XPC+Background.swift in Sources */, DCEB2795282D7A9F0096B87E /* KotlinPublishers+Phoenix.swift in Sources */, DC641C6B2820803100862DCD /* PhoenixManager.swift in Sources */, + DC90A9582D02194200E145CB /* KotlinExtensions+Cards.swift in Sources */, DC641C7B2821726F00862DCD /* FormattedAmount.swift in Sources */, DC641C77282171D200862DCD /* KotlinExtensions+Currency.swift in Sources */, DC641C762821716A00862DCD /* UserDefaults+Serialization.swift in Sources */, + DC90A9592D023FCA00E145CB /* Helper.swift in Sources */, DCCFE6BF2B713FBB002FFF11 /* LogFileManager.swift in Sources */, DCF9CFD52862656E001AD33F /* Asserts.swift in Sources */, + DCA8972F2D009B27009F1304 /* WithdrawRequest.swift in Sources */, DC641C7C282172BB00862DCD /* DelayedSave.swift in Sources */, DCA6DECE282AB12B0073C658 /* SecurityFile.swift in Sources */, DCCFE6BE2B713FB8002FFF11 /* LogFileInfo.swift in Sources */, @@ -2151,8 +2386,10 @@ DCA6DEC72829BFD70073C658 /* XPC.swift in Sources */, DCE81C172BC883BE0094B950 /* KotlinFlow.swift in Sources */, DC641C7428208BD600862DCD /* String+VersionComparison.swift in Sources */, + DC90A9572D02181C00E145CB /* Ntag424.swift in Sources */, DCA6DEC92829C3180073C658 /* GenericPasswordConvertible.swift in Sources */, DCCFE6A72B630836002FFF11 /* KotlinLogger.swift in Sources */, + DC90A95C2D03564700E145CB /* KotlinEnums.swift in Sources */, DC641C78282171EA00862DCD /* KotlinAssociatedObject.swift in Sources */, DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DCCFE6B32B680DF5002FFF11 /* LoggerFactory.swift in Sources */, @@ -2669,6 +2906,14 @@ version = 0.4.1; }; }; + DC71CB612CF63E8E00EBCBD6 /* XCRemoteSwiftPackageReference "SwCrypt" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/soyersoyer/SwCrypt"; + requirement = { + kind = exactVersion; + version = 5.1.4; + }; + }; DC72C2EA25A3CADC008A927A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; @@ -2724,11 +2969,21 @@ package = DC46BAE226C6FDD300E760A6 /* XCRemoteSwiftPackageReference "CircularCheckmarkProgress" */; productName = CircularCheckmarkProgress; }; + DC71CB622CF63F3500EBCBD6 /* SwCrypt */ = { + isa = XCSwiftPackageProductDependency; + package = DC71CB612CF63E8E00EBCBD6 /* XCRemoteSwiftPackageReference "SwCrypt" */; + productName = SwCrypt; + }; DC72C31825A3CF87008A927A /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = DC72C2EA25A3CADC008A927A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; + DC90A95A2D02403900E145CB /* SwCrypt */ = { + isa = XCSwiftPackageProductDependency; + package = DC71CB612CF63E8E00EBCBD6 /* XCRemoteSwiftPackageReference "SwCrypt" */; + productName = SwCrypt; + }; DC9B15432B7D0DCB0023743B /* AsyncAlgorithms */ = { isa = XCSwiftPackageProductDependency; package = DC9B15422B7D0DCB0023743B /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38ac3a938..772cc0175 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "d649fbe2be50831fdb28cb2641e76bb148d3b4a074f1350225d4c038ab447877", "pins" : [ { "identity" : "abseil-cpp-swiftpm", @@ -126,6 +127,15 @@ "version" : "2.1.1" } }, + { + "identity" : "swcrypt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soyersoyer/SwCrypt", + "state" : { + "revision" : "d18cf90973a32dc0d28f94eec91faf14ae8b4443", + "version" : "5.1.4" + } + }, { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", @@ -172,5 +182,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/phoenix-ios/phoenix-ios/AppDelegate.swift b/phoenix-ios/phoenix-ios/AppDelegate.swift index 328c50a05..9a130a143 100644 --- a/phoenix-ios/phoenix-ios/AppDelegate.swift +++ b/phoenix-ios/phoenix-ios/AppDelegate.swift @@ -62,9 +62,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { UINavigationBar.appearance().compactAppearance = navBarAppearance UINavigationBar.appearance().standardAppearance = navBarAppearance - #if !targetEnvironment(simulator) // push notifications don't work on iOS simulator - UIApplication.shared.registerForRemoteNotifications() - #endif + // Push notifictions now work on the iOS simulator. + // But only for: + // - Macs with Apple Silicon processor + // - Macs with Intel processor & the T2 security chip + // https://support.apple.com/en-us/103265 + // + UIApplication.shared.registerForRemoteNotifications() FirebaseApp.configure() Messaging.messaging().delegate = self @@ -218,15 +222,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { log.trace("application(_:didReceiveRemoteNotification:fetchCompletionHandler:)") log.debug("remote notification: \(userInfo)") - // If the app is in the foreground: - // - we can ignore this notification - // - // If the app is in the background: - // - this notification was delivered to the notifySrvExt, which is in charge of processing it - - DispatchQueue.main.async { - completionHandler(.noData) - } + PushManager.processRemoteNotification(userInfo, completionHandler) } func messaging( @@ -281,7 +277,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { business.databaseManager.paymentsDb { paymentsDb, _ in let fakePaymentId = WalletPaymentId.IncomingPaymentId(paymentHash: Bitcoin_kmpByteVector32.random()) - paymentsDb?.deletePayment(paymentId: fakePaymentId) { _ in + paymentsDb?.deletePayment(paymentId: fakePaymentId, notify: false) { _ in // Nothing is actually deleted } } diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json new file mode 100644 index 000000000..32dfe5551 --- /dev/null +++ b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "boltcard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg new file mode 100644 index 000000000..89d8d6851 --- /dev/null +++ b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phoenix-ios/phoenix-ios/Info.plist b/phoenix-ios/phoenix-ios/Info.plist index f4b53d368..67997a4e8 100644 --- a/phoenix-ios/phoenix-ios/Info.plist +++ b/phoenix-ios/phoenix-ios/Info.plist @@ -2,6 +2,10 @@ + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + D2760000850101 + BGTaskSchedulerPermittedIdentifiers co.acinq.phoenix.WatchTower @@ -85,12 +89,16 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED + FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS + NFCReaderUsageDescription + The app would like to scan an NFC card. NSCameraUsageDescription Phoenix would like to use the camera to scan payment requests. NSFaceIDUsageDescription @@ -136,7 +144,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED - diff --git a/phoenix-ios/phoenix-ios/InfoPlist.xcstrings b/phoenix-ios/phoenix-ios/InfoPlist.xcstrings index b2c77de4d..d2cc2dc28 100644 --- a/phoenix-ios/phoenix-ios/InfoPlist.xcstrings +++ b/phoenix-ios/phoenix-ios/InfoPlist.xcstrings @@ -37,6 +37,18 @@ } } }, + "NFCReaderUsageDescription" : { + "comment" : "Privacy - NFC Scan Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app would like to scan an NFC card." + } + } + } + }, "NSCameraUsageDescription" : { "comment" : "Privacy - Camera Usage Description", "extractionState" : "extracted_with_value", diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 2020b5872..23cb38f07 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -80,6 +80,21 @@ } } } + }, + " - Counter:" : { + + }, + " - Name:" : { + + }, + " - Status:" : { + + }, + " - UID:" : { + + }, + " - Verified:" : { + }, " (not available)" : { "localizations" : { @@ -282,6 +297,30 @@ } } } + }, + " • [Bolt Ring](https://bitcoin-ring.com/)" : { + + }, + " • [CoinCorner.com](https://www.coincorner.com/BuyTheBoltCard)" : { + + }, + " • [Hirsch](https://shop.hirschsecure.com/products/printed-nxp-ntag-424-dna-tag-5-pack)" : { + + }, + " • [Laser Eyes Cards](https://lasereyes.cards/)" : { + + }, + " • [NFC.cards](https://nfc.cards/en/white-cards/46-nfc-card-ntag424-dna.html)" : { + + }, + " • [PlebTag](https://plebtag.com/)" : { + + }, + " • [Yanabu Bolt Card - Korea](https://marpple.shop/kr/yanabu/products/13356281)" : { + + }, + " • [ZipNFC.com](https://zipnfc.com/nfc-pvc-card-credit-card-size-ntag424-dna.html)" : { + }, "-" : { "extractionState" : "manual", @@ -1598,6 +1637,9 @@ } } } + }, + "**Daily** spending limit:" : { + }, "**disabled**: sent payments will be anonymous" : { "localizations" : { @@ -1800,6 +1842,9 @@ } } } + }, + "**Monthly** spending limit:" : { + }, "**This Bolt12 payment request is the Lightning equivalent to a Bitcoin address.**\n\nUnlike traditional Lightning invoices, it does not expire and can be reused.\n\nShare it widely : for donations, tips, or to get paid by your friends.\n\n🛟 **Who supports Bolt12?**\n\nFor the moment, few services support Bolt12. If a service rejects this invoice, let them know they're missing out!\n\n🪫 **Restrictions**\n\nYour phone must be turned on and connected to the internet to receive a payment. Also enabling TOR in Phoenix may cause issues." : { "localizations" : { @@ -4952,6 +4997,9 @@ } } } + }, + "A sheet will appear to guide you through the process" : { + }, "A temporary error occurred, and Phoenix is unable to continue loading." : { "localizations" : { @@ -5274,6 +5322,9 @@ } } } + }, + "Active" : { + }, "active commitments" : { "localizations" : { @@ -5801,6 +5852,12 @@ } } } + }, + "Afterwards, the card will be Archived, and can never be activated again. The card will remain in your list, but will be moved to the Archived section." : { + + }, + "All payment attempts will be rejected." : { + }, "All payment channels will be closed." : { "localizations" : { @@ -6578,6 +6635,9 @@ } } } + }, + "Amount required for card payment" : { + }, "amount sent" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -6660,6 +6720,9 @@ } } } + }, + "An active card can be used for payments." : { + }, "An error occurred on a node in the payment route. The payment may succeed if you try again." : { "localizations" : { @@ -6700,6 +6763,12 @@ } } } + }, + "An error occurred while attempting to write to the NFC tag." : { + + }, + "An NFC session is already running." : { + }, "An on-chain operation will be likely required for you to receive this amount.\n\nThe fee is estimated to be around %@." : { "localizations" : { @@ -6820,6 +6889,9 @@ } } } + }, + "An unexpected error occurred while attempting to reset the card. Please try resetting it again. If the problem persists, you may need to destroy the card by cutting it up." : { + }, "An unknown error has occurred." : { "localizations" : { @@ -6900,6 +6972,9 @@ } } } + }, + "An unknown error occurred while attempting to clear the keys from the card. Please try resetting it again. If the problem persists, you may need to destroy the card by cutting it up." : { + }, "An unknown error occurred." : { "comment" : "error details", @@ -7023,6 +7098,9 @@ } } } + }, + "Any payments made with this card will remain in your transaction history, but will no longer be linked with any card." : { + }, "App access" : { "localizations" : { @@ -7225,6 +7303,21 @@ } } } + }, + "Archive" : { + + }, + "Archive card" : { + + }, + "Archive card…" : { + + }, + "Archived Cards" : { + + }, + "Are you sure you want to delete this card?" : { + }, "Are you sure you want to pay this invoice?" : { "extractionState" : "manual", @@ -7801,6 +7894,9 @@ } } } + }, + "Awaiting payment…" : { + }, "Back" : { "localizations" : { @@ -8366,6 +8462,9 @@ } } } + }, + "Be your own bank" : { + }, "Below the expected fee. Some payments may be rejected." : { "localizations" : { @@ -8973,6 +9072,9 @@ } } } + }, + "Bitcoin payments over the lightning network with a contactless payment card." : { + }, "Bitcoin supercharged" : { "localizations" : { @@ -9218,6 +9320,18 @@ } } } + }, + "Bolt Card" : { + + }, + "Bolt Card:" : { + + }, + "Bolt cards" : { + + }, + "Bolt Cards" : { + }, "Bolt12 offer:" : { "localizations" : { @@ -9779,6 +9893,15 @@ } } } + }, + "card" : { + "comment" : "button label - try to make it short" + }, + "Card" : { + + }, + "Card not associated with this wallet." : { + }, "caused by" : { @@ -11531,6 +11654,9 @@ } } } + }, + "Communicating with card's host…" : { + }, "completed at" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -12871,6 +12997,9 @@ } } } + }, + "Copy and paste into simulator:" : { + }, "Copy certificate" : { "localizations" : { @@ -13276,6 +13405,15 @@ } } }, + "Could not authenticate with card." : { + + }, + "Could not communicate with card's wallet" : { + "comment" : "Error message - scanning lightning invoice" + }, + "Could not connect to card's host" : { + "comment" : "Error message - processing bolt card payment" + }, "Could not connect to host:" : { "localizations" : { "ar" : { @@ -13402,6 +13540,9 @@ }, "Could not connect to service: %@" : { "comment" : "Error message - scanning lightning invoice" + }, + "Could not connect to the NFC tag." : { + }, "Could not retrieve payment details within a reasonable time. The recipient may be offline or unreachable." : { "localizations" : { @@ -13482,6 +13623,12 @@ } } } + }, + "Cound not communicate with card's wallet" : { + + }, + "Create New Debit Card" : { + }, "Create new wallet" : { "localizations" : { @@ -14407,6 +14554,12 @@ } } } + }, + "Delete card" : { + + }, + "Delete card…" : { + }, "Delete contact" : { "localizations" : { @@ -15420,6 +15573,9 @@ } } } + }, + "Details: %@" : { + }, "Disable PIN" : { "comment" : "Navigation bar title", @@ -16063,6 +16219,9 @@ } } } + }, + "Does not appear to be a bolt card." : { + }, "Don't lose your funds:" : { "localizations" : { @@ -17476,6 +17635,9 @@ } } } + }, + "Error fetching registration. Please check internet connection." : { + }, "Error fetching wallet backups from iCloud" : { "localizations" : { @@ -17516,6 +17678,9 @@ } } } + }, + "Error reading tag" : { + }, "Error: %@" : { "localizations" : { @@ -19784,6 +19949,12 @@ } } }, + "Frozen" : { + + }, + "Frozen (archived)" : { + "comment" : "translate: archived" + }, "full payment history" : { "localizations" : { "ar" : { @@ -20385,6 +20556,9 @@ } } } + }, + "Go to: Configuration > Bolt cards" : { + }, "Good news! Your transaction has been mined!" : { "localizations" : { @@ -20627,6 +20801,12 @@ } } }, + "Hold your card near the device to program it." : { + "comment" : "Message in iOS NFC dialog" + }, + "Hold your card near the device to reset it." : { + "comment" : "Message in iOS NFC dialog" + }, "Home screen" : { "comment" : "Navigation bar title", "localizations" : { @@ -20909,6 +21089,9 @@ } } } + }, + "How does Bolt card work ?" : { + }, "How to use" : { "localizations" : { @@ -21877,6 +22060,9 @@ } } } + }, + "If you still have access to the physical card, it's recommended that you **reset** the card first. This will allow it to be linked again with any wallet." : { + }, "If you switch to a new device (or reinstall the app) then you'll lose this information." : { @@ -23818,6 +24004,9 @@ } } } + }, + "It can be linked again with any wallet." : { + }, "just now" : { "comment" : "Timestamp for notification", @@ -23981,6 +24170,9 @@ } } } + }, + "Key slots unavailable" : { + }, "know when fees may occur" : { "localizations" : { @@ -24143,6 +24335,9 @@ } } } + }, + "learn more" : { + }, "Learn more" : { "localizations" : { @@ -25092,6 +25287,12 @@ } } } + }, + "Limit applies from 1st of the month at midnight to the following 1st (local time)." : { + + }, + "Limit applies from midnight to midnight (local time)." : { + }, "Link" : { "comment" : "lnurl-auth: login button title", @@ -25133,6 +25334,12 @@ } } } + }, + "Link a **physical card** to your Phoenix wallet." : { + + }, + "Link a card to a simulator wallet for testing." : { + }, "Linked" : { "comment" : "lnurl-auth: success text", @@ -25174,6 +25381,9 @@ } } } + }, + "Linked Cards" : { + }, "Liquidity Added" : { "extractionState" : "stale", @@ -25859,6 +26069,12 @@ } } } + }, + "Manage Card" : { + + }, + "Management Tasks" : { + }, "Manual" : { @@ -26639,6 +26855,9 @@ } } } + }, + "Message Authentication Code:" : { + }, "Metadata" : { "localizations" : { @@ -27297,6 +27516,9 @@ } } }, + "My Bolt Card" : { + "comment" : "Default name for a bolt card when creating a new one" + }, "Name" : { "localizations" : { "ar" : { @@ -27701,6 +27923,21 @@ } } } + }, + "NFC cababilities not available on this device" : { + + }, + "NFC capabilities not available on this device." : { + + }, + "NFC error: %@" : { + + }, + "NFC process terminated unexpectedly" : { + + }, + "NFC reader is already scanning" : { + }, "No" : { "extractionState" : "manual", @@ -28227,6 +28464,9 @@ } } } + }, + "No URI detected in NFC tag" : { + }, "Node id" : { "localizations" : { @@ -28308,6 +28548,9 @@ } } } + }, + "Note that simulators do not support background execution" : { + }, "Note: Server must have a certificate" : { "localizations" : { @@ -28470,6 +28713,9 @@ } } } + }, + "Nothing scanned" : { + }, "Notifications" : { "localizations" : { @@ -28631,6 +28877,9 @@ } } } + }, + "On a real device:" : { + }, "on-chain <-> lightning" : { "localizations" : { @@ -28959,6 +29208,9 @@ } } } + }, + "Once a card is archived it can never be activated again. The card will remain in your list, but will be moved to the Archived section." : { + }, "Only show in-flight outgoing payments." : { "comment" : "Recent payments option", @@ -29161,6 +29413,9 @@ } } } + }, + "Open Phoenix app (debug build)" : { + }, "opens browser" : { "localizations" : { @@ -29487,6 +29742,12 @@ } } } + }, + "Paste here" : { + + }, + "Paste JSON output from device here" : { + }, "Path" : { "extractionState" : "manual", @@ -31836,6 +32097,9 @@ } } } + }, + "Picc Data:" : { + }, "PIN" : { "localizations" : { @@ -32359,6 +32623,9 @@ } } } + }, + "Please try again. And be sure to hold the card close to the phone until the writing process completes." : { + }, "plus hidden amount incoming" : { "localizations" : { @@ -32519,6 +32786,12 @@ } } } + }, + "Preparing system for NFC..." : { + + }, + "Press and hold \"create new debit card\" button for 3 seconds" : { + }, "Privacy" : { "comment" : "Navigation bar title", @@ -32641,6 +32914,9 @@ } } } + }, + "Protocol error: %@" : { + }, "Public key" : { "localizations" : { @@ -33008,6 +33284,12 @@ } } } + }, + "Read card" : { + + }, + "Read card…" : { + }, "Ready For Swap" : { "localizations" : { @@ -33858,6 +34140,12 @@ } } } + }, + "Remaining" : { + + }, + "Remember:" : { + }, "Remove" : { "localizations" : { @@ -33898,6 +34186,9 @@ } } } + }, + "Requesting payment…" : { + }, "Requesting Swap-In Address..." : { "extractionState" : "manual", @@ -33939,6 +34230,15 @@ } } } + }, + "Reset" : { + + }, + "Reset card" : { + + }, + "Reset physical card…" : { + }, "Reset user preferences" : { "localizations" : { @@ -34716,6 +35016,9 @@ } } } + }, + "Scanned NDEF tag with unknown type" : { + }, "Search" : { "localizations" : { @@ -37848,6 +38151,15 @@ } } } + }, + "Simulator debugging" : { + + }, + "Simulator instructions" : { + + }, + "Simulator's HEX address:" : { + }, "Skip" : { "localizations" : { @@ -37968,6 +38280,9 @@ } } } + }, + "So to make a payment using the card, the simulator must be open, with Phoenix running in the foreground" : { + }, "Something is amiss with this invoice..." : { "extractionState" : "manual", @@ -38092,6 +38407,12 @@ } } } + }, + "Spending Limits" : { + + }, + "Spent" : { + }, "Splice already in progress" : { "localizations" : { @@ -38493,6 +38814,12 @@ } } } + }, + "Status: Active" : { + + }, + "Status: Frozen" : { + }, "Step #%lld" : { "localizations" : { @@ -39394,6 +39721,15 @@ } } }, + "The card is **NOT** ready to be used. Please try writing it again." : { + + }, + "The card's host returned error code: %d" : { + "comment" : "Error message - processing bolt card payment" + }, + "The card's host returned error message: %@" : { + "comment" : "Error message - processing bolt card payment" + }, "The closing transaction is in your transactions list." : { "comment" : "label text", "localizations" : { @@ -40190,6 +40526,9 @@ } } } + }, + "The merchant can use these values to make a one time request to your wallet. After that, the card must be tapped again to get fresh values." : { + }, "The migration will cost %@ (≈ %@)" : { "localizations" : { @@ -40236,6 +40575,9 @@ } } } + }, + "The NFC card is programmed with a BLIP XX address and a set of secure keys. The card then produces the address plus two unique hashes that change each time the card is scanned." : { + }, "The payment amount is invalid." : { "localizations" : { @@ -40877,6 +41219,12 @@ } } } + }, + "The simulator doesn't support NFC. But you can link a card to this wallet for testing." : { + + }, + "The simulator must be running on a Mac with either Apple Silicon or the T2 security chip (to receive push notifications)" : { + }, "The swap-in wallet is a bridge to Lightning. Funds on this wallet will automatically be moved to Lightning according to your liquidity policy setting." : { "localizations" : { @@ -41157,6 +41505,9 @@ } } } + }, + "Then make **contactless payments** at supporting merchants." : { + }, "These funds were not swapped in time. Tap to spend." : { "localizations" : { @@ -41401,6 +41752,7 @@ } }, "This appears to be a website (not a lightning invoice):" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -41439,6 +41791,15 @@ } } } + }, + "This appears to be a website:" : { + + }, + "This card has been improperly programmed or reset too many times, and it's now impossible to use the card." : { + + }, + "This card is already linked to another wallet. To re-use this card you must first unlink the card. In Phoenix there is an option called \"reset physical card\" which will unlink it." : { + }, "This doesn't appear to be a Lightning invoice" : { "comment" : "Error message - scanning lightning invoice", @@ -41480,6 +41841,9 @@ } } } + }, + "This doesn't appear to be the linked card. Perhaps this card is associated with a different wallet, or a different card in this wallet." : { + }, "This feature is not a \"fix everything magic button\". It is here as a safety measure and **should only be used in extreme scenarios**. For example, if your peer (ACINQ) disappears permanently, preventing you from spending your money. In all other cases, **if you experience issues with Phoenix you should contact support**." : { "comment" : "ForceCloseChannelsView", @@ -42285,6 +42649,12 @@ } } } + }, + "This will clear the card, allowing it to be linked again with any wallet." : { + + }, + "This will create a new card that is linked to a wallet running on a simulator" : { + }, "This will reset the app, as if you had just installed it." : { "localizations" : { @@ -43752,6 +44122,9 @@ } } } + }, + "True" : { + }, "Trust certificate & pin public key" : { "localizations" : { @@ -44566,6 +44939,9 @@ } } }, + "Unreadable response from card's host" : { + "comment" : "Error message - processing bolt card payment" + }, "Unreadable response from service: (origin)" : { "comment" : "Error message - scanning lightning invoice", "extractionState" : "stale", @@ -45134,6 +45510,12 @@ } } } + }, + "Use this option if your card is permanantly lost or stolen." : { + + }, + "Use this screen to manage your card anytime you need." : { + }, "Use this screen to spend funds from your final wallet. These funds come from channels that have been closed in the past. This does not affect your existing Lightning channels." : { @@ -46626,6 +47008,9 @@ } } } + }, + "What you need are blank NFC \"NTAG 424 DNA\" cards. You can buy them from many different vendors." : { + }, "What's new:" : { "localizations" : { @@ -46706,6 +47091,9 @@ } } } + }, + "Where can I buy bolt cards ?" : { + }, "Which PIN?" : { "localizations" : { @@ -47070,6 +47458,9 @@ } } } + }, + "Write error" : { + }, "Yes" : { "extractionState" : "manual", @@ -47476,6 +47867,9 @@ } } } + }, + "You can link multiple debit cards to your wallet. Set custom spending limits per card, and freeze a card at anytime." : { + }, "You can make all your unconfirmed transactions use a higher effective feerate to encourage miners to favour your payments." : { "localizations" : { @@ -48609,8 +49003,18 @@ } } } + }, + "Your bank (this device) needs to be online to process payments with your card." : { + + }, + "Your card is now ready to use." : { + + }, + "Your card is now reset." : { + }, "Your channel are not connected yet. Wait for a stable connection and try again." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -48689,6 +49093,9 @@ } } } + }, + "Your channels are not connected yet. Wait for a stable connection and try again." : { + }, "Your comment will be sent when you pay." : { "localizations" : { @@ -49140,6 +49547,9 @@ } } } + }, + "Your wallet verifies the card is not frozen, and checks the payment amount against any daily/monthly spending limits you may have configured." : { + } }, "version" : "1.0" diff --git a/phoenix-ios/phoenix-ios/Phoenix.entitlements b/phoenix-ios/phoenix-ios/Phoenix.entitlements index d024ead32..f377cf3cf 100644 --- a/phoenix-ios/phoenix-ios/Phoenix.entitlements +++ b/phoenix-ios/phoenix-ios/Phoenix.entitlements @@ -12,6 +12,10 @@ CloudKit + com.apple.developer.nfc.readersession.formats + + TAG + com.apple.security.application-groups group.co.acinq.phoenix diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift index 8ca094103..eeedc4c5c 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift @@ -13,7 +13,7 @@ extension Lightning_kmpFinalFailure { } if let _ = self.asChannelNotConnected() { return String(localized: - "Your channel are not connected yet. Wait for a stable connection and try again.") + "Your channels are not connected yet. Wait for a stable connection and try again.") } if let _ = self.asChannelOpening() { return String(localized: diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift new file mode 100644 index 000000000..bf83bfaa6 --- /dev/null +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift @@ -0,0 +1,99 @@ +import Foundation +import PhoenixShared + +extension BoltCardInfo { + + var sanitizedName: String { + let result = name.trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? BoltCardInfo.defaultName : result + } + + var isActive: Bool { + return !isFrozen + } + + var createdAtDate: Date { + return createdAt.toDate() + } + + func withUpdatedLastKnownCounter(_ counter: UInt32) -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : counter, + isFrozen : self.isFrozen, + isArchived : self.isArchived, + isReset : self.isReset, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + func archivedCopy() -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : self.lastKnownCounter, + isFrozen : true, // this should also be set (just to be careful) + isArchived : true, + isReset : self.isReset, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + func resetCopy() -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : self.lastKnownCounter, + isFrozen : true, // this should also be set (just to be careful) + isArchived : true, // this must be set + isReset : true, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + static var defaultName: String { + return String( + localized: "My Bolt Card", + comment: "Default name for a bolt card when creating a new one" + ) + } +} + +extension BoltCardKeySet { + + var key0_bytes: [UInt8] { + return Helper.bytesFromData(data: key0.toSwiftData()) + } + + var piccDataKey_data: Data { + return piccDataKey.toSwiftData() + } + + var piccDataKey_bytes: [UInt8] { + return Helper.bytesFromData(data: piccDataKey_data) + } + + var cmacKey_data: Data { + return cmacKey.toSwiftData() + } + + var cmacKey_bytes: [UInt8] { + return Helper.bytesFromData(data: cmacKey_data) + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift index 2fab9ee0a..06cbc6f27 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift @@ -70,6 +70,11 @@ extension Data { return result */ } + + func toKotlinByteVector() -> Bitcoin_kmpByteVector { + + return Bitcoin_kmpByteVector(bytes: self.toKotlinByteArray()) + } } extension Array { @@ -83,3 +88,19 @@ extension Array { } } } + +extension Kotlinx_datetimeInstant { + + func toDate() -> Date { + let milliseconds: Int64 = self.toEpochMilliseconds() + return Date(timeIntervalSince1970: TimeInterval(milliseconds) / TimeInterval(1_000)) + } +} + +extension Date { + + func toKotlinInstant() -> Kotlinx_datetimeInstant { + let milliseconds = Int64(self.timeIntervalSince1970 * 1_000) + return Kotlinx_datetimeInstant.companion.fromEpochMilliseconds(epochMilliseconds: milliseconds) + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift index 215662fe8..60894d554 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift @@ -13,6 +13,20 @@ extension BalanceManager { } } +extension CardsManager { + + var cardsListValue: [BoltCardInfo] { + return self.cardsList.value as? [BoltCardInfo] ?? [] + } +} + +extension CurrencyManager { + + var ratesFlowValue: [ExchangeRate] { + return self.ratesFlow.value as? [ExchangeRate] ?? [] + } +} + extension ConnectionsManager { var currentValue: Connections { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 1c29f6d7f..3e6f1f050 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -208,6 +208,18 @@ extension WalletPaymentMetadata { originalFiat: nil, userDescription: nil, userNotes: nil, + cardId: nil, + modifiedAt: nil + ) + } + + static func withCard(_ cardID: Lightning_kmpUUID) -> WalletPaymentMetadata { + return WalletPaymentMetadata( + lnurl: nil, + originalFiat: nil, + userDescription: nil, + userNotes: nil, + cardId: cardID, modifiedAt: nil ) } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift index 9c8fcb124..7ed641821 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift @@ -69,6 +69,9 @@ extension ContactInfo: @retroactive Identifiable { } } +extension BoltCardInfo: @retroactive Identifiable { +} + extension Lightning_kmpWalletState.Utxo: @retroactive Identifiable { public var id: String { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 5789dee92..fda8ac029 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -168,6 +168,30 @@ extension ConnectionsManager { } } +// MARK: - +extension CardsManager { + + fileprivate struct _Key { + static var cardsListPublisher = 0 + } + + func cardsListPublisher() -> AnyPublisher<[BoltCardInfo], Never> { + + self.getSetAssociatedObject(storageKey: &_Key.cardsListPublisher) { + + // Transforming from Kotlin: + // `cardsList: StateFlow>` + // + KotlinCurrentValueSubject( + self.cardsList + ) + .compactMap { $0 as? [BoltCardInfo] } + .eraseToAnyPublisher() + } + } +} + +// MARK: - extension ContactsManager { fileprivate struct _Key { diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DNACommunicator+FileCommands.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DNACommunicator+FileCommands.swift new file mode 100644 index 000000000..7ea2d89ee --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DNACommunicator+FileCommands.swift @@ -0,0 +1,126 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +extension DnaCommunicator { + + func readFileData( + fileNum : FileSpecifier, + offset : Int = 0, + length : Int, + mode : CommuncationMode + ) async -> Result<[UInt8], Error> { + + // Pg. 73 + let offsetBytes = Helper.byteArrayLE(from: Int32(offset))[0...2] + let lengthBytes = Helper.byteArrayLE(from: Int32(length))[0...2] // <- Bug fix + + let result = await nxpSwitchedCommand( + mode : mode, + command : 0xad, + header : [fileNum.rawValue] + offsetBytes + lengthBytes, + data : [] + ) + + switch result { + case .failure(let error): + return .failure(error) + + case .success(let result): + if let error = makeErrorIfNotExpectedStatus(result) { + return .failure(error) + } else { + return .success(result.data) + } + } + } + + func writeFileData( + fileNum : FileSpecifier, + offset : Int = 0, + data : [UInt8], + mode : CommuncationMode + ) async -> Result { + + // Pg. 75 + let dataSizeBytes = Helper.byteArrayLE(from: Int32(data.count))[0...2] + let offsetBytes = Helper.byteArrayLE(from: Int32(offset))[0...2] + + let result = await nxpSwitchedCommand( + mode : mode, + command : 0x8d, + header : [fileNum.rawValue] + offsetBytes + dataSizeBytes, + data : data + ) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } else { + return .success(()) + } + } + } + + func getFileSettings( + fileNum: FileSpecifier + ) async -> Result { + + // Pg. 69 + let result = await nxpMacCommand( + command : 0xf5, + header : [fileNum.rawValue], + data : [] + ) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } + + if let settings = FileSettings(data: result.data) { + return .success(settings) + } else { + let err = Helper.makeError(110, "Invalid FileSettings response") + return .failure(err) + } + } + } + + func changeFileSettings( + fileNum : FileSpecifier, + data : [UInt8] + ) async -> Result { + + // Pg. 65 + let result = await nxpEncryptedCommand( + command : 0x5f, + header : [fileNum.rawValue], + data : data + ) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } else { + return .success(()) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Authentication.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Authentication.swift new file mode 100644 index 000000000..10c7b3dd9 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Authentication.swift @@ -0,0 +1,122 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +extension DnaCommunicator { + + public static var defaultKey: [UInt8] { + return Array(repeating: 0, count: 16) + } + + public func authenticateEV2First( + keyNum : KeySpecifier, + keyData : [UInt8] + ) async -> Result { + + guard keyData.count == 16 else { + return .failure(Helper.makeError(400, "Parameter keyData must be 16 bytes long.")) + } + + // STAGE 1 Authentication (pg. 46) + let outerResult = await nxpNativeCommand( + command : 0x71, + header : [keyNum.rawValue, 0x00], + data : [] + ) + + switch outerResult { + case .failure(let err): + self.debugPrint("Err: \(err)") + return .failure(err) + + case .success(let result): + + if (result.statusMajor != 0x91) { + self.debugPrint("Authentication: Stage 1: Bad status major: \(result.statusMajor)") + return .failure(Helper.makeError(103, "Bad status major: \(result.statusMajor)")) + } + + if (result.statusMinor == 0xad) { + self.debugPrint("Authentication: Stage 1: Requested retry") + // Unsure - retry? pg. 52 + return .failure(Helper.makeError(104, "Don't know how to handle retries")) + } + + if (result.statusMinor != 0xaf) { + self.debugPrint("Authentication: Stage 1: Bad status minor: \(result.statusMinor)") + return .failure(Helper.makeError(105, "Bad status minor: \(result.statusMinor)")) + } + + if (result.data.count != 16) { + self.debugPrint("Authentication: Stage 1: Incorrect data count") + return .failure(Helper.makeError(106, "Incorrect data size")) + } + + let encryptedChallengeB = result.data + let challengeB = Helper.simpleAesDecrypt(key: keyData, data: encryptedChallengeB) + let challengeBPrime = Helper.rotateLeft(Array(challengeB[0...])) + let challengeA = Helper.randomBytes(ofLength: 16) + self.debugPrint("Challenge A: \(challengeA)") + let combinedChallenge = Helper.simpleAesEncrypt(key: keyData, data: (challengeA + challengeBPrime)) + + // STAGE 2 (pg. 47) + let innerResult = await self.nxpNativeCommand( + command : 0xaf, + header : combinedChallenge, + data : nil + ) + + // {innerResult, err in + + switch innerResult { + case .failure(let error): + return .failure(error) + + case .success(let result): + + if (result.statusMajor != 0x91) { + self.debugPrint("Authentication: Stage 2: Bad status major: \(result.statusMajor)") + return .failure(Helper.makeError(107, "Bad status major: \(result.statusMajor)")) + } + + if (result.statusMinor != 0x00) { + self.debugPrint("Authentication: Stage 2: Bad status minor: \(result.statusMinor)") + return .failure(Helper.makeError(108, "Bad status minor: \(result.statusMinor)")) + } + + let resultData = Helper.simpleAesDecrypt(key: keyData, data: result.data) + let ti = Array(resultData[0...3]) + let challengeAPrime = Array(resultData[4...19]) + let pdCap = resultData[20...25] + let pcCap = resultData[26...31] + let newChallengeA = Helper.rotateRight(challengeAPrime) + + if !newChallengeA.elementsEqual(challengeA) { + self.debugPrint("Challenge A response not valid") + return .failure(Helper.makeError(109, "Invalid Challenge A response")) + } + + self.debugPrint("Data: TI: \(ti), challengeA: \(newChallengeA), pdCap: \(pdCap), pcCap: \(pcCap)") + + // Activate Session + self.activeKeyNumber = keyNum + self.commandCounter = 0 + self.activeTransactionIdentifier = ti + + self.debugPrint("Starting AES encryption") + self.sessionEncryptionMode = AESEncryptionMode( + communicator : self, + key : keyData, + challengeA : challengeA, + challengeB : challengeB + ) + + return .success(()) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+ChipInfo.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+ChipInfo.swift new file mode 100644 index 000000000..73afea576 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+ChipInfo.swift @@ -0,0 +1,26 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +extension DnaCommunicator { + + public func getChipUid() async -> Result<[UInt8], Error> { + + let result = await nxpEncryptedCommand(command: 0x51, header: [], data: []) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = self.makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } else { + let uid = Array(result.data[0...6]) + return .success(uid) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Iso.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Iso.swift new file mode 100644 index 000000000..f51998fde --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+Iso.swift @@ -0,0 +1,37 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +extension DnaCommunicator { + + public func isoSelectFileByFileId( + mode : UInt8, + fileId : Int + ) async -> Result { + + let packet: [UInt8] = [ + 0x00, // class + 0xa4, // ISOSelectFile + 0x00, // select by file identifier (1, 2, 3, and 4 have various meanings as well) + 0x0c, // Don't return FCI + 0x02, // Length of file identifier + UInt8(fileId / 256), // File identifier + UInt8(fileId % 256), + 0x00 // Length of expected response + ] + + let result = await isoTransceive(packet: packet) + + switch result { + case .success(_): + return .success(()) + + case .failure(let err): + return .failure(err) + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+KeyCommands.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+KeyCommands.swift new file mode 100644 index 000000000..821eef17c --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator+KeyCommands.swift @@ -0,0 +1,81 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +extension DnaCommunicator { + + public func getKeyVersion( + keyNum: KeySpecifier + ) async -> Result { + + let result = await nxpMacCommand( + command : 0x64, + header : [keyNum.rawValue], + data : nil + ) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } else { + let resultValue = result.data.count < 1 ? 0 : result.data[0] + return .success(resultValue) + } + } + } + + public func changeKey( + keyNum : KeySpecifier, + oldKey : [UInt8], + newKey : [UInt8], + keyVersion : UInt8 + ) async -> Result { + + if activeKeyNumber != .KEY_0 { + debugPrint( + """ + Not sure if changing keys when not authenticated as key0 is allowed -\ + documentation is unclear + """ + ) + } + + var data: [UInt8] = [] + if (keyNum == .KEY_0) { + // If we are changing key0, can just send the request + data = newKey + [keyVersion] + + } else { + // Weird validation methodology + let crc = Helper.crc32(newKey) + let xorkey = Helper.xor(oldKey, newKey) + data = xorkey + [keyVersion] + crc + } + + let result = await nxpEncryptedCommand( + command : 0xc4, + header : [keyNum.rawValue], + data : data + ) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + if let err = makeErrorIfNotExpectedStatus(result) { + return .failure(err) + } else { + return .success(()) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator.swift new file mode 100644 index 000000000..96ca30d71 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Communicator/DnaCommunicator.swift @@ -0,0 +1,239 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation +import CoreNFC + +public struct NxpCommandResult { + var data: [UInt8] + var statusMajor: UInt8 + var statusMinor: UInt8 + + static func emptyResult() -> NxpCommandResult { + return NxpCommandResult(data: [], statusMajor: 0, statusMinor: 0) + } +} + +public class DnaCommunicator { + + public let tag: NFCISO7816Tag + public var activeKeyNumber: KeySpecifier = .KEY_0 + + public var debug: Bool = false + + var activeTransactionIdentifier: [UInt8] = [0,0,0,0] + var commandCounter: Int = 0 + var sessionEncryptionMode: EncryptionMode? + + public init(tag: NFCISO7816Tag) { + self.tag = tag + } + + func debugPrint(_ value: String) { + if debug { + print(value) + } + } + + func makeErrorIfNotExpectedStatus(_ result: NxpCommandResult) -> Error? { + if result.statusMajor != 0x91 || (result.statusMinor != 0x00 && result.statusMinor != 0xaf) { + let major = String(format:"%02X", result.statusMajor) + let minor = String(format:"%02X", result.statusMinor) + return Helper.makeError(102, "Unexpected status: \(major) / \(minor)") + } else { + return nil + } + } + + func isoTransceive( + packet: [UInt8] + ) async -> Result { + + let data = Helper.dataFromBytes(bytes: packet) + let apdu = NFCISO7816APDU(data: data) + if debug { + Helper.logBytes("Outbound", packet) + } + + guard let apdu else { + debugPrint("APDU Failure: Attempt") + return .failure(Helper.makeError(100, "APDU Failure")) + } + + do { + let (data, sw1, sw2) = try await tag.sendCommand(apdu: apdu) + + let bytes = Helper.bytesFromData(data: data) + if debug { + Helper.logBytes("Inbound", bytes + [sw1] + [sw2]) + } + + let result = NxpCommandResult(data: bytes, statusMajor: sw1, statusMinor: sw2) + return .success(result) + + } catch { + self.debugPrint("An error occurred: \(error)") + return .failure(error) + } + } + + func nxpNativeCommand( + command : UInt8, + header : [UInt8], + data : [UInt8]?, + macData : [UInt8]? = nil + ) async -> Result { + + let data = data ?? [UInt8]() + var packet: [UInt8] = [ + 0x90, + command, + 0x00, + 0x00, + UInt8(header.count + data.count + (macData?.count ?? 0)) + ] + packet.append(contentsOf: header) + packet.append(contentsOf: data) + if let macData = macData { + packet.append(contentsOf: macData) + } + packet.append(0x00) + + return await isoTransceive(packet: packet) + } + + public func nxpPlainCommand( + command : UInt8, + header : [UInt8], + data : [UInt8]? + ) async -> Result { + + let result = await nxpNativeCommand(command: command, header: header, data: data) + self.commandCounter += 1 + + return result + } + + public func nxpMacCommand( + command : UInt8, + header : [UInt8], + data : [UInt8]? + ) async -> Result { + + let data = data ?? [UInt8]() + var macInputData: [UInt8] = [ + command, + UInt8(commandCounter % 256), + UInt8(commandCounter / 256), + activeTransactionIdentifier[0], + activeTransactionIdentifier[1], + activeTransactionIdentifier[2], + activeTransactionIdentifier[3], + ] + macInputData.append(contentsOf: header) + macInputData.append(contentsOf: data) + let macData = sessionEncryptionMode!.generateMac(message: macInputData) + + let result = await nxpNativeCommand(command: command, header: header, data: data, macData: macData) + self.commandCounter += 1 + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + + guard result.data.count >= 8 else { + // No MAC available for this command + let noDataResult = NxpCommandResult( + data: [UInt8](), + statusMajor: result.statusMajor, + statusMinor: result.statusMinor + ) + + return .success(noDataResult) + } + + let dataBytes = (result.data.count > 8) ? result.data[0...(result.data.count - 9)] : [] + let macBytes = result.data[(result.data.count - 8)...(result.data.count - 1)] + + // Check return MAC + var returnMacInputData: [UInt8] = [ + result.statusMinor, + UInt8(commandCounter % 256), + UInt8(commandCounter / 256), + activeTransactionIdentifier[0], + activeTransactionIdentifier[1], + activeTransactionIdentifier[2], + activeTransactionIdentifier[3], + ] + returnMacInputData.append(contentsOf: dataBytes) + let returnMacData = self.sessionEncryptionMode!.generateMac(message: returnMacInputData) + + if returnMacData.elementsEqual(macBytes) { + let finalResult = NxpCommandResult( + data: [UInt8](dataBytes), + statusMajor: result.statusMajor, + statusMinor: result.statusMinor + ) + return .success(finalResult) + } else { + self.debugPrint("Invalid MAC! (\(returnMacData)) / (\(macBytes)") + return .failure(Helper.makeError(101, "Invalid MAC")) + } + } + } + + public func nxpEncryptedCommand( + command : UInt8, + header : [UInt8], + data : [UInt8]? + ) async -> Result { + + let data = data ?? [UInt8]() + if debug { + Helper.logBytes("Unencryped outgoing data", data) + } + let encryptedData = data.count == 0 ? [UInt8]() : sessionEncryptionMode!.encryptData(message: data) + + let result = await nxpMacCommand(command: command, header: header, data: encryptedData) + + switch result { + case .failure(let err): + return .failure(err) + + case .success(let result): + let decryptedResultData = result.data.count == 0 ? [UInt8]() : self.sessionEncryptionMode!.decryptData(message: result.data) + if debug { + Helper.logBytes("Unencrypted incoming data", decryptedResultData) + } + + let finalResult = NxpCommandResult( + data: decryptedResultData, + statusMajor: result.statusMajor, + statusMinor: result.statusMinor + ) + return .success(finalResult) + } + } + + public func nxpSwitchedCommand( + mode : CommuncationMode, + command : UInt8, + header : [UInt8], + data : [UInt8] + ) async -> Result { + + switch mode { + case .PLAIN: + return await nxpPlainCommand(command: command, header: header, data: data) + case .MAC: + return await nxpMacCommand(command: command, header: header, data: data) + case .FULL: + return await nxpEncryptedCommand(command: command, header: header, data: data) + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CapabilitiesContainer.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CapabilitiesContainer.swift new file mode 100644 index 000000000..beb19abf1 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CapabilitiesContainer.swift @@ -0,0 +1,142 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +struct CapabilitiesContainer { + + static let byteCount: Int = 7 + (CtrlTLV.byteCount * 2) + + var len: UInt16 + var t4tVNo: UInt8 + var mLe: UInt16 + var mLc: UInt16 + + var file2: CtrlTLV + var file3: CtrlTLV + + init(len: UInt16, t4tVno: UInt8, mLe: UInt16, mLc: UInt16, file2: CtrlTLV, file3: CtrlTLV) { + self.len = len + self.t4tVNo = t4tVno + self.mLe = mLe + self.mLc = mLc + self.file2 = file2 + self.file3 = file3 + } + + init?(data: [UInt8]) { + + guard data.count >= CapabilitiesContainer.byteCount else { return nil } + + len = data.readBigEndian(offset: 0, as: UInt16.self) + t4tVNo = data[2] + mLe = data.readBigEndian(offset: 3, as: UInt16.self) + mLc = data.readBigEndian(offset: 5, as: UInt16.self) + + guard let f2 = CtrlTLV(data: Array(data[7..<15])) else { return nil } + guard let f3 = CtrlTLV(data: Array(data[15..<23])) else { return nil } + + file2 = f2 + file3 = f3 + } + + func encode() -> [UInt8] { + + var buffer: [UInt8] = Array() + buffer.reserveCapacity(CapabilitiesContainer.byteCount) + + buffer.append(contentsOf: Helper.byteArrayBE(from: len)) + buffer.append(t4tVNo) + buffer.append(contentsOf: Helper.byteArrayBE(from: mLe)) + buffer.append(contentsOf: Helper.byteArrayBE(from: mLc)) + + buffer.append(contentsOf: file2.encode()) + buffer.append(contentsOf: file3.encode()) + + return buffer + } + + static func defaultValue() -> CapabilitiesContainer { + return CapabilitiesContainer( + len: 23, + t4tVno: 0x20, + mLe: 256, + mLc: 255, + file2: CtrlTLV.defaultFile2(), + file3: CtrlTLV.defaultFile3() + ) + } +} + +struct CtrlTLV { + + static let byteCount = 8 + + var t: UInt8 + let l: UInt8 + let fileId: [UInt8] + let fileSize: UInt16 + var readAccess: UInt8 + var writeAccess: UInt8 + + init(t: UInt8, l: UInt8, fileId: [UInt8], fileSize: UInt16, readAccess: UInt8, writeAccess: UInt8) { + self.t = t + self.l = l + self.fileId = fileId + self.fileSize = fileSize + self.readAccess = readAccess + self.writeAccess = writeAccess + } + + init?(data: [UInt8]) { + + guard data.count >= CtrlTLV.byteCount else { return nil } + + t = data[0] + l = data[1] + fileId = Array(data[2...3]) + fileSize = data.readBigEndian(offset: 4, as: UInt16.self) + readAccess = data[6] + writeAccess = data[7] + } + + func encode() -> [UInt8] { + + var buffer: [UInt8] = Array() + buffer.reserveCapacity(CtrlTLV.byteCount) + + buffer.append(t) + buffer.append(l) + buffer.append(contentsOf: fileId) + buffer.append(contentsOf: Helper.byteArrayBE(from: fileSize)) + buffer.append(readAccess) + buffer.append(writeAccess) + + return buffer + } + + static func defaultFile2() -> CtrlTLV { + return CtrlTLV( + t: 4, + l: 6, + fileId: [0xe1, 0x04], + fileSize: 256, + readAccess: 0x00, + writeAccess: 0x00 + ) + } + + static func defaultFile3() -> CtrlTLV { + return CtrlTLV( + t: 5, + l: 6, + fileId: [0xe1, 0x05], + fileSize: 128, + readAccess: 0x82, + writeAccess: 0x83 + ) + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CommunicationMode.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CommunicationMode.swift new file mode 100644 index 000000000..3f959c7d4 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/CommunicationMode.swift @@ -0,0 +1,13 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +public enum CommuncationMode: UInt8 { + case PLAIN = 0 + case MAC = 1 + case FULL = 3 +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSettings.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSettings.swift new file mode 100644 index 000000000..f72273b90 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSettings.swift @@ -0,0 +1,311 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +public enum FileSettingsEncodingError: Error { + case sdmUidOffsetRequired + case sdmReadCounterOffsetRequired + case sdmPiccDataOffsetRequired + case sdmMacInputOffsetRequired + case sdmEncOffsetRequired + case sdmEncLengthRequired + case sdmMacOffsetRequired + case sdmReadCounterLimitRequired +} + +public struct FileSettings { + + static let minByteCount: Int = 7 + + var fileType: UInt8 = 0 + var sdmEnabled: Bool = false + var communicationMode: CommuncationMode = .PLAIN + var readPermission: Permission = .NONE + var writePermission: Permission = .NONE + var readWritePermission: Permission = .NONE + var changePermission: Permission = .NONE + var fileSize: Int = 0 + var sdmOptionUid: Bool = false + var sdmOptionReadCounter: Bool = false + var sdmOptionReadCounterLimit: Bool = false + var sdmOptionEncryptFileData: Bool = false + var sdmOptionUseAscii: Bool = false + var sdmMetaReadPermission: Permission = .NONE + var sdmFileReadPermission: Permission = .NONE + var sdmReadCounterRetrievalPermission: Permission = .NONE + var sdmUidOffset: Int? + var sdmReadCounterOffset: Int? + var sdmPiccDataOffset: Int? + var sdmMacInputOffset: Int? + var sdmMacOffset: Int? + var sdmEncOffset: Int? + var sdmEncLength: Int? + var sdmReadCounterLimit: Int? + + init() {} + + init?(data: [UInt8]) { + // Pg. 13 + + guard data.count >= FileSettings.minByteCount else { return nil } + + self.fileType = data[0] + let options = data[1] + self.sdmEnabled = Helper.getBitLSB(options, 6) + + if Helper.getBitLSB(options, 0) { + if Helper.getBitLSB(options, 1) { + self.communicationMode = .FULL + } else { + self.communicationMode = .MAC + } + } + + readPermission = Permission(from: Helper.leftNibble(data[3])) + writePermission = Permission(from: Helper.rightNibble(data[3])) + readWritePermission = Permission(from: Helper.leftNibble(data[2])) + changePermission = Permission(from: Helper.rightNibble(data[2])) + + fileSize = Helper.bytesToIntLE(Array(data[4...6])) + + var currentOffset = 7 + + if sdmEnabled { + + guard data.count >= (currentOffset + 3) else { return nil } + + let sdmOptions = data[currentOffset] + currentOffset += 1 + + sdmOptionUid = Helper.getBitLSB(sdmOptions, 7) + sdmOptionReadCounter = Helper.getBitLSB(sdmOptions, 6) + sdmOptionReadCounterLimit = Helper.getBitLSB(sdmOptions, 5) + sdmOptionEncryptFileData = Helper.getBitLSB(sdmOptions, 4) + sdmOptionUseAscii = Helper.getBitLSB(sdmOptions, 0) + + let sdmAccessRights1 = data[currentOffset] + currentOffset += 1 + let sdmAccessRights2 = data[currentOffset] + currentOffset += 1 + sdmMetaReadPermission = Permission(from: Helper.leftNibble(sdmAccessRights2)) + sdmFileReadPermission = Permission(from: Helper.rightNibble(sdmAccessRights2)) + sdmReadCounterRetrievalPermission = Permission(from: Helper.rightNibble(sdmAccessRights1)) + + if sdmMetaReadPermission == .ALL { + if sdmOptionUid { + guard data.count >= (currentOffset + 3) else { return nil } + sdmUidOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset + 2)])) + currentOffset += 3 + } + if sdmOptionReadCounter { + guard data.count >= (currentOffset + 3) else { return nil } + sdmReadCounterOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset + 2)])) + currentOffset += 3 + } + } else if sdmMetaReadPermission != .NONE { + guard data.count >= (currentOffset + 3) else { return nil } + sdmPiccDataOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset + 2)])) + currentOffset += 3 + } + + if sdmFileReadPermission != .NONE { + guard data.count >= (currentOffset + 3) else { return nil } + sdmMacInputOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset + 2)])) + currentOffset += 3 + + if sdmOptionEncryptFileData { + guard data.count >= (currentOffset + 6) else { return nil } + sdmEncOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset+2)])) + currentOffset += 3 + sdmEncLength = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset+2)])) + currentOffset += 3 + } + + guard data.count >= (currentOffset + 3) else { return nil } + sdmMacOffset = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset+2)])) + currentOffset += 3 + } + + if sdmOptionReadCounterLimit { + guard data.count >= (currentOffset + 3) else { return nil } + sdmReadCounterLimit = Helper.bytesToIntLE(Array(data[currentOffset...(currentOffset+2)])) + currentOffset += 3 + } + } + } + + enum EncodingMode { + case GetFileSettings + case ChangeFileSettings + } + + func encode(mode: EncodingMode = .ChangeFileSettings) -> Result<[UInt8], FileSettingsEncodingError> { + + var buffer: [UInt8] = Array() + + if mode == .GetFileSettings { + buffer.append(fileType) + } + + do { // File Options + + let maskA: UInt8 = sdmEnabled ? 0b01000000 : 0b00000000 + + let maskB: UInt8 + switch communicationMode { + case .PLAIN : maskB = 0b00000000 + case .MAC : maskB = 0b00000001 + case .FULL : maskB = 0b00000011 + } + + let fileOptions: UInt8 = maskA | maskB + buffer.append(fileOptions) + } + do { // Access Rights + + let byteA: UInt8 = readWritePermission.rawValue << 4 | changePermission.rawValue + let byteB: UInt8 = readPermission.rawValue << 4 | writePermission.rawValue + + buffer.append(byteA) + buffer.append(byteB) + } + if mode == .GetFileSettings { // File Size + + let bytes = Helper.byteArrayLE(from: fileSize)[0...2] + buffer.append(contentsOf: bytes) + + } + if sdmEnabled { + + do { // SDM Options + + let maskA: UInt8 = sdmOptionUid ? 0b10000000 : 0b00000000 // bit 7 + let maskB: UInt8 = sdmOptionReadCounter ? 0b01000000 : 0b00000000 // bit 6 + let maskC: UInt8 = sdmOptionReadCounterLimit ? 0b00100000 : 0b00000000 // bit 5 + let maskD: UInt8 = sdmOptionEncryptFileData ? 0b00010000 : 0b00000000 // bit 4 + let maskE: UInt8 = sdmOptionUseAscii ? 0b00000001 : 0b00000000 // bit 0 + + let options: UInt8 = maskA | maskB | maskC | maskD | maskE + buffer.append(options) + } + do { // SDM Access Rights + + let byteA: UInt8 = 0xF << 4 | sdmReadCounterRetrievalPermission.rawValue + let byteB: UInt8 = sdmMetaReadPermission.rawValue << 4 | sdmFileReadPermission.rawValue + + buffer.append(byteA) + buffer.append(byteB) + } + + if sdmMetaReadPermission == .ALL { + if sdmOptionUid { + if let sdmUidOffset { + let bytes = Helper.byteArrayLE(from: sdmUidOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmUidOffsetRequired) + } + } + if sdmOptionReadCounter { + if let sdmReadCounterOffset { + let bytes = Helper.byteArrayLE(from: sdmReadCounterOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmReadCounterOffsetRequired) + } + } + } else if sdmMetaReadPermission != .NONE { + if let sdmPiccDataOffset { + let bytes = Helper.byteArrayLE(from: sdmPiccDataOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmPiccDataOffsetRequired) + } + } + + if sdmFileReadPermission != .NONE { + if let sdmMacInputOffset { + let bytes = Helper.byteArrayLE(from: sdmMacInputOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmMacInputOffsetRequired) + } + + if sdmOptionEncryptFileData { + if let sdmEncOffset { + let bytes = Helper.byteArrayLE(from: sdmEncOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmEncOffsetRequired) + } + + if let sdmEncLength { + let bytes = Helper.byteArrayLE(from: sdmEncLength)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmEncLengthRequired) + } + } + + if let sdmMacOffset { + let bytes = Helper.byteArrayLE(from: sdmMacOffset)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmMacOffsetRequired) + } + } + + if sdmOptionReadCounterLimit { + if let sdmReadCounterLimit { + let bytes = Helper.byteArrayLE(from: sdmReadCounterLimit)[0...2] + buffer.append(contentsOf: bytes) + } else { + return .failure(.sdmReadCounterLimitRequired) + } + } + } + + return .success(buffer) + } + + static func defaultFile1() -> FileSettings { + + var settings = FileSettings() + settings.readPermission = .ALL + settings.writePermission = .KEY_0 + settings.readWritePermission = .KEY_0 + settings.changePermission = .KEY_0 + settings.fileSize = 32 + + return settings + } + + static func defaultFile2() -> FileSettings { + + var settings = FileSettings() + settings.readPermission = .ALL + settings.writePermission = .ALL + settings.readWritePermission = .ALL + settings.changePermission = .KEY_0 + settings.fileSize = 256 + + return settings + } + + static func defaultFile3() -> FileSettings { + + var settings = FileSettings() + settings.communicationMode = .FULL + settings.readPermission = .KEY_2 + settings.writePermission = .KEY_3 + settings.readWritePermission = .KEY_3 + settings.changePermission = .KEY_0 + settings.fileSize = 128 + + return settings + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSpecifier.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSpecifier.swift new file mode 100644 index 000000000..8538f742a --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/FileSpecifier.swift @@ -0,0 +1,29 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +public enum FileSpecifier: UInt8, CustomStringConvertible { + case CC_FILE = 1 + case NDEF_FILE = 2 + case PROPRIETARY = 3 + + public var description: String { + switch self { + case .CC_FILE : return "CC File (#1)" + case .NDEF_FILE : return "NDEF File (#2)" + case .PROPRIETARY : return "Proprietary File (#3)" + } + } + + public var fileSize: Int { // page 10 + switch self { + case .CC_FILE : return 32 + case .NDEF_FILE : return 256 + case .PROPRIETARY : return 128 + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/KeySpecifier.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/KeySpecifier.swift new file mode 100644 index 000000000..78992c4a7 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/KeySpecifier.swift @@ -0,0 +1,47 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +public enum KeySpecifier: UInt8, CaseIterable, CustomStringConvertible { + case KEY_0 = 0 + case KEY_1 = 1 + case KEY_2 = 2 + case KEY_3 = 3 + case KEY_4 = 4 + + func next() -> KeySpecifier? { + var found = false + for key in KeySpecifier.allCases { + if key == self { + found = true + } else if found { + return key + } + } + return nil + } + + func toPermission() -> Permission { + switch self { + case .KEY_0 : return Permission.KEY_0 + case .KEY_1 : return Permission.KEY_1 + case .KEY_2 : return Permission.KEY_2 + case .KEY_3 : return Permission.KEY_3 + case .KEY_4 : return Permission.KEY_4 + } + } + + public var description: String { + switch self { + case .KEY_0 : return "Key 0" + case .KEY_1 : return "Key 1" + case .KEY_2 : return "Key 2" + case .KEY_3 : return "Key 3" + case .KEY_4 : return "Key 4" + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/Ndef.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/Ndef.swift new file mode 100644 index 000000000..a15267b8e --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Configuration/Ndef.swift @@ -0,0 +1,140 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +struct NdefHeaderFlags: OptionSet { + let rawValue: UInt8 + + /// Message begin flag + static let MB = NdefHeaderFlags(rawValue: 0b10000000) + /// Message end flag + static let ME = NdefHeaderFlags(rawValue: 0b01000000) + /// Chunked flag + static let CF = NdefHeaderFlags(rawValue: 0b00100000) + /// Short record flag + static let SR = NdefHeaderFlags(rawValue: 0b00010000) + /// IL (ID Length) is present + static let IL = NdefHeaderFlags(rawValue: 0b00001000) + + + static let TNF_WELL_KNOWN = NdefHeaderFlags(rawValue: 0b00000001) // 0x01 + static let TNF_MIME = NdefHeaderFlags(rawValue: 0b00000010) // 0x02 + /// Note: don't use this for URLS, use WELLKNOWN instead. + static let TNF_ABSOLUTE_URI = NdefHeaderFlags(rawValue: 0b00000011) // 0x03 + static let TNF_EXTERNAL = NdefHeaderFlags(rawValue: 0b00000100) // 0x04 + static let TNF_UNKNOWN = NdefHeaderFlags(rawValue: 0b00000101) // 0x05 + static let TNF_UNCHANGED = NdefHeaderFlags(rawValue: 0b00000110) // 0x06 + static let TNF_RESERVED = NdefHeaderFlags(rawValue: 0b00000111) // 0x07 +} + +enum NdefHeaderType: UInt8 { + case TEXT = 0x54 // 'T'.ascii + case URL = 0x55 // 'U'.ascii +} + +public class Ndef { + + static let HEADER_SIZE = 7 + + class func ndefDataForUrl(url: URL) -> [UInt8] { + + // See pgs. 30-31 of AN12196 + + let flags: NdefHeaderFlags = [.MB, .ME, .SR, .TNF_WELL_KNOWN] + let type = NdefHeaderType.URL + + let header: [UInt8] = [ + 0x00, // Placeholder for data size (two bytes MSB) + 0x00, + flags.rawValue, // NDEF header flags + 0x01, // Length of "type" field + 0x00, // URL size placeholder + type.rawValue, // This will be a URL record + 0x00 // Just the URI (no prepended protocol) + ] + + let urlData = url.absoluteString.data(using: .utf8) ?? Data() + var urlBytes = Helper.bytesFromData(data: urlData) + + let maxUrlSize = 255 - header.count + if urlBytes.count > maxUrlSize { + urlBytes = Array(urlBytes[0.. [UInt8] { + let iv = Helper.simpleAesEncrypt(key: sessionEncryptionKey, data: generateIvInput(purpose: purpose)) + if let dna = communicator, dna.debug { + Helper.logBytes("IV", iv) + } + return iv + } + + func generateIvInput(purpose:[UInt8]) -> [UInt8] { + let ivInput: [UInt8] = [ + purpose[0], + purpose[1], + communicator!.activeTransactionIdentifier[0], + communicator!.activeTransactionIdentifier[1], + communicator!.activeTransactionIdentifier[2], + communicator!.activeTransactionIdentifier[3], + UInt8(communicator!.commandCounter % 256), + UInt8(communicator!.commandCounter / 256), + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ] + if let dna = communicator, dna.debug { + Helper.logBytes("IV Input", ivInput) + } + return ivInput + } + + func encryptData(message: [UInt8]) -> [UInt8] { + return Helper.simpleAesEncrypt( + key: sessionEncryptionKey, + data: Helper.messageWithPadding(message), + iv: generateIv(purpose: AESEncryptionMode.encryptionPurpose) + ) + } + + func decryptData(message: [UInt8]) -> [UInt8] { + return Helper.simpleAesDecrypt( + key: sessionEncryptionKey, + data: message, + iv: generateIv(purpose: AESEncryptionMode.decryptionPurpose) + ) + } + + func generateMac(message: [UInt8]) -> [UInt8] { + let fullMac = Helper.simpleCMAC(key: sessionMacKey, data: message) + return Helper.evensOnly(fullMac) + } + + static func generateAESSessionKey( + key : [UInt8], + purpose : [UInt8], + challengeA : [UInt8], + challengeB : [UInt8] + ) -> [UInt8] { + let sessionVector = generateAESSessionVector( + purpose: purpose, + challengeA: challengeA, + challengeB: challengeB + ) + return Helper.simpleCMAC(key: key, data: sessionVector) + } + + static func generateAESSessionVector( + purpose : [UInt8], + challengeA : [UInt8], + challengeB : [UInt8] + ) -> [UInt8] { + let a: [UInt8] = challengeA.reversed() + let b: [UInt8] = challengeB.reversed() + let sessionVector: [UInt8] = [ + purpose[0], + purpose[1], + 0x00, 0x01, // counter + 0x00, 0x80, // bits + a[15], a[14], + Helper.xor(a[13], b[15]), + Helper.xor(a[12], b[14]), + Helper.xor(a[11], b[13]), + Helper.xor(a[10], b[12]), + Helper.xor(a[9], b[11]), + Helper.xor(a[8], b[10]), + b[9], b[8], b[7], b[6], b[5], b[4], b[3], b[2], b[1], b[0], + a[7], a[6], a[5], a[4], a[3], a[2], a[1], a[0] + ] + return sessionVector + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/EncryptionModes/EncryptionMode.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/EncryptionModes/EncryptionMode.swift new file mode 100644 index 000000000..07c882154 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/EncryptionModes/EncryptionMode.swift @@ -0,0 +1,13 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +protocol EncryptionMode { + func encryptData(message: [UInt8]) -> [UInt8] + func decryptData(message: [UInt8]) -> [UInt8] + func generateMac(message: [UInt8]) -> [UInt8] +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Extensions/Array+Read.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Extensions/Array+Read.swift new file mode 100644 index 000000000..b17176968 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Extensions/Array+Read.swift @@ -0,0 +1,50 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation + +extension Array where Element == UInt8 { + + func readLittleEndian( + offset: Int, + as: T.Type + ) -> T { + + assert(offset + MemoryLayout.size <= self.count) + + // Prepare a region aligned for `T` + var value: T = 0 + // Copy the misaligned bytes at `offset` to aligned region `value` + _ = Swift.withUnsafeMutableBytes(of: &value) {valueBP in + self.withUnsafeBytes { bufPtr in + let range = offset...size + bufPtr.copyBytes(to: valueBP, from: range) + } + } + + return T(littleEndian: value) + } + + func readBigEndian( + offset: Int, + as: T.Type + ) -> T { + + assert(offset + MemoryLayout.size <= self.count) + + // Prepare a region aligned for `T` + var value: T = 0 + // Copy the misaligned bytes at `offset` to aligned region `value` + _ = Swift.withUnsafeMutableBytes(of: &value) {valueBP in + self.withUnsafeBytes { bufPtr in + let range = offset...size + bufPtr.copyBytes(to: valueBP, from: range) + } + } + + return T(bigEndian: value) + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Helper.swift b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Helper.swift new file mode 100644 index 000000000..7a0dfd4a3 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/DnaCommunicator/Helper.swift @@ -0,0 +1,293 @@ +/** + * Special thanks to Jonathan Bartlett. + * DnaCommunicator sources are derived from: + * https://github.com/johnnyb/nfc-dna-kit + */ + +import Foundation +import SwCrypt + +class Helper { + /* **** HELPER FUNCTIONS **** */ + static let zeroIVPS: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + + static func bytesAsString(_ data: [UInt8]) -> String { + var str = "" + for x in data { + let st = String(format:"%02X", x) + str = str + st + " " + } + return str + } + + static func logBytes(_ name: String, _ data: [UInt8]) { + print(name + ": " + bytesAsString(data)) + } + + static func makeError(_ code: Int, _ message: String) -> Error { + return NSError(domain: "DNA", code: code, userInfo: ["message":message]) + } + + static func evensOnly(_ data: [UInt8]) -> [UInt8] { + var newData = [UInt8](repeating: 0, count: data.count / 2) + var idx = 0 + while idx < newData.count { + newData[idx] = data[idx * 2 + 1] + idx += 1 + } + return newData + } + + static func simpleCMAC(key: [UInt8], data: [UInt8]) -> [UInt8] { + + guard SwCrypt.CC.CMAC.available() else { + print("CC.CMAC.available() == false") + return [UInt8]() + } + + let _key = dataFromBytes(bytes: key) + let _data = dataFromBytes(bytes: data) + + let result = SwCrypt.CC.CMAC.AESCMAC(_data, key: _key) + return bytesFromData(data: result) + } + + static func simpleAesEncrypt(key: [UInt8]?, data: [UInt8]?, iv: [UInt8]? = nil) -> [UInt8] { + + guard SwCrypt.CC.cryptorAvailable() else { + print("CC.cryptorAvailable() == false") + return [UInt8]() + } + + let _key = dataFromBytes(bytes: key) + let _data = dataFromBytes(bytes: data) + let _iv = dataFromBytes(bytes: iv ?? zeroIVPS) + + do { + let result = try SwCrypt.CC.crypt( + .encrypt, + blockMode : .cbc, + algorithm : .aes, + padding : .noPadding, + data : _data, + key : _key, + iv : _iv + ) + return bytesFromData(data: result) + + } catch { + print("CC.crypt(): error: \(error)") + return [UInt8]() + } + } + + static func simpleAesDecrypt(key: [UInt8]?, data: [UInt8]?, iv: [UInt8]? = nil) -> [UInt8] { + + guard SwCrypt.CC.cryptorAvailable() else { + print("CC.cryptorAvailable() == false") + return [UInt8]() + } + + let _key = dataFromBytes(bytes: key) + let _data = dataFromBytes(bytes: data) + let _iv = dataFromBytes(bytes: iv ?? zeroIVPS) + + do { + let result = try SwCrypt.CC.crypt( + .decrypt, + blockMode : .cbc, + algorithm : .aes, + padding : .noPadding, + data : _data, + key : _key, + iv : _iv + ) + return bytesFromData(data: result) + + } catch { + print("CC.crypt(): error: \(error)") + return [UInt8]() + } + } + + static func messageWithPadding(_ message: [UInt8]) -> [UInt8] { + let blockSize = 16 + + // This is wrong. From page 24: + // + // > Padding is applied according to Padding Method 2 of ISO/IEC 9797-1 [7], + // > i.e. by adding always 80h followed, if required, by zero bytes until a + // > string with a length of a multiple of 16 byte is obtained. Note that if + // > the plain data is a multiple of 16 bytes already, an additional padding + // > block is added. The only exception is during the authentication itself + // > (AuthenticateEV2First and AuthenticateEV2NonFirst), where no padding is + // > applied at all. + // + // This helper method isn't used during AuthenticateEV2First, so we always + // need to add padding here. + // + // let remainder = message.count % blockSize + // if remainder == 0 { + // return message + // } + + let blocks = message.count / blockSize + var result = [UInt8](repeating: 0, count: (blocks + 1)*blockSize) + + // Copy existing message + var idx = 0 + while idx < message.count { + result[idx] = message[idx] + idx += 1 + } + + // Add the boundary marker + result[message.count] = 0x80 + + return result + } + + static func dataFromBytes(bytes: [UInt8]?) -> Data { + guard let bytes = bytes else { return Data() } + return Data(bytes: bytes, count: bytes.count) + } + + static func bytesFromData(data:Data?) -> [UInt8] { + guard let data = data else { return [UInt8]() } + var buffer = [UInt8]() + data.withUnsafeBytes { + buffer.append(contentsOf: $0) + } + return buffer + } + + static func randomBytes(ofLength length: Int) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: length) + let status = SecRandomCopyBytes(kSecRandomDefault, length, &bytes) + + if status != errSecSuccess { + print("Bad mojo in randomBytes") + } + + return bytes + } + + static func decodeHexString(_ str: String) -> [UInt8] { + let strs = str.split(separator: " ") + var vals = [UInt8]() + + for x in strs { + vals.append(UInt8(x, radix: 16)!) + } + + return vals + } + + static func rotateLeft(_ value: [UInt8], _ numRots:Int = 1) -> [UInt8] { + var newAry = [UInt8](repeating: 0, count: value.count) + var idx = 0 + while idx < value.count { + let newIdx = (idx < numRots) ? (value.count - (numRots + idx)) : (idx - numRots) + newAry[newIdx] = value[idx] + idx += 1 + } + return newAry + } + + static func rotateRight(_ value: [UInt8], _ numRots:Int = 1) -> [UInt8] { + var newAry = [UInt8](repeating: 0, count: value.count) + var idx = 0 + while idx < value.count { + let newIdx = (idx >= (value.count - numRots)) ? (idx - value.count + numRots) : (idx + numRots) + newAry[newIdx] = value[idx] + idx += 1 + } + return newAry + } + + static func xor(_ value1: UInt8, _ value2: UInt8) -> UInt8 { + return (value1 | value2) - (value1 & value2) + } + + static func xor(_ value1:[UInt8], _ value2: [UInt8]) -> [UInt8] { + guard value1.count == value2.count else { return [] } + var newValue = [UInt8](repeating: 0, count: value1.count) + for idx in 0...(value1.count - 1) { + newValue[idx] = Helper.xor(value1[idx], value2[idx]) + } + + return newValue + } + + static func diversifyKey(key: [UInt8], applicationInfo: [UInt8], identifier: [UInt8]) -> [UInt8] { + var newData: [UInt8] = [0x01] + newData.append(contentsOf: identifier) + newData.append(contentsOf: applicationInfo) + + return simpleCMAC(key: key, data: newData) + } + + static func byteArrayLE(from value: T) -> [UInt8] where T: FixedWidthInteger { + + withUnsafeBytes(of: value.littleEndian, Array.init) + } + + static func byteArrayBE(from value: T) -> [UInt8] where T: FixedWidthInteger { + + withUnsafeBytes(of: value.bigEndian, Array.init) + } + + static func getBitLSB(_ byte: UInt8, _ index: Int) -> Bool { + let mask = UInt8(1 << index) + let result = byte & mask + return result != 0 + } + + static func bytesToInt32LE(_ data:[UInt8]) -> UInt32 { + var multiplier: UInt32 = 1 + var value: UInt32 = 0 + for x in data { + value += UInt32(x) * multiplier + multiplier *= 256 + } + return value + } + + + static func bytesToIntLE(_ data:[UInt8]) -> Int { + return Int(bytesToInt32LE(data)) + } + + static func crc32(_ data: [UInt8]) -> [UInt8] { + + guard SwCrypt.CC.CRC.available() else { + print("CC.CRC.available() == false") + return [UInt8]() + } + + let _data = dataFromBytes(bytes: data) + + do { + let rawVal: UInt64 = try SwCrypt.CC.CRC.crc(_data, mode: .crc32) + let val = UInt32(rawVal) + + let basicCRC = byteArrayLE(from: val) + let jamXorMask: [UInt8] = [0xff, 0xff, 0xff, 0xff] + + let jamCRC = Helper.xor(basicCRC, jamXorMask) + return jamCRC + + } catch { + print("CC.CRC.crc(): error: \(error)") + return [UInt8]() + } + } + + static func leftNibble(_ data: UInt8) -> UInt8 { + return (data >> 4) + } + + static func rightNibble(_ data: UInt8) -> UInt8 { + return (data & UInt8(15)) + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/NfcReader.swift b/phoenix-ios/phoenix-ios/nfc/NfcReader.swift new file mode 100644 index 000000000..87eb24704 --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/NfcReader.swift @@ -0,0 +1,154 @@ +import Foundation +import CoreNFC + +fileprivate let filename = "NfcReader" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class NfcReader: NSObject, NFCNDEFReaderSessionDelegate { + + enum ReadError: Error { + case readingNotAvailable + case alreadyStarted + case errorReadingTag + case scanningTerminated(NFCReaderError) + } + + static let shared = NfcReader() + + private let queue: DispatchQueue + + private var session: NFCNDEFReaderSession? = nil + private var callback: ((Result) -> Void)? = nil + + override init() { + queue = DispatchQueue(label: "NfcReader") + } + + func readCard(_ callback: @escaping (Result) -> Void) { + log.trace("readCard()") + + let fail = { (error: ReadError) in + DispatchQueue.main.async { + callback(Result.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCNDEFReaderSession(delegate: self, queue: queue, invalidateAfterFirstRead: true) + session?.alertMessage = "Hold your card near the device to read it." + + self.callback = callback + session?.begin() + + log.info("session is ready") + } + } + + // -------------------------------------------------- + // MARK: Private + // -------------------------------------------------- + + private func finishWithSuccess(_ message: NFCNDEFMessage) { + + guard let session, let callback else { + return + } + log.trace("finishWithSuccess()") + + session.invalidate() + self.session = nil + self.callback = nil + DispatchQueue.main.async { + callback(.success(message)) + } + } + + private func finishWithError(_ error: ReadError) { + + guard let session, let callback else { + return + } + log.trace("finishWithError()") + + session.invalidate() + self.session = nil + self.callback = nil + + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + // -------------------------------------------------- + // MARK: NFCNDEFReaderSessionDelegate + // -------------------------------------------------- + + func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) { + log.trace("readerSessionDidBecomeActive(_)") + } + + func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: any Error) { + log.trace("readerSession(_, didInvalidateWithError:)") + log.trace("error: \(error)") + + let nfcError = (error as? NFCReaderError) ?? // this is always the case + NFCReaderError(NFCReaderError.readerSessionInvalidationErrorSessionTimeout) // but just to be safe + + if let _ = error as? NFCReaderError { + log.debug("is NFCReaderError") + } else { + log.debug("!is NFCReaderError") + } + finishWithError(.scanningTerminated(nfcError)) + } + + func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { + log.trace("readerSession(_, didDetectNDEFs:)") + log.trace("messages.count = \(messages.count)") + + if messages.count > 1 { + log.warning("NfcReader: Multiple messages detected: this is unsupported, only the first will be read") + } + + if let message = messages.first { + finishWithSuccess(message) + } + } + + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [any NFCNDEFTag]) { + log.trace("readerSession(_, didDetectTags:)") + log.trace("messages.count = \(tags.count)") + + if tags.count > 1 { + log.warning("NfcReader: Multiple tags detected: this is unsupported, only the first will be read") + } + + if let tag = tags.first { + tag.readNDEF { [self] (result, error) in + + if let result { + finishWithSuccess(result) + + } else if let error { + log.error("readNDEF: error = \(error)") + finishWithError(.errorReadingTag) + } + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift b/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift new file mode 100644 index 000000000..daf8b140c --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift @@ -0,0 +1,1147 @@ +import Foundation +import CoreNFC + +fileprivate let filename = "NfcWriter" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class NfcWriter: NSObject, NFCTagReaderSessionDelegate { + + // -------------------------------------------------- + // MARK: Struct's & Enum's + // -------------------------------------------------- + + struct WriteInput { + let template: Ndef.Template + let key0: [UInt8] + let piccDataKey: [UInt8] + let cmacKey: [UInt8] + } + + struct WriteOutput { + let chipUid: [UInt8] + } + + enum WriteError: Error { + case readingNotAvailable + case alreadyStarted + case couldNotConnect + case couldNotAuthenticate + case keySlotsUnavailable + case protocolError(WriteStep, Error) + case scanningTerminated(NFCReaderError) + } + + enum WriteStep: Int { + case readChipUid + case writeFile2Settings + case writeFile2Data + case writeKey0 + } + + struct ResetInput { + let key0: [UInt8] + let piccDataKey: [UInt8] + let cmacKey: [UInt8] + } + + enum DebugError: Error { + case readingNotAvailable + case alreadyStarted + case couldNotConnect + case couldNotAuthenticate + case readChipUid(Error) + case readFile1Settings(Error) + case readFile1Data(Error) + case readFile2Settings(Error) + case readFile2Data(Error) + case readFile3Settings(Error) + case readFile3Data(Error) + case scanningTerminated(NFCReaderError) + } + + // -------------------------------------------------- + // MARK: Variables + // -------------------------------------------------- + + static let shared = NfcWriter() + + private let queue: DispatchQueue + + private var session: NFCTagReaderSession? = nil + + private var writeInput: WriteInput? = nil + private var writeCallback: ((Result) -> Void)? = nil + + private var resetInput: ResetInput? = nil + private var resetCallback: ((Result) -> Void)? = nil + + private var debugCallback: ((Result) -> Void)? = nil + + // -------------------------------------------------- + // MARK: General + // -------------------------------------------------- + + override init() { + queue = DispatchQueue(label: "NfcWriter") + } + + private var isWriting: Bool { + return (writeCallback != nil) + } + + private var isResetting: Bool { + return (resetCallback != nil) + } + + private func connectToTag(_ tag: NFCTag) async { + log.trace("connectToTag()") + + guard case let .iso7816(isoTag) = tag else { + preconditionFailure("invalid tag parameter") + } + + guard let session else { + log.warning("connectToTag: ignoring: session is nil") + return + } + + do { + try await session.connect(to: tag) + + log.debug("session.connect(): success") + + let dna = DnaCommunicator(tag: isoTag) + dna.debug = true + + Task { + await authenticate(dna) + } + + } catch { + log.debug("session.connect(): failed: \(error)") + if isWriting { + writeDisconnect(error: .couldNotConnect) + } else if isResetting { + resetDisconnect(error: .couldNotConnect) + } else { + debugDisconnect(error: .couldNotConnect) + } + } + } + + private func authenticate( + _ dna: DnaCommunicator + ) async { + + log.trace("authenticateToTag()") + + let key0: [UInt8] + if writeInput != nil { + // We are expecting an empty card here. + key0 = DnaCommunicator.defaultKey + } else if let input = resetInput { + // We are expecting a non-empty card, so we need to use proper key. + key0 = input.key0 + } else { + // We're debugging, and expecting an empty card. + key0 = DnaCommunicator.defaultKey + } + + let result = await dna.authenticateEV2First( + keyNum : .KEY_0, + keyData : key0 + ) + + switch result { + case .failure(let error): + log.debug("dna.authenticateEV2First(keyNum: 0): failed: \(error)") + if isWriting { + writeDisconnect(error: .couldNotAuthenticate) + } else if isResetting { + resetDisconnect(error: .couldNotAuthenticate) + } else { + debugDisconnect(error: .couldNotAuthenticate) + } + + case .success(_): + log.debug("dna.authenticateEV2First(keyNum: 0): success") + + Task { + if isWriting { + await writeDriver(dna) + } else if isResetting { + await resetDriver(dna) + } else { + await debugDriver(dna) + } + } + } + } + + // -------------------------------------------------- + // MARK: Write Logic + // -------------------------------------------------- + + func writeCard( + _ input: WriteInput, + _ callback: @escaping (Result) -> Void + ) { + log.trace("startWriting()") + + let fail = { (error: WriteError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = String( + localized: "Hold your card near the device to program it.", + comment: "Message in iOS NFC dialog" + ) + + self.writeInput = input + self.writeCallback = callback + session?.begin() + + log.info("session is ready") + } + } + + private func writeDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("writeDriver()") + + guard let input = writeInput else { + fatalError("input is nil") + } + + // Step 1 of 5: + // Read the chip UID + + let chipUid: [UInt8] + do { + chipUid = try await readChipUid(dna).get() + } catch { + return writeDisconnect(error: .protocolError(.readChipUid, error)) + } + + // Step 2 of 5: + // Write piccDataKey & cmacKey to the card. + // + // Ideally we'll put: + // - piccDataKey=key1, cmacKey=key2 + // + // But that may not be possible. + // If the card was reset incorrectly, then certain keys may not be available to us. + // + // However, other key configurations are perfectly acceptable for our use case: + // - piccDataKey=key1, cmacKey=key3 + // - piccDataKey=key1, cmacKey=key4 + // - piccDataKey=key2, cmacKey=key3 + // - piccDataKey=key2, cmacKey=key4 + // - piccDataKey=key3, cmacKey=key4 + // + // So we'll try our best to program the card with the keys that are available to us. + + var position = await writeKey(dna, input.piccDataKey, startingPosition: .KEY_1) + guard let piccDataKeyPosition = position else { + return writeDisconnect(error: .keySlotsUnavailable) + } + log.debug("piccDataKeyPosition: \(piccDataKeyPosition.description)") + + position = await writeKey(dna, input.cmacKey, startingPosition: position?.next()) + guard let cmacKeyPosition = position else { + return writeDisconnect(error: .keySlotsUnavailable) + } + log.debug("cmacKeyPosition: \(cmacKeyPosition.description)") + + // Step 3 of 5: + // Write file2 settings. + + let file2Settings: FileSettings + do { + file2Settings = try await writeFile2Settings(dna, input.template, + piccDataKeyPosition: piccDataKeyPosition, + cmacKeyPosition: cmacKeyPosition + ).get() + } catch { + return writeDisconnect(error: .protocolError(.writeFile2Settings, error)) + } + + // Step 4 of 5: + // Write file2 data. + + do { + let data = Ndef.ndefDataForUrl(url: input.template.url) + try await writeFile2Data(dna, data, file2Settings).get() + } catch { + return writeDisconnect(error: .protocolError(.writeFile2Data, error)) + } + + // Step 5 of 5: + // Change key0 + // + // Note that after you perform this step, + // if you wanted to make other changes to the card, + // then you would need to reauthenticate. + + do { + let _ = try await changeKey(dna, .KEY_0, + oldKey : DnaCommunicator.defaultKey, + newKey : input.key0 + ).get() + } catch { + return writeDisconnect(error: .protocolError(.writeKey0, error)) + } + + writeDisconnect(output: WriteOutput(chipUid: chipUid)) + } + + private func writeDisconnect(error: WriteError) { + writeDisconnect(result: .failure(error)) + } + + private func writeDisconnect(output: WriteOutput) { + writeDisconnect(result: .success(output)) + } + + private func writeDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.writeCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.writeInput = nil + self.writeCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Reset Logic + // -------------------------------------------------- + + func resetCard( + _ input: ResetInput, + _ callback: @escaping (Result) -> Void + ) { + log.trace("resetCard()") + + let fail = { (error: WriteError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = String( + localized: "Hold your card near the device to reset it.", + comment: "Message in iOS NFC dialog" + ) + + self.resetInput = input + self.resetCallback = callback + session?.begin() + + log.info("session is ready") + } + } + + private func resetDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("resetDriver()") + + guard let input = resetInput else { + fatalError("input is nil") + } + + // Step 1 of 4: + // Reset piccDataKey & cmacKey. + // + // As documented in the `writeDriver` above, there are a number of combinations that are possible: + // - piccDataKey=key1, cmacKey=key2 + // - piccDataKey=key1, cmacKey=key3 + // - piccDataKey=key1, cmacKey=key4 + // - piccDataKey=key2, cmacKey=key3 + // - piccDataKey=key2, cmacKey=key4 + // - piccDataKey=key3, cmacKey=key4 + // + // For our purposes here, we will consider the card properly reset + // if we are able to reset 2 key positions. + + var position = await resetKey(dna, input.piccDataKey, startingPosition: .KEY_1) + guard let piccDataKeyPosition = position else { + return resetDisconnect(error: .keySlotsUnavailable) + } + log.debug("piccDataKeyPosition: \(piccDataKeyPosition.description)") + + position = await resetKey(dna, input.cmacKey, startingPosition: position?.next()) + guard let cmacKeyPosition = position else { + return resetDisconnect(error: .keySlotsUnavailable) + } + log.debug("cmacKeyPosition: \(cmacKeyPosition.description)") + + // Step 2 of 4: + // Reset file2 settings. + + let file2Settings = FileSettings.defaultFile2() + do { + let _ = try await writeFile2Settings(dna, file2Settings).get() + } catch { + return resetDisconnect(error: .protocolError(.writeFile2Settings, error)) + } + + // Step 3 of 4: + // Reset file2 data. + + do { + let url = URL(string: "https://phoenix.acinq.co")! + let data = Ndef.ndefDataForUrl(url: url) + try await writeFile2Data(dna, data, file2Settings).get() + } catch { + return resetDisconnect(error: .protocolError(.writeFile2Data, error)) + } + + // Step 4 of 4: + // Change key0 + // + // Note that after you perform this step, + // if you wanted to make other changes to the card, + // then you would need to reauthenticate. + + do { + let _ = try await changeKey(dna, .KEY_0, + oldKey : input.key0, + newKey : DnaCommunicator.defaultKey + ).get() + } catch { + return resetDisconnect(error: .protocolError(.writeKey0, error)) + } + + resetDisconnect(result: .success(())) + } + + private func resetDisconnect(error: WriteError) { + resetDisconnect(result: .failure(error)) + } + + private func resetDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.resetCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.debugCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Debug Logic + // -------------------------------------------------- + +#if DEBUG + func debugSession( + _ callback: @escaping (Result) -> Void + ) { + log.trace("debugSession()") + + let fail = { (error: DebugError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = "Hold your card near the device to start debugging." + + self.debugCallback = callback + session?.begin() + + log.info("session is ready") + } + } +#endif + + private func debugDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("debugDriver()") + + do { + let _ = try await readChipUid(dna).get() + } catch { + return debugDisconnect(error: .readChipUid(error)) + } + + let file1Settings: FileSettings + do { + file1Settings = try await readFile1Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile1Settings(error)) + } + + do { + let _ = try await readFile1Data(dna, file1Settings).get() + } catch { + return debugDisconnect(error: .readFile1Data(error)) + } + + let file2Settings: FileSettings + do { + file2Settings = try await readFile2Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile2Settings(error)) + } + + do { + let _ = try await readFile2Data(dna, file2Settings).get() + } catch { + return debugDisconnect(error: .readFile2Data(error)) + } + + do { + let _ = try await readFile3Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile3Settings(error)) + } + + debugDisconnect(result: .success(())) + } + + private func debugDisconnect(error: DebugError) { + debugDisconnect(result: .failure(error)) + } + + private func debugDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.debugCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.debugCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Reading + // -------------------------------------------------- + + private func readChipUid( + _ dna: DnaCommunicator + ) async -> Result<[UInt8], Error> { + + log.trace("readChipUid()") + + let result = await dna.getChipUid() + + switch result { + case .failure(let error): + log.debug("dna.getChipUid: failed: \(error)") + return .failure(error) + + case .success(let uid): + log.debug("dna.getChipUid: success") + log.debug("UID: \(uid.toHex())") + + return .success(uid) + } + } + + private func readFile1Settings( + _ dna: DnaCommunicator + ) async -> Result { + + log.trace("readFile1Settings()") + + let result = await dna.getFileSettings(fileNum: .CC_FILE) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(1): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(1): success") + self.printFileSettings(settings, fileNum: 1) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile1Data( + _ dna: DnaCommunicator, + _ settings: FileSettings + ) async -> Result { + + log.trace("readFile1Data()") + + let length = 32 + let result = await dna.readFileData( + fileNum: .CC_FILE, + length: length, + mode: settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(1): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(1): success") + + var fileData: [UInt8] = data + if fileData.count > length { + fileData = Array(fileData[0.. Result { + + log.trace("readFile2Settings()") + + let result = await dna.getFileSettings(fileNum: .NDEF_FILE) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(2): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(2): success") + self.printFileSettings(settings, fileNum: 2) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile2Data( + _ dna : DnaCommunicator, + _ settings : FileSettings, + _ prvData : [UInt8]? = nil + ) async -> Result<[UInt8], Error> { + + log.trace("readFile2Data()") + + let length = 128 // this appears to be the max + let offset = prvData?.count ?? 0 + + let result = await dna.readFileData( + fileNum : .NDEF_FILE, + offset : offset, + length : length, + mode : settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(2): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(2): success") + log.debug("data.count = \(data.count)") + + var fixedData: [UInt8] = data + if fixedData.count > length { + fixedData = Array(data[0.. Result { + + log.trace("readFile3Settings()") + + let result = await dna.getFileSettings(fileNum: .PROPRIETARY) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(3): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(3): success") + self.printFileSettings(settings, fileNum: 3) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile3Data( + _ dna: DnaCommunicator, + _ settings: FileSettings + ) async -> Result<[UInt8], Error> { + + log.trace("readFile3Data()") + + let length = 128 + let result = await dna.readFileData( + fileNum: .PROPRIETARY, + length: length, + mode: settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(3): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(3): success") + + var fileData: [UInt8] = data + if fileData.count > length { + fileData = Array(fileData[0.. KeySpecifier? { + + guard var position = startingPosition else { + return nil + } + + while true { + do { + try await changeKey(dna, position, + oldKey : DnaCommunicator.defaultKey, + newKey : newKey + ).get() + return position + + } catch { + log.info("Unable to write to: \(position.description)") + if let nextPosition = position.next() { + position = nextPosition + } else { + return nil + } + } + } + } + + private func resetKey( + _ dna : DnaCommunicator, + _ oldKey : [UInt8], + startingPosition : KeySpecifier? + ) async -> KeySpecifier? { + + guard var position = startingPosition else { + return nil + } + + while true { + do { + try await changeKey(dna, position, + oldKey : oldKey, + newKey : DnaCommunicator.defaultKey + ).get() + return position + + } catch { + log.info("Unable to write to: \(position.description)") + if let nextPosition = position.next() { + position = nextPosition + } else { + return nil + } + } + } + } + + private func changeKey( + _ dna : DnaCommunicator, + _ keyNum : KeySpecifier, + oldKey : [UInt8], + newKey : [UInt8] + ) async -> Result { + + log.trace("changeKey(\(keyNum))") + + let currentKeyVersion: UInt8 + let resultA = await dna.getKeyVersion(keyNum: keyNum) + + switch resultA { + case .failure(let error): + log.error("dna.getKeyVersion(\(keyNum)): error: \(error)") + return .failure(error) + + case .success(let version): + log.debug("dna.getKeyVersion(\(keyNum)): success: \(version)") + currentKeyVersion = version + } + + let newKeyVersion = currentKeyVersion + 1 + + let result = await dna.changeKey( + keyNum : keyNum, + oldKey : oldKey, + newKey : newKey, + keyVersion : newKeyVersion + ) + + switch result { + case .failure(let error): + log.error("dna.changeKey(\(keyNum)): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.changeKey(\(keyNum)): success") + return .success(()) + } + } + + private func writeFile2Settings( + _ dna : DnaCommunicator, + _ template : Ndef.Template, + piccDataKeyPosition : KeySpecifier, + cmacKeyPosition : KeySpecifier + ) async -> Result { + + log.debug("writeFile2Settings()") + + var settings = FileSettings.defaultFile2() + settings.sdmEnabled = true + settings.communicationMode = .FULL + settings.readPermission = .ALL + settings.writePermission = .KEY_0 + settings.readWritePermission = .KEY_0 + settings.changePermission = .KEY_0 + settings.sdmOptionUid = true + settings.sdmOptionReadCounter = true + settings.sdmOptionUseAscii = true + settings.sdmMetaReadPermission = piccDataKeyPosition.toPermission() + settings.sdmFileReadPermission = cmacKeyPosition.toPermission() + settings.sdmPiccDataOffset = template.piccDataOffset + settings.sdmMacOffset = template.cmacOffset + settings.sdmMacInputOffset = template.cmacOffset + + return await writeFile2Settings(dna, settings) + } + + private func writeFile2Settings( + _ dna : DnaCommunicator, + _ settings : FileSettings + ) async -> Result { + + log.debug("writeFile2Settings()") + + printFileSettings(settings, fileNum: 2) + + var data: [UInt8] = [] + switch settings.encode(mode: .ChangeFileSettings) { + case .failure(let error): + log.error("FileSettings.encode(): error: \(error)") + return .failure(error) + + case .success(let bytes): + log.debug("FileSettings.encode(): success: \(bytes.toHex())") + data = bytes + } + + let result = await dna.changeFileSettings(fileNum: .NDEF_FILE, data: data) + + switch result { + case .failure(let error): + log.error("dna.changeFileSettings(2): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.changeFileSettings(2): success") + return .success(settings) + } + } + + private func writeFile2Data( + _ dna : DnaCommunicator, + _ data : [UInt8], + _ settings : FileSettings + ) async -> Result { + + log.debug("writeFile2Data()") + log.debug("data.count = \(data.count)") + + let result = await dna.writeFileData( + fileNum : .NDEF_FILE, + data : data, + mode : settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.writeFileData(2): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.writeFileData(2): success") + return .success(()) + } + } + + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func printFileSettings(_ fileSettings: FileSettings, fileNum: Int) { + + var output: String = "" + output += "FileSettings(\(fileNum)):\n" + output += " - fileType: \(fileSettings.fileType)\n" + output += " - sdmEnabled: \(fileSettings.sdmEnabled)\n" + output += " - communicationMode: \(fileSettings.communicationMode)\n" + output += " - readPermission: \(fileSettings.readPermission)\n" + output += " - writePermission: \(fileSettings.writePermission)\n" + output += " - readWritePermission: \(fileSettings.readWritePermission)\n" + output += " - changePermission: \(fileSettings.changePermission)\n" + output += " - fileSize: \(fileSettings.fileSize)\n" + output += " - sdmOptionUid: \(fileSettings.sdmOptionUid)\n" + output += " - sdmOptionReadCounter: \(fileSettings.sdmOptionReadCounter)\n" + output += " - sdmOptionReadCounterLimit: \(fileSettings.sdmOptionReadCounterLimit)\n" + output += " - sdmOptionEncryptFileData: \(fileSettings.sdmOptionEncryptFileData)\n" + output += " - sdmOptionUseAscii: \(fileSettings.sdmOptionUseAscii)\n" + output += " - sdmMetaReadPermission: \(fileSettings.sdmMetaReadPermission)\n" + output += " - sdmFileReadPermission: \(fileSettings.sdmFileReadPermission)\n" + output += " - sdmReadCounterRetrievalPermission: \(fileSettings.sdmReadCounterRetrievalPermission)\n" + output += " - sdmUidOffset: \(fileSettings.sdmUidOffset?.description ?? "nil")\n" + output += " - sdmReadCounterOffset: \(fileSettings.sdmReadCounterOffset?.description ?? "nil")\n" + output += " - sdmPiccDataOffset: \(fileSettings.sdmPiccDataOffset?.description ?? "nil")\n" + output += " - sdmMacInputOffset: \(fileSettings.sdmMacInputOffset?.description ?? "nil")\n" + output += " - sdmMacOffset: \(fileSettings.sdmMacOffset?.description ?? "nil")\n" + output += " - sdmEncOffset: \(fileSettings.sdmEncOffset?.description ?? "nil")\n" + output += " - sdmEncLength: \(fileSettings.sdmEncLength?.description ?? "nil")\n" + output += " - sdmReadCounterLimit: \(fileSettings.sdmReadCounterLimit?.description ?? "nil")" + + log.debug("\(output)") + } + + func printCapabilitiesContainer(_ cc: CapabilitiesContainer) { + + var output: String = "" + output += "CapabilitiesContainer:\n" + output += " - cclen: \(cc.len)\n" + output += " - t4tVNo: 0x\([cc.t4tVNo].toHex())\n" + output += " - mLe: \(cc.mLe)\n" + output += " - mLc: \(cc.mLc)\n" + + output += " - f2.t: \(cc.file2.t)\n" + output += " - f2.l: \(cc.file2.l)\n" + output += " - f2.fileId: 0x\(cc.file2.fileId.toHex())\n" + output += " - f2.fileSize: \(cc.file2.fileSize)\n" + output += " - f2.readAccess: 0x\([cc.file2.readAccess].toHex())\n" + output += " - f2.writeAccess: 0x\([cc.file2.writeAccess].toHex())\n" + + output += " - f3.t: \(cc.file3.t)\n" + output += " - f3.l: \(cc.file3.l)\n" + output += " - f3.fileId: 0x\(cc.file3.fileId.toHex())\n" + output += " - f3.fileSize: \(cc.file3.fileSize)\n" + output += " - f3.readAccess: 0x\([cc.file3.readAccess].toHex())\n" + output += " - f3.writeAccess: 0x\([cc.file3.writeAccess].toHex())\n" + + log.debug("\(output)") + } + + // -------------------------------------------------- + // MARK: NFCTagReaderSessionDelegate + // -------------------------------------------------- + + func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { + log.trace("tagReaderSessionDidBecomeActive(_):") + } + + func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: any Error) { + log.trace("tagReaderSession(_, didInvalidateWithError:)") + log.trace("error: \(error)") + + let nfcError = (error as? NFCReaderError) ?? // this is always the case + NFCReaderError(NFCReaderError.readerSessionInvalidationErrorSessionTimeout) // but just to be safe + + if isWriting { + writeDisconnect(error: .scanningTerminated(nfcError)) + } else if isResetting { + resetDisconnect(error: .scanningTerminated(nfcError)) + } else { + debugDisconnect(error: .scanningTerminated(nfcError)) + } + } + + func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + log.trace("tagReaderSession(_, didDetect:): \(tags)") + log.trace("tags.count = \(tags.count)") + + for tag in tags { + log.debug("tag: \(tag)") + } + + var properTag: NFCTag? = nil + for tag in tags { + if case .iso7816 = tag { + if properTag == nil { + properTag = tag + } + } + } + + if let properTag { + Task { + await connectToTag(properTag) + } + } else { + session.restartPolling() + log.debug("did NOT find properTag") + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/Ntag424.swift b/phoenix-ios/phoenix-ios/nfc/Ntag424.swift new file mode 100644 index 000000000..d32db6a7c --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/Ntag424.swift @@ -0,0 +1,253 @@ +import Foundation +import SwCrypt + +fileprivate let filename = "Ntag424" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class Ntag424 { + + struct QueryItems { + let piccData: Data + let cmac: Data + let encString: String? + } + + enum QueryItemsError: Error { + case piccDataMissing + case piccDataInvalid + case cmacMissing + case cmacInvalid + } + + static func extractQueryItems( + url: URL + ) -> Result { + + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems + else { + log.debug("extractQueryItems: failed: queryItems is nil") + return .failure(.piccDataMissing) + } + + var piccString: String? = nil + var cmacString: String? = nil + var encString: String? = nil + + for queryItem in queryItems { + if queryItem.name.caseInsensitiveCompare("picc_data") == .orderedSame { + piccString = queryItem.value + } else if queryItem.name.caseInsensitiveCompare("cmac") == .orderedSame { + cmacString = queryItem.value + } else if queryItem.name.caseInsensitiveCompare("enc") == .orderedSame { + encString = queryItem.value + } + } + + guard let piccString else { + log.debug("extractQueryItems: failed: misssing 'picc_data' value") + return .failure(.piccDataMissing) + } + + guard let piccData = Data(fromHex: piccString) else { + log.debug("extractQueryItems: failed: 'picc_data' string not hexadecimal") + return .failure(.piccDataInvalid) + } + + guard let cmacString else { + log.debug("extractQueryItems: failed: misssing 'cmac' value") + return .failure(.cmacMissing) + } + + guard let cmacData = Data(fromHex: cmacString) else { + log.debug("extractQueryItems: failed: 'cmac' string not hexadecimal") + return .failure(.cmacInvalid) + } + + return .success(QueryItems(piccData: piccData, cmac: cmacData, encString: encString)) + } + + struct KeySet { + let piccDataKey: Data + let cmacKey: Data + + static func `default`() -> KeySet { + return KeySet( + piccDataKey: Data(repeating: 0x00, count: 16), + cmacKey: Data(repeating: 0x00, count: 16) + ) + } + } + + struct PiccDataInfo { + let uid: Data // 7 bytes + let counter: UInt32 // 3 bytes (actual size in decrypted data) + + static let maxCounterValue: UInt32 = 0xffffff // 16,777,215 (it's only 3 bytes) + } + + enum ExtractionError: Error { + case decryptionFailed + case cmacCalculationFailed + case cmacMismatch + } + + static func extractPiccDataInfo( + piccData : Data, + cmac : Data, + keySet : KeySet + ) -> Result { + + guard let tuple = decryptPiccData(piccData, keySet) else { + log.debug("extractPiccData: failed: could not decrypt picc_data") + return .failure(.decryptionFailed) + } + + let decryptedPiccData = tuple.0 + let piccDataInfo = tuple.1 + + guard let calculatedCmac = calculateCmac(decryptedPiccData, nil, keySet) else { + log.debug("extractPiccData: failed: could not calculate cmac") + return .failure(.cmacCalculationFailed) + } + + guard calculatedCmac == cmac else { + log.debug("extractPiccData: failed: calculated cmac does not match given cmac") + return .failure(.cmacMismatch) + } + + return .success(piccDataInfo) + } + + private static func decryptPiccData( + _ encryptedPiccData: Data, + _ keySet: KeySet + ) -> (Data, PiccDataInfo)? { + + guard let decryptedPiccData = decrypt(data: encryptedPiccData, key: keySet.piccDataKey) else { + log.debug("decryptPiccData: failed: cannot decrypt picc data") + return nil + } + + log.debug("decryptedPiccData: \(decryptedPiccData.toHex())") + + guard decryptedPiccData.count == 16 else { + log.debug("decryptPiccData: failed: decrypted picc data not 16 bytes") + return nil + } + + let piccDataHeader: UInt8 = 0xc7 + guard decryptedPiccData[0] == piccDataHeader else { + log.debug("decryptPiccData: failed: picc header missing") + return nil + } + + let uid: Data = decryptedPiccData[1..<8] + log.debug("uid: \(uid.toHex())") + + var ctr: Data = decryptedPiccData[8..<11] + log.debug("ctr: \(ctr.toHex())") + + var counter: UInt32 = 0 + ctr.append(contentsOf: [0x00]) + ctr.withUnsafeBytes { ptr in + let littleEndian = ptr.load(as: UInt32.self) + counter = UInt32(littleEndian: littleEndian) + } + + log.debug("counter = \(counter)") + + let result = PiccDataInfo(uid: uid, counter: counter) + return (decryptedPiccData, result) + } + + private static func calculateCmac( + _ decryptedPiccData: Data, + _ encString: String?, + _ keySet: KeySet + ) -> Data? { + + var inputA = Data() + inputA.append(contentsOf: [0x3C, 0xC3, 0x00, 0x01, 0x00, 0x80]) + inputA += decryptedPiccData[1..<11] + + while (inputA.count % 16) != 0 { + inputA.append(contentsOf: [0x00]) + } + + let resultA: Data = cmac(data: inputA, key: keySet.cmacKey)! + log.debug("resultA: \(resultA.toHex(options: .upperCase))") + + var inputB = Data() + if let encString { + if let encData = encString.uppercased().data(using: .ascii) { + inputB += encData + } + if let suffix = "&cmac=".data(using: .ascii) { + inputB += suffix + } + } + + guard let resultB: Data = cmac(data: inputB, key: resultA) else { + return nil + } + log.debug("resultB: \(resultB.toHex(options: .upperCase))") + + var truncated = Data() + resultB.enumerated().forEach { (index, value) in + if (index % 2) == 1 { + truncated.append(contentsOf: [value]) + } + } + + log.debug("calculatedCmac: \(truncated.toHex(options: .upperCase))") + + return truncated + } + + private static func decrypt( + data : Data, + key : Data + ) -> Data? { + + guard SwCrypt.CC.cryptorAvailable() else { + log.error("CC.cryptorAvailable() == false") + return nil + } + + do { + let result = try SwCrypt.CC.crypt( + .decrypt, + blockMode : .ecb, + algorithm : .aes, + padding : .noPadding, + data : data, + key : key, + iv : Data(repeating: 0, count: 16) // not used in ECB mode + ) + return result + + } catch { + log.error("AES decrypt error: \(error)") + return nil + } + } + + private static func cmac( + data : Data, + key : Data + ) -> Data? { + + guard SwCrypt.CC.CMAC.available() else { + log.error("CC.CMAC.available() == false") + return nil + } + + return SwCrypt.CC.CMAC.AESCMAC(data, key: key) + } +} diff --git a/phoenix-ios/phoenix-ios/notifications/PushNotification.swift b/phoenix-ios/phoenix-ios/notifications/PushNotification.swift new file mode 100644 index 000000000..a3855749a --- /dev/null +++ b/phoenix-ios/phoenix-ios/notifications/PushNotification.swift @@ -0,0 +1,107 @@ +import Foundation +import PhoenixShared + +fileprivate let filename = "PushNotification" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class PushNotification { + + static func isFCM(userInfo: [AnyHashable : Any]) -> Bool { + + /* This could be a push notification coming from Google Firebase or from AWS. + * + * Example from Google FCM: + * { + * "aps": { + * "alert": { + * "title": "foobar" + * } + * "mutable-content": 1 + * }, + * "reason": "IncomingPayment", + * "gcm.message_id": 1676919817341932, + * "google.c.a.e": 1, + * "google.c.fid": "f7Wfr_yqG00Gt6B9O7qI13", + * "google.c.sender.id": 358118532563 + * } + * + * Example from AWS: + * { + * "aps": { + * "alert": { + * "title": "Missed incoming payment" + * } + * "mutable-content": 1 + * }, + * "acinq": { + * "amt": 120000, + * "h": "d48bf163c0e24d68567e80b10cc7dd583e2f44390c9592df56a61f79559611e6", + * "n": "02ed721545840184d1544328059e8b20c01965b73b301a7d03fc89d3d84aba0642", + * "t": "invoice", + * "ts": 1676920273561 + * } + * } + */ + + return userInfo["gcm.message_id"] != nil || + userInfo["google.c.a.e"] != nil || + userInfo["google.c.fid"] != nil || + userInfo["google.c.sender.id"] != nil || + userInfo["reason"] != nil // just in-case google changes format + } + + static func parseWithdrawRequest(userInfo: [AnyHashable : Any]) -> WithdrawRequest? { + log.trace("parseWithdrawRequest()") + + // It should look like this: + // + // acinq: { + // t : "withdraw", + // n : "", + // picc : "", + // cmac : "", + // invc : "", + // ts : + // } + + guard + let acinq = userInfo["acinq"] as? [String: Any], + let t = acinq["t"] as? String, + let n = acinq["n"] as? String, + let picc = acinq["picc"] as? String, + let cmac = acinq["cmac"] as? String, + let invc = acinq["invc"] as? String, + let ts = acinq["ts"] as? Int64 + else { + log.debug("parseLnurlWithdraw: missing one or more parameters") + return nil + } + + guard t == "withdraw" else { + log.debug("parseLnurlWithdraw: t != withdraw") + return nil + } + + guard let piccData = Data(fromHex: picc) else { + log.debug("parseLnurlWithdraw: picc is not hexadecimal") + return nil + } + + guard let cmacData = Data(fromHex: cmac) else { + log.debug("parseLnurlWithdraw: cmac is not hexadecimal") + return nil + } + + return WithdrawRequest( + nodeId : n, + piccData : piccData, + cmac : cmacData, + invoice : invc, + timestamp : ts.toDate(from: .milliseconds) + ) + } +} diff --git a/phoenix-ios/phoenix-ios/notifications/WithdrawRequest.swift b/phoenix-ios/phoenix-ios/notifications/WithdrawRequest.swift new file mode 100644 index 000000000..d94dd3e8b --- /dev/null +++ b/phoenix-ios/phoenix-ios/notifications/WithdrawRequest.swift @@ -0,0 +1,550 @@ +import Foundation +import PhoenixShared +import CryptoKit + +fileprivate let filename = "WithdrawRequest" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct WithdrawRequest { + let nodeId: String + let piccData: Data + let cmac: Data + let invoice: String + let timestamp: Date + let withdrawHash: String + + init(nodeId: String, piccData: Data, cmac: Data, invoice: String, timestamp: Date) { + self.nodeId = nodeId + self.piccData = piccData + self.cmac = cmac + self.invoice = invoice + self.timestamp = timestamp + self.withdrawHash = Self.calculateWithdrawHash( + nodeId: nodeId, piccData: piccData, cmac: cmac, invoice: invoice + ) + } + + private static func calculateWithdrawHash( + nodeId : String, + piccData : Data, + cmac : Data, + invoice : String + ) -> String { + + var hashMe = Data() + hashMe.append(nodeId.lowercased().data(using: .utf8)!) + hashMe.append(piccData.toHex(options: .lowerCase).data(using: .utf8)!) + hashMe.append(cmac.toHex(options: .lowerCase).data(using: .utf8)!) + hashMe.append(invoice.data(using: .utf8)!) + + let digest = SHA256.hash(data: hashMe) + return digest.toHex(options: .lowerCase) + } + + func postResponse(errorReason: String?) async -> Bool { + log.trace("postResponse(\(errorReason ?? ""))") + + let url = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/response")! + + var body: [String: String] = [ + "node_id" : nodeId, + "withdraw_hash" : withdrawHash, + ] + if let errorReason { + body["err_message"] = errorReason + } + + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = bodyData + + do { + log.debug("/v1/pub/lnurlw/response: sending...") + let (data, response) = try await URLSession.shared.data(for: request) + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/v1/pub/lnurlw/response: success") + } else { + log.debug("/v1/pub/lnurlw/response: statusCode: \(statusCode)") + if let dataString = String(data: data, encoding: .utf8) { + log.debug("/v1/pub/lnurlw/response: response:\n\(dataString)") + } + } + + return success + } catch { + log.debug("/v1/pub/lnurlw/response: error: \(String(describing: error))") + return false + } + } +} + +enum WithdrawRequestStatus { + case continueAndSendPayment( + card : BoltCardInfo, + invoice : Lightning_kmpBolt11Invoice, + amount : Lightning_kmpMilliSatoshi + ) + case abortHandledElsewhere(card: BoltCardInfo) +} + +enum WithdrawRequestError: Error, CustomStringConvertible { + case unknownCard + case replayDetected(card: BoltCardInfo) + case frozenCard(card: BoltCardInfo) + case dailyLimitExceeded(card: BoltCardInfo, amount: CurrencyAmount) + case monthlyLimitExceeded(card: BoltCardInfo, amount: CurrencyAmount) + case badInvoice(card: BoltCardInfo, details: String) + case alreadyPaidInvoice(card: BoltCardInfo) + case paymentPending(card: BoltCardInfo) + case internalError(card: BoltCardInfo, details: String) + + var description: String { + switch self { + case .unknownCard : return "unknown card" + case .replayDetected : return "replay detected" + case .frozenCard : return "frozen card" + case .dailyLimitExceeded : return "daily limit exceeded" + case .monthlyLimitExceeded : return "monthly limit exceeded" + case .badInvoice : return "bad invoice" + case .alreadyPaidInvoice : return "already paid invoice" + case .paymentPending : return "payment pending" + case .internalError : return "internal error" + } + } +} + +extension PhoenixBusiness { + + @MainActor + func checkWithdrawRequest( + _ request: WithdrawRequest + ) async -> Result { + + log.trace("checkWithdrawRequest()") + + // Step 1 of 9: + // Decrypt the piccData & verify the cmac values. + // + // Note that the user may have multiple cards, + // and we don't know which card is sending the request. + // So we simply make an attempt with each linked card. + + var cards: [BoltCardInfo] = self.cardsManager.cardsListValue + if cards.isEmpty { + // The CardManager instance is created lazily. + // And if we triggered creation just now, + // then the `cardsList` hasn't had time to update yet. + // So we work around this by directly querying the database. + // + do { + cards = try await self.appDb.listCards() + } catch { + log.error("appDb.listCards(): error: \(error)") + } + } + + log.debug("cards.count = \(cards.count)") + + var matchingCard: BoltCardInfo? = nil + var piccDataInfo: Ntag424.PiccDataInfo? = nil + + for card in cards { + + let keySet = Ntag424.KeySet( + piccDataKey : card.keys.piccDataKey_data, + cmacKey : card.keys.cmacKey_data + ) + let result = Ntag424.extractPiccDataInfo( + piccData : request.piccData, + cmac : request.cmac, + keySet : keySet + ) + + switch result { + case .failure(let err): + log.debug("card[\(card.id)]: err: \(err)") + + case .success(let result): + log.debug("card[\(card.id)]: success") + + matchingCard = card + piccDataInfo = result + break + } + } + + guard let matchingCard, let piccDataInfo else { + return .failure(.unknownCard) + } + + // Step 2 of 9: + // Check to make sure the counter has been incremented. + + guard piccDataInfo.counter > matchingCard.lastKnownCounter else { + return .failure(.replayDetected(card: matchingCard)) + } + + + let asyncDeferred = { @MainActor (result: Result) async + -> Result in + + var shouldUpdateCard = true + if case .success(let status) = result { + if case .abortHandledElsewhere = status { + shouldUpdateCard = false + } + } + + if shouldUpdateCard { + let updatedCard = matchingCard.withUpdatedLastKnownCounter(piccDataInfo.counter) + do { + try await self.cardsManager.saveCard(card: updatedCard) + } catch { + log.error("cardsManager.saveCard(): error: \(error)") + } + } + + return result + } + + // Step 3 of 9: + // Check to make sure the card isn't frozen. + + guard matchingCard.isActive else { + log.debug("card[\(matchingCard.id)]: isFrozen") + return await asyncDeferred(.failure(.frozenCard(card: matchingCard))) + } + + // Step 4 of 9: + // Check to make sure the given invoice is a valid Bolt11 invoice. + + guard let invoice = Parser.shared.readBolt11Invoice(input: request.invoice) else { + log.debug("request.invoice is not Bolt11Invoice") + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "not bolt 11 invoice"))) + } + + guard let invoiceAmount: Lightning_kmpMilliSatoshi = invoice.amount else { + log.debug("request.invoice.amount is nil") + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "amountless invoice"))) + } + + // Step 5 of 9: + // Validate the invoice. + // + // We know the invoice is a proper Bolt 11 invoice. + // But the SendManager performs additional checks such as: + // - chain mismatch + // - invoice is expired + // - already paid invoice + // - invoice has payment pending + // + // So we use the SendManager to perform those checks. + // + // Note that we already know the input is Bolt11 invoice, + // so we know which route it will take thru the parser. + + do { + let result: SendManager.ParseResult = + try await self.sendManager.parse( + request: request.invoice, + progress: { _ in /* ignore */ } + ) + + switch onEnum(of: result) { + case .badRequest(let badRequest): + log.debug("SendManager.ParseResult = BadRequest: \(badRequest)") + + switch onEnum(of: badRequest.reason) { + case .alreadyPaidInvoice(_): + return await asyncDeferred(.failure(.alreadyPaidInvoice(card: matchingCard))) + + case .paymentPending(_): + return await asyncDeferred(.failure(.paymentPending(card: matchingCard))) + + case .expired(_): + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "expired"))) + + case .chainMismatch(_): + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "chain mismatch"))) + + default: + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "parse error"))) + } + + case .success(_): + log.debug("SendManager.ParseResult = Success") + } + + } catch { + log.error("SendManager.parse(): threw error: \(error)") + return await asyncDeferred(.failure(.internalError(card: matchingCard, details: "parse error"))) + } + + // Step 6 of 9: + // Check the amount against any set daily/monthly spending limits. + + let checkSpendingLimit = { + (cardAmounts: CardsManager.CardAmounts, limit: CurrencyAmount, isDaily: Bool) -> WithdrawRequestError? in + + switch limit.currency { + case .bitcoin(let bitcoinUnit): + let limitMsat: Int64 = Utils.toMsat(from: limit.amount, bitcoinUnit: bitcoinUnit) + + let prvSpendMsat: Int64 = isDaily + ? cardAmounts.dailyBitcoinAmount().msat + : cardAmounts.monthlyBitcoinAmount().msat + + let newSpendMsat: Int64 = prvSpendMsat + invoiceAmount.msat + + log.debug( + """ + \(isDaily ? "dailySpendingLimit" : "monthlySpendingLimit"): \ + prvSpendMsat(\(prvSpendMsat)) + invoiceMsat(\(invoiceAmount.msat)) = \ + newSpendMsat(\(newSpendMsat)) ?>? limitMsat(\(limitMsat)) + """) + + if newSpendMsat > limitMsat { + let targetAmt = Utils.convertBitcoin(msat: invoiceAmount.msat, to: bitcoinUnit) + let currencyAmt = CurrencyAmount(currency: limit.currency, amount: targetAmt) + + return isDaily + ? .dailyLimitExceeded(card: matchingCard, amount: currencyAmt) + : .monthlyLimitExceeded(card: matchingCard, amount: currencyAmt) + } + + case .fiat(let fiatCurrency): + let limitFiat: Double = limit.amount + + let exchangeRates = self.currencyManager.ratesFlowValue + guard let exchangeRate = Utils.exchangeRate(for: fiatCurrency, fromRates: exchangeRates) else { + return .internalError(card: matchingCard, details: "missing exchange rate") + } + let invoiceFiat: Double = Utils.convertToFiat( + msat: invoiceAmount.msat, + exchangeRate: exchangeRate + ) + + let prvSpendFiat: Double = isDaily + ? cardAmounts.dailyFiatAmount(target: fiatCurrency, exchangeRates: exchangeRates) + : cardAmounts.monthlyFiatAmount(target: fiatCurrency, exchangeRates: exchangeRates) + + let newSpendFiat: Double = prvSpendFiat + invoiceFiat + + log.debug( + """ + \(isDaily ? "dailySpendingLimit" : "monthlySpendingLimit"): \ + prvSpendFiat(\(prvSpendFiat)) + invoiceFiatt(\(invoiceFiat)) = \ + newSpendFiat(\(newSpendFiat)) ?>? limitFiat(\(limitFiat)) + """) + + if newSpendFiat > limitFiat { + let targetAmt = CurrencyAmount(currency: limit.currency, amount: invoiceFiat) + + return isDaily + ? .dailyLimitExceeded(card: matchingCard, amount: targetAmt) + : .monthlyLimitExceeded(card: matchingCard, amount: targetAmt) + } + } + + return nil + } + + if matchingCard.dailyLimit != nil || matchingCard.monthlyLimit != nil { + + do { + let cardPaymentsMap: [Lightning_kmpUUID : CardsManager.CardPayments] = + try await self.cardsManager.fetchCardPayments() + + var cardAmounts = CardsManager.CardAmounts(daily: [], monthly: []) + if let cardPayments = cardPaymentsMap[matchingCard.id] { + cardAmounts = try await self.cardsManager.fetchCardAmounts( + payments : cardPayments, + fetcher : nil // use default fetcher + ) + } + + if let dailyLimit = matchingCard.dailyLimit?.toCurrencyAmount() { + if let error = checkSpendingLimit(cardAmounts, dailyLimit, true) { + return await asyncDeferred(.failure(error)) + } + } + if let monthlyLimit = matchingCard.monthlyLimit?.toCurrencyAmount() { + if let error = checkSpendingLimit(cardAmounts, monthlyLimit, false) { + return await asyncDeferred(.failure(error)) + } + } + + } catch { + return await asyncDeferred(.failure( + .internalError(card: matchingCard, details: "checking spending limits") + )) + } + } + + // Step 7 of 9: + // Wait until our peer is connected & all channels are ready. + // + // Note that there are safety mechanisms in place to ensure that + // only one process (mainPhoenixApp vs notifySrvExt) is able to + // connect to the peer at a time. + // That's why this step must preceed the following step. + + let target = AppConnectionsDaemon.ControlTarget.companion.Peer + for try await connections in self.connectionsManager.connectionsPublisher().values { + + log.debug("connections = \(connections)") + if connections.targetsEstablished(target) { + log.debug("Connected to peer") + break + } + } + + for try await channels in self.peerManager.channelsPublisher().values { + let allChannelsReady = channels.allSatisfy { $0.isTerminated || $0.isUsable || $0.isLegacyWait } + if allChannelsReady { + log.debug("All channels ready") + break + } else { + log.debug("One or more channels not ready...") + } + } + + // Step 8 of 9: + // Atomically mark request as handled. + // + // At this point we've decided that it's safe to pay the invoice. + // The only question is WHO is going to pay it: + // - mainPhoenixApp (us) + // - notifySrvExt (background process that could be running) + // + // So to be sure we don't accidentally pay an invoice TWICE, + // we have an atomic database method that will fail if the other + // process has already marked it as handled. + + let handledByUs = await self.appDb.tryMarkHandled(request, process: .phoenixApp) + + if handledByUs { + return await asyncDeferred(.success(.continueAndSendPayment( + card: matchingCard, invoice: invoice, amount: invoiceAmount + ))) + } else { + // The payment is being handled else. + // Or has already been handled elsewhere. + // Probably by the notifySrvExt. + // + // So we need to abort processing (do NOT pay invoice). + // + return await asyncDeferred(.success(.abortHandledElsewhere(card: matchingCard))) + } + } +} + +extension SqliteAppDb { + + enum ProcessId: String, Codable { + case phoenixApp = "phoenixApp" + case notifySrvExt = "notifySrvExt" + } + + struct LnurlWithdrawHandler: Codable { + let withdrawHash: String + let process: ProcessId + let date: Date + } + + @MainActor + func tryMarkHandled(_ request: WithdrawRequest, process: ProcessId) async -> Bool { + + let key = "lnurlWithdrawHandlers" + do { + while true { + let existing: KotlinPair? = try await self.getValue(key: key) + + var handlers: [LnurlWithdrawHandler] = [] + if let existing, let existingData = existing.first?.toSwiftData() { + + handlers = try JSONDecoder().decode([LnurlWithdrawHandler].self, from: existingData) + } + + log.debug("tryMarkHandled(): existing handlers.count = \(handlers.count)") + + let isHandledAlready = handlers.contains(where: { (item: LnurlWithdrawHandler) in + item.withdrawHash == request.withdrawHash + }) + + if isHandledAlready { + log.debug("tryMarkHandled(): isHandledAlready") + return false + } + + if !handlers.isEmpty { + // Cleanup: remove any handlers older than 7 days + + let oldDate = Date.now.addingTimeInterval(60 * 60 * 24 * -7) + handlers.removeAll(where: { item in + item.date < oldDate + }) + + log.debug("tryMarkHandled(): post-clean: handlers.count = \(handlers.count)") + } + + handlers.append(LnurlWithdrawHandler( + withdrawHash : request.withdrawHash, + process : process, + date : Date.now + )) + + log.debug("tryMarkHandled(): new handlers.count = \(handlers.count)") + + let updatedData = try JSONEncoder().encode(handlers) + let lastUpdated: KotlinLong? = existing?.second + + log.debug("tryMarkHandled(): lastUpdated = \(lastUpdated?.description ?? "")") + + let result = try await setValueIfUnchanged( + value : updatedData.toKotlinByteArray(), + key : key, + lastUpdated : lastUpdated + ) + + log.debug("tryMarkHandled(): result = \(result?.description ?? "")") + + if result != nil { + return true + } else { + // The call to setValueIfUnchanged failed. + // But that could happen for a number of reasons: + // - background app marked this request as handled + // - background app marked a different request as handled + // - foreground app marked a different request as handled + // + // So we need to start the process over again. + } + + } // + + } catch { + log.error("tryMarkHandled(): error: \(error)") + return false + } + } +} diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 3993cca8d..660046681 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -106,6 +106,12 @@ class BusinessManager { }.store(in: &appCancellables) WatchTower.shared.prepare() + + #if DEBUG + if let path = PlatformIosKt.getDatabaseFilesDirectoryPath(ctx: PlatformContext.default) { + log.debug("DB path: \(path)") + } + #endif } // -------------------------------------------------- @@ -447,6 +453,7 @@ class BusinessManager { self.walletInfo = _walletInfo maybeRegisterFcmToken() + maybeRegisterPushToken() let walletId = WalletIdentifier(chain: business.chain, walletInfo: _walletInfo) @@ -527,7 +534,7 @@ class BusinessManager { assertMainThread() self.pushToken = value - maybeRegisterFcmToken() + maybeRegisterPushToken() } public func setFcmToken(_ value: String) { @@ -547,6 +554,7 @@ class BusinessManager { if !oldPeerConnectionState.isEstablished() && newPeerConnectionState.isEstablished() { maybeRegisterFcmToken() + maybeRegisterPushToken() } } @@ -554,24 +562,19 @@ class BusinessManager { log.trace("maybeRegisterFcmToken()") assertMainThread() - if walletInfo == nil { - log.debug("maybeRegisterFcmToken: walletInfo is nil") - return - } - if fcmToken == nil { + guard let fcmToken else { log.debug("maybeRegisterFcmToken: fcmToken is nil") return } - if !(peerConnectionState is Lightning_kmpConnection.ESTABLISHED) { + guard peerConnectionState is Lightning_kmpConnection.ESTABLISHED else { log.debug("maybeRegisterFcmToken: peerConnection not established") return } - let token = self.fcmToken - log.debug("registering fcm token: \(token?.description ?? "")") - business.registerFcmToken(token: token) { error in - if let e = error { - log.error("failed to register fcm token: \(e.localizedDescription)") + log.debug("registering fcm token: \(fcmToken.description)") + business.registerFcmToken(token: fcmToken) { error in + if let error { + log.error("failed to register fcm token: \(error.localizedDescription)") } } @@ -594,6 +597,105 @@ class BusinessManager { // registration. Which we could then use to trigger a storage in UserDefaults. } + func maybeRegisterPushToken() -> Void { + log.trace("maybeRegisterPushToken()") + assertMainThread() + + guard let pushToken else { + log.debug("maybeRegisterPushToken: pushToken is nil") + return + } + guard let walletInfo else { + log.debug("maybeRegisterPushToken: walletInfo is nil") + return + } + guard peerConnectionState is Lightning_kmpConnection.ESTABLISHED else { + log.debug("maybeRegisterPushToken: peerConnection not established") + return + } + + let nodeIdHash = walletInfo.nodeId.hash160().toSwiftData().toHex() + assert(nodeIdHash == walletInfo.nodeIdHash) + + if let prvRegistration = Prefs.shared.pushTokenRegistration { + + if prvRegistration.pushToken == pushToken && + prvRegistration.nodeIdHash == nodeIdHash + { + // We've already registered our {pushToken, nodeId} tuple. + + if abs(prvRegistration.registrationDate.timeIntervalSinceNow) < 30.days() { + // The last registration was recent, so we can skip registration. + log.debug("Push token already registered") + return + + } else { + // It's been awhile since we last registered, so let's re-register. + // This is a self-healing mechanism, in case of server problems. + } + } + } + + let registration = PushTokenRegistration( + pushToken: pushToken, + nodeIdHash: nodeIdHash, + registrationDate: Date() + ) + + let url = URL(string: "https://s7r6lsmzk7.execute-api.us-west-2.amazonaws.com/v1/pub/push/register") + guard let requestUrl = url else { return } + + #if DEBUG + let platform = "iOS-development" + #else + // Note: This is actually wrong if you build-and-run using RELEASE mode. + let platform = "iOS-production" + #endif + + let body = [ + "app_id" : "co.acinq.phoenix", + "platform" : platform, + "push_token" : pushToken, + "node_id" : walletInfo.nodeId.value.toHex() + ] + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.httpBody = bodyData + + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/push/register: success") + Prefs.shared.pushTokenRegistration = registration + } + else if let error = error { + log.debug("/push/register: error: \(String(describing: error))") + } else { + log.debug("/push/register: statusCode: \(statusCode)") + if let data = data, let dataString = String(data: data, encoding: .utf8) { + log.debug("/push/register: response:\n\(dataString)") + } + } + } + + log.debug("/push/register ...") + task.resume() + } + // -------------------------------------------------- // MARK: Long-Lived Tasks // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/officers/PushManager.swift b/phoenix-ios/phoenix-ios/officers/PushManager.swift new file mode 100644 index 000000000..bfc231a40 --- /dev/null +++ b/phoenix-ios/phoenix-ios/officers/PushManager.swift @@ -0,0 +1,151 @@ +import SwiftUI +import PhoenixShared +import CryptoKit + +fileprivate let filename = "PushManager" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class PushManager { + + public static func processRemoteNotification( + _ userInfo: [AnyHashable : Any], + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("processRemoteNotification()") + + // This could be a push notification coming from either: + // - Google's Firebase Cloud Messaging (FCM) + // - Amazon Web Services (AWS) + + if PushNotification.isFCM(userInfo: userInfo) { + processRemoteNotification_fcm(userInfo, completionHandler) + } else { + processRemoteNotification_aws(userInfo, completionHandler) + } + } + + private static func processRemoteNotification_fcm( + _ userInfo: [AnyHashable : Any], + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("processRemoteNotification_fcm()") + + // All FCM notifications are for incoming payments. + // + // If the app is in the foreground: + // - we can ignore this notification + // + // If the app is in the background: + // - this notification was delivered to the notifySrvExt, which is in charge of processing it + + invoke(completionHandler, .noData) + } + + private static func processRemoteNotification_aws( + _ userInfo: [AnyHashable : Any], + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("processRemoteNotification_aws()") + + if let withdrawRequest = PushNotification.parseWithdrawRequest(userInfo: userInfo) { + Task { + await processRemoteNotification_aws_withdraw(withdrawRequest, completionHandler) + } + } else { + log.error("processRemoteNotification_aws: missing/invalid `acinq` section") + invoke(completionHandler, .noData) + } + } + + @MainActor + private static func processRemoteNotification_aws_withdraw( + _ request: WithdrawRequest, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) async { + + log.trace("processRequest_aws_withdraw()") + + let result = await Biz.business.checkWithdrawRequest(request) + + switch result { + case .failure(let error): + return reject(request, error, completionHandler) + + case .success(let status): + switch status { + case .abortHandledElsewhere: + return invoke(completionHandler, .newData) + + case .continueAndSendPayment(let card, let invoice, let amount): + guard + let peer = Biz.business.peerManager.peerStateValue(), + let defaultTrampolineFees = peer.walletParams.trampolineFees.first + else { + return reject( + request, + .internalError(card: card, details: "peer is nil"), + completionHandler + ) + } + + do { + try await Biz.business.sendManager.payBolt11Invoice( + amountToSend : amount, + trampolineFees : defaultTrampolineFees, + invoice : invoice, + metadata : WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("SendManager.payBolt11Invoice(): threw error: \(error)") + return reject( + request, + .internalError(card: card, details: "payBolt11Invoice failed"), + completionHandler + ) + } + + return accept(request, completionHandler) + } // + } // + } + + private static func reject( + _ request : WithdrawRequest, + _ error : WithdrawRequestError, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("reject(\(error.description))") + + Task { + let _ = await request.postResponse(errorReason: error.description) + invoke(completionHandler, .newData) + } + } + + private static func accept( + _ request: WithdrawRequest, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("accept()") + + Task { + let _ = await request.postResponse(errorReason: nil) + invoke(completionHandler, .newData) + } + } + + private static func invoke( + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void, + _ result: UIBackgroundFetchResult + ) { + log.trace("invoke(completionHandler, \(result))") + + DispatchQueue.main.async { + completionHandler(result) + } + } +} diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs.swift b/phoenix-ios/phoenix-ios/prefs/Prefs.swift index 721d4a135..ff107c3e5 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs.swift @@ -24,6 +24,8 @@ fileprivate enum Key: String { case serverMessageReadIndex case allowOverpayment case doNotShowChannelImpactWarning + case pushTokenRegistration + case lnurlWithdrawRegistration } fileprivate enum KeyDeprecated: String { @@ -172,6 +174,16 @@ class Prefs { set { defaults.doNotShowChannelImpactWarning = newValue } } + var pushTokenRegistration: PushTokenRegistration? { + get { defaults.pushTokenRegistration?.jsonDecode() } + set { defaults.pushTokenRegistration = newValue?.jsonEncode() } + } + + var lnurlWithdrawRegistration: LnurlWithdrawRegistration? { + get { defaults.lnurlWithdrawRegistration?.jsonDecode() } + set { defaults.lnurlWithdrawRegistration = newValue?.jsonEncode() } + } + // -------------------------------------------------- // MARK: Wallet State // -------------------------------------------------- @@ -258,6 +270,7 @@ class Prefs { defaults.removeObject(forKey: Key.serverMessageReadIndex.rawValue) defaults.removeObject(forKey: Key.allowOverpayment.rawValue) defaults.removeObject(forKey: Key.doNotShowChannelImpactWarning.rawValue) + defaults.removeObject(forKey: Key.pushTokenRegistration.rawValue) defaults.removeObject(forKey: KeyDeprecated.showChannelsRemoteBalance.rawValue) defaults.removeObject(forKey: KeyDeprecated.recentPaymentSeconds.rawValue) @@ -377,4 +390,14 @@ extension UserDefaults { get { bool(forKey: Key.doNotShowChannelImpactWarning.rawValue) } set { set(newValue, forKey: Key.doNotShowChannelImpactWarning.rawValue) } } + + @objc fileprivate var pushTokenRegistration: Data? { + get { data(forKey: Key.pushTokenRegistration.rawValue) } + set { set(newValue, forKey: Key.pushTokenRegistration.rawValue) } + } + + @objc fileprivate var lnurlWithdrawRegistration: Data? { + get { data(forKey: Key.lnurlWithdrawRegistration.rawValue) } + set { set(newValue, forKey: Key.lnurlWithdrawRegistration.rawValue) } + } } diff --git a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift index 1db1ca7aa..af931c254 100644 --- a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift +++ b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift @@ -58,6 +58,18 @@ enum PushPermissionQuery: String, Codable { case userAccepted } +struct PushTokenRegistration: Equatable, Codable { + let pushToken: String + let nodeIdHash: String + let registrationDate: Date +} + +struct LnurlWithdrawRegistration: Equatable, Codable { + let hexAddr: String + let nodeIdHash: String + let registrationDate: Date +} + struct ElectrumConfigPrefs: Equatable, Codable { let host: String let port: UInt16 diff --git a/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift b/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift index 29935be36..d481310e0 100644 --- a/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift +++ b/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift @@ -16,7 +16,7 @@ extension Currency { /// - currencyPrefs: Pass the view's EvironmentObject instance /// - plus: Optionally add an additional Currency to the end of the list /// - static func displayable(currencyPrefs: CurrencyPrefs, plus: Currency? = nil) -> [Currency] { + static func displayable(currencyPrefs: CurrencyPrefs, plus: [Currency]? = nil) -> [Currency] { var all = [Currency](GroupPrefs.shared.currencyConverterList) @@ -30,9 +30,11 @@ extension Currency { all.insert(preferredBitcoinUnit, at: 0) } - if let plus = plus { - if !all.contains(plus) { - all.append(plus) + if let plus { + for currency in plus { + if !all.contains(currency) { + all.append(currency) + } } } diff --git a/phoenix-ios/phoenix-ios/utils/Currency.swift b/phoenix-ios/phoenix-ios/utils/Currency.swift index f38a8f08a..e64b6f984 100644 --- a/phoenix-ios/phoenix-ios/utils/Currency.swift +++ b/phoenix-ios/phoenix-ios/utils/Currency.swift @@ -53,4 +53,14 @@ enum Currency: Hashable, Identifiable, CustomStringConvertible { return "fiat(\(currency.shortName))" } } + + static func fromKotlin(_ value: CurrencyUnit) -> Currency { + if let btcUnit = value as? BitcoinUnit { + return .bitcoin(btcUnit) + } + if let fiatCurrency = value as? FiatCurrency { + return .fiat(fiatCurrency) + } + fatalError("Unknown CurrencyUnit: \(value)") + } } diff --git a/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift b/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift index 3e890ea38..c426e777f 100644 --- a/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift +++ b/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift @@ -1,6 +1,25 @@ import Foundation +import PhoenixShared -struct CurrencyAmount: Equatable { +struct CurrencyAmount: Equatable, Hashable { let currency: Currency let amount: Double + + func toSpendingLimit() -> SpendingLimit { + switch currency { + case .bitcoin(let bitcoinUnit): + return SpendingLimit(currency: bitcoinUnit as CurrencyUnit, amount: amount) + case .fiat(let fiatCurrency): + return SpendingLimit(currency: fiatCurrency as CurrencyUnit, amount: amount) + } + } +} + +extension SpendingLimit { + func toCurrencyAmount() -> CurrencyAmount { + return CurrencyAmount( + currency: Currency.fromKotlin(self.currency), + amount: self.amount + ) + } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 75d0685f1..589c06506 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -42,6 +42,7 @@ struct ConfigurationList: View { case WalletInfo case ChannelsConfiguration case LogsConfiguration + case BoltCardsList case Experimental // Danger Zone case DrainWallet @@ -83,6 +84,7 @@ struct ConfigurationList: View { @Namespace var linkID_WalletInfo @Namespace var linkID_ChannelsConfiguration @Namespace var linkID_LogsConfiguration + @Namespace var linkID_BoltCardsList @Namespace var linkID_Experimental @Namespace var linkID_DrainWallet @Namespace var linkID_ResetWallet @@ -361,6 +363,15 @@ struct ConfigurationList: View { } .id(linkID_LogsConfiguration) + if hasWallet { + navLink_label(.BoltCardsList) { + Label { Text("Bolt cards") } icon: { + Image(systemName: "creditcard") + } + } + .id(linkID_BoltCardsList) + } + if hasWallet { navLink_label(.Experimental) { Label { Text("Experimental") } icon: { @@ -454,6 +465,7 @@ struct ConfigurationList: View { case .WalletInfo : WalletInfoView(popTo: popTo) case .ChannelsConfiguration : ChannelsConfigurationView() case .LogsConfiguration : LogsConfigurationView() + case .BoltCardsList : BoltCardsList() case .Experimental : Experimental() // Danger Zone case .DrainWallet : DrainWalletView(popTo: popTo) @@ -550,6 +562,7 @@ struct ConfigurationList: View { case .WalletInfo : return linkID_WalletInfo case .ChannelsConfiguration : return linkID_ChannelsConfiguration case .LogsConfiguration : return linkID_LogsConfiguration + case .BoltCardsList : return linkID_BoltCardsList case .Experimental : return linkID_Experimental case .DrainWallet : return linkID_DrainWallet diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift new file mode 100644 index 000000000..f0306c4e8 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift @@ -0,0 +1,127 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ArchiveCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ArchiveCardSheet: View { + + let card: BoltCardInfo + let didArchive: (BoltCardInfo) -> Void + + @State var isUpdatingCard: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Archive card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text( + """ + Once a card is archived it can never be activated again. \ + The card will remain in your list, but will be moved to the Archived section. + """ + ) + + Text( + """ + Use this option if your card is permanantly lost or stolen. + """ + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + if isUpdatingCard { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + } + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .disabled(isUpdatingCard) + .padding(.trailing, 24) + + Button { + archiveButtonTapped() + } label: { + Text("Archive").font(.title3).foregroundStyle(Color.red) + } + .disabled(isUpdatingCard) + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace("cancelButtonTapped()") + + smartModalState.close() + } + + func archiveButtonTapped() { + log.trace("archiveButtonTapped()") + + isUpdatingCard = true + Task { @MainActor in + + var result: BoltCardInfo? = nil + do { + // Try to get the most recent version of the card, + // just in-case any changes were made elsewhere in the system. + // + let currentCard = Biz.business.cardsManager.cardForId(cardId: card.id) ?? card + let updatedCard = currentCard.archivedCopy() + + try await Biz.business.cardsManager.saveCard(card: updatedCard) + result = updatedCard + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + + self.isUpdatingCard = false + self.smartModalState.close(animationCompletion: { + if let result { + self.didArchive(result) + } + }) + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift new file mode 100644 index 000000000..449670e8d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift @@ -0,0 +1,141 @@ +import SwiftUI + +fileprivate let filename = "BoltCardsHelp" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct BoltCardsHelp: View { + + @Binding var isShowing: Bool + + @ViewBuilder + var body: some View { + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + Spacer() + } + } + + @ViewBuilder + func header() -> some View { + + // close button + // (required for landscapse mode, where swipe-to-dismiss isn't possible) + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + close() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + } // + .padding() + } + + @ViewBuilder + func content() -> some View { + + ScrollView(.vertical) { + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + content_intro() + content_whereToBuy() + content_howDoesItWork() + } // + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal) + .padding(.horizontal) + .padding(.bottom) + } + } + + @ViewBuilder + func content_intro() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 24) { + + Text("Bolt Card") + .font(.title) + + Text("Bitcoin payments over the lightning network with a contactless payment card.") + + Text( + """ + You can link multiple debit cards to your wallet. \ + Set custom spending limits per card, and freeze a card at anytime. + """ + ) + + } // + } + + @ViewBuilder + func content_whereToBuy() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + + Text("Where can I buy bolt cards ?") + .font(.headline) + + Text( + """ + What you need are blank NFC "NTAG 424 DNA" cards. \ + You can buy them from many different vendors. + """ + ) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) // text truncation bugs + .padding(.bottom, 8) + + Text(" • [CoinCorner.com](https://www.coincorner.com/BuyTheBoltCard)") + Text(" • [Laser Eyes Cards](https://lasereyes.cards/)") + Text(" • [PlebTag](https://plebtag.com/)") + Text(" • [Yanabu Bolt Card - Korea](https://marpple.shop/kr/yanabu/products/13356281)") + Text(" • [Bolt Ring](https://bitcoin-ring.com/)") + Text(" • [NFC.cards](https://nfc.cards/en/white-cards/46-nfc-card-ntag424-dna.html)") + Text(" • [ZipNFC.com](https://zipnfc.com/nfc-pvc-card-credit-card-size-ntag424-dna.html)") + Text(" • [Hirsch](https://shop.hirschsecure.com/products/printed-nxp-ntag-424-dna-tag-5-pack)") + } + } + + @ViewBuilder + func content_howDoesItWork() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 24) { + + Text("How does Bolt card work ?") + .font(.headline) + + Text( + """ + The NFC card is programmed with a BLIP XX address and a set of secure keys. \ + The card then produces the address plus two unique hashes that change each \ + time the card is scanned. + """ + ) + Text( + """ + The merchant can use these values to make a one time request to your wallet. \ + After that, the card must be tapped again to get fresh values. + """ + ) + Text( + """ + Your wallet verifies the card is not frozen, and checks the payment amount \ + against any daily/monthly spending limits you may have configured. + """ + ) + } // + } + + func close() { + log.trace("close()") + + isShowing = false + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsList.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsList.swift new file mode 100644 index 000000000..dd84ca962 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsList.swift @@ -0,0 +1,548 @@ +import SwiftUI +import PhoenixShared +import CoreNFC + +fileprivate let filename = "BoltCardsList" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct BoltCardsList: View { + + enum NavLinkTag: Hashable, CustomStringConvertible { + case ManageBoltCard(cardInfo: BoltCardInfo, isNewCard: Bool) + + var description: String { + switch self { + case .ManageBoltCard(let info, _) : return "ManageBoltCard(\(info.name))" + } + } + } + + @State var sortedCards: [BoltCardInfo] = [] + @State var archivedCards: [BoltCardInfo] = [] + + @State var isFetchingLnurlwAddr: Bool = false + @State var lnurlwAddrFetchError: Bool = false + + @State var archivedCardsHidden: Bool = true + @State var nfcUnavailable: Bool = false + @State var showHelpSheet: Bool = false + @State var didAppear: Bool = false + + // + @State var navLinkTag: NavLinkTag? = nil + // + + @EnvironmentObject var navCoordinator: NavigationCoordinator + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + content() + .navigationTitle("Bolt Cards") + .navigationBarTitleDisplayMode(.inline) + .navigationStackDestination(isPresented: navLinkTagBinding()) { // iOS 16 + navLinkView() + } + .navigationStackDestination(for: NavLinkTag.self) { tag in // iOS 17+ + navLinkView(tag) + } + .toolbar { toolbarItems() } + } + + @ToolbarContentBuilder + func toolbarItems() -> some ToolbarContent { + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + readCard() + } label: { + Label { + Text("Read card…") + } icon: { + Image(systemName: "creditcard") + } + } + .disabled(nfcUnavailable) + + } label: { + Image(systemName: "ellipsis") + } + } + } + + @ViewBuilder + func content() -> some View { + + List { + section_info() + if !sortedCards.isEmpty { + section_cards() + } + if !archivedCards.isEmpty { + section_archived_cards() + } + section_new() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onAppear { + onAppear() + } + .onReceive(Biz.business.cardsManager.cardsListPublisher()) { + cardsListChanged($0) + } + .sheet(isPresented: $showHelpSheet) { + BoltCardsHelp(isShowing: $showHelpSheet) + } + } + + @ViewBuilder + func section_info() -> some View { + + Section { + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + Text("Link a **physical card** to your Phoenix wallet.") + .multilineTextAlignment(.center) + + Text("Then make **contactless payments** at supporting merchants.") + .multilineTextAlignment(.center) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 0) + Button { + showHelpSheet = true + } label: { + Text("learn more") + .font(.callout) + } + } + .padding(.top, 5) + + } // + .background( + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Image(systemName: "creditcard").resizable().scaledToFit() + Spacer() + Image(systemName: "wave.3.forward").resizable().scaledToFit() + } // + .opacity(0.03) + ) + } // + } + + @ViewBuilder + func section_cards() -> some View { + + Section { + ForEach(sortedCards) { cardInfo in + navLink(.ManageBoltCard(cardInfo: cardInfo, isNewCard: false)) { + section_cards_item(cardInfo) + } + } + + } header: { + Text("Linked Cards") + } + } + + @ViewBuilder + func section_cards_item(_ cardInfo: BoltCardInfo) -> some View { + + HStack(alignment: VerticalAlignment.top, spacing: 0) { + + Group { + if cardInfo.isForeign { + Image(systemName: "key.radiowaves.forward.fill") + .resizable() + .foregroundStyle(Color.white) + } else { + Image("boltcard") + .resizable() + } + } + .scaledToFit() + .aspectRatio(contentMode: .fit) + .frame(width: 42, height: 42, alignment: .center) + .padding(.all, 8) + .background(Color.black.cornerRadius(8)) + .padding(.trailing, 10) + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + Text(cardInfo.sanitizedName) + .lineLimit(1) + .truncationMode(.tail) + .font(.title2) + + Group { + if cardInfo.isFrozen { + Text("Status: Frozen") + } else { + Text("Status: Active") + } + } + .foregroundStyle(.secondary) + } + } + .listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + + @ViewBuilder + func section_archived_cards() -> some View { + + Section { + if !archivedCardsHidden { + ForEach(archivedCards) { cardInfo in + navLink(.ManageBoltCard(cardInfo: cardInfo, isNewCard: false)) { + Text(cardInfo.sanitizedName) + } + } + } + + } header: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Archived Cards") + Spacer() + Button { + withAnimation { + archivedCardsHidden.toggle() + } + } label: { + if archivedCardsHidden { + Image(systemName: "eye") + } else { + Image(systemName: "eye.slash") + } + } + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + func section_new() -> some View { + + Section { + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + + #if DEBUG + Button {/* using simultaneousGesture below */} label: { + Text("Create New Debit Card") + .font(.title3.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(nfcUnavailable || isFetchingLnurlwAddr) + .simultaneousGesture(TapGesture().onEnded { _ in + log.debug("simultaneousGesture: TapGesture") + createNewDebitCard() + }) + .simultaneousGesture(LongPressGesture(minimumDuration: 2.0).onEnded { _ in + log.debug("simultaneousGesture: LongPressGesture") + createDebitCardForSimulator() + }) + #else + Button { + createNewDebitCard() + } label: { + Text("Create New Debit Card") + .font(.title3.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(nfcUnavailable || isFetchingLnurlwAddr) + #endif + + if nfcUnavailable { + Text("NFC capabilities not available on this device.") + .multilineTextAlignment(.center) + .foregroundStyle(Color.appNegative) + } else if lnurlwAddrFetchError { + Text("Error fetching registration. Please check internet connection.") + .multilineTextAlignment(.center) + .foregroundStyle(Color.appNegative) + } else if isFetchingLnurlwAddr { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + Text("Preparing system for NFC...") + } + } + + } // + .frame(maxWidth: .infinity) + + } // + .listRowBackground(Color.clear) + } + + @ViewBuilder + private func navLink( + _ tag: NavLinkTag, + label: @escaping () -> Content + ) -> some View where Content: View { + + if #available(iOS 17, *) { + NavigationLink(value: tag, label: label) + } else { + NavigationLink_16( + destination: navLinkView(tag), + tag: tag, + selection: $navLinkTag, + label: label + ) + } + } + + @ViewBuilder + func navLinkView() -> some View { + + if let tag = self.navLinkTag { + navLinkView(tag) + } else { + EmptyView() + } + } + + @ViewBuilder + func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .ManageBoltCard(let cardInfo, let isNewCard): + ManageBoltCard(cardInfo: cardInfo, isNewCard: isNewCard) + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + if !didAppear { + didAppear = true + + // First time displaying this View + + #if targetEnvironment(simulator) + // We know the simulator doesn't have NFC capabilities. + // But we have a workaround to support linking a card to a simulator. + // Which is quite helpful for testing. + #else + if !NFCReaderSession.readingAvailable { + nfcUnavailable = true + } + #endif + + } else { + // We are returning to this View + } + } + + func cardsListChanged(_ updatedList: [BoltCardInfo]) { + log.trace("cardsListChanged()") + + sortedCards = updatedList.filter { !$0.isArchived }.sorted { cardA, cardB in + // return true if `cardA` should be ordered before `cardB`; otherwise return false + return (cardA.createdAtDate < cardB.createdAtDate) + } + + archivedCards = updatedList.filter { $0.isArchived }.sorted { cardA, cardB in + // return true if `cardA` should be ordered before `cardB`; otherwise return false + return (cardA.createdAtDate < cardB.createdAtDate) + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func navigateTo(_ tag: NavLinkTag) { + log.trace("navigateTo(\(tag.description))") + + if #available(iOS 17, *) { + navCoordinator.path.append(tag) + } else { + navLinkTag = tag + } + } + + func createNewDebitCard() { + log.trace("createNewDebitCard()") + + fetchLnurlWithdrawAddress() + } + + func createDebitCardForSimulator() { + log.trace("createDebitCardForSimulator()") + + smartModalState.display(dismissable: true) { + SimulatorWriteSheet() + } + } + + // -------------------------------------------------- + // MARK: Create Card + // -------------------------------------------------- + + func fetchLnurlWithdrawAddress() { + log.trace("fetchLnurlWithdrawAddress()") + + // Developer Note: + // This registration process will **NOT** be needed after we develop the new protocol. + + let continueToNextStep = {(registration: LnurlWithdrawRegistration?) in + + if let hexAddr = registration?.hexAddr { + #if targetEnvironment(simulator) + presentSimulatorPasteSheet(hexAddr) + #else + writeToNfcCard(hexAddr) + #endif + } else { + lnurlwAddrFetchError = true + } + } + + if let existingRegistration = LnurlwRegistration.existingRegistration() { + continueToNextStep(existingRegistration) + } else { + isFetchingLnurlwAddr = true + lnurlwAddrFetchError = false + + Task { @MainActor in + let registration = await LnurlwRegistration.fetchRegistration() + isFetchingLnurlwAddr = false + continueToNextStep(registration) + } + } + } + + func presentSimulatorPasteSheet(_ hexAddr: String) { + log.trace("presentSimulatorPasteSheet()") + + smartModalState.display(dismissable: true) { + SimulatorPasteSheet(hexAddr: hexAddr) + } + } + + func writeToNfcCard(_ hexAddr: String) { + log.trace("writeToNfcCard()") + + let keys = BoltCardKeySet.companion.random() + + let baseUrl = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/info?id=\(hexAddr)")! + let template = Ndef.Template(baseUrl: baseUrl)! + + log.debug("template.url: \(template.urlString)") + log.debug("template.piccDataOffset: \(template.piccDataOffset)") + log.debug("template.cmacOffset: \(template.cmacOffset)") + + let input = NfcWriter.WriteInput( + template : template, + key0 : keys.key0_bytes, + piccDataKey : keys.piccDataKey_bytes, + cmacKey : keys.cmacKey_bytes + ) + + NfcWriter.shared.writeCard(input) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("error: \(error)") + showWriteErrorSheet(error) + + case .success(let output): + log.debug("output.chipUid: \(output.chipUid.toHex())") + saveNewCard(keys, output) + } + } + } + + func saveNewCard( + _ keys: BoltCardKeySet, + _ output: NfcWriter.WriteOutput + ) { + + // Conversion madness: [UInt8] -> Data -> ByteArray -> ByteVector + let uidByteArray = Helper.dataFromBytes(bytes: output.chipUid).toKotlinByteArray() + let uid = Bitcoin_kmpByteVector(bytes: uidByteArray) + + let cardInfo = BoltCardInfo(name: "", keys: keys, uid: uid, isForeign: false) + + Task { @MainActor in + do { + try await Biz.business.cardsManager.saveCard(card: cardInfo) + navigateTo(.ManageBoltCard(cardInfo: cardInfo, isNewCard: true)) + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace("showWriteErrorSheet()") + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileWriting) + } + } + + // -------------------------------------------------- + // MARK: Read Card + // -------------------------------------------------- + + func readCard() { + log.trace("readCard()") + + NfcReader.shared.readCard { (result: Result) in + + var shouldIgnoreError = false + if case let .failure(error) = result { + if case let .scanningTerminated(nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + } + + guard !shouldIgnoreError else { + log.debug("NfcReader.readCard(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + ReadCardSheet(result: result) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift new file mode 100644 index 000000000..49165476c --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift @@ -0,0 +1,124 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "DeleteCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct DeleteCardSheet: View { + + let card: BoltCardInfo + let didDelete: () -> Void + + @State var isUpdatingCard: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Delete card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text( + """ + Are you sure you want to delete this card? + """ + ) + + Text( + """ + Any payments made with this card will remain in your transaction history, \ + but will no longer be linked with any card. + """ + ) + + if !card.isReset { + Text( + """ + If you still have access to the physical card, \ + it's recommended that you **reset** the card first. \ + This will allow it to be linked again with any wallet. + """ + ) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + if isUpdatingCard { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + } + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .padding(.trailing, 24) + + Button { + deleteButtonTapped() + } label: { + Text("Delete").font(.title3).foregroundStyle(Color.red) + } + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace("cancelButtonTapped()") + + smartModalState.close() + } + + func deleteButtonTapped() { + log.trace("deleteButtonTapped()") + + isUpdatingCard = true + Task { @MainActor in + do { + try await Biz.business.cardsManager.deleteCard(cardId: card.id) + } catch { + log.error("CardsManager.updateCard(): error: \(error)") + } + + self.isUpdatingCard = false + self.smartModalState.close(animationCompletion: { + self.didDelete() + }) + } + } +} + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift new file mode 100644 index 000000000..a328d826d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift @@ -0,0 +1,112 @@ +import Foundation + +fileprivate let filename = "LnurlwRegistration" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +/// Developer Note: +/// This registration process will **NOT** be needed after we develop the new protocol. +/// +class LnurlwRegistration { + + struct LnurlWithdrawRegisterResponse: Decodable { + let node_id: String + let hex_addr: String + } + + static func existingRegistration() -> LnurlWithdrawRegistration? { + log.trace("existingRegistration()") + + guard let nodeIdHash: String = Biz.nodeIdHash else { + return nil + } + + if let prvRegistration = Prefs.shared.lnurlWithdrawRegistration { + if prvRegistration.nodeIdHash == nodeIdHash { + // We've already registered. + log.debug("LnurlWithdraw: already registered") + return prvRegistration + } + } + + return nil + } + + static func fetchRegistration() async -> LnurlWithdrawRegistration? { + log.trace("fetchRegistration()") + + // **Developer Note**: + // This registration process will NOT be needed after we develop the new protocol. + + guard + let nodeId: String = Biz.nodeId, + let nodeIdHash: String = Biz.nodeIdHash + else { + return nil + } + + let url = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/me") + guard let requestUrl = url else { return nil } + + let body = [ + "node_id": nodeId + ] + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.httpBody = bodyData + + var registration: LnurlWithdrawRegistration? = nil + do { + log.debug("/lnurlw/me: sending...") + let (data, response) = try await URLSession.shared.data(for: request) + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/lnurlw/me: success") + + let response: LnurlWithdrawRegisterResponse + do { + response = try JSONDecoder().decode(LnurlWithdrawRegisterResponse.self, from: data) + log.debug("/lnurlw/me: hex_addr: \(response.hex_addr)") + + // Store the value in Prefs so we can skip this step in the future + registration = LnurlWithdrawRegistration( + hexAddr: response.hex_addr, + nodeIdHash: nodeIdHash, + registrationDate: Date.now + ) + Prefs.shared.lnurlWithdrawRegistration = registration + + } catch { + log.debug("/lnurlw/me: JSON decoding error: \(error)") + } + } else { + log.debug("/lnurlw/me: statusCode: \(statusCode)") + if let dataString = String(data: data, encoding: .utf8) { + log.debug("/lnurlw/me: response:\n\(dataString)") + } + } + + } catch { + log.debug("/lnurlw/me: error: \(error)") + } + + return registration + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift new file mode 100644 index 000000000..fe70d01ed --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift @@ -0,0 +1,1342 @@ +import SwiftUI +import PhoenixShared +import Combine + +fileprivate let filename = "ManageBoltCard" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ManageBoltCard: View { + + enum NavLinkTag: Hashable, CustomStringConvertible { + + case CurrencyConverter( + initialAmount : CurrencyAmount?, + didChange : ((CurrencyAmount?) -> Void)?, + didClose : (() -> Void)? + ) + + private var internalValue: Int { + switch self { + case .CurrencyConverter(_, _, _): return 1 + } + } + + static func == (lhs: NavLinkTag, rhs: NavLinkTag) -> Bool { + return lhs.internalValue == rhs.internalValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.internalValue) + } + + var description: String { + switch self { + case .CurrencyConverter: return "CurrencyConverter" + } + } + } + + struct SpendingLimitGraphInfo { + let spent: String + let spentAmount: Double + + let remaining: String + let remainingAmount: Double + + let total: String + let totalAmount: Double + } + + @State var cardInfo: BoltCardInfo + let isNewCard: Bool + + @State var name: String = "" + @State var isActive: Bool = true + + @State var currencyList: [Currency] = [Currency.bitcoin(.sat)] + + @State var dailyLimit_currencyStr: String = Currency.bitcoin(.sat).shortName + @State var dailyLimit_currency: Currency = Currency.bitcoin(.sat) + @State var dailyLimit_amountStr: String = "" + @State var dailyLimit_parsedAmount: Result = .failure(.emptyInput) + + @State var monthlyLimit_currencyStr: String = Currency.bitcoin(.sat).shortName + @State var monthlyLimit_currency: Currency = Currency.bitcoin(.sat) + @State var monthlyLimit_amountStr: String = "" + @State var monthlyLimit_parsedAmount: Result = .failure(.emptyInput) + + @State var cardAmounts: CardsManager.CardAmounts? = nil + + @State var dailyCardPaymentsAmount: Double = 0 + @State var monthlyCardPaymentsAmount: Double = 0 + + @State var isSaving: Bool = false + @State var showDiscardChangesConfirmationDialog: Bool = false + @State var showDeleteContactConfirmationDialog: Bool = false + + @State var didAppear: Bool = false + @State var didDisplayWelcome: Bool = false + + @State var ignoreChanges: Bool = true + @State var isFirstUserEdit: Bool = true + + @State var popoverPresent_dailyLimit: Bool = false + @State var popoverPresent_monthlyLimit: Bool = false + + @State var cardWasArchived: Bool = false + @State var cardWasReset: Bool = false + + // + @State var navLinkTag: NavLinkTag? = nil + // + + @StateObject var toast = Toast() + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var smartModalState: SmartModalState + @EnvironmentObject var navCoordinator: NavigationCoordinator + + let didBecomeActivePublisher = NotificationCenter.default.publisher( + for: UIApplication.didBecomeActiveNotification + ) + + init(cardInfo: BoltCardInfo, isNewCard: Bool) { + self.cardInfo = cardInfo + self.isNewCard = isNewCard + } + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + layers() + .navigationTitle("Manage Card") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { toolbarItems() } + .navigationStackDestination(isPresented: navLinkTagBinding()) { // iOS 16 + navLinkView() + } + .navigationStackDestination(for: NavLinkTag.self) { tag in // iOS 17+ + navLinkView(tag) + } + .task { + await fetchCardAmounts() + } + } + + @ToolbarContentBuilder + func toolbarItems() -> some ToolbarContent { + + if isNewCard || cardWasArchived || cardWasReset { + ToolbarItem(placement: .navigationBarLeading) { + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) + } + .disabled(!canSave || isSaving) // subtle difference here + .accessibilityLabel("Save changes") + } + } else { + ToolbarItem(placement: .navigationBarLeading) { + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.headline) + } + .disabled(isSaving) + .accessibilityLabel("Discard changes") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) + } + .disabled(!hasChanges || !canSave || isSaving) + .accessibilityLabel("Save changes") + } + } + } + + @ViewBuilder + func layers() -> some View { + + ZStack { + content() + toast.view() + } + } + + @ViewBuilder + func content() -> some View { + + List { + section_name() + if !cardInfo.isForeign { + section_status() + } + if !cardInfo.isForeign && !cardInfo.isArchived { + section_limits() + } + section_managementTasks() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onAppear { + onAppear() + } + .onChange(of: cardInfo) { _ in + cardInfoChanged() + } + .onChange(of: isActive) { _ in + isActiveChanged() + } + .onChange(of: dailyLimit_currencyStr) { _ in + dailyLimit_currencyPickerDidChange() + } + .onChange(of: monthlyLimit_currencyStr) { _ in + monthlyLimit_currencyPickerDidChange() + } + .onChange(of: dailyLimit_currency) { _ in + dailyLimit_currencyChanged() + } + .onChange(of: monthlyLimit_currency) { _ in + monthlyLimit_currencyChanged() + } + .onChange(of: cardAmounts) { _ in + cardAmountsChanged() + } + .onReceive(didBecomeActivePublisher) { _ in + applicationDidBecomeActive() + } + .confirmationDialog("Discard changes?", + isPresented: $showDiscardChangesConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Discard changes", role: ButtonRole.destructive) { + close() + } + } + } + + @ViewBuilder + func section_name() -> some View { + + Section { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + TextField(BoltCardInfo.defaultName, text: $name) + + // Clear button (appears when TextField's text is non-empty) + Button { + name = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .isHidden(name.isEmpty) + } + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + } header: { + Text("Name") + } + } + + @ViewBuilder + func section_status() -> some View { + + Section { + + HStack(alignment: VerticalAlignment.centerTopLine) { + + Group { + if isActive { + Text("Active") + } else if cardInfo.isArchived { + Text("Frozen (archived)", comment: "translate: archived") + } else { + Text("Frozen") + } + } + .font(.title3.weight(.medium)) + + Spacer() + + Toggle("", isOn: $isActive) + .labelsHidden() + .disabled(cardInfo.isArchived) + .padding(.trailing, 2) + + } // + + Group { + if isActive { + Text("An active card can be used for payments.") + } else { + Text("All payment attempts will be rejected.") + } + } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) // SwiftUI truncation bugs + .foregroundColor(Color.secondary) + .padding(.top, 8) + .padding(.bottom, 4) + + } header: { + Text("Status") + } + } + + @ViewBuilder + func section_limits() -> some View { + + Section { + + section_limits_daily() + .padding(.top, 4) + .padding(.bottom, 8) + + section_limits_monthly() + .padding(.top, 8) + .padding(.bottom, 4) + + } header: { + Text("Spending Limits") + } + } + + @ViewBuilder + func section_limits_daily() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Text("**Daily** spending limit:") + Spacer(minLength: 0) + Button { + popoverPresent_dailyLimit = true + } label: { + Image(systemName: "info.circle") + } + .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped + .foregroundColor(.secondary) + .popover(present: $popoverPresent_dailyLimit) { + InfoPopoverWindow { + Text("Limit applies from midnight to midnight (local time).") + } + } + + } // + .padding(.bottom, 8) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + TextField( + "None", + text: dailyLimit_currencyStyler().amountProxy + ) + .keyboardType(.decimalPad) + .disableAutocorrection(true) + .disabled(isSaving) + .foregroundColor(dailyLimit_parsedAmount.isError ? Color.appNegative : Color.primaryForeground) + + Picker( + selection: $dailyLimit_currencyStr, + label: Text("") + ) { + ForEach(currencyPickerOptions(), id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + .disabled(isSaving) + .accessibilityLabel("") // see below + .accessibilityHint("Currency picker") + + // For a Picker, iOS is setting the VoiceOver text twice: + // > "sat sat, Button" + // + // If we change the accessibilityLabel to "foobar", then we get: + // > "sat foobar, Button" + // + // So we have to set it to the empty string to avoid the double-word. + + } // + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom, 16) + + section_limits_graph(dailyLimit_graphInfo()) + .padding(.bottom, 8) + + } // + } + + @ViewBuilder + func section_limits_monthly() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Text("**Monthly** spending limit:") + Spacer(minLength: 0) + Button { + popoverPresent_monthlyLimit = true + } label: { + Image(systemName: "info.circle") + } + .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped + .foregroundColor(.secondary) + .popover(present: $popoverPresent_monthlyLimit) { + InfoPopoverWindow { + Text("Limit applies from 1st of the month at midnight to the following 1st (local time).") + } + } + } // + .padding(.bottom, 8) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + TextField( + "None", + text: monthlyLimit_currencyStyler().amountProxy + ) + .keyboardType(.decimalPad) + .disableAutocorrection(true) + .disabled(isSaving) + .foregroundColor(monthlyLimit_parsedAmount.isError ? Color.appNegative : Color.primaryForeground) + + Picker( + selection: $monthlyLimit_currencyStr, + label: Text("") + ) { + ForEach(currencyPickerOptions(), id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + .disabled(isSaving) + .accessibilityLabel("") // see below + .accessibilityHint("Currency picker") + + // For a Picker, iOS is setting the VoiceOver text twice: + // > "sat sat, Button" + // + // If we change the accessibilityLabel to "foobar", then we get: + // > "sat foobar, Button" + // + // So we have to set it to the empty string to avoid the double-word. + + } // + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom, 16) + + section_limits_graph(monthlyLimit_graphInfo()) + .padding(.bottom, 8) + + } // + } + + @ViewBuilder + func section_limits_graph(_ graphInfo: SpendingLimitGraphInfo) -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Image(systemName: "square.fill") + .font(.subheadline) + .imageScale(.small) + .foregroundColor(spentBalanceColor) + Text("Spent") + Spacer(minLength: 0) + Text("Remaining") + Image(systemName: "square.fill") + .imageScale(.small) + .font(.subheadline) + .foregroundColor(remainingBalanceColor) + } + + ProgressView(value: graphInfo.spentAmount, total: graphInfo.totalAmount) + .tint(spentBalanceColor) + .background(remainingBalanceColor) + .padding(.vertical, 4) + + HStack(alignment: VerticalAlignment.center, spacing: 2) { + Text(graphInfo.spent) + Spacer(minLength: 0) + Text(graphInfo.remaining) + } + .font(.callout) + .foregroundColor(.primary.opacity(0.8)) + + } // + } + + @ViewBuilder + func section_managementTasks() -> some View { + + Section { + + if !cardInfo.isArchived && !cardInfo.isForeign { + Button { + archiveCard() + } label: { + Text("Archive card…") + } + } + if !cardInfo.isReset { + Button { + resetPhysicalCard() + } label: { + Text("Reset physical card…") + } + } + Button("Delete card…", role: ButtonRole.destructive) { + deleteCard() + } + + } header: { + Text("Management Tasks") + } + } + + @ViewBuilder + func currencyText(_ option: CurrencyPickerOption) -> some View { + + // From what I can tell, Apple won't let us do any formatting here. + // Things I've tried that don't work: + // + // #1 + // ``` + // HStack {Text("A") Text("B")} + // ``` + // ^ You just get "A" + // + // #2 + // ``` + // Text("A") + Text("B").fontWeight(.thin) + // ``` + // ^ You just get "AB" without the formatting + + switch option { + case .currency(let currency): + Text(currency.shortName) + case .other: + Text(option.description) + } + } + + @ViewBuilder + func navLinkView() -> some View { + + if let tag = self.navLinkTag { + navLinkView(tag) + } else { + EmptyView() + } + } + + @ViewBuilder + func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .CurrencyConverter(let initialAmount, let didChange, let didClose): + CurrencyConverterView( + initialAmount: initialAmount, + didChange: didChange, + didClose: didClose + ) + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + + func dailyLimit_currencyStyler() -> TextFieldCurrencyStyler { + + return TextFieldCurrencyStyler( + currency: dailyLimit_currency, + amount: $dailyLimit_amountStr, + parsedAmount: $dailyLimit_parsedAmount, + hideMsats: false, + userDidEdit: dailyLimit_userDidEdit + ) + } + + func monthlyLimit_currencyStyler() -> TextFieldCurrencyStyler { + + return TextFieldCurrencyStyler( + currency: monthlyLimit_currency, + amount: $monthlyLimit_amountStr, + parsedAmount: $monthlyLimit_parsedAmount, + hideMsats: false, + userDidEdit: monthlyLimit_userDidEdit + ) + } + + func currencyPickerOptions() -> [String] { + + var options = [String]() + for currency in currencyList { + options.append(currency.shortName) + } + + options.append( + String( + localized: "other", + comment: "Option in currency picker list. Sends user to Currency Converter" + ) + ) + + return options + } + + func dailyLimit_isInvalidAmount() -> Bool { + return isInvalidAmount(dailyLimit_parsedAmount) + } + + func monthlyLimit_isInvalidAmount() -> Bool { + return isInvalidAmount(monthlyLimit_parsedAmount) + } + + func isInvalidAmount(_ result: Result) -> Bool { + + switch result { + case .success(let amt): + return amt <= 0 + + case .failure(let reason): + switch reason { + case .emptyInput: + return false + case .invalidInput: + return true + } + } + } + + func dailyLimit_graphInfo() -> SpendingLimitGraphInfo { + return graphInfo(dailyLimit_currency, dailyCardPaymentsAmount, dailyLimit_parsedAmount) + } + + func monthlyLimit_graphInfo() -> SpendingLimitGraphInfo { + return graphInfo(monthlyLimit_currency, monthlyCardPaymentsAmount, monthlyLimit_parsedAmount) + } + + func graphInfo( + _ currency: Currency, + _ paymentsAmount: Double, + _ parsedAmount: Result + ) -> SpendingLimitGraphInfo { + + var spentAmount: Double = 0.0 + var spent = "" + + if cardAmounts != nil { + spentAmount = paymentsAmount + switch currency { + case .bitcoin(let bitcoinUnit): + spent = Utils.formatBitcoin(amount: spentAmount, bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + spent = Utils.formatFiat(amount: spentAmount, fiatCurrency: fiatCurrency).digits + } + } else { + switch currency { + case .bitcoin(let bitcoinUnit): + spent = Utils.unknownBitcoinAmount(bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + spent = Utils.unknownFiatAmount(fiatCurrency: fiatCurrency).digits + } + } + + var totalAmount: Double = 0.0 + var total = "" + + var remainingAmount: Double = 0.0 + var remaining = "" + + switch parsedAmount { + case .success(let limit): + totalAmount = limit + remainingAmount = max(0.0, limit - spentAmount) + switch currency { + case .bitcoin(let bitcoinUnit): + total = Utils.formatBitcoin(amount: totalAmount, bitcoinUnit: bitcoinUnit).digits + remaining = Utils.formatBitcoin(amount: remainingAmount, bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + total = Utils.formatFiat(amount: totalAmount, fiatCurrency: fiatCurrency).digits + remaining = Utils.formatFiat(amount: remainingAmount, fiatCurrency: fiatCurrency).digits + } + + case .failure(_): + totalAmount = Double.greatestFiniteMagnitude + total = "♾️" + remainingAmount = Double.greatestFiniteMagnitude + remaining = "♾️" + } + + return SpendingLimitGraphInfo( + spent: spent, + spentAmount: spentAmount, + remaining: remaining, + remainingAmount: remainingAmount, + total: total, + totalAmount: totalAmount + ) + } + + var spentBalanceColor: Color { + if BusinessManager.isTestnet { + return Color.appAccentTestnet + } else { + return Color.appAccentMainnet + } + } + + var remainingBalanceColor: Color { + return Color.appAccentOrange + } + + var hasChanges: Bool { + + if cardInfo.sanitizedName != name { + return true + } + if cardInfo.isActive != isActive { + return true + } + + if cardInfo.dailyLimit?.toCurrencyAmount() != dailyLimit_currencyAmount() { + return true + } + + if cardInfo.monthlyLimit?.toCurrencyAmount() != monthlyLimit_currencyAmount() { + return true + } + + return false + } + + var canSave: Bool { + + if dailyLimit_isInvalidAmount() { + return false + } + if monthlyLimit_isInvalidAmount() { + return false + } + + return true + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + if !didAppear { + didAppear = true + cardInfoChanged() + } + } + + func cardInfoChanged() { + log.trace("cardInfoChanged()") + + ignoreChanges = true + + name = cardInfo.sanitizedName + isActive = cardInfo.isActive + + let dsl = cardInfo.dailyLimit?.toCurrencyAmount() + let msl = cardInfo.monthlyLimit?.toCurrencyAmount() + + var plus: [Currency] = [] + if let dsl { + plus.append(dsl.currency) + } + if let msl { + plus.append(msl.currency) + } + currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + + if let dsl { + dailyLimit_currencyStr = dsl.currency.shortName + dailyLimit_currency = dsl.currency + + let formattedAmt = Utils.format(currencyAmount: dsl) + dailyLimit_parsedAmount = Result.success(formattedAmt.amount) // do this first ! + dailyLimit_amountStr = formattedAmt.digits + + } else { + dailyLimit_currencyStr = currencyPrefs.currency.shortName + dailyLimit_currency = currencyPrefs.currency + } + + if let msl { + monthlyLimit_currencyStr = msl.currency.shortName + monthlyLimit_currency = msl.currency + + let formattedAmt = Utils.format(currencyAmount: msl) + monthlyLimit_parsedAmount = Result.success(formattedAmt.amount) // do this first ! + monthlyLimit_amountStr = formattedAmt.digits + + } else { + monthlyLimit_currencyStr = currencyPrefs.currency.shortName + monthlyLimit_currency = currencyPrefs.currency + } + + // If the user has only edit one limit, change the currency of the other to match. + if dailyLimit_amountStr.isEmpty && !monthlyLimit_amountStr.isEmpty { + dailyLimit_currencyStr = monthlyLimit_currencyStr + dailyLimit_currency = monthlyLimit_currency + } + if monthlyLimit_amountStr.isEmpty && !dailyLimit_amountStr.isEmpty { + monthlyLimit_currencyStr = dailyLimit_currencyStr + monthlyLimit_currency = dailyLimit_currency + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.ignoreChanges = false + } + } + + func applicationDidBecomeActive() { + log.trace("applicationDidBecomeActive()") + + if isNewCard && !didDisplayWelcome { + didDisplayWelcome = true + + smartModalState.display(dismissable: true) { + NewCardSheet() + } + } + } + + func isActiveChanged() { + log.trace("isActiveChanged()") + + maybeShowSaveToast() + } + + func dailyLimit_currencyPickerDidChange() { + log.trace("dailyLimit_currencyPickerDidChange()") + + guard !ignoreChanges else { + log.debug("dailyLimit_currencyPickerDidChange(): ignoreChanges") + return + } + + if let newCurrency = currencyList.first(where: { $0.shortName == dailyLimit_currencyStr }) { + if dailyLimit_currency != newCurrency { + dailyLimit_currency = newCurrency + + // We might want to apply a different formatter + let result = TextFieldCurrencyStyler.format( + input : dailyLimit_amountStr, + currency : dailyLimit_currency, + hideMsats : false + ) + dailyLimit_parsedAmount = result.1 + dailyLimit_amountStr = result.0 + + // If the user hasn't edited the other field, change the currency to match. + if monthlyLimit_amountStr.isEmpty { + monthlyLimit_currencyStr = dailyLimit_currencyStr + } + } + + } else { // user selected "other" + + dailyLimit_currencyStr = dailyLimit_currency.shortName // revert to last real currency + navigateTo( + .CurrencyConverter( + initialAmount : dailyLimit_currencyAmount(), + didChange : dailyLimit_currencyConverterAmountChanged, + didClose : nil + ) + ) + } + } + + func monthlyLimit_currencyPickerDidChange() { + log.trace("monthlyLimit_currencyPickerDidChange()") + + guard !ignoreChanges else { + log.debug("monthlyLimit_currencyPickerDidChange(): ignoreChanges") + return + } + + if let newCurrency = currencyList.first(where: { $0.shortName == dailyLimit_currencyStr }) { + if monthlyLimit_currency != newCurrency { + monthlyLimit_currency = newCurrency + + // We might want to apply a different formatter + let result = TextFieldCurrencyStyler.format( + input : monthlyLimit_amountStr, + currency : monthlyLimit_currency, + hideMsats : false + ) + monthlyLimit_parsedAmount = result.1 + monthlyLimit_amountStr = result.0 + + // If the user hasn't edited the other field, change the currency to match. + if dailyLimit_amountStr.isEmpty { + dailyLimit_currencyStr = monthlyLimit_currencyStr + } + } + + } else { // user selected "other" + + monthlyLimit_currencyStr = monthlyLimit_currency.shortName // revert to last real currency + navigateTo( + .CurrencyConverter( + initialAmount : monthlyLimit_currencyAmount(), + didChange : monthlyLimit_currencyConverterAmountChanged, + didClose : nil + ) + ) + } + } + + func dailyLimit_currencyConverterAmountChanged(_ result: CurrencyAmount?) { + log.trace("dailyLimit_currencyConverterAmountChanged()") + + if let newAmt = result { + + let plus: [Currency] = [newAmt.currency, monthlyLimit_currency] + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + if currencyList != newCurrencyList { + currencyList = newCurrencyList + } + + dailyLimit_currency = newAmt.currency + dailyLimit_currencyStr = newAmt.currency.shortName + + let formattedAmt = Utils.format(currencyAmount: newAmt, policy: .showMsatsIfNonZero) + dailyLimit_parsedAmount = Result.success(newAmt.amount) + dailyLimit_amountStr = formattedAmt.digits + + } else { + + dailyLimit_parsedAmount = Result.failure(.emptyInput) + dailyLimit_amountStr = "" + } + } + + func monthlyLimit_currencyConverterAmountChanged(_ result: CurrencyAmount?) { + log.trace("monthlyLimit_currencyConverterAmountChanged()") + + if let newAmt = result { + + let plus: [Currency] = [newAmt.currency, dailyLimit_currency] + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + if currencyList != newCurrencyList { + currencyList = newCurrencyList + } + + monthlyLimit_currency = newAmt.currency + monthlyLimit_currencyStr = newAmt.currency.shortName + + let formattedAmt = Utils.format(currencyAmount: newAmt, policy: .showMsatsIfNonZero) + monthlyLimit_parsedAmount = Result.success(newAmt.amount) + monthlyLimit_amountStr = formattedAmt.digits + + } else { + + monthlyLimit_parsedAmount = Result.failure(.emptyInput) + monthlyLimit_amountStr = "" + } + } + + func dailyLimit_currencyChanged() { + log.trace("dailyLimit_currencyChanged()") + + updateDailyCardPaymentsAmount() + maybeShowSaveToast() + } + + func monthlyLimit_currencyChanged() { + log.trace("monthlyLimit_currencyChanged()") + + updateMonthlyCardPaymentsAmount() + maybeShowSaveToast() + } + + func dailyLimit_userDidEdit() { + log.trace("dailyLimit_userDidEdit()") + + // This is called if the user manually edits the TextField. + // Which is distinct from `amountDidChange`, which may be triggered via code. + + maybeShowSaveToast() + } + + func monthlyLimit_userDidEdit() { + log.trace("monthlyLimit_userDidEdit()") + + // This is called if the user manually edits the TextField. + // Which is distinct from `amountDidChange`, which may be triggered via code. + + maybeShowSaveToast() + } + + func cardAmountsChanged() { + log.trace("cardAmountsChanged()") + + updateDailyCardPaymentsAmount() + updateMonthlyCardPaymentsAmount() + } + + // -------------------------------------------------- + // MARK: Utils + // -------------------------------------------------- + + func dailyLimit_currencyAmount() -> CurrencyAmount? { + + if case .success(let amount) = dailyLimit_parsedAmount { + return CurrencyAmount(currency: dailyLimit_currency, amount: amount) + } else { + return nil + } + } + + func monthlyLimit_currencyAmount() -> CurrencyAmount? { + + if case .success(let amount) = monthlyLimit_parsedAmount { + return CurrencyAmount(currency: dailyLimit_currency, amount: amount) + } else { + return nil + } + } + + func updateDailyCardPaymentsAmount() { + + guard let cardAmounts else { + dailyCardPaymentsAmount = 0.0 + log.debug("dailyCardPaymentsAmount = 0.0 (cardAmounts == nil)") + return + } + + switch dailyLimit_currency { + case .bitcoin(let bitcoinUnit): + let msat = cardAmounts.dailyBitcoinAmount() + dailyCardPaymentsAmount = Utils.convertBitcoin(msat: msat, to: bitcoinUnit) + log.debug("dailyCardPaymentsAmount = \(dailyCardPaymentsAmount) \(bitcoinUnit.shortName)") + + case .fiat(let fiatCurrency): + dailyCardPaymentsAmount = cardAmounts.dailyFiatAmount( + target: fiatCurrency, + exchangeRates: currencyPrefs.fiatExchangeRates + ) + log.debug("dailyCardPaymentsAmount = \(dailyCardPaymentsAmount) \(fiatCurrency.shortName)") + } + } + + func updateMonthlyCardPaymentsAmount() { + + guard let cardAmounts else { + monthlyCardPaymentsAmount = 0.0 + log.debug("monthlyCardPaymentsAmount = 0.0 (cardAmounts == nil)") + return + } + + switch dailyLimit_currency { + case .bitcoin(let bitcoinUnit): + let msat = cardAmounts.dailyBitcoinAmount() + monthlyCardPaymentsAmount = Utils.convertBitcoin(msat: msat, to: bitcoinUnit) + log.debug("monthlyCardPaymentsAmount = \(monthlyCardPaymentsAmount) \(bitcoinUnit.shortName)") + + case .fiat(let fiatCurrency): + monthlyCardPaymentsAmount = cardAmounts.dailyFiatAmount( + target: fiatCurrency, + exchangeRates: currencyPrefs.fiatExchangeRates + ) + log.debug("monthlyCardPaymentsAmount = \(monthlyCardPaymentsAmount) \(fiatCurrency.shortName)") + } + } + + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchCardAmounts() async { + log.trace("fetchCardAmounts()") + + guard !cardInfo.isArchived else { + log.debug("fetchCardAmounts(): skipping: isArchived") + return + } + + let cardPaymentsMap: [Lightning_kmpUUID : CardsManager.CardPayments] + do { + cardPaymentsMap = try await Biz.business.cardsManager.fetchCardPayments() + + } catch { + log.error("CardsManager.fetchCardPayments(): error: \(error)") + return + } + + guard let cardPayments = cardPaymentsMap[cardInfo.id] else { + cardAmounts = CardsManager.CardAmounts(daily: [], monthly: []) + return + } + + do { + cardAmounts = try await Biz.business.cardsManager.fetchCardAmounts( + payments: cardPayments, + fetcher: nil // use default fetcher + ) + } catch { + log.error("CardsManager.fetchCardAmounts(): error: \(error)") + return + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func navigateTo(_ tag: NavLinkTag) { + log.trace("navigateTo(\(tag.description))") + + if #available(iOS 17, *) { + navCoordinator.path.append(tag) + } else { + navLinkTag = tag + } + } + + func maybeShowSaveToast() { + log.trace("maybeShowSaveToast()") + + guard !ignoreChanges else { + return + } + + if isFirstUserEdit { + isFirstUserEdit = false + + toast.pop( + "Changes take effect after you Save", + colorScheme: colorScheme.opposite, + style: .chrome, + duration: 5.0, + alignment: .top(padding: 0), + transition: .asymmetric(insertion: .push(from: .leading), removal: .move(edge: .trailing)), + showCloseButton: false + ) + } + } + + func cancelButtonTapped() { + log.trace("cancelButtonTapped()") + + if hasChanges && canSave { + showDiscardChangesConfirmationDialog = true + } else { + close() + } + } + + func saveButtonTapped() { + log.trace("saveButtonTapped()") + + if hasChanges && canSave { + saveCard() + } else { + close() + } + } + + func saveCard() { + log.trace("saveCard()") + + let updatedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedIsFrozen = !isActive + let updatedDailyLimit = dailyLimit_currencyAmount()?.toSpendingLimit() + let updatedMonthlyLimit = monthlyLimit_currencyAmount()?.toSpendingLimit() + + Task { + do { + // Get the most recent version of the card. + // If a payment was made with the card while this screen was open, + // then we might not have the lastest version of `lastKnownCounter`. + // + let currentCard = Biz.business.cardsManager.cardForId(cardId: cardInfo.id) ?? cardInfo + let updatedCard = currentCard.doCopy( + id : currentCard.id, + name : updatedName, + keys : currentCard.keys, + uid : currentCard.uid, + lastKnownCounter : currentCard.lastKnownCounter, + isFrozen : updatedIsFrozen, + isArchived : currentCard.isArchived, + isReset : currentCard.isReset, + isForeign : currentCard.isForeign, + dailyLimit : updatedDailyLimit, + monthlyLimit : updatedMonthlyLimit, + createdAt : currentCard.createdAt + ) + + try await Biz.business.cardsManager.saveCard(card: updatedCard) + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + + self.close() + } + } + + func close() { + log.trace("close()") + + presentationMode.wrappedValue.dismiss() + } + + // -------------------------------------------------- + // MARK: Management Tasks + // -------------------------------------------------- + + func archiveCard() { + log.trace("archiveCard()") + + smartModalState.display(dismissable: false) { + ArchiveCardSheet(card: cardInfo, didArchive: { + cardInfo = $0 + cardWasArchived = true + }) + } + } + + func resetPhysicalCard() { + log.trace("resetPhysicalCard()") + + smartModalState.display(dismissable: false) { + ResetCardSheet(card: cardInfo, didRequestReset: { startResetPhysicalCardProcess() }) + } + } + + func deleteCard() { + log.trace("deleteCard()") + + smartModalState.display(dismissable: false) { + DeleteCardSheet(card: cardInfo, didDelete: { close() }) + } + } + + // -------------------------------------------------- + // MARK: Card Reset + // -------------------------------------------------- + + func startResetPhysicalCardProcess() { + log.trace("startResetPhysicalCardProcess()") + + let input = NfcWriter.ResetInput( + key0 : cardInfo.keys.key0_bytes, + piccDataKey : cardInfo.keys.piccDataKey_bytes, + cmacKey : cardInfo.keys.cmacKey_bytes + ) + + NfcWriter.shared.resetCard(input) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("NfcWriter.resetCard(): error: \(error)") + showWriteErrorSheet(error) + + case .success(): + resetSuccess() + } + } + } + + func resetSuccess() { + log.trace("resetSuccess()") + + // Step 1 of 2: + // Show the success screen + + smartModalState.display(dismissable: true) { + ResetSuccessSheet() + } + + // Step 2 of 2: + // Update the card in the database + Task { @MainActor in + do { + // Try to get the most recent version of the card, + // just in-case any changes were made elsewhere in the system. + // + let currentCard = Biz.business.cardsManager.cardForId(cardId: cardInfo.id) ?? cardInfo + let updatedCard = currentCard.resetCopy() + + try await Biz.business.cardsManager.saveCard(card: updatedCard) + cardInfo = updatedCard + cardWasReset = true + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace("showWriteErrorSheet()") + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileResetting) + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NFCReaderError+Ignore.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NFCReaderError+Ignore.swift new file mode 100644 index 000000000..86d0daa1d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NFCReaderError+Ignore.swift @@ -0,0 +1,37 @@ +import Foundation +import CoreNFC + +fileprivate let filename = "NFCReaderError+Ignore" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +extension NFCReaderError { + + func isIgnorable() -> Bool { + + switch self.code { + case .readerSessionInvalidationErrorUserCanceled: + // User tapped "cancel" button + log.debug("readerSessionInvalidationErrorUserCanceled") + return true + + case .readerSessionInvalidationErrorSessionTimeout: + // User didn't present a card to the reader. + // The NFC reader automatically cancelled after 60 seconds. + log.debug("readerSessionInvalidationErrorSessionTimeout") + return true + + case .readerSessionInvalidationErrorSessionTerminatedUnexpectedly: + // User locked the phone, which automatically terminates the NFC reader. + log.debug("readerSessionInvalidationErrorSessionTerminatedUnexpectedly") + return true + + default: + return false + } + + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift new file mode 100644 index 000000000..1d49a00ab --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift @@ -0,0 +1,94 @@ +import SwiftUI + +fileprivate let filename = "NewCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct NewCardSheet: View { + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 12) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + Text("Your card is now ready to use.") + .font(.title2.weight(.medium)) + .padding(.bottom, 8) + + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .foregroundColor(.appAccent) + .padding(.bottom, 24) + + Text("Use this screen to manage your card anytime you need.") + .font(.callout) + .padding(.bottom, 24) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { + Text("Be your own bank").font(.headline) + + if #available(iOS 18, *) { + Image(systemName: "bitcoinsign.bank.building") + } else { + Image(systemName: "bitcoinsign.circle") + } + } + .padding(.bottom, 8) + + Text("Remember:") + .textCase(.uppercase) + .font(.subheadline) + .foregroundStyle(Color.secondary) + .padding(.bottom, 4) + + Text("Your bank (this device) needs to be online to process payments with your card.") + .font(.subheadline) + .foregroundStyle(Color.secondary) + .padding(.bottom, 16) + } + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift new file mode 100644 index 000000000..a9f1e5be4 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift @@ -0,0 +1,375 @@ +import SwiftUI +import PhoenixShared +import CoreNFC + +fileprivate let filename = "ReadCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ReadCardSheet: View { + + let result: Result + + @State var scannedUri: URL? = nil + @State var scannedText: String? = nil + @State var scannedUnknown: Bool = false + + @State var errorMessage: String? = nil + + @State var isBoltCard: Bool = false + @State var matchingCard: BoltCardInfo? = nil + @State var piccDataInfo: Ntag424.PiccDataInfo? = nil + + @State var didAppear: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onAppear { + onAppear() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Read card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + if let link = scannedUri?.absoluteString { + // The URL is long because of the query parameters. + // And when SwiftUI displays the text, it automatically adds + // hyphen characters at the end of some lines. + // + // E.g. + // id=3fabbe50&picc_data=FB9B4202A7- <- added hyphen + // C37842120BE2D... + // + // I don't like this. And there's a simple way to prevent it. + // You just add zero-width characters in-between every character + // in the string. + // + // https://stackoverflow.com/q/78208090 + // + let linkWitZeroWidthSpaces = link.map({ String($0) }).joined(separator: "\u{200B}") + Button { + openScannedUri() + } label: { + Text(linkWitZeroWidthSpaces) + .multilineTextAlignment(.leading) + } + + } else if let scannedText { + Text(scannedText) + + } else if scannedUnknown { + Text("Scanned NDEF tag with unknown type") + + } else if let errorMessage { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.red) + } + + if isBoltCard { + boltCardDetails() + .padding(.top, 16) + } + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func boltCardDetails() -> some View { + + if let matchingCard, let piccDataInfo { + boltCardDetails(matchingCard, piccDataInfo) + } else { + Text("Card not associated with this wallet.") + } + } + + @ViewBuilder + func boltCardDetails(_ matchingCard: BoltCardInfo, _ piccDataInfo: Ntag424.PiccDataInfo) -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + HStack(spacing: 0) { + Text("Bolt Card:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Name:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(matchingCard.sanitizedName) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Status:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(cardStatus(matchingCard)) + .gridCellAnchor(.leading) + } + #if DEBUG && false + // For testing: make sure `func updateLastKnownCounter` is working properly. + GridRow { + Text(" - LastKnownCounter:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(matchingCard.lastKnownCounter.description) + .gridCellAnchor(.leading) + } + #endif + GridRow { + HStack(spacing: 0) { + Text("Picc Data:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - UID:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(piccDataInfo.uid.toHex(options: .upperCase)) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Counter:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(piccDataInfo.counter.description) + .gridCellAnchor(.leading) + } + GridRow { + HStack(spacing: 0) { + Text("Message Authentication Code:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Verified:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text("True") + .gridCellAnchor(.leading) + } + } + .padding() + } + + func cardStatus(_ card: BoltCardInfo) -> String { + + if card.isArchived { + return String(localized: "Frozen (archived)") + } else if card.isFrozen { + return String(localized: "Frozen") + } else { + return String(localized: "Active") + } + } + + func onAppear() { + log.trace("onAppear()") + + guard !didAppear else { + log.debug("onAppear(): ignoring: didAppear is true") + return + } + didAppear = true + + switch result { + case .failure(let failure): + switch failure { + case .readingNotAvailable: + errorMessage = "NFC cababilities not available on this device." + case .alreadyStarted: + errorMessage = "An NFC session is already running." + case .errorReadingTag: + errorMessage = "Error reading NDEF tag." + case .scanningTerminated(let nfcError): + errorMessage = "NFC reader error: \(nfcError.localizedDescription)" + } + case .success(let result): + log.debug("NFCNDEFMessage: \(result)") + + var detectedUri: URL? = nil + var detectedText: String? = nil + var detectedUnknown: Bool = false + + result.records.forEach { payload in + if let uri = payload.wellKnownTypeURIPayload() { + log.debug("found uri = \(uri)") + + if detectedUri == nil { + detectedUri = uri + } + + } else if let text = payload.wellKnownTypeTextPayload().0 { + log.debug("found text = \(text)") + + if detectedText == nil { + detectedText = text + } + + } else { + log.debug("found tag with unknown type") + detectedUnknown = true + + } + } + + if let detectedUri { + scannedUri = detectedUri + let result = Ntag424.extractQueryItems(url: detectedUri) + if case .success(let queryItems) = result { + isBoltCard = true + tryMatchCard(queryItems) + } + + } else if let detectedText { + scannedText = detectedText + + } else if detectedUnknown { + scannedUnknown = true + + } else { + errorMessage = "No URI detected in NFC tag" + } + } + } + + func tryMatchCard(_ queryItems: Ntag424.QueryItems) { + log.trace("tryMatchCard()") + + let cards: [BoltCardInfo] = Biz.business.cardsManager.cardsListValue + Task { + var matchingCard: BoltCardInfo? = nil + var piccDataInfo: Ntag424.PiccDataInfo? = nil + + for card in cards { + + let keySet = Ntag424.KeySet( + piccDataKey : card.keys.piccDataKey_data, + cmacKey : card.keys.cmacKey_data + ) + let result = Ntag424.extractPiccDataInfo( + piccData : queryItems.piccData, + cmac : queryItems.cmac, + keySet : keySet + ) + + switch result { + case .failure(let err): + log.debug("card[\(card.id)]: err: \(err)") + + case .success(let result): + log.debug("card[\(card.id)]: success") + + matchingCard = card + piccDataInfo = result + break + } + } + + guard let matchingCard, let piccDataInfo else { + return + } + + DispatchQueue.main.async { [matchingCard, piccDataInfo] in + self.matchingCard = matchingCard + self.piccDataInfo = piccDataInfo + } + + updateLastKnownCounter(matchingCard, piccDataInfo.counter) + } + } + + /// While we're here, we might as well take advantage, and update the card's `lastKnownCounter`. + /// Technically this could be considered a safety mechanism too. + /// For example, if you worry somebody may have scanned your card without your permission. + /// + func updateLastKnownCounter(_ matchingCard: BoltCardInfo, _ lastKnownCounter: UInt32) { + log.trace("updateLastKnownCounter(\(lastKnownCounter))") + + Task { @MainActor in + do { + let currentCard = Biz.business.cardsManager.cardForId(cardId: matchingCard.id) ?? matchingCard + let updatedCounter = max(currentCard.lastKnownCounter, lastKnownCounter) + let updatedCard = currentCard.withUpdatedLastKnownCounter(updatedCounter) + + try await Biz.business.cardsManager.saveCard(card: updatedCard) + } catch { + log.debug("CardsManager.saveCard(): error: \(error)") + } + } + } + + func openScannedUri() { + log.trace("openScannedUri()") + + guard let uri = scannedUri else { + return + } + + if UIApplication.shared.canOpenURL(uri) { + UIApplication.shared.open(uri) + } + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift new file mode 100644 index 000000000..5ba45257d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift @@ -0,0 +1,97 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ResetCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ResetCardSheet: View { + + let card: BoltCardInfo + let didRequestReset: () -> Void + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Reset card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text("This will clear the card, allowing it to be linked again with any wallet.") + + if !card.isArchived { + Text( + """ + Afterwards, the card will be Archived, and can never be activated again. \ + The card will remain in your list, but will be moved to the Archived section. + """ + ) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .padding(.trailing, 24) + + Button { + resetButtonTapped() + } label: { + Text("Reset").font(.title3).foregroundStyle(Color.red) + } + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace("cancelButtonTapped()") + + smartModalState.close() + } + + func resetButtonTapped() { + log.trace("resetButtonTapped()") + + smartModalState.close(animationCompletion: { + didRequestReset() + }) + } +} + + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift new file mode 100644 index 000000000..a85c8fc15 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift @@ -0,0 +1,77 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ResetSuccessSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ResetSuccessSheet: View { + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 12) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + Text("Your card is now reset.") + .font(.title2.weight(.medium)) + .padding(.bottom, 8) + + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .foregroundColor(.appAccent) + .padding(.bottom, 24) + + Text("It can be linked again with any wallet.") + .font(.callout) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} + + + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift new file mode 100644 index 000000000..5c4f578cc --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift @@ -0,0 +1,239 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "SimulatorPasteSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct SimulatorBoltCardInput: Codable { + let key0: String + let chipUid: String +} + +struct SimulatorPasteSheet: View { + + let hexAddr: String + + @State var jsonInput: String = "" + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onChange(of: jsonInput) { _ in + jsonInputChanged() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Simulator instructions") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + + Text( + """ + The simulator doesn't support NFC. \ + But you can link a card to this wallet for testing. + """ + ) + .fixedSize(horizontal: false, vertical: true) // text truncation bugs + + content_instructions() + content_address() + content_json() + + } // + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func content_instructions() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + + Text("On a real device:") + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Open Phoenix app (debug build)") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Go to: Configuration > Bolt cards") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Press and hold \"create new debit card\" button for 3 seconds") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("A sheet will appear to guide you through the process") + } + } + } + + @ViewBuilder + func content_address() -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + Text("Simulator's HEX address:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + + Button { + copyHexAddrToClipboard() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(hexAddr) + Image(systemName: "square.on.square") + } + } + .gridCellAnchor(.leading) + } + } + } + + @ViewBuilder + func content_json() -> some View { + + TextField("Paste JSON output from device here", text: $jsonInput, axis: .vertical) + .lineLimit(3, reservesSpace: true) + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) + .offset(y: -3) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func jsonInputChanged() { + log.trace("jsonInputChanged()") + + do { + let data = jsonInput.data(using: .utf8)! + let result = try JSONDecoder().decode(SimulatorBoltCardInput.self, from: data) + + importCard(result) + + } catch { + log.debug("Invalid JSON") + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func copyHexAddrToClipboard() { + log.trace("copyHexAddrToClipboard()") + + UIPasteboard.general.string = hexAddr + } + + func importCard(_ input: SimulatorBoltCardInput) { + log.trace("importCard()") + + let key0_data = Data(fromHex: input.key0)! + let key0_vector = Bitcoin_kmpByteVector(bytes: key0_data.toKotlinByteArray()) + + let keySet = BoltCardKeySet(key0: key0_vector) + + let chipUid_data = Data(fromHex: input.chipUid)! + let chipUid_vector = Bitcoin_kmpByteVector(bytes: chipUid_data.toKotlinByteArray()) + + let cardInfo = BoltCardInfo( + id: Lightning_kmpUUID.companion.randomUUID(), + name: "", + keys: keySet, + uid: chipUid_vector, + lastKnownCounter: 0, + isFrozen: false, + isArchived: false, + isReset: false, + isForeign: false, + dailyLimit: nil, + monthlyLimit: nil, + createdAt: Date.now.toKotlinInstant() + ) + + Task { @MainActor in + do { + try await Biz.business.cardsManager.saveCard(card: cardInfo) + smartModalState.close() + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + } + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift new file mode 100644 index 000000000..49dcd28c7 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift @@ -0,0 +1,281 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "SimulatorWriteSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct SimulatorWriteSheet: View { + + @State var hexAddrString: String = "" + @State var jsonOutput: String = "" + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onChange(of: hexAddrString) { _ in + hexAddrStringChanged() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Simulator debugging") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + + Text("Link a card to a simulator wallet for testing.") + .fixedSize(horizontal: false, vertical: true) + + content_notes() + content_address() + if !jsonOutput.isEmpty { + content_json() + } + + } // + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func content_notes() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("This will create a new card that is linked to a wallet running on a simulator") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Note that simulators do not support background execution") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text( + """ + So to make a payment using the card, the simulator must be open, \ + with Phoenix running in the foreground + """ + ) + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text( + """ + The simulator must be running on a Mac with either Apple Silicon or \ + the T2 security chip (to receive push notifications) + """ + ) + } + } + } + + @ViewBuilder + func content_address() -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + Text("Simulator's HEX address:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + + TextField("Paste here", text: $hexAddrString) + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .gridCellAnchor(.leading) + } + } + } + + @ViewBuilder + func content_json() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + Text("Copy and paste into simulator:") + Button { + copyJsonToClipboard() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(jsonOutput) + Image(systemName: "square.on.square") + } + } + } + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) + .offset(y: -3) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func hexAddrStringChanged() { + log.trace("hexAddrStringChanged()") + + let trimmed = hexAddrString.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count == 8, let hexAddrData = Data(fromHex: trimmed) { + + let sanitizedHexAddr = hexAddrData.toHex(options: .lowerCase) + writeToNfcCard(sanitizedHexAddr) + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func writeToNfcCard(_ hexAddr: String) { + log.trace("writeToNfcCard()") + + let keys = BoltCardKeySet.companion.random() + + let baseUrl = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/info?id=\(hexAddr)")! + let template = Ndef.Template(baseUrl: baseUrl)! + + log.debug("template.url: \(template.urlString)") + log.debug("template.piccDataOffset: \(template.piccDataOffset)") + log.debug("template.cmacOffset: \(template.cmacOffset)") + + let input = NfcWriter.WriteInput( + template : template, + key0 : keys.key0_bytes, + piccDataKey : keys.piccDataKey_bytes, + cmacKey : keys.cmacKey_bytes + ) + + NfcWriter.shared.writeCard(input) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("error: \(error)") + showWriteErrorSheet(error) + + case .success(let output): + log.debug("output.chipUid: \(output.chipUid.toHex())") + saveNewCard(keys, output) + } + } + } + + func saveNewCard( + _ keys: BoltCardKeySet, + _ output: NfcWriter.WriteOutput + ) { + + // Conversion madness: [UInt8] -> Data -> ByteArray -> ByteVector + let uidByteArray = Helper.dataFromBytes(bytes: output.chipUid).toKotlinByteArray() + let uid = Bitcoin_kmpByteVector(bytes: uidByteArray) + + let cardInfo = BoltCardInfo(name: "", keys: keys, uid: uid, isForeign: true) + + Task { @MainActor in + do { + try await Biz.business.cardsManager.saveCard(card: cardInfo) + + let rawOutput = SimulatorBoltCardInput( + key0: keys.key0_bytes.toHex(options: .lowerCase), + chipUid: output.chipUid.toHex(options: .lowerCase) + ) + let jsonData = try JSONEncoder().encode(rawOutput) + jsonOutput = String(data: jsonData, encoding: .utf8) ?? "" + + } catch { + log.error("CardsManager.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace("showWriteErrorSheet()") + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.close(animationCompletion: { + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileWriting) + } + }) + } + + func copyJsonToClipboard() { + log.trace("copyJsonToClipboard()") + + UIPasteboard.general.string = jsonOutput + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift new file mode 100644 index 000000000..76ebdd377 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift @@ -0,0 +1,168 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "WriteErrorSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct WriteErrorSheet: View { + + enum Context { + case whileWriting + case whileResetting + } + + let error: NfcWriter.WriteError + let context: Context + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Write error") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text("An error occurred while attempting to write to the NFC tag.") + + switch error { + case .readingNotAvailable: + Text("NFC capabilities not available on this device.").bold() + + case .alreadyStarted: + Text("An NFC session is already running.").bold() + + case .couldNotConnect: + Text("Could not connect to the NFC tag.").bold() + Text( + """ + Please try again. And be sure to hold the card close \ + to the phone until the writing process completes. + """ + ) + + case .couldNotAuthenticate: + Text("Could not authenticate with card.").bold() + switch context { + case .whileWriting: + Text( + """ + This card is already linked to another wallet. \ + To re-use this card you must first unlink the card. \ + In Phoenix there is an option called "reset physical card" which will unlink it. + """ + ) + case .whileResetting: + Text( + """ + This doesn't appear to be the linked card. \ + Perhaps this card is associated with a different wallet, \ + or a different card in this wallet. + """ + ) + } + + + case .keySlotsUnavailable: + Text("Key slots unavailable").bold() + switch context { + case .whileWriting: + Text( + """ + This card has been improperly programmed or reset too many times, \ + and it's now impossible to use the card. + """ + ) + case .whileResetting: + Text( + """ + An unknown error occurred while attempting to clear the keys from the card. \ + Please try resetting it again. If the problem persists, you may need to \ + destroy the card by cutting it up. + """ + ) + } + + case .protocolError(let writeStep, let error): + Text("Protocol error: \(writeStepName(writeStep))").bold() + Text("Details: \(error.localizedDescription)") + switch context { + case .whileWriting: + Text( + """ + The card is **NOT** ready to be used. \ + Please try writing it again. + """ + ) + case .whileResetting: + Text( + """ + An unexpected error occurred while attempting to reset the card. \ + Please try resetting it again. If the problem persists, you may need to \ + destroy the card by cutting it up. + """ + ) + } + + case .scanningTerminated(let nfcError): + Text("NFC process terminated unexpectedly").bold() + Text("NFC error: \(nfcError.localizedDescription)") + } + + } // + .padding(.horizontal) + .padding(.vertical, 16) + } + + func writeStepName(_ writeStep: NfcWriter.WriteStep) -> String { + switch writeStep { + case .readChipUid : return "Read Chip UID" + case .writeFile2Settings : return "Write File(2) Settings" + case .writeFile2Data : return "Write File(2) Data" + case .writeKey0 : return "Write Key(0)" + } + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift index fec102032..840f34c6e 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift @@ -74,6 +74,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc recipientRow() paymentTypeRow() channelClosingRow() + cardRow() paymentFeesRow_StandardFees() paymentFeesRow_MinerFees() @@ -479,6 +480,29 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + @ViewBuilder + func cardRow() -> some View { + let identifier: String = #function + + if let cardId = paymentInfo.metadata.cardId, + let card = Biz.business.cardsManager.cardForId(cardId: cardId) + { + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: horizontalSpacingBetweenColumns, + keyColumnWidth: keyColumnWidth(identifier: identifier), + keyColumnAlignment: .trailing + ) { + keyColumn("Card") + + } valueColumn: { + Text(card.sanitizedName) + + } // + } + } + @ViewBuilder func paymentFeesRow_StandardFees() -> some View { diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index 891fcb96e..c42107137 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -1343,12 +1343,11 @@ struct SummaryView: View { Biz.business.databaseManager.paymentsDb { paymentsDb, _ in - paymentsDb?.deletePayment(paymentId: paymentInfo.id(), completionHandler: { error in - + paymentsDb?.deletePayment(paymentId: paymentInfo.id(), notify: true) { error in if let error = error { log.error("Error deleting payment: \(String(describing: error))") } - }) + } } switch location { diff --git a/phoenix-ios/phoenix-ios/views/layers/Toast.swift b/phoenix-ios/phoenix-ios/views/layers/Toast.swift index af2c4627c..3a1974d00 100644 --- a/phoenix-ios/phoenix-ios/views/layers/Toast.swift +++ b/phoenix-ios/phoenix-ios/views/layers/Toast.swift @@ -10,9 +10,11 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) class Toast: ObservableObject { enum ToastAlignment { - case top + case top(padding: CGFloat = 45) + case topLeading(padding: CGFloat = 25) + case topTrailing(padding: CGFloat = 25) case middle - case bottom + case bottom(padding: CGFloat = 45) case none } @@ -29,7 +31,8 @@ class Toast: ObservableObject { @Published private var message: String? = nil @Published private var colorScheme: ColorScheme = ColorScheme.light @Published private var style: ToastStyle = .regular - @Published private var alignment: ToastAlignment = .bottom + @Published private var alignment: ToastAlignment = .bottom() + @Published private var transition: AnyTransition = .opacity @Published private var showCloseButton: Bool = false func pop( @@ -37,7 +40,8 @@ class Toast: ObservableObject { colorScheme: ColorScheme, style: ToastStyle = .regular, duration: TimeInterval = 1.5, - alignment: ToastAlignment = .bottom, + alignment: ToastAlignment = .bottom(), + transition: AnyTransition = .opacity, showCloseButton: Bool = false ) { @@ -49,6 +53,7 @@ class Toast: ObservableObject { self.colorScheme = colorScheme self.style = style self.alignment = alignment + self.transition = transition self.showCloseButton = showCloseButton } DispatchQueue.main.asyncAfter(deadline: .now() + duration) { @@ -65,7 +70,7 @@ class Toast: ObservableObject { if let message = message { aligned(message) - .transition(.opacity) + .transition(transition) .zIndex(1001) .onAppear { self.onAppear() @@ -77,9 +82,25 @@ class Toast: ObservableObject { private func aligned(_ message: String) -> some View { switch alignment { - case .top: + case .top(let padding): VStack { - wrapped(message).padding(.top, 45) + wrapped(message).padding(.top, padding) + Spacer() + } + case .topLeading(let padding): + VStack { + HStack { + wrapped(message).padding(.leading, padding) + Spacer() + } + Spacer() + } + case .topTrailing(let padding): + VStack { + HStack { + Spacer() + wrapped(message).padding(.leading, padding) + } Spacer() } case .middle: @@ -88,10 +109,10 @@ class Toast: ObservableObject { wrapped(message) Spacer() } - case .bottom: + case .bottom(let padding): VStack { Spacer() - wrapped(message).padding(.bottom, 45) + wrapped(message).padding(.bottom, padding) } case .none: wrapped(message) diff --git a/phoenix-ios/phoenix-ios/views/receive/BoltCardView.swift b/phoenix-ios/phoenix-ios/views/receive/BoltCardView.swift new file mode 100644 index 000000000..77a203a26 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/receive/BoltCardView.swift @@ -0,0 +1,810 @@ +import SwiftUI +import PhoenixShared +import CoreNFC + +fileprivate let filename = "BoltCardView" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + + +struct BoltCardView: View { + + @ObservedObject var inboundFeeState: InboundFeeState + @ObservedObject var toast: Toast + + let navigateTo: (ReceiveView.NavLinkTag) -> Void + + @State var currency = Currency.bitcoin(.sat) + @State var currencyList: [Currency] = [Currency.bitcoin(.sat)] + @State var currencyPickerChoice: String = Currency.bitcoin(.sat).shortName + + @State var amount: String = "" + @State var parsedAmount: Result = Result.failure(.emptyInput) + @State var altAmount: String = "" + + @State var description: String = "" + + @State var isScanning: Bool = false + @State var nfcUnavailable: Bool = false + @State var readErrorMessage: String = "" + + @State var isParsing: Bool = false + @State var parseIndex: Int = 0 + @State var parseProgress: SendManager.ParseProgress? = nil + + @State var isReceiving: Bool = false + @State var bolt11Invoice: Lightning_kmpBolt11Invoice? = nil + + @State var didAppear = false + + enum Field: Hashable { + case amountTextField + case descriptionTextField + } + @FocusState var focusedField: Field? + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + layers() + .navigationTitle(NSLocalizedString("Receive", comment: "Navigation bar title")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func layers() -> some View { + + ZStack { + contentWrapper() + } + } + + @ViewBuilder + func contentWrapper() -> some View { + + GeometryReader { geometry in + ScrollView(.vertical) { + content() + .frame(width: geometry.size.width) + .frame(minHeight: geometry.size.height) + } + .onTapGesture { + dismissKeyboardIfVisible() + } + } + } + + @ViewBuilder + func content() -> some View { + + VStack { + + Text("Bolt Card") + .font(.title3) + .foregroundColor(Color(UIColor.tertiaryLabel)) + .padding(.top) + + boltCardView() + .padding(.bottom, 20) + + amountField() + .padding(.bottom, 6) + + Text(altAmount) + .font(.callout) + .foregroundColor(altAmountColor()) + .padding(.bottom, 30) + + descriptionTextField() + .padding(.bottom, 30) + + readCardButton() + + if let warning = inboundFeeState.calculateInboundFeeWarning(invoiceAmount: msatAmount()) { + inboundFeeInfo(warning) + .padding(.top) + .padding(.horizontal) + } + + Spacer() + + } // + .onAppear { + onAppear() + } + .onChange(of: amount) { _ in + amountDidChange() + } + .onChange(of: currencyPickerChoice) { _ in + currencyPickerDidChange() + } + .onReceive(Biz.business.paymentsManager.lastIncomingPaymentPublisher()) { + lastIncomingPaymentChanged($0) + } + } + + func boltCardView() -> some View { + + ZStack(alignment: Alignment.top) { + + Image(systemName: "creditcard") + .resizable() + .font(.body.weight(.ultraLight)) + .scaledToFit() + .frame(width: 280, alignment: .top) + .foregroundColor(.primary) + + Image("boltcard") + .resizable() + .scaledToFit() + .aspectRatio(contentMode: .fit) + .frame(width: 42, height: 42, alignment: .topLeading) + .padding(.trailing, 157) + .padding(.top, 122) + + if isParsing || isReceiving { + HorizontalActivity(color: Color(UIColor.systemBackground), diameter: 10, speed: 1.6) + .frame(width: 240, height: 10) + .padding(.top, 45) + } + + // if isParsing, parseProgress is SendManager.ParseProgress_LnurlServiceFetch { + + Text("Fetching Lightning URL") + .lineLimit(1) + .truncationMode(.tail) + .padding(.top, 75) + // } + } + .frame(width: 280) + } + + @ViewBuilder + func amountField() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline) { + TextField(verbatim: "123", text: currencyStyler().amountProxy) + .keyboardType(.decimalPad) + .disableAutocorrection(true) + .fixedSize() + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(isInvalidAmount() ? Color.appNegative : Color.primaryForeground) + .accessibilityHint("amount in \(currency.shortName)") + .focused($focusedField, equals: .amountTextField) + .disabled(isScanning || isParsing || isReceiving) + + Picker( + selection: $currencyPickerChoice, + label: Text(currencyPickerChoice).bold().frame(minWidth: 40) + ) { + ForEach(currencyPickerOptions(), id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + .disabled(isScanning || isParsing || isReceiving) + .accessibilityLabel("") // see below + .accessibilityHint("Currency picker") + + // For a Picker, iOS is setting the VoiceOver text twice: + // > "sat sat, Button" + // + // If we change the accessibilityLabel to "foobar", then we get: + // > "sat foobar, Button" + // + // So we have to set it to the empty string to avoid the double-word. + + } // + .padding([.leading, .trailing]) + .background( + VStack { + Spacer() + Line() + .stroke(Color.appAccent, style: StrokeStyle(lineWidth: 2, dash: [3])) + .frame(height: 1) + } + ) + } + + @ViewBuilder + func descriptionTextField() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Image(systemName: "multiply.circle.fill") + .foregroundStyle(Color.clear) + + Spacer() + + TextField( + String(localized: "Description (optional)", comment: "TextField placeholder; translate: optional"), + text: $description, + axis: .vertical + ) + .multilineTextAlignment(.center) + .lineLimit(4) + .frame(maxWidth: 320) + .focused($focusedField, equals: .descriptionTextField) + .disabled(isScanning || isParsing || isReceiving) + + Spacer() + + // Clear button + Button { + description = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .isHidden(focusedField != .descriptionTextField || description.isEmpty) + } + .padding(.horizontal) + } + + @ViewBuilder + func readCardButton() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + + Button { + startNfcReader() + } label: { + Text("Read Card") + .font(.title3) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(readButtonDisabled()) + + Text(readErrorMessage) + .multilineTextAlignment(.center) + .foregroundStyle(Color.appNegative) + } + } + + @ViewBuilder + func inboundFeeInfo(_ warning: InboundFeeWarning) -> some View { + + Button { + showInboundFeeWarning(warning) + } label: { + switch warning.type { + case .willFail: + Label { + Text("Payment will fail") + } icon: { + Image(systemName: "exclamationmark.triangle").foregroundColor(.appNegative) + } + + case .feeExpected: + Label { + Text("On-chain fee expected") + } icon: { + Image(systemName: "info.circle").foregroundColor(.appAccent) + } + + } // + } + .font(.headline) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func currencyStyler() -> TextFieldCurrencyStyler { + return TextFieldCurrencyStyler( + currency: currency, + amount: $amount, + parsedAmount: $parsedAmount, + hideMsats: false, + userDidEdit: { userDidEditTextField() } + ) + } + + func currencyPickerOptions() -> [String] { + + var options = [String]() + for currency in currencyList { + options.append(currency.shortName) + } + + options.append(NSLocalizedString("other", + comment: "Option in currency picker list. Sends user to Currency Converter") + ) + + return options + } + + func msatAmount() -> Lightning_kmpMilliSatoshi? { + + guard let amt = try? parsedAmount.get(), amt > 0 else { + return nil + } + + var msat: Int64? = nil + switch currency { + case .bitcoin(let bitcoinUnit): + msat = Utils.toMsat(from: amt, bitcoinUnit: bitcoinUnit) + + case .fiat(let fiatCurrency): + if let exchangeRate = currencyPrefs.fiatExchangeRate(fiatCurrency: fiatCurrency) { + msat = Utils.toMsat(fromFiat: amt, exchangeRate: exchangeRate) + } + } + + if let msat { + return Lightning_kmpMilliSatoshi(msat: msat) + } else { + return nil + } + } + + func currencyAmount() -> CurrencyAmount? { + + if let amt = try? parsedAmount.get(), amt > 0 { + return CurrencyAmount(currency: currency, amount: amt) + } else { + return nil + } + } + + func isInvalidAmount() -> Bool { + + switch parsedAmount { + case .success(let amt): + return amt <= 0 + + case .failure(let reason): + switch reason { + case .emptyInput: + return false + case .invalidInput: + return true + } + } + } + + func altAmountColor() -> Color { + + switch parsedAmount { + case .success(_): + return Color.secondary + + case .failure(let reason): + switch reason { + case .emptyInput: + return Color.secondary + case .invalidInput: + return Color.appNegative + } + } + } + + func readButtonDisabled() -> Bool { + +// if isScanning || isReceiving || nfcUnavailable { +// return true +// } +// if parsedAmount.isError { +// return true +// } + + return false + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + if !didAppear { + didAppear = true + + // First time displaying this View + + currency = Currency.bitcoin(currencyPrefs.bitcoinUnit) + currencyList = Currency.displayable(currencyPrefs: currencyPrefs) + currencyPickerChoice = currency.shortName + + altAmount = NSLocalizedString("Enter an amount", comment: "error message") + + if !NFCReaderSession.readingAvailable { + nfcUnavailable = true + readErrorMessage = String(localized: "NFC capabilities not available on this device.") + } + + } else { + + // We are returning to this View + } + } + + func userDidEditTextField() { + log.trace("userDidEditTextField()") + + // This is called if the user manually edits the TextField. + // Which is distinct from `amountDidChange`, which may be triggered via code. + + // Nothing to do here currently + } + + func amountDidChange() { + log.trace("amountDidChange()") + + refreshAltAmount() + } + + func currencyPickerDidChange() -> Void { + log.trace("currencyPickerDidChange()") + + if let newCurrency = currencyList.first(where: { $0.shortName == currencyPickerChoice }) { + if currency != newCurrency { + currency = newCurrency + + // We might want to apply a different formatter + let result = TextFieldCurrencyStyler.format(input: amount, currency: currency, hideMsats: false) + parsedAmount = result.1 + amount = result.0 + + // This seems to be needed, because `amountDidChange` isn't automatically called + refreshAltAmount() + } + + } else { // user selected "other" + + currencyPickerChoice = currency.shortName // revert to last real currency + navigateTo( + .CurrencyConverter( + initialAmount: currencyAmount(), + didChange: currencyConverterAmountChanged, + didClose: nil + ) + ) + } + } + + func currencyConverterAmountChanged(_ result: CurrencyAmount?) { + log.trace("currencyConverterAmountChanged()") + + if let newAmt = result { + + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: [newAmt.currency]) + + if currencyList != newCurrencyList { + currencyList = newCurrencyList + } + + currency = newAmt.currency + currencyPickerChoice = newAmt.currency.shortName + + let formattedAmt = Utils.format(currencyAmount: newAmt, policy: .showMsatsIfNonZero) + parsedAmount = Result.success(newAmt.amount) + amount = formattedAmt.digits + + } else { + + parsedAmount = Result.failure(.emptyInput) + amount = "" + } + } + + func lastIncomingPaymentChanged(_ lastIncomingPayment: Lightning_kmpIncomingPayment) { + log.trace("lastIncomingPaymentChanged()") + + guard let invoice = bolt11Invoice else { + return + } + + let state = lastIncomingPayment.state() + if state == WalletPaymentState.successOffChain { + if lastIncomingPayment.paymentHash.toHex() == invoice.paymentHash.toHex() { + presentationMode.wrappedValue.dismiss() + } + } + } + + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func refreshAltAmount() { + log.trace("refreshAltAmount()") + + switch parsedAmount { + case .failure(let error): + + switch error { + case .emptyInput: + altAmount = String(localized: "Enter an amount", comment: "error message") + case .invalidInput: + altAmount = String(localized: "Enter a valid amount", comment: "error message") + } + + case .success(let amt): + + var msat: Int64? = nil + switch currency { + case .bitcoin(let bitcoinUnit): + msat = Utils.toMsat(from: amt, bitcoinUnit: bitcoinUnit) + + case .fiat(let fiatCurrency): + if let exchangeRate = currencyPrefs.fiatExchangeRate(fiatCurrency: fiatCurrency) { + msat = Utils.toMsat(fromFiat: amt, exchangeRate: exchangeRate) + } + } + + if let msat { + + var altBitcoinUnit: FormattedAmount? = nil + var altFiatCurrency: FormattedAmount? = nil + + let preferredBitcoinUnit = currencyPrefs.bitcoinUnit + if currency != Currency.bitcoin(preferredBitcoinUnit) { + altBitcoinUnit = Utils.formatBitcoin(msat: msat, bitcoinUnit: preferredBitcoinUnit) + } + + let preferredFiatCurrency = currencyPrefs.fiatCurrency + if currency != Currency.fiat(preferredFiatCurrency) { + if let exchangeRate = currencyPrefs.fiatExchangeRate(fiatCurrency: preferredFiatCurrency) { + altFiatCurrency = Utils.formatFiat(msat: msat, exchangeRate: exchangeRate) + } + } + + if let altBitcoinUnit = altBitcoinUnit, let altFiatCurrency = altFiatCurrency { + altAmount = "≈ \(altBitcoinUnit.string) / ≈ \(altFiatCurrency.string)" + + } else if let altBitcoinUnit = altBitcoinUnit { + altAmount = "≈ \(altBitcoinUnit.string)" + + } else if let altFiatCurrency = altFiatCurrency { + altAmount = "≈ \(altFiatCurrency.string)" + + } else { + // We don't know the exchange rate + altAmount = "" + } + + } else { + // We don't know the exchange rate + altAmount = "" + } + + } // + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func fakeItTillYouMakeIt() { + log.trace("fakeItTillYouMakeIt()") + + isScanning = true + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.isScanning = false + } + } + + func startNfcReader() { + log.trace("startNfcReader()") + + isScanning = true + NfcReader.shared.readCard { result in + + isScanning = false + switch result { + case .success(let result): + log.debug("NFCNDEFMessage: \(result)") + + var scannedUri: URL? = nil + + result.records.forEach { payload in + if let uri = payload.wellKnownTypeURIPayload() { + log.debug("found uri = \(uri)") + if scannedUri == nil { + scannedUri = uri + } + + } else if let text = payload.wellKnownTypeTextPayload().0 { + log.debug("found text = \(text)") + + } else { + log.debug("found tag with unknown type") + } + } + + if let scannedUri { + readErrorMessage = "" + handleScannedUri(scannedUri) + + } else { + readErrorMessage = String(localized: "No URI detected in NFC tag") + } + + case .failure(let failure): + switch failure { + case .readingNotAvailable: + readErrorMessage = String(localized: "NFC cababilities not available on this device") + case .alreadyStarted: + readErrorMessage = String(localized: "NFC reader is already scanning") + case .scanningTerminated(_): + readErrorMessage = String(localized: "Nothing scanned") + case .errorReadingTag: + readErrorMessage = String(localized: "Error reading tag") + } + } + } + } + + func showInboundFeeWarning(_ warning: InboundFeeWarning) { + + smartModalState.display(dismissable: true) { + InboundFeeSheet(warning: warning) + } + } + + func dismissKeyboardIfVisible() -> Void { + log.trace("dismissKeyboardIfVisible()") + + focusedField = nil + } + + // -------------------------------------------------- + // MARK: Payment Logic + // -------------------------------------------------- + + func handleScannedUri(_ scannedUri: URL) { + log.trace("handleScannedUri(\(scannedUri.absoluteString))") + + isParsing = true + parseIndex += 1 + let index = parseIndex + + Task { @MainActor in + do { + let progressHandler = {(progress: SendManager.ParseProgress) -> Void in + if index == parseIndex { + self.parseProgress = progress + } else { + log.warning("handleScannedUri: progressHandler: ignoring: cancelled") + } + } + + let result: SendManager.ParseResult = try await Biz.business.sendManager.parse( + request: scannedUri.absoluteString, + progress: progressHandler + ) + + if index == parseIndex { + isParsing = false + parseProgress = nil + handleParseResult(result) + } else { + log.warning("handleScannedUri: result: ignoring: cancelled") + } + + } catch { + log.error("handleScannedUri: error: \(error)") + + if index == parseIndex { + isParsing = false + parseProgress = nil + } + } + + } // + } + + func handleParseResult(_ result: SendManager.ParseResult) { + log.trace("handleParseResult()") + + guard let expectedResult = result as? SendManager.ParseResult_Lnurl_Withdraw else { + showErrorMessage(result) + return + } + + guard let msat = msatAmount() else { + return + } + + isReceiving = true + + Task { @MainActor in + do { + + bolt11Invoice = try await Biz.business.sendManager.lnurlWithdraw_createInvoice( + lnurlWithdraw: expectedResult.lnurlWithdraw, + amount: msat, + description: description + ) + + let err: SendManager.LnurlWithdrawError? = + try await Biz.business.sendManager.lnurlWithdraw_sendInvoice( + lnurlWithdraw: expectedResult.lnurlWithdraw, + invoice: bolt11Invoice! + ) + + if let remoteErr = err as? SendManager.LnurlWithdrawErrorRemoteError { + + // Todo: map this to BadRequestReason_ServiceError, and call showErrorMessage() + } + + } catch { + log.error("handleParseResult: error: \(error)") + + isReceiving = false + } + } // + } + + func showErrorMessage(_ result: SendManager.ParseResult) { + log.trace("showErrorMessage()") + + var msg = String(localized: "Does not appear to be a bolt card.") + var websiteLink: URL? = nil + + if let badRequest = result as? SendManager.ParseResult_BadRequest { + + if let serviceError = badRequest.reason as? SendManager.BadRequestReason_ServiceError { + + let remoteFailure: LnurlError.RemoteFailure = serviceError.error + let origin = remoteFailure.origin + + if remoteFailure is LnurlError.RemoteFailure_IsWebsite { + websiteLink = URL(string: serviceError.url.description()) + msg = String( + localized: "Unreadable response from service: \(origin)", + comment: "Error message - scanning lightning invoice" + ) + } + } + } + + if let websiteLink { + popoverState.display(dismissable: true) { + WebsiteLinkPopover( + link: websiteLink, + didCopyLink: didCopyLink, + didOpenLink: nil + ) + } + + } else { + toast.pop( + msg, + colorScheme: colorScheme.opposite, + style: .chrome, + duration: 10.0, + alignment: .middle, + showCloseButton: true + ) + } + } + + func didCopyLink() { + log.trace("didCopyLink()") + + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift index 0034a47ba..1e09e2279 100644 --- a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift @@ -1,5 +1,6 @@ import SwiftUI import PhoenixShared +import CoreNFC fileprivate let filename = "LightningDualView" #if DEBUG && true @@ -40,8 +41,20 @@ struct LightningDualView: View { @State var notificationPermissions = NotificationsManager.shared.permissions.value @State var modificationAmount: CurrencyAmount? = nil + @State var modificationTitleType: ModifyInvoiceSheet.TitleType = .normal - let lastIncomingPaymentPublisher = Biz.business.paymentsManager.lastIncomingPaymentPublisher() + @State var nfcPending: Bool = false + @State var nfcErrorMessage: String? = nil + + @State var nfcScanning: Bool = false + @State var nfcParsing: Bool = false + @State var nfcRequesting: Bool = false + @State var nfcReceiving: Bool = false + + @State var nfcParseIndex: Int = 0 + @State var nfcRequestIndex: Int = 0 + + @State var nfcParseProgress: SendManager.ParseProgress? = nil // For the cicular buttons: [copy, share, edit] enum MaxButtonWidth: Preference {} @@ -51,6 +64,8 @@ struct LightningDualView: View { ) @State var maxButtonWidth: CGFloat? = nil + let lastIncomingPaymentPublisher = Biz.business.paymentsManager.lastIncomingPaymentPublisher() + // To workaround a bug in SwiftUI, we're using multiple namespaces for our animation. // In particular, animating the border around the qrcode doesn't work well. @Namespace private var qrCodeAnimation_inner @@ -170,11 +185,15 @@ struct LightningDualView: View { actionButtons() .padding(.bottom) - switchTypeButton() - - if activeType == .bolt12_offer { - howToUseButton() - .padding(.top) + if nfcScanning || nfcParsing || nfcRequesting || nfcReceiving { + nfcActivity() + + } else { + switchTypeButton() + if activeType == .bolt12_offer { + howToUseButton() + .padding(.top) + } } if notificationPermissions == .disabled { @@ -307,12 +326,12 @@ struct LightningDualView: View { if activeType == .bolt11_invoice { invoiceAmountView() - .font(.footnote) + .font(.callout) .foregroundColor(.secondary) invoiceDescriptionView() .lineLimit(1) - .font(.footnote) + .font(.callout) .foregroundColor(.secondary) } else { @@ -321,7 +340,7 @@ struct LightningDualView: View { bip353AddressView(address) .lineLimit(2) .multilineTextAlignment(.center) - .font(.footnote) + .font(.callout) .foregroundColor(.secondary) } else { @@ -415,6 +434,7 @@ struct LightningDualView: View { shareButton() if activeType == .bolt11_invoice { editButton() + cardButton() } } .assignMaxPreference(for: maxButtonWidthReader.key, to: $maxButtonWidth) @@ -468,7 +488,7 @@ struct LightningDualView: View { func copyButton() -> some View { actionButton( - text: NSLocalizedString("copy", comment: "button label - try to make it short"), + text: String(localized: "copy", comment: "button label - try to make it short"), image: Image(systemName: "square.on.square"), width: 20, height: 20, xOffset: 0, yOffset: 0 @@ -485,7 +505,7 @@ struct LightningDualView: View { func shareButton() -> some View { actionButton( - text: NSLocalizedString("share", comment: "button label - try to make it short"), + text: String(localized: "share", comment: "button label - try to make it short"), image: Image(systemName: "square.and.arrow.up"), width: 21, height: 21, xOffset: 0, yOffset: -1 @@ -502,7 +522,7 @@ struct LightningDualView: View { func editButton() -> some View { actionButton( - text: NSLocalizedString("edit", comment: "button label - try to make it short"), + text: String(localized: "edit", comment: "button label - try to make it short"), image: Image(systemName: "square.and.pencil"), width: 19, height: 19, xOffset: 1, yOffset: -1 @@ -512,6 +532,20 @@ struct LightningDualView: View { .disabled(!(mvi.model is Receive.Model_Generated)) } + @ViewBuilder + func cardButton() -> some View { + + actionButton( + text: String(localized: "card", comment: "button label - try to make it short"), + image: Image(systemName: "creditcard"), + width: 21, height: 21, + xOffset: 0, yOffset: 0 + ) { + didTapCardButton() + } + .disabled(nfcScanning || nfcParsing || nfcRequesting || nfcReceiving) + } + @ViewBuilder func switchTypeButton() -> some View { @@ -563,6 +597,34 @@ struct LightningDualView: View { } } + @ViewBuilder + func nfcActivity() -> some View { + + if nfcParsing || nfcRequesting || nfcReceiving { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + HorizontalActivity(color: .appAccent, diameter: 10, speed: 1.6) + .frame(width: 240, height: 10) + .padding(.horizontal) + .padding(.bottom, 4) + + Group { + if nfcParsing { + Text("Communicating with card's host…") + } else if nfcRequesting { + Text("Requesting payment…") + } else { + Text("Awaiting payment…") + } + } + .multilineTextAlignment(.center) + + } // + .padding(.top) + } + } + @ViewBuilder func backgroundPaymentsDisabledWarning() -> some View { @@ -659,7 +721,7 @@ struct LightningDualView: View { // MARK: View Transitions // -------------------------------------------------- - func onAppear() -> Void { + func onAppear() { log.trace("onAppear()") // Careful: this function may be called multiple times @@ -714,6 +776,12 @@ struct LightningDualView: View { original: m.request, rendered: m.request.uppercased() )) + + if nfcPending { + nfcPending = false + + startNfcReader() + } } } @@ -760,6 +828,14 @@ struct LightningDualView: View { )) } + func modifyInvoiceSheetDidCancel() { + log.trace("modifyInvoiceSheetDidCancel()") + + if nfcPending { + nfcPending = false + } + } + func currencyConverterDidChange(_ amount: CurrencyAmount?) { log.trace("currencyConverterDidChange()") @@ -779,11 +855,13 @@ struct LightningDualView: View { smartModalState.display(dismissable: true) { ModifyInvoiceSheet( + titleType: modificationTitleType, savedAmount: $modificationAmount, amount: amount, desc: desc ?? "", openCurrencyConverter: openCurrencyConverter, - didSave: modifyInvoiceSheetDidSave + didSave: modifyInvoiceSheetDidSave, + didCancel: modifyInvoiceSheetDidCancel ) } } @@ -906,19 +984,55 @@ struct LightningDualView: View { log.trace("didTapEditButton()") // The edit button is only displayed for Bolt 11 invoices. + guard let model = mvi.model as? Receive.Model_Generated else { + log.warning("didTapEditButton(): ignoring: model is not Receive.Model_Generated") + return + } - if let model = mvi.model as? Receive.Model_Generated { + modificationTitleType = .normal + smartModalState.display(dismissable: true) { + ModifyInvoiceSheet( + titleType: modificationTitleType, + savedAmount: $modificationAmount, + amount: model.amount, + desc: model.desc ?? "", + openCurrencyConverter: openCurrencyConverter, + didSave: modifyInvoiceSheetDidSave, + didCancel: modifyInvoiceSheetDidCancel + ) + } + } + + func didTapCardButton() { + log.trace("didCardEditButton()") + + // The card button is only displayed for Bolt 11 invoices. + guard let model = mvi.model as? Receive.Model_Generated else { + log.warning("didTapCardButton(): ignoring: model is not Receive.Model_Generated") + return + } + + if model.amount == nil { + // We need the user to enter an amount first. + + nfcPending = true + modificationTitleType = .cardPaymentNeedsAmount smartModalState.display(dismissable: true) { ModifyInvoiceSheet( + titleType: modificationTitleType, savedAmount: $modificationAmount, amount: model.amount, desc: model.desc ?? "", openCurrencyConverter: openCurrencyConverter, - didSave: modifyInvoiceSheetDidSave + didSave: modifyInvoiceSheetDidSave, + didCancel: modifyInvoiceSheetDidCancel ) } + + } else { + startNfcReader() } } @@ -1024,5 +1138,237 @@ struct LightningDualView: View { let uiImg = UIImage(cgImage: cgImage) activeSheet = ActiveSheet.sharingImage(image: uiImg) } + + // -------------------------------------------------- + // MARK: Card Payment + // -------------------------------------------------- + + func startNfcReader() { + log.trace("startNfcReader()") + + nfcScanning = true + NfcReader.shared.readCard { result in + + nfcScanning = false + switch result { + case .failure(let failure): + switch failure { + case .readingNotAvailable: + nfcErrorMessage = String(localized: "NFC cababilities not available on this device") + case .alreadyStarted: + nfcErrorMessage = String(localized: "NFC reader is already scanning") + case .scanningTerminated(_): + nfcErrorMessage = String(localized: "Nothing scanned") + case .errorReadingTag: + nfcErrorMessage = String(localized: "Error reading tag") + } + + case .success(let result): + log.debug("NFCNDEFMessage: \(result)") + + var scannedUri: URL? = nil + + result.records.forEach { payload in + if let uri = payload.wellKnownTypeURIPayload() { + log.debug("found uri = \(uri)") + if scannedUri == nil { + scannedUri = uri + } + + } else if let text = payload.wellKnownTypeTextPayload().0 { + log.debug("found text = \(text)") + + } else { + log.debug("found tag with unknown type") + } + } + + if let scannedUri { + nfcErrorMessage = nil + handleScannedUri(scannedUri) + + } else { + nfcErrorMessage = String(localized: "No URI detected in NFC tag") + } + } + } + } + + func handleScannedUri(_ scannedUri: URL) { + log.trace("handleScannedUri(\(scannedUri.absoluteString))") + + nfcParsing = true + nfcParseIndex += 1 + let index = nfcParseIndex + + Task { @MainActor in + do { + let progressHandler = {(progress: SendManager.ParseProgress) -> Void in + if index == nfcParseIndex { + nfcParseProgress = progress + } else { + log.warning("handleScannedUri: progressHandler: ignoring: cancelled") + } + } + + let result: SendManager.ParseResult = try await Biz.business.sendManager.parse( + request: scannedUri.absoluteString, + progress: progressHandler + ) + + if index == nfcParseIndex { + nfcParsing = false + nfcParseProgress = nil + handleParseResult(result) + } else { + log.info("handleScannedUri: result: ignoring: cancelled") + } + + } catch { + log.error("handleScannedUri: error: \(error)") + + if index == nfcParseIndex { + nfcParsing = false + nfcParseProgress = nil + nfcErrorMessage = String(localized: "Could not communicate with card's wallet") + } else { + log.info("handleScannedUri: error: ignoring: cancelled") + } + } + + } // + } + + func handleParseResult(_ result: SendManager.ParseResult) { + log.trace("handleParseResult()") + + guard let expectedResult = result as? SendManager.ParseResult_Lnurl_Withdraw else { + handleParseError(result) + return + } + + guard let model = mvi.model as? Receive.Model_Generated else { + return + } + + nfcRequesting = true + nfcRequestIndex += 1 + let index = nfcRequestIndex + + Task { @MainActor in + do { + + let err: SendManager.LnurlWithdrawError? = + try await Biz.business.sendManager.lnurlWithdraw_sendInvoice( + lnurlWithdraw: expectedResult.lnurlWithdraw, + invoice: model.invoice + ) + + if index == nfcRequestIndex { + nfcRequesting = false + if let remoteErr = err as? SendManager.LnurlWithdrawErrorRemoteError { + handleRequestError(remoteErr) + } else { + nfcReceiving = true + } + } else { + log.info("handleParseResult: result: ignoring: cancelled") + } + + } catch { + log.error("handleParseResult: error: \(error)") + + if index == nfcRequestIndex { + nfcRequesting = false + nfcErrorMessage = String(localized: "Cound not communicate with card's wallet") + } else { + log.error("handleParseResult: error: ignoring: cancelled") + } + } + } // + } + + func handleParseError(_ result: SendManager.ParseResult) { + log.trace("handleParseError()") + + var msg = String(localized: "Does not appear to be a bolt card.") + var websiteLink: URL? = nil + + if let badRequest = result as? SendManager.ParseResult_BadRequest { + + if let serviceError = badRequest.reason as? SendManager.BadRequestReason_ServiceError { + + let remoteFailure: LnurlError.RemoteFailure = serviceError.error + let origin = remoteFailure.origin + + if remoteFailure is LnurlError.RemoteFailure_IsWebsite { + websiteLink = URL(string: serviceError.url.description()) + msg = String( + localized: "Unreadable response from service: \(origin)", + comment: "Error message - scanning lightning invoice" + ) + } + } + } + + if let websiteLink { + popoverState.display(dismissable: true) { + WebsiteLinkPopover( + link: websiteLink, + didCopyLink: didCopyLink, + didOpenLink: nil + ) + } + + } else { + nfcErrorMessage = msg + } + } + + func handleRequestError(_ result: SendManager.LnurlWithdrawErrorRemoteError) { + log.trace("handleRequestError()") + + let remoteFailure = result.err + switch remoteFailure { + + case is LnurlError.RemoteFailure_CouldNotConnect: + nfcErrorMessage = String( + localized: "Could not connect to card's host", + comment: "Error message - processing bolt card payment" + ) + + case is LnurlError.RemoteFailure_Unreadable: + nfcErrorMessage = String( + localized: "Unreadable response from card's host", + comment: "Error message - processing bolt card payment" + ) + + case let rfDetailed as LnurlError.RemoteFailure_Detailed: + nfcErrorMessage = String( + localized: "The card's host returned error message: \(rfDetailed.reason)", + comment: "Error message - processing bolt card payment" + ) + + case let rfCode as LnurlError.RemoteFailure_Code: + nfcErrorMessage = String( + localized: "The card's host returned error code: \(rfCode.code.value)", + comment: "Error message - processing bolt card payment" + ) + + default: + nfcErrorMessage = String( + localized: "Could not communicate with card's wallet", + comment: "Error message - scanning lightning invoice" + ) + } + } + + func didCopyLink() { + log.trace("didCopyLink()") + + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) + } } - diff --git a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift index 21cc4fe5b..cbfc1f00c 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift @@ -10,11 +10,18 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct ModifyInvoiceSheet: View { + enum TitleType { + case normal + case cardPaymentNeedsAmount + } + let titleType: TitleType + @Binding var savedAmount: CurrencyAmount? let initialAmount: Lightning_kmpMilliSatoshi? let openCurrencyConverter: () -> Void let didSave: (Lightning_kmpMilliSatoshi?, String) -> Void + let didCancel: () -> Void @State var desc: String @@ -27,6 +34,8 @@ struct ModifyInvoiceSheet: View { @State var altAmount: String = "" + @State var didTapSave: Bool = false + @EnvironmentObject var currencyPrefs: CurrencyPrefs @EnvironmentObject var smartModalState: SmartModalState @@ -39,17 +48,21 @@ struct ModifyInvoiceSheet: View { @State var textHeight: CGFloat? = nil init( + titleType: TitleType, savedAmount: Binding, amount: Lightning_kmpMilliSatoshi?, desc: String, openCurrencyConverter: @escaping () -> Void, - didSave: @escaping (Lightning_kmpMilliSatoshi?, String) -> Void + didSave: @escaping (Lightning_kmpMilliSatoshi?, String) -> Void, + didCancel: @escaping () -> Void ) { + self.titleType = titleType self._savedAmount = savedAmount self.initialAmount = amount self._desc = State(initialValue: desc) self.openCurrencyConverter = openCurrencyConverter self.didSave = didSave + self.didCancel = didCancel } // -------------------------------------------------- @@ -60,10 +73,18 @@ struct ModifyInvoiceSheet: View { var body: some View { VStack(alignment: .leading) { - Text("Edit payment request") - .font(.title3) - .padding([.top, .bottom]) - .accessibilityAddTraits(.isHeader) + + Group { + switch titleType { + case .normal: + Text("Edit payment request") + case .cardPaymentNeedsAmount: + Text("Amount required for card payment").foregroundStyle(Color.appNegative).bold() + } + } + .font(.title3) + .padding([.top, .bottom]) + .accessibilityAddTraits(.isHeader) HStack { TextField( @@ -266,8 +287,14 @@ struct ModifyInvoiceSheet: View { refreshAltAmount() } - currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: currency) + currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: [currency]) currencyPickerChoice = CurrencyPickerOption.currency(currency) + + smartModalState.onNextDidDisappear { + if !didTapSave { + didCancel() + } + } } func amountDidChange() -> Void { @@ -402,6 +429,7 @@ struct ModifyInvoiceSheet: View { savedAmount = nil } + didTapSave = true smartModalState.close(animationCompletion: { didSave(msat, trimmedDesc) }) diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 47325b113..534be7d41 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -2102,8 +2102,7 @@ struct ValidateView: View { if let newAmt = result { - let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: newAmt.currency) - + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: [newAmt.currency]) if currencyList != newCurrencyList { currencyList = newCurrencyList } diff --git a/phoenix-ios/phoenix-ios/views/send/WebsiteLinkPopover.swift b/phoenix-ios/phoenix-ios/views/send/WebsiteLinkPopover.swift index 11881a393..40cba8eee 100644 --- a/phoenix-ios/phoenix-ios/views/send/WebsiteLinkPopover.swift +++ b/phoenix-ios/phoenix-ios/views/send/WebsiteLinkPopover.swift @@ -27,7 +27,7 @@ struct WebsiteLinkPopover: View { VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - Text("This appears to be a website (not a lightning invoice):") + Text("This appears to be a website:") .padding(.bottom, 10) Text(verbatim: link.absoluteString) diff --git a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift index 9a79ba86b..d1d52234c 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift @@ -10,11 +10,8 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct PaymentCell : View { - static let fetchOptions = WalletPaymentFetchOptions.companion.Descriptions.plus( - other: WalletPaymentFetchOptions.companion.OriginalFiat - ).plus( - other: WalletPaymentFetchOptions.companion.Contact - ) + // Common options = Descriptions + OriginalFiat + Contacts + Card + static let fetchOptions = WalletPaymentFetchOptions.companion.Common private let paymentsManager = Biz.business.paymentsManager diff --git a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift index ec8c3e429..f1c135436 100644 --- a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift +++ b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift @@ -31,6 +31,22 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) */ class NotificationService: UNNotificationServiceExtension { + enum PushNotificationReason: CustomStringConvertible { + case incomingPayment + case pendingSettlement + case withdrawRequest + case unknown + + var description: String { + switch self { + case .incomingPayment : return "incomingPayment" + case .pendingSettlement : return "pendingSettlement" + case .withdrawRequest : return "withdrawRequest" + case .unknown : return "unknown" + } + } + } + private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? @@ -38,12 +54,19 @@ class NotificationService: UNNotificationServiceExtension { private var phoenixStarted: Bool = false private var srvExtDone: Bool = false + private var business: PhoenixBusiness? = nil + private var pushNotificationReason: PushNotificationReason = .unknown + private var isConnectedToPeer = false private var receivedPayments: [Lightning_kmpIncomingPayment] = [] + private var withdrawRequestResult: Result? = nil + private var withdrawResponseSent: Bool = false + private var sentPayment: Lightning_kmpLightningOutgoingPayment? = nil + private var totalTimer: Timer? = nil private var connectionTimer: Timer? = nil - private var postPaymentTimer: Timer? = nil + private var postReceivedPaymentTimer: Timer? = nil private var cancellables = Set() @@ -69,6 +92,7 @@ class NotificationService: UNNotificationServiceExtension { self.startTotalTimer() self.startXpc() self.startPhoenix() + self.processRequest(request) } } @@ -81,9 +105,178 @@ class NotificationService: UNNotificationServiceExtension { // IMPORTANT: This function is called on a NON-main thread. DispatchQueue.main.async { + self.displayPushNotification() + } + } + + // -------------------------------------------------- + // MARK: Request Processing + // -------------------------------------------------- + + private func processRequest(_ request: UNNotificationRequest) { + log.trace("processRequest()") + assertMainThread() + + let userInfo = request.content.userInfo + + // This could be a push notification coming from either: + // - Google's Firebase Cloud Messaging (FCM) + // - Amazon Web Services (AWS) + + if PushNotification.isFCM(userInfo: userInfo) { + processRequest_fcm(userInfo) + } else { + processRequest_aws(userInfo) + } + } + + private func processRequest_fcm(_ userInfo: [AnyHashable : Any]) { + log.trace("processRequest_fcm()") + assertMainThread() + + // Example: request.content.userInfo: + // { + // "gcm.message_id": 1605136272123442, + // "google.c.sender.id": 458618232423, + // "google.c.a.e": 1, + // "google.c.fid": "dRLLO-mxUxbDvmV1urj5Tt", + // "reason": "IncomingPayment", + // "aps": { + // "alert": { + // "title": "Missed incoming payment", + // }, + // "mutable-content": 1 + // } + // } + + if let reason = userInfo["reason"] as? String { + switch reason { + case "IncomingPayment" : pushNotificationReason = .incomingPayment + case "PendingSettlement" : pushNotificationReason = .pendingSettlement + default : pushNotificationReason = .unknown + } + } else { + pushNotificationReason = .unknown + } + + log.debug("pushNotificationReason = \(pushNotificationReason)") + + // Nothing else to do here. + // No custom processing is needed for either `.incomingPayment` or `.pendingSettlement`. + // Those types of requests are handled automatically by the Peer. + } + + private func processRequest_aws(_ userInfo: [AnyHashable : Any]) { + log.trace("processRequest_aws()") + assertMainThread() + + if let withdrawRequest = PushNotification.parseWithdrawRequest(userInfo: userInfo) { + pushNotificationReason = .withdrawRequest + log.debug("pushNotificationReason = \(pushNotificationReason)") + + Task { + await processRequest_aws_withdraw(withdrawRequest) + } + } else { + pushNotificationReason = .unknown + log.debug("pushNotificationReason = \(pushNotificationReason)") + + return displayPushNotification() + } + } + + @MainActor + private func processRequest_aws_withdraw( + _ request: WithdrawRequest + ) async { + log.trace("processRequest_aws_withdraw()") + + guard let business else { + log.warning("processRequest_aws_withdraw(): business is nil") + return + } + + let reject = { @MainActor (error: WithdrawRequestError) async -> Void in + + // Stop other processing + self.stopPhoenix() + self.stopXpc() + + // Send the response to the merchant + let _ = await request.postResponse(errorReason: error.description) + // And finally, display notification to the user self.displayPushNotification() } + + let result = await business.checkWithdrawRequest(request) + withdrawRequestResult = result + + switch result { + case .failure(let error): + await reject(error) + + case .success(let status): + switch status { + case .abortHandledElsewhere: + displayPushNotification() + + case .continueAndSendPayment(let card, let invoice, let amount): + guard + let peer = business.peerManager.peerStateValue(), + let defaultTrampolineFees = peer.walletParams.trampolineFees.first + else { + return await reject(.internalError(card: card, details: "peer is nil")) + } + + do { + try await business.sendManager.payBolt11Invoice( + amountToSend : amount, + trampolineFees : defaultTrampolineFees, + invoice : invoice, + metadata : WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("SendManager.payBolt11Invoice(): threw error: \(error)") + return await reject(.internalError(card: card, details: "payBolt11Invoice failed")) + } + + // We have 2 tasks to finish before we're done: + // 1). Send the response to the merchant + // 2). Wait for our outgoing payment to complete + // + // We can perform these in parallel. + + Task { @MainActor in + let _ = await request.postResponse(errorReason: nil) + + self.withdrawResponseSent = true + log.debug("withdrawResponseSent = true") + + if self.sentPayment != nil { + self.displayPushNotification() + } + } + + business.paymentsManager.lastCompletedPaymentPublisher().sink { payment in + if let lnPayment = payment as? Lightning_kmpLightningOutgoingPayment, + let details = lnPayment.details as? Lightning_kmpLightningOutgoingPayment.DetailsNormal + { + if details.paymentHash == invoice.paymentHash { + self.sentPayment = lnPayment + log.debug("sentPayment = \(lnPayment)") + + if self.withdrawResponseSent { + self.displayPushNotification() + } + } + } + } + .store(in: &cancellables) + + // return accept(request) + } // + } // } // -------------------------------------------------- @@ -137,14 +330,14 @@ class NotificationService: UNNotificationServiceExtension { } } - private func startPostPaymentTimer() { - log.trace("startPostPaymentTimer()") + private func startPostReceivedPaymentTimer() { + log.trace("startPostReceivedPaymentTimer()") assertMainThread() // This method is called everytime we receive a payment, // and it's possible we receive multiple payments. // So for every payment, we want to restart the timer. - postPaymentTimer?.invalidate() + postReceivedPaymentTimer?.invalidate() #if DEBUG let delay: TimeInterval = 5.0 @@ -152,13 +345,13 @@ class NotificationService: UNNotificationServiceExtension { let delay: TimeInterval = 5.0 #endif - postPaymentTimer = Timer.scheduledTimer( + postReceivedPaymentTimer = Timer.scheduledTimer( withTimeInterval : delay, repeats : false ) {[weak self](_: Timer) -> Void in if let self = self { - log.debug("postPaymentTimer.fire()") + log.debug("postReceivedPaymentTimer.fire()") self.displayPushNotification() } } @@ -235,6 +428,7 @@ class NotificationService: UNNotificationServiceExtension { phoenixStarted = true let newBusiness = PhoenixManager.shared.setupBusiness() + business = newBusiness newBusiness.connectionsManager.connectionsPublisher().sink { [weak self](connections: Connections) in @@ -242,7 +436,7 @@ class NotificationService: UNNotificationServiceExtension { self?.connectionsChanged(connections) } .store(in: &cancellables) - + let pushReceivedAt = Date() newBusiness.paymentsManager.lastIncomingPaymentPublisher().sink { [weak self](payment: Lightning_kmpIncomingPayment) in @@ -269,6 +463,7 @@ class NotificationService: UNNotificationServiceExtension { phoenixStarted = false PhoenixManager.shared.teardownBusiness() + business = nil } } @@ -288,7 +483,7 @@ class NotificationService: UNNotificationServiceExtension { receivedPayments.append(payment) if !srvExtDone { - startPostPaymentTimer() + startPostReceivedPaymentTimer() } } @@ -296,42 +491,6 @@ class NotificationService: UNNotificationServiceExtension { // MARK: Finish // -------------------------------------------------- - enum PushNotificationReason { - case incomingPayment - case pendingSettlement - case unknown - } - - private func pushNotificationReason() -> PushNotificationReason { - - // Example: request.content.userInfo: - // { - // "gcm.message_id": 1605136272123442, - // "google.c.sender.id": 458618232423, - // "google.c.a.e": 1, - // "google.c.fid": "dRLLO-mxUxbDvmV1urj5Tt", - // "reason": "IncomingPayment", - // "aps": { - // "alert": { - // "title": "Phoenix is running in the background", - // }, - // "mutable-content": 1 - // } - // } - - if let userInfo = bestAttemptContent?.userInfo, - let reason = userInfo["reason"] as? String - { - switch reason { - case "IncomingPayment" : return .incomingPayment - case "PendingSettlement" : return .pendingSettlement - default : break - } - } - - return .unknown - } - private func displayPushNotification() { log.trace("displayPushNotification()") assertMainThread() @@ -349,17 +508,23 @@ class NotificationService: UNNotificationServiceExtension { totalTimer = nil connectionTimer?.invalidate() connectionTimer = nil - postPaymentTimer?.invalidate() - postPaymentTimer = nil + postReceivedPaymentTimer?.invalidate() + postReceivedPaymentTimer = nil stopXpc() stopPhoenix() - updateBestAttemptContent() + switch pushNotificationReason { + case .incomingPayment : updateBestAttemptContent_fcm() + case .pendingSettlement : updateBestAttemptContent_fcm() + case .withdrawRequest : updateBestAttemptContent_aws() + case .unknown : updateBestAttemptContent_fcm() + } + contentHandler(bestAttemptContent) } - private func updateBestAttemptContent() { - log.trace("updateBestAttemptContent()") + private func updateBestAttemptContent_fcm() { + log.trace("updateBestAttemptContent_fcm()") assertMainThread() guard let bestAttemptContent else { @@ -368,11 +533,11 @@ class NotificationService: UNNotificationServiceExtension { if receivedPayments.isEmpty { - if pushNotificationReason() == .pendingSettlement { - bestAttemptContent.title = NSLocalizedString("Please start Phoenix", comment: "") - bestAttemptContent.body = NSLocalizedString("An incoming settlement is pending.", comment: "") + if pushNotificationReason == .pendingSettlement { + bestAttemptContent.title = String(localized: "Please start Phoenix", comment: "") + bestAttemptContent.body = String(localized: "An incoming settlement is pending.", comment: "") } else { - bestAttemptContent.title = NSLocalizedString("Missed incoming payment", comment: "") + bestAttemptContent.title = String(localized: "Missed incoming payment", comment: "") } } else { // received 1 or more payments @@ -425,6 +590,139 @@ class NotificationService: UNNotificationServiceExtension { } } + private func updateBestAttemptContent_aws() { + log.trace("updateBestAttemptContent_aws()") + assertMainThread() + + guard let bestAttemptContent, let result = withdrawRequestResult else { + return + } + + switch result { + case .failure(let error): + bestAttemptContent.title = String(localized: "Payment rejected") + + switch error { + case .unknownCard: + bestAttemptContent.body = String(localized: "Unknown bolt card") + + case .replayDetected(let card): + bestAttemptContent.body = String(localized: + """ + Replay attempt detected + Card: \(card.sanitizedName) + """) + + case .frozenCard(let card): + bestAttemptContent.body = String(localized: + """ + Card is frozen + Card: \(card.sanitizedName) + """) + + case .dailyLimitExceeded(let card, let amount): + let amtStr = Utils.format(currencyAmount: amount).string + bestAttemptContent.body = String(localized: + """ + Daily limit exceeded + Payment amount: \(amtStr) + Card: \(card.sanitizedName) + """) + + case .monthlyLimitExceeded(let card, let amount): + let amtStr = Utils.format(currencyAmount: amount).string + bestAttemptContent.body = String(localized: + """ + Monthly limit exceeded + Payment amount: \(amtStr) + Card: \(card.sanitizedName) + """) + + case .badInvoice(let card, let details): + bestAttemptContent.body = String(localized: + """ + Bad invoice: \(details) + Card: \(card.sanitizedName) + """) + + case .alreadyPaidInvoice(let card): + bestAttemptContent.body = String(localized: + """ + You've already paid this invoice + Card: \(card.sanitizedName) + """) + + case .paymentPending(let card): + bestAttemptContent.body = String(localized: + """ + A payment for this invoice is in-flight + Card: \(card.sanitizedName) + """) + + case .internalError(let card, let details): + bestAttemptContent.body = String(localized: + """ + Internal error: \(details) + Card: \(card.sanitizedName) + """) + } + + case .success(let status): + switch status { + case .abortHandledElsewhere(let card): + bestAttemptContent.title = String(localized: "Payment attempt ignored") + bestAttemptContent.subtitle = card.sanitizedName + bestAttemptContent.body = String(localized: "Handled elsewhere in the system") + + case .continueAndSendPayment(let card, let invoice, let amount): + + if let sentPayment, let failedStatus = sentPayment.status.asFailed() { + + bestAttemptContent.title = String(localized: "Payment attempt failed") + + let localizedReason = failedStatus.reason.localizedDescription() + + if failedStatus.reason is Lightning_kmpFinalFailure.InsufficientBalance { + let amountString = formatAmount(msat: amount.msat) + bestAttemptContent.body = String(localized: + """ + \(localizedReason) + Payment amount: \(amountString) + Card: \(card.sanitizedName) + """) + + } else { + bestAttemptContent.body = String(localized: + """ + \(localizedReason) + Card: \(card.sanitizedName) + """) + } + + } else { + + bestAttemptContent.title = String(localized: "Payment successful 💳") + let amountString = formatAmount(msat: amount.msat) + + if let desc = invoice.description_ { + bestAttemptContent.body = String(localized: + """ + \(amountString) + For: \(desc) + Card: \(card.sanitizedName) + """) + } else { + bestAttemptContent.body = String(localized: + """ + \(amountString) + Card: \(card.sanitizedName) + """) + } + } + } + } + } + private func formatAmount(msat: Int64) -> String { let bitcoinUnit = GroupPrefs.shared.bitcoinUnit diff --git a/phoenix-shared/build.gradle.kts b/phoenix-shared/build.gradle.kts index 8f57b666a..fb6ca6c94 100644 --- a/phoenix-shared/build.gradle.kts +++ b/phoenix-shared/build.gradle.kts @@ -59,7 +59,7 @@ kotlin { } } - listOf(iosX64(), iosArm64()).forEach { + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { it.binaries { framework { optimized = false @@ -89,7 +89,6 @@ kotlin { dependencies { // lightning-kmp api("fr.acinq.lightning:lightning-kmp:${Versions.lightningKmp}") - api("fr.acinq.tor:tor-mobile-kmp:${Versions.torMobile}") // ktor implementation("io.ktor:ktor-client-core:${Versions.ktor}") implementation("io.ktor:ktor-client-json:${Versions.ktor}") 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 92cfeab29..bb40f3185 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 @@ -11,6 +11,8 @@ actual fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: Payment actual fun didSaveContact(contactId: UUID, database: AppDatabase) {} actual fun didDeleteContact(contactId: UUID, database: AppDatabase) {} +actual fun didSaveCard(cardId: 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/BoltCards.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/BoltCards.sq new file mode 100644 index 000000000..e77f15ae8 --- /dev/null +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/BoltCards.sq @@ -0,0 +1,66 @@ +import kotlin.Boolean; + +CREATE TABLE IF NOT EXISTS bolt_cards ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + key0 BLOB NOT NULL, + uid BLOB NOT NULL, + counter INTEGER NOT NULL, + is_frozen INTEGER AS Boolean NOT NULL, + is_archived INTEGER AS Boolean NOT NULL, + is_reset INTEGER AS Boolean NOT NULL, + is_foreign INTEGER AS Boolean NOT NULL, + daily_limit_currency TEXT, + daily_limit_amount REAL, + monthly_limit_currency TEXT, + monthly_limit_amount REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +listCards: +SELECT * FROM bolt_cards +ORDER BY created_at DESC; + +scanCards: +SELECT id, created_at FROM bolt_cards; + +existsCard: +SELECT COUNT(*) FROM bolt_cards +WHERE id = :cardId; + +getCard: +SELECT * FROM bolt_cards +WHERE id = :cardId; + +insertCard: +INSERT INTO bolt_cards( + id, name, key0, uid, counter, + is_frozen, is_archived, is_reset, is_foreign, + daily_limit_currency, daily_limit_amount, + monthly_limit_currency, monthly_limit_amount, + created_at, updated_at +) VALUES ( + :id, :name, :key0, :uid, :counter, + :isFrozen, :isArchived, :isReset, :isForeign, + :dailyLimitCurrency, :dailyLimitAmount, + :monthlyLimitCurrency, :monthlyLimitAmount, + :createdAt, :updatedAt +); + +updateCard: +UPDATE bolt_cards +SET name=:name, + counter=:counter, + is_frozen=:isFrozen, + is_archived=:isArchived, + is_reset=:isReset, + daily_limit_currency=:dailyLimitCurrency, + daily_limit_amount=:dailyLimitAmount, + monthly_limit_currency=:monthlyLimitCurrency, + monthly_limit_amount=:monthlyLimitAmount, + updated_at=:updatedAt +WHERE id=:cardId; + +deleteCard: +DELETE FROM bolt_cards WHERE id=:cardId; \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/7.sqm b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/7.sqm new file mode 100644 index 000000000..617db1883 --- /dev/null +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/7.sqm @@ -0,0 +1,24 @@ +-- Migration: v7 -> v8 +-- +-- Changes: +-- * Added table bolt_cards +-- +-- See BoltCards.sq for more details. + +CREATE TABLE IF NOT EXISTS bolt_cards ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + key0 BLOB NOT NULL, + uid BLOB NOT NULL, + counter INTEGER NOT NULL, + is_frozen INTEGER AS Boolean NOT NULL, + is_archived INTEGER AS Boolean NOT NULL, + is_reset INTEGER AS Boolean NOT NULL, + is_foreign INTEGER AS Boolean NOT NULL, + daily_limit_currency TEXT, + daily_limit_amount REAL, + monthly_limit_currency TEXT, + monthly_limit_amount REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt index 578a58f4a..e3aac97f9 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt @@ -36,7 +36,6 @@ import fr.acinq.phoenix.db.createAppDbDriver import fr.acinq.phoenix.managers.* import fr.acinq.phoenix.utils.* import fr.acinq.phoenix.utils.logger.PhoenixLoggerConfig -import fr.acinq.tor.Tor import io.ktor.client.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* @@ -57,12 +56,7 @@ class PhoenixBusiness( private val tcpSocketBuilder = TcpSocket.Builder() internal val tcpSocketBuilderFactory = suspend { - val isTorEnabled = appConfigurationManager.isTorEnabled.filterNotNull().first() - if (isTorEnabled) { - tcpSocketBuilder.torProxy(loggerFactory) - } else { - tcpSocketBuilder - } + tcpSocketBuilder } internal val httpClient by lazy { @@ -80,7 +74,7 @@ class PhoenixBusiness( var appConnectionsDaemon: AppConnectionsDaemon? = null - val appDb by lazy { SqliteAppDb(createAppDbDriver(ctx)) } + val appDb by lazy { SqliteAppDb(loggerFactory, createAppDbDriver(ctx)) } val networkMonitor by lazy { NetworkMonitor(loggerFactory, ctx) } val walletManager by lazy { WalletManager(chain) } val nodeParamsManager by lazy { NodeParamsManager(this) } @@ -94,8 +88,8 @@ class PhoenixBusiness( val lnurlManager by lazy { LnurlManager(this) } val notificationsManager by lazy { NotificationsManager(this) } val contactsManager by lazy { ContactsManager(this) } + val cardsManager by lazy { CardsManager(this) } val blockchainExplorer by lazy { BlockchainExplorer(chain) } - val tor by lazy { Tor(getApplicationCacheDirectoryPath(ctx), TorHelper.torLogger(loggerFactory)) } val sendManager by lazy { SendManager(this) } fun start(startupParams: StartupParams) { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt index c4923aab3..efbdd2f16 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.controllers.payments import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.controllers.MVI object Receive { @@ -8,7 +9,13 @@ object Receive { sealed class Model : MVI.Model() { object Awaiting : Model() object Generating: Model() - data class Generated(val request: String, val paymentHash: String, val amount: MilliSatoshi?, val desc: String?): Model() + data class Generated( + val invoice: Bolt11Invoice, + val request: String, + val paymentHash: String, + val amount: MilliSatoshi?, + val desc: String? + ): Model() } sealed class Intent : MVI.Intent() { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt index 22fe15523..8f06b8f3c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt @@ -51,7 +51,13 @@ class AppReceiveController( description = Either.Left(intent.description), expiry = intent.expirySeconds.seconds ) - model(Receive.Model.Generated(paymentRequest.write(), paymentRequest.paymentHash.toHex(), paymentRequest.amount, paymentRequest.description)) + model(Receive.Model.Generated( + invoice = paymentRequest, + request = paymentRequest.write(), + paymentHash = paymentRequest.paymentHash.toHex(), + amount = paymentRequest.amount, + desc = paymentRequest.description + )) } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt new file mode 100644 index 000000000..33f3fdb97 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt @@ -0,0 +1,103 @@ +package fr.acinq.phoenix.data + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Crypto +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toByteVector +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class BoltCardInfo( + val id: UUID, + val name: String, + val keys: BoltCardKeySet, + val uid: ByteVector, + val lastKnownCounter: UInt, + val isFrozen: Boolean, + val isArchived: Boolean, + val isReset: Boolean, + val isForeign: Boolean, + val dailyLimit: SpendingLimit?, + val monthlyLimit: SpendingLimit?, + val createdAt: Instant +) { + init { + require(uid.size() == UID_SIZE) { "Invalid uid size: ${uid.size()} != $UID_SIZE" } + } + + constructor( + name: String, + keys: BoltCardKeySet, + uid: ByteVector, + isForeign: Boolean = false + ) : this( + id = UUID.randomUUID(), + name = name, + keys = keys, + uid = uid, + lastKnownCounter = 0u, + isFrozen = false, + isArchived = false, + isReset = false, + isForeign = isForeign, + dailyLimit = null, + monthlyLimit = null, + createdAt = Clock.System.now() + ) + + companion object { + /** UID size in bytes. */ + const val UID_SIZE = 7 + + /** + * Useful for debugging & unit testing. + * Note that the UID of a card is programmed into the chip (immutable). + */ + fun randomUid() = Lightning.randomBytes(length = UID_SIZE).toByteVector() + } +} + +data class BoltCardKeySet( + val key0: ByteVector +) { + init { + require(key0.size() == KEY_SIZE) { "Invalid key size: ${key0.size()} != $KEY_SIZE" } + } + + val piccDataKey: ByteVector by lazy { + keyGen("piccDataKey") + } + + val cmacKey: ByteVector by lazy { + keyGen("cmacKey") + } + + private fun keyGen(keyId: String): ByteVector { + val inner: ByteArray = sha256Hash(key0.toByteArray()) + val outer: ByteArray = sha256Hash(keyId.toByteArray(Charsets.UTF_8)) + + val hashMe: ByteArray = outer + inner + outer + return sha256Hash(hashMe).toByteVector().take(KEY_SIZE) + } + + private fun sha256Hash(bytes: ByteArray): ByteArray { + return Crypto.sha256(bytes) + } + + companion object { + /** Key size in bytes. */ + const val KEY_SIZE = 16 + + fun random() = BoltCardKeySet( + key0 = Lightning.randomBytes(length = KEY_SIZE).toByteVector() + ) + } +} + +data class SpendingLimit( + val currency: CurrencyUnit, + val amount: Double +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt index 04a2eb821..a2bc582e3 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt @@ -165,6 +165,7 @@ data class WalletPaymentMetadata( val originalFiat: ExchangeRate.BitcoinPriceRate? = null, val userDescription: String? = null, val userNotes: String? = null, + val cardId: UUID? = null, val modifiedAt: Long? = null ) @@ -210,7 +211,9 @@ data class WalletPaymentFetchOptions(val flags: Int) { // <- bitmask val UserNotes = WalletPaymentFetchOptions(1 shl 2) val OriginalFiat = WalletPaymentFetchOptions(1 shl 3) val Contact = WalletPaymentFetchOptions(1 shl 4) + val CardID = WalletPaymentFetchOptions(1 shl 5) - val All = Descriptions + Lnurl + UserNotes + OriginalFiat + Contact + val Common = Descriptions + OriginalFiat + Contact + CardID + val All = Descriptions + Lnurl + UserNotes + OriginalFiat + Contact + CardID } } \ No newline at end of file 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 index 005f9e31e..4c2f6d801 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt @@ -46,6 +46,16 @@ expect fun didSaveContact(contactId: UUID, database: AppDatabase) */ expect fun didDeleteContact(contactId: UUID, database: AppDatabase) +/** + * 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 didSaveCard(cardId: UUID, database: AppDatabase) + /** * Implemented on Apple platforms with support for CloudKit. */ 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 fc957fde1..efbdd17c5 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 @@ -4,12 +4,15 @@ import app.cash.sqldelight.EnumColumnAdapter import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.db.SqlDriver import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.BoltCardInfo import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.db.notifications.BoltCardQueries import fr.acinq.phoenix.db.notifications.ContactQueries import fr.acinq.phoenix.db.notifications.NotificationsQueries import fracinqphoenixdb.Exchange_rates @@ -19,7 +22,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -class SqliteAppDb(private val driver: SqlDriver) { +class SqliteAppDb( + loggerFactory: LoggerFactory, + private val driver: SqlDriver +) { + private val log = loggerFactory.newLogger(this::class) internal val database = AppDatabase( driver = driver, @@ -32,6 +39,7 @@ class SqliteAppDb(private val driver: SqlDriver) { private val keyValueStoreQueries = database.keyValueStoreQueries private val notificationsQueries = NotificationsQueries(database) internal val contactQueries = ContactQueries(database) + internal val cardQueries = BoltCardQueries(database) /** * Save a list of [ExchangeRate] items to the database. @@ -124,28 +132,55 @@ class SqliteAppDb(private val driver: SqlDriver) { } suspend fun getValue(key: String): Pair? { - return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { - Pair(it.value_, it.updated_at) + return withContext(Dispatchers.Default) { + keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + Pair(it.value_, it.updated_at) + } } } suspend fun getValue(key: String, transform: (ByteArray) -> T): Pair? { - return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { - val tValue = transform(it.value_) - Pair(tValue, it.updated_at) + return withContext(Dispatchers.Default) { + keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + val tValue = transform(it.value_) + Pair(tValue, it.updated_at) + } } } suspend fun setValue(value: ByteArray, key: String): Long { - return database.transactionWithResult { - val exists = keyValueStoreQueries.exists(key).executeAsOne() > 0 - val now = currentTimestampMillis() - if (exists) { - keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) - } else { - keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + return withContext(Dispatchers.Default) { + database.transactionWithResult { + val exists = keyValueStoreQueries.exists(key).executeAsOne() > 0 + val now = currentTimestampMillis() + if (exists) { + keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) + } else { + keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + } + now + } + } + } + + suspend fun setValueIfUnchanged(value: ByteArray, key: String, lastUpdated: Long?): Long? { + return withContext(Dispatchers.Default) { + database.transactionWithResult { + val updated = keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + it.updated_at + } + if (updated == lastUpdated) { + val now = currentTimestampMillis() + if (updated != null) { + keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) + } else { + keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + } + now + } else { + null + } } - now } } @@ -201,6 +236,25 @@ class SqliteAppDb(private val driver: SqlDriver) { contactQueries.deleteOfferContactLink(offerId) } + suspend fun listCards(): List = withContext(Dispatchers.Default) { + cardQueries.listCards() + } + + fun monitorCardsFlow(): Flow> { + return cardQueries.monitorCardsFlow(Dispatchers.Default) + } + + /** + * Saves a new card, or updates an existing card. + */ + suspend fun saveCard(card: BoltCardInfo) = withContext(Dispatchers.Default) { + cardQueries.saveCard(card) + } + + suspend fun deleteCard(cardId: UUID) = withContext(Dispatchers.Default) { + cardQueries.deleteCard(cardId) + } + fun close() { driver.close() } 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 c0496d306..370b74f80 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 @@ -26,6 +26,7 @@ import fr.acinq.lightning.db.* import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.utils.* +import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.data.WalletPaymentMetadata @@ -448,7 +449,7 @@ class SqlitePaymentsDb( // ---- list ALL payments fun listPaymentsCountFlow(): Flow { - return aggrQueries.listAllPaymentsCount(::allPaymentsCountMapper) + return aggrQueries.listAllPaymentsCount(::countMapper) .asFlow() .map { withContext(Dispatchers.Default) { @@ -471,7 +472,7 @@ class SqlitePaymentsDb( return aggrQueries.listAllPaymentsOrder( limit = count.toLong(), offset = skip.toLong(), - mapper = ::allPaymentsOrderMapper + mapper = ::walletPaymentsOrderRowMapper ) .asFlow() .map { @@ -483,6 +484,19 @@ class SqlitePaymentsDb( } } + suspend fun listRecentPaymentsOrder( + date: Long, + count: Int, + skip: Int + ): List = withContext(Dispatchers.Default) { + aggrQueries.listRecentPaymentsOrder( + date = date, + limit = count.toLong(), + offset = skip.toLong(), + mapper = ::walletPaymentsOrderRowMapper + ).executeAsList() + } + fun listRecentPaymentsOrderFlow( date: Long, count: Int, @@ -492,7 +506,7 @@ class SqlitePaymentsDb( date = date, limit = count.toLong(), offset = skip.toLong(), - mapper = ::allPaymentsOrderMapper + mapper = ::walletPaymentsOrderRowMapper ) .asFlow() .map { @@ -511,7 +525,7 @@ class SqlitePaymentsDb( return aggrQueries.listOutgoingInFlightPaymentsOrder( limit = count.toLong(), offset = skip.toLong(), - mapper = ::allPaymentsOrderMapper + mapper = ::walletPaymentsOrderRowMapper ) .asFlow() .map { @@ -542,7 +556,7 @@ class SqlitePaymentsDb( endDate = endDate, limit = count.toLong(), offset = skip.toLong(), - mapper = ::allPaymentsOrderMapper + mapper = ::walletPaymentsOrderRowMapper ).executeAsList() } @@ -559,10 +573,31 @@ class SqlitePaymentsDb( aggrQueries.listRangeSuccessfulPaymentsCount( startDate = startDate, endDate = endDate, - mapper = ::allPaymentsCountMapper + mapper = ::countMapper ).executeAsList().first() } + suspend fun listRecentCardPaymentsOrder( + newerThanDate: Long, + count: Int, + skip: Int + ): List = withContext(Dispatchers.Default) { + aggrQueries.listRecentCardPaymentsOrder( + date = newerThanDate, + limit = count.toLong(), + offset = skip.toLong() + ).executeAsList().mapNotNull { + cardPaymentsOrderRowMapper( + type = it.type, + id = it.id, + created_at = it.created_at, + completed_at = it.completed_at, + metadata_modified_at = it.metadata_modified_at, + card_id = it.card_id + ) + } + } + suspend fun getOldestCompletedDate(): Long? = withContext(Dispatchers.Default) { val oldestIncoming = inQueries.getOldestReceivedDate() val oldestOutgoing = outQueries.getOldestCompletedDate() @@ -613,7 +648,7 @@ class SqlitePaymentsDb( ) } - suspend fun deletePayment(paymentId: WalletPaymentId) = withContext(Dispatchers.Default) { + suspend fun deletePayment(paymentId: WalletPaymentId, notify: Boolean = true) = withContext(Dispatchers.Default) { database.transaction { when (paymentId) { is WalletPaymentId.IncomingPaymentId -> { @@ -648,20 +683,22 @@ class SqlitePaymentsDb( database.inboundLiquidityOutgoingQueries.delete(id = paymentId.dbId) } } - didDeleteWalletPayment(paymentId, database) + if (notify) { + didDeleteWalletPayment(paymentId, database) + } } } fun close() = driver.close() companion object { - private fun allPaymentsCountMapper( + private fun countMapper( result: Long? ): Long { return result ?: 0 } - private fun allPaymentsOrderMapper( + private fun walletPaymentsOrderRowMapper( type: Long, id: String, created_at: Long, @@ -684,6 +721,27 @@ class SqlitePaymentsDb( metadataModifiedAt = metadata_modified_at ) } + + private fun cardPaymentsOrderRowMapper( + type: Long, + id: String, + created_at: Long, + completed_at: Long?, + metadata_modified_at: Long?, + card_id: String + ): CardPaymentOrderRow? { + val walletPaymentOrderRow = walletPaymentsOrderRowMapper( + type = type, + id = id, + created_at = created_at, + completed_at = completed_at, + metadata_modified_at = metadata_modified_at + ) + return try { + val cardId = UUID.fromString(card_id) + CardPaymentOrderRow(walletPaymentOrderRow, cardId) + } catch (e: Exception) { null } + } } } @@ -721,3 +779,8 @@ data class WalletPaymentOrderRow( return "${id.identifier}|${createdAt}|" } } + +data class CardPaymentOrderRow( + val walletPaymentOrderRow: WalletPaymentOrderRow, + val cardId: UUID +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/BoltCardQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/BoltCardQueries.kt new file mode 100644 index 000000000..102a76e97 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/BoltCardQueries.kt @@ -0,0 +1,148 @@ +package fr.acinq.phoenix.db.notifications + +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.BoltCardInfo +import fr.acinq.phoenix.data.BoltCardKeySet +import fr.acinq.phoenix.data.CurrencyUnit +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.SpendingLimit +import fr.acinq.phoenix.db.AppDatabase +import fr.acinq.phoenix.db.didSaveCard +import fracinqphoenixdb.Bolt_cards +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext + +class BoltCardQueries(val database: AppDatabase) { + + val queries = database.boltCardsQueries + + fun saveCard(card: BoltCardInfo, notify: Boolean = true) { + database.transaction { + val cardExists = queries.existsCard( + cardId = card.id.toString() + ).executeAsOne() > 0 + if (cardExists) { + updateExistingCard(card) + } else { + saveNewCard(card) + } + if (notify) { + didSaveCard(card.id, database) + } + } + } + + private fun saveNewCard(card: BoltCardInfo) { + queries.insertCard( + id = card.id.toString(), + name = card.name, + key0 = card.keys.key0.toByteArray(), + uid = card.uid.toByteArray(), + counter = card.lastKnownCounter.toLong(), + isFrozen = card.isFrozen, + isArchived = card.isArchived, + isReset = card.isReset, + isForeign = card.isForeign, + dailyLimitCurrency = card.dailyLimit?.currency?.displayCode, + dailyLimitAmount = card.dailyLimit?.amount, + monthlyLimitCurrency = card.monthlyLimit?.currency?.displayCode, + monthlyLimitAmount = card.monthlyLimit?.amount, + createdAt = card.createdAt.toEpochMilliseconds(), + updatedAt = null + ) + } + + fun updateExistingCard(card: BoltCardInfo) { + queries.updateCard( + name = card.name, + counter = card.lastKnownCounter.toLong(), + isFrozen = card.isFrozen, + isArchived = card.isArchived, + isReset = card.isReset, + dailyLimitCurrency = card.dailyLimit?.currency?.displayCode, + dailyLimitAmount = card.dailyLimit?.amount, + monthlyLimitCurrency = card.monthlyLimit?.currency?.displayCode, + monthlyLimitAmount = card.monthlyLimit?.amount, + updatedAt = currentTimestampMillis(), + cardId = card.id.toString() + ) + } + + fun listCards(): List { + return database.transactionWithResult { + queries.listCards().executeAsList().mapNotNull { row -> + parseRow(row) + } + } + } + + fun monitorCardsFlow(context: CoroutineContext): Flow> { + return queries.listCards().asFlow().map { + withContext(context) { + listCards() + } + } + } + + fun getCard(cardId: UUID): BoltCardInfo? { + return database.transactionWithResult { + queries.getCard( + cardId = cardId.toString() + ).executeAsOneOrNull()?.let { row -> + parseRow(row) + } + } + } + + fun deleteCard(cardId: UUID) { + return database.transaction { + queries.deleteCard(cardId = cardId.toString()) + } + } + + private fun parseRow(row: Bolt_cards): BoltCardInfo? { + val id: UUID + val keys: BoltCardKeySet + try { // these can throw exceptions if input is incorrect length + id = UUID.fromString(row.id) + keys = BoltCardKeySet(key0 = row.key0.toByteVector()) + } catch (e: Exception) { + return null + } + + return BoltCardInfo( + id = id, + name = row.name, + keys = keys, + uid = row.uid.toByteVector(), + lastKnownCounter = row.counter.toUInt(), + isFrozen = row.is_frozen, + isArchived = row.is_archived, + isReset = row.is_reset, + isForeign = row.is_foreign, + dailyLimit = parseSpendingLimit(row.daily_limit_currency, row.daily_limit_amount), + monthlyLimit = parseSpendingLimit(row.monthly_limit_currency, row.monthly_limit_amount), + createdAt = Instant.fromEpochMilliseconds(row.created_at) + ) + } + + private fun parseSpendingLimit(currency: String?, amount: Double?): SpendingLimit? { + if (currency != null && amount != null) { + val parsedCurrency: CurrencyUnit? = + FiatCurrency.valueOfOrNull(currency) ?: + BitcoinUnit.valueOfOrNull(currency) + + if (parsedCurrency != null && amount > 0) { + return SpendingLimit(parsedCurrency, amount) + } + } + return null + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt index e33c8562d..b419bb075 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt @@ -3,6 +3,7 @@ package fr.acinq.phoenix.db.payments import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.data.* import fr.acinq.phoenix.data.lnurl.LnurlPay @@ -32,7 +33,8 @@ class MetadataQueries(val database: PaymentsDatabase) { user_notes = data.user_notes, modified_at = data.modified_at, original_fiat_type = data.original_fiat?.first, - original_fiat_rate = data.original_fiat?.second + original_fiat_rate = data.original_fiat?.second, + card_id = data.card_id ) } @@ -48,11 +50,8 @@ class MetadataQueries(val database: PaymentsDatabase) { WalletPaymentFetchOptions.None -> { null } - WalletPaymentFetchOptions.Descriptions + WalletPaymentFetchOptions.OriginalFiat -> { - getMetadataDescriptionsAndOriginalFiat(id) - } - WalletPaymentFetchOptions.Descriptions -> { - getMetadataDescriptions(id) + WalletPaymentFetchOptions.Common -> { + getMetadataCommon(id) } else -> { getMetadataAll(id) @@ -60,21 +59,13 @@ class MetadataQueries(val database: PaymentsDatabase) { } } - private fun getMetadataDescriptions(id: WalletPaymentId): WalletPaymentMetadata? { - return queries.fetchDescriptions( - type = id.dbType.value, - id = id.dbId, - mapper = ::mapDescriptions - ).executeAsOneOrNull() - } - - private fun getMetadataDescriptionsAndOriginalFiat( + private fun getMetadataCommon( id: WalletPaymentId ): WalletPaymentMetadata? { - return queries.fetchDescriptionsAndOriginalFiat( + return queries.fetchCommon( type = id.dbType.value, id = id.dbId, - mapper = ::mapDescriptionsAndOriginalFiat + mapper = ::mapCommon ).executeAsOneOrNull() } @@ -120,7 +111,8 @@ class MetadataQueries(val database: PaymentsDatabase) { user_notes = userNotes, modified_at = modifiedAt, original_fiat_type = null, - original_fiat_rate = null + original_fiat_rate = null, + card_id = null ) } didUpdateWalletPaymentMetadata(id, database) @@ -128,31 +120,18 @@ class MetadataQueries(val database: PaymentsDatabase) { } companion object { - fun mapDescriptions( - lnurl_description: String?, - user_description: String?, - modified_at: Long? - ): WalletPaymentMetadata { - val lnurl = if (lnurl_description != null) { - LnurlPayMetadata.placeholder(lnurl_description) - } else null - return WalletPaymentMetadata( - userDescription = user_description, - lnurl = lnurl, - modifiedAt = modified_at - ) - } - fun mapDescriptionsAndOriginalFiat( + fun mapCommon( lnurl_description: String?, user_description: String?, modified_at: Long?, original_fiat_type: String?, - original_fiat_rate: Double? + original_fiat_rate: Double?, + card_id: String? ): WalletPaymentMetadata { - val lnurl = if (lnurl_description != null) { - LnurlPayMetadata.placeholder(lnurl_description) - } else null + val lnurl = lnurl_description?.let { + LnurlPayMetadata.placeholder(it) + } val originalFiat = if (original_fiat_type != null && original_fiat_rate != null) { @@ -166,10 +145,17 @@ class MetadataQueries(val database: PaymentsDatabase) { } } else null + val cardId = card_id?.let { + try { + UUID.fromString(it) + } catch (e: Exception) { null } + } + return WalletPaymentMetadata( lnurl = lnurl, originalFiat = originalFiat, userDescription = user_description, + cardId = cardId, modifiedAt = modified_at ) } @@ -189,7 +175,8 @@ class MetadataQueries(val database: PaymentsDatabase) { user_notes: String?, modified_at: Long?, original_fiat_type: String?, - original_fiat_rate: Double? + original_fiat_rate: Double?, + card_id: String? ): WalletPaymentMetadata { val lnurlBase = if (lnurl_base_type != null && lnurl_base_blob != null) { @@ -219,6 +206,7 @@ class MetadataQueries(val database: PaymentsDatabase) { original_fiat = originalFiat, user_description = user_description, user_notes = user_notes, + card_id = card_id, modified_at = modified_at ).deserialize() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt index 21166ab4c..eae094f78 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt @@ -2,6 +2,7 @@ package fr.acinq.phoenix.db.payments import fr.acinq.bitcoin.ByteVector import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.UUID import fr.acinq.phoenix.db.cloud.cborSerializer import fr.acinq.phoenix.data.* import fr.acinq.phoenix.data.lnurl.LnurlPay @@ -223,6 +224,7 @@ data class WalletPaymentMetadataRow( val original_fiat: Pair? = null, val user_description: String? = null, val user_notes: String? = null, + val card_id: String? = null, val modified_at: Long? = null ) { @@ -266,11 +268,18 @@ data class WalletPaymentMetadataRow( } } + val cardId = card_id?.let { + try { + UUID.fromString(card_id) + } catch (e: Exception) { null } + } + return WalletPaymentMetadata( lnurl = lnurl, originalFiat = originalFiat, userDescription = user_description, userNotes = user_notes, + cardId = cardId, modifiedAt = modified_at ) } @@ -286,6 +295,7 @@ data class WalletPaymentMetadataRow( && original_fiat == null && user_description == null && user_notes == null + && card_id == null } companion object { @@ -319,6 +329,7 @@ data class WalletPaymentMetadataRow( original_fiat = originalFiat, user_description = metadata.userDescription, user_notes = metadata.userNotes, + card_id = metadata.cardId?.toString(), modified_at = metadata.modifiedAt ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt index fb0062dfd..767d1a291 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt @@ -7,12 +7,9 @@ import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.ServerAddress import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.ElectrumConfig -import fr.acinq.phoenix.utils.TorHelper.connectionState import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.error import fr.acinq.lightning.logging.info -import fr.acinq.tor.Tor -import fr.acinq.tor.TorState import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel @@ -35,7 +32,6 @@ class AppConnectionsDaemon( private val currencyManager: CurrencyManager, private val networkMonitor: NetworkMonitor, private val tcpSocketBuilder: suspend () -> TcpSocket.Builder, - private val tor: Tor, private val electrumClient: ElectrumClient, ) : CoroutineScope by MainScope() { @@ -47,7 +43,6 @@ class AppConnectionsDaemon( currencyManager = business.currencyManager, networkMonitor = business.networkMonitor, tcpSocketBuilder = business.tcpSocketBuilderFactory, - tor = business.tor, electrumClient = business.electrumClient ) @@ -55,7 +50,6 @@ class AppConnectionsDaemon( private var peerConnectionJob: Job? = null private var electrumConnectionJob: Job? = null - private var torConnectionJob: Job? = null private var httpControlFlowEnabled: Boolean = false private data class TrafficControl( @@ -193,51 +187,6 @@ class AppConnectionsDaemon( } } - // Tor state monitor - launch { - tor.state.collect { - val newValue = it == TorState.RUNNING - logger.debug { "torIsAvailable = $newValue" } - torControlChanges.send { copy(torIsAvailable = newValue) } - peerControlChanges.send { copy(torIsAvailable = newValue) } - electrumControlChanges.send { copy(torIsAvailable = newValue) } - httpApiControlChanges.send { copy(torIsAvailable = newValue) } - } - } - - // Tor - launch { - torControlFlow.collect { - when { - it.internetIsAvailable && it.disconnectCount <= 0 && it.torIsEnabled -> { - if (torConnectionJob == null) { - logger.info { "starting tor" } - torConnectionJob = connectionLoop( - name = "Tor", - statusStateFlow = tor.state.connectionState(this), - ) { - try { - tor.startInProperScope(this) - } catch (t: Throwable) { - logger.error(t) { "tor cannot be started: ${t.message}" } - } - } - } - } - else -> { - torConnectionJob?.let { - logger.info { "shutting down tor" } - it.cancel() - tor.stop() - torConnectionJob = null - // Tor runs it's own process, and needs time to shutdown before restarting. - delay(500) - } - } - } - } - } - // Peer launch { var configVersion = 0 @@ -522,6 +471,3 @@ class AppConnectionsDaemon( } } } - -/** The start function must run on a different dispatcher depending on the platform. */ -expect suspend fun Tor.startInProperScope(scope: CoroutineScope) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CardsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CardsManager.kt new file mode 100644 index 000000000..10bf6169a --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CardsManager.kt @@ -0,0 +1,294 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.managers + +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.BoltCardInfo +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.WalletPaymentFetchOptions +import fr.acinq.phoenix.db.CardPaymentOrderRow +import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.db.WalletPaymentOrderRow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime + +class CardsManager( + private val loggerFactory: LoggerFactory, + private val appDb: SqliteAppDb, + private val databaseManager: DatabaseManager, + private val paymentsManager: PaymentsManager +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + appDb = business.appDb, + databaseManager = business.databaseManager, + paymentsManager = business.paymentsManager + ) + + private val log = loggerFactory.newLogger(this::class) + + private val _cardsList = MutableStateFlow>(emptyList()) + val cardsList = _cardsList.asStateFlow() + + private val _cardsMap = MutableStateFlow>(emptyMap()) + val cardsMap = _cardsMap.asStateFlow() + + init { + launch { + appDb.monitorCardsFlow().collect { list -> + val newMap = list.associateBy { it.id } + _cardsList.value = list + _cardsMap.value = newMap + } + } + } + + /** + * This method will insert or update the card in the database + * (depending on whether it already exists). + */ + suspend fun saveCard(card: BoltCardInfo) { + appDb.saveCard(card) + } + + suspend fun deleteCard(cardId: UUID) { + appDb.deleteCard(cardId) + } + + fun cardForId(cardId: UUID): BoltCardInfo? { + return cardsMap.value[cardId] + } + + data class CardPayments( + val daily: List, + val monthly: List + ) { + companion object { + fun fromMonthly(monthly: List, startOfDay: Long): CardPayments { + val daily = monthly.filter { (it.completedAt ?: it.createdAt) > startOfDay } + return CardPayments(monthly = monthly, daily = daily) + } + } + } + + suspend fun fetchCardPayments(): Map { + val nowInstant = Clock.System.now() + val timezone = TimeZone.currentSystemDefault() + val nowLDT = nowInstant.toLocalDateTime(timezone) + + val startOfMonth = LocalDateTime( + year = nowLDT.year, month = nowLDT.month, dayOfMonth = 1, + hour = 0, minute = 0, second = 0, nanosecond = 0 + ) + + val fullList = fetchRecentCardPayments(minInstant = startOfMonth.toInstant(timezone)) + + val tempResults: MutableMap> = mutableMapOf() + fullList.forEach { row -> + tempResults[row.cardId]?.add(row.walletPaymentOrderRow) ?: run { + tempResults[row.cardId] = mutableListOf(row.walletPaymentOrderRow) + } + } + + val startOfDay = LocalDateTime( + date = nowLDT.date, + time = LocalTime(hour = 0, minute = 0) + ) + val startOfDayMillis = startOfDay.toInstant(timezone).toEpochMilliseconds() + + return tempResults.mapValues { + CardPayments.fromMonthly( + monthly = it.value, + startOfDay = startOfDayMillis + ) + } + } + + private suspend fun fetchRecentCardPayments(minInstant: Instant): List { + val paymentsDb = databaseManager.paymentsDb() + + var done = false + val maxBatchCount = 50 + var offset = 0 + var results = mutableListOf() + do { + val batch = paymentsDb.listRecentCardPaymentsOrder( + newerThanDate = minInstant.toEpochMilliseconds(), + count = maxBatchCount, + skip = offset + ) + results.addAll(batch) + if (batch.size >= maxBatchCount) { + offset += batch.size + } else { + done = true + } + + } while (!done) + + return results + } + + data class CardAmounts( + val daily: List, + val monthly: List + ) { + data class Info( + val paymentAmount: MilliSatoshi, + val originalFiat: ExchangeRate.BitcoinPriceRate? + ) + + fun dailyBitcoinAmount() = MilliSatoshi(msat = daily.sumOf { it.paymentAmount.msat }) + fun monthlyBitcoinAmount() = MilliSatoshi(msat = monthly.sumOf { it.paymentAmount.msat }) + + fun dailyFiatAmount( + target: FiatCurrency, + exchangeRates: List + ): Double { + return calculateFiatAmount(daily, target, exchangeRates) + } + + fun monthlyFiatAmount( + target: FiatCurrency, + exchangeRates: List + ): Double { + return calculateFiatAmount(monthly, target, exchangeRates) + } + + companion object { + fun calculateFiatAmount( + list: List, + targetFiatCurrency: FiatCurrency, + exchangeRates: List + ): Double { + var totalAmt = 0.0 + val currentDstRate = CurrencyManager.exchangeRate(targetFiatCurrency, exchangeRates) + + list.forEach { row -> + var rowAmt = 0.0 + row.originalFiat?.let { originalFiat -> + // For this payment, we stored the original fiat value. + // (Note that this should ALWAYS be the case.) + // + // We want to use this original value because + // it makes the most sense to the user. + + if (originalFiat.fiatCurrency == targetFiatCurrency) { + // This is the common case. + // For example: + // - the user's preferred fiatCurrency is set to EUR + // - thus the stored originalFiat rates are in EUR + // - and their daily/monthly amounts are also in EUR + + rowAmt = CurrencyManager.convertToFiat(row.paymentAmount, originalFiat) + } else { + // This is the uncommon case. + // E.g. + // - the stored originalFiat rate is in USD + // - but their daily/monthly amounts are in EUR + // + // To deal with this situation we're going to use the current exchange + // rates between USD & EUR to calculate the (approximate) original + // amount in EUR. + // + // For example: + // - paymentAmount = 0.1 BTC + // - originalFiat = BitcoinPriceRate(USD, 60_000) + // - rates = List< + // BitcoinPriceRate(USD, 100_000), + // BitcoinPriceRate(EUR, 94_738) + // > + // + // originalFiatAmount = 0.1 * 60_000 => 6_000 USD + // percent = 94_738 / 100_000 = 0.94738 + // estimatedFiatAmount = 6_000 * 0.94738 = 5_684 EUR + + val originalFiatAmount = + CurrencyManager.convertToFiat(row.paymentAmount, originalFiat) + + val currentSrcRate = CurrencyManager.exchangeRate( + originalFiat.fiatCurrency, + exchangeRates + ) + if (currentSrcRate != null && currentDstRate != null) { + val percent = currentDstRate.price / currentSrcRate.price + val estimatedFiatAmount = originalFiatAmount * percent + + rowAmt = estimatedFiatAmount + } + } + } + + if (rowAmt == 0.0) { + // We were unable to calculate `amt` using the `originalFiat` value. + // So we'll have to do it using the current exchange rates. + if (currentDstRate != null) { + rowAmt = CurrencyManager.convertToFiat(row.paymentAmount, currentDstRate) + } + } + + totalAmt += rowAmt + } + + return totalAmt + } + } + } + + suspend fun fetchCardAmounts( + payments: CardPayments, + fetcher: PaymentsFetcher? = null + ): CardAmounts { + @Suppress("NAME_SHADOWING") + val fetcher = fetcher ?: paymentsManager.fetcher + + val daily: MutableList = mutableListOf() + val monthly: MutableList = mutableListOf() + payments.monthly.forEach { row -> + fetcher.getPayment(row, WalletPaymentFetchOptions.Common)?.let { paymentInfo -> + val info = CardAmounts.Info( + paymentAmount = paymentInfo.payment.amount, + originalFiat = paymentInfo.metadata.originalFiat + ) + monthly.add(info) + if (payments.daily.contains(row)) { + daily.add(info) + } + } + } + + return CardAmounts( + daily = daily.toList(), + monthly = monthly.toList() + ) + } +} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ConnectionsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ConnectionsManager.kt index 2d8d33879..bf74daa93 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ConnectionsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ConnectionsManager.kt @@ -4,8 +4,6 @@ import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.utils.Connection import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.utils.TorHelper.connectionState -import fr.acinq.tor.Tor import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import plus @@ -30,17 +28,13 @@ class ConnectionsManager( peerManager: PeerManager, electrumClient: ElectrumClient, networkMonitor: NetworkMonitor, - appConfigurationManager: AppConfigurationManager, - tor: Tor ): CoroutineScope { constructor(business: PhoenixBusiness): this( loggerFactory = business.loggerFactory, peerManager = business.peerManager, electrumClient = business.electrumClient, - networkMonitor = business.networkMonitor, - appConfigurationManager = business.appConfigurationManager, - tor = business.tor + networkMonitor = business.networkMonitor ) val log = loggerFactory.newLogger(this::class) @@ -52,10 +46,8 @@ class ConnectionsManager( combine( peer.connectionState, electrumClient.connectionStatus, - networkMonitor.networkState, - appConfigurationManager.isTorEnabled.filterNotNull(), - tor.state.connectionState(this) - ) { peerState, electrumStatus, internetState, torEnabled, torState -> + networkMonitor.networkState + ) { peerState, electrumStatus, internetState -> Connections( peer = peerState, electrum = electrumStatus.toConnectionState(), @@ -63,8 +55,8 @@ class ConnectionsManager( NetworkState.Available -> Connection.ESTABLISHED NetworkState.NotAvailable -> Connection.CLOSED(reason = null) }, - tor = if (torEnabled) torState else Connection.CLOSED(reason = null), - torEnabled = torEnabled + tor = Connection.CLOSED(reason = null), + torEnabled = false ) } }.stateIn( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CurrencyManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CurrencyManager.kt index 833e72fa4..df09635d5 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CurrencyManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/CurrencyManager.kt @@ -1,5 +1,6 @@ package fr.acinq.phoenix.managers +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.* @@ -187,34 +188,9 @@ class CurrencyManager( fun calculateOriginalFiat(): ExchangeRate.BitcoinPriceRate? { val fiatCurrency = configurationManager.preferredFiatCurrencies.value?.primary ?: return null - val rates = ratesFlow.value - val fiatRate = rates.firstOrNull { it.fiatCurrency == fiatCurrency } ?: return null - - return when (fiatRate) { - is ExchangeRate.BitcoinPriceRate -> { - // We have a direct exchange rate. - // BitcoinPriceRate.rate => The price of 1 BTC in this currency - fiatRate - } - is ExchangeRate.UsdPriceRate -> { - // We have an indirect exchange rate. - // UsdPriceRate.price => The price of 1 US Dollar in this currency - rates.filterIsInstance().firstOrNull { - it.fiatCurrency == FiatCurrency.USD - }?.let { usdRate -> - ExchangeRate.BitcoinPriceRate( - fiatCurrency = fiatCurrency, - price = usdRate.price * fiatRate.price, - source = "${fiatRate.source}/${usdRate.source}", - timestampMillis = fiatRate.timestampMillis.coerceAtMost( - usdRate.timestampMillis - ) - ) - } - } - } + return exchangeRate(fiatCurrency, rates) } /** Utility class used to track refresh progress on a per-currency basis. */ @@ -816,4 +792,81 @@ class CurrencyManager( log.debug { "${fiatCurrency.name}: coinbase($coinbaseValue), coindesk($coindeskValue)" } } } + + companion object { + + /** + * Exchange rates can be confusing. + * - BitcoinPriceRate: converts between BTC and fiat + * - UsdPriceRate: converts between USD and fiat + * + * This function takes a list of exchange rates, + * and returns a standardized BitcoinPriceRate, + * which is easier to work with. + */ + fun exchangeRate( + fiatCurrency: FiatCurrency, + rates: List + ): ExchangeRate.BitcoinPriceRate? { + + val fiatRate = rates.firstOrNull { it.fiatCurrency == fiatCurrency } ?: return null + + return when (fiatRate) { + is ExchangeRate.BitcoinPriceRate -> { + // We have a direct exchange rate. + // BitcoinPriceRate.rate => The price of 1 BTC in this currency + fiatRate + } + is ExchangeRate.UsdPriceRate -> { + // We have an indirect exchange rate. + // UsdPriceRate.price => The price of 1 US Dollar in this currency + rates.filterIsInstance().firstOrNull { + it.fiatCurrency == FiatCurrency.USD + }?.let { usdRate -> + ExchangeRate.BitcoinPriceRate( + fiatCurrency = fiatCurrency, + price = usdRate.price * fiatRate.price, + source = "${fiatRate.source}/${usdRate.source}", + timestampMillis = fiatRate.timestampMillis.coerceAtMost( + usdRate.timestampMillis + ) + ) + } + } + } + } + + /** + * Converts the given amount (in MilliSatoshi) into a fiat value. + */ + fun convertToFiat( + msat: MilliSatoshi, + exchangeRate: ExchangeRate.BitcoinPriceRate + ): Double { + + // exchangeRate.price => value of 1.0 BTC in fiat + // data class MilliSatoshi(val msat: Long) + + val btc = msat.toLong().toDouble() / 100_000_000_000.0 + val fiat = btc * exchangeRate.price + + return fiat + } + + /** + * Converts the given amount (in fiat) into a bitcoin amount. + */ + fun convertToMsat( + fiatAmount: Double, + exchangeRate: ExchangeRate.BitcoinPriceRate + ): MilliSatoshi { + + // exchangeRate.price => value of 1.0 BTC in fiat + + val btc: Double = fiatAmount / exchangeRate.price + val msat = (btc * 100_000_000_000.0).toLong() + + return MilliSatoshi(msat) + } + } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt index 8031d023b..06e846c6c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt @@ -1,5 +1,6 @@ package fr.acinq.phoenix.managers +import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.TxId import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment @@ -12,6 +13,8 @@ import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.* import fr.acinq.phoenix.db.SqlitePaymentsDb import fr.acinq.lightning.logging.debug +import fr.acinq.phoenix.db.CardPaymentOrderRow +import fr.acinq.phoenix.db.WalletPaymentOrderRow import fr.acinq.phoenix.utils.extensions.relatedPaymentIds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope @@ -20,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.datetime.* class PaymentsManager( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Socks5Proxy.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Socks5Proxy.kt deleted file mode 100644 index 43dfc592c..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Socks5Proxy.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils - -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.info -import fr.acinq.tor.Tor -import fr.acinq.tor.socks.socks5Handshake - -class Socks5Proxy( - private val socketBuilder: TcpSocket.Builder, - loggerFactory: LoggerFactory, - private val proxyHost: String, - private val proxyPort: Int -): TcpSocket.Builder { - - val logger = loggerFactory.newLogger(this::class) - - override suspend fun connect( - host: String, - port: Int, - tls: TcpSocket.TLS, - loggerFactory: LoggerFactory, - ): TcpSocket { - val socket = socketBuilder.connect(proxyHost, proxyPort, TcpSocket.TLS.DISABLED, loggerFactory) - val (cHost, cPort) = socks5Handshake( - destinationHost = host, - destinationPort = port, - receive = { socket.receiveFully(it, offset = 0, length = it.size) }, - send = { socket.send(it, offset = 0, length = it.size, flush = true) } - ) - logger.info { "connected through socks5 to $cHost:$cPort" } - val updatedTls = when (tls) { - is TcpSocket.TLS.TRUSTED_CERTIFICATES -> - TcpSocket.TLS.TRUSTED_CERTIFICATES(tls.expectedHostName ?: host) - else -> tls - } - return socket.startTls(updatedTls) - } -} - -fun TcpSocket.Builder.torProxy( - loggerFactory: LoggerFactory -) = Socks5Proxy( - socketBuilder = this, - loggerFactory = loggerFactory, - proxyHost = Tor.SOCKS_ADDRESS, - proxyPort = Tor.SOCKS_PORT -) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/TorHelper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/TorHelper.kt deleted file mode 100644 index d87d3254e..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/TorHelper.kt +++ /dev/null @@ -1,40 +0,0 @@ -package fr.acinq.phoenix.utils - -import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.logging.warning -import fr.acinq.tor.Tor -import fr.acinq.tor.TorState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn - - -object TorHelper { - fun torLogger(loggerFactory: LoggerFactory): (Tor.LogLevel, String) -> Unit { - val logger = loggerFactory.newLogger("Tor") - return { level, message -> - when (level) { - Tor.LogLevel.DEBUG -> logger.debug { message } - Tor.LogLevel.NOTICE -> logger.info { message } - Tor.LogLevel.WARN -> logger.warning { message } - Tor.LogLevel.ERR -> logger.error { message } - } - } - } - - suspend fun StateFlow.connectionState(scope: CoroutineScope) = flow { - collect { torState -> - val newState = when (torState) { - TorState.STARTING -> Connection.ESTABLISHING - TorState.RUNNING -> Connection.ESTABLISHED - TorState.STOPPED -> Connection.CLOSED(null) - } - emit(newState) - } - }.stateIn(scope) -} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq index fd680c4e9..a0ed9c80e 100644 --- a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq @@ -272,4 +272,38 @@ UNION ALL SELECT COUNT(*) AS result FROM incoming_payments WHERE incoming_payments.received_at BETWEEN :startDate AND :endDate AND incoming_payments.received_with_blob IS NOT NULL -); \ No newline at end of file +); + +listRecentCardPaymentsOrder: +SELECT + combined_payments.type AS type, + combined_payments.id AS id, + combined_payments.created_at AS created_at, + combined_payments.completed_at AS completed_at, + payments_metadata.modified_at AS metadata_modified_at, + payments_metadata.card_id AS card_id +FROM ( + SELECT + 2 AS type, + id AS id, + created_at AS created_at, + completed_at AS completed_at + FROM outgoing_payments + WHERE completed_at IS NULL AND + created_at >= :date +UNION ALL + SELECT + 2 AS type, + id AS id, + created_at AS created_at, + completed_at AS completed_at + FROM outgoing_payments + WHERE completed_at >= :date +) combined_payments +LEFT OUTER JOIN payments_metadata ON + payments_metadata.type = combined_payments.type AND + payments_metadata.id = combined_payments.id +WHERE + payments_metadata.card_id IS NOT NULL +ORDER BY COALESCE(combined_payments.completed_at, combined_payments.created_at) DESC +LIMIT :limit OFFSET :offset; \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/PaymentsMetadata.sq b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/PaymentsMetadata.sq index 815de3594..65bc04b85 100644 --- a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/PaymentsMetadata.sq +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/PaymentsMetadata.sq @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS payments_metadata ( modified_at INTEGER DEFAULT NULL, original_fiat_type TEXT DEFAULT NULL, original_fiat_rate REAL DEFAULT NULL, + card_id TEXT DEFAULT NULL, PRIMARY KEY (type, id) ); @@ -47,8 +48,9 @@ INSERT INTO payments_metadata ( lnurl_successAction_type, lnurl_successAction_blob, user_description, user_notes, modified_at, - original_fiat_type, original_fiat_rate) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + original_fiat_type, original_fiat_rate, + card_id) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); updateUserInfo: UPDATE payments_metadata @@ -57,19 +59,13 @@ SET user_description = ?, modified_at = ? WHERE type = ? AND id = ?; -fetchDescriptions: -SELECT lnurl_description, - user_description, - modified_at -FROM payments_metadata -WHERE type = ? AND id = ?; - -fetchDescriptionsAndOriginalFiat: +fetchCommon: SELECT lnurl_description, user_description, modified_at, original_fiat_type, - original_fiat_rate + original_fiat_rate, + card_id FROM payments_metadata WHERE type = ? AND id = ?; diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/10.sqm b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/10.sqm new file mode 100644 index 000000000..4ad0a38ac --- /dev/null +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/10.sqm @@ -0,0 +1,7 @@ +-- Migration: v10 -> v11 +-- +-- Changes: +-- * Added a new column [card_id] in table [payments_metadata] + +ALTER TABLE payments_metadata +ADD COLUMN card_id TEXT DEFAULT NULL; diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt index 78727f096..e5462c69a 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt @@ -653,7 +653,8 @@ class CloudKitPaymentsDb( user_notes = row.user_notes, modified_at = row.modified_at, original_fiat_type = row.original_fiat?.first, - original_fiat_rate = row.original_fiat?.second + original_fiat_rate = row.original_fiat?.second, + card_id = row.card_id ) } } // 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 8b098b5f9..0aadf34d7 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 @@ -44,6 +44,10 @@ actual fun didDeleteContact(contactId: UUID, database: AppDatabase) { ) } +actual fun didSaveCard(cardId: UUID, database: AppDatabase) { + // Todo... +} + 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/managers/AppConnectionsDaemon.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt deleted file mode 100644 index e282b8182..000000000 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt +++ /dev/null @@ -1,16 +0,0 @@ -package fr.acinq.phoenix.managers - -import fr.acinq.tor.Tor -import kotlinx.coroutines.* - -@OptIn(ExperimentalStdlibApi::class) -actual suspend fun Tor.startInProperScope(scope: CoroutineScope) { - val currentDispatcher = scope.coroutineContext[CoroutineDispatcher.Key] - if (currentDispatcher != Dispatchers.Main) { - // on iOS, we must run tor operations on the main thread, to prevent issues with frozen objects. - // TODO: remove this once we moved to the new memory model - this.start(CoroutineScope(scope.coroutineContext.job + Dispatchers.Main)) - } else { - this.start(scope) - } -} \ No newline at end of file