diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 400291688..2a2dbda45 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -87,8 +87,7 @@ DC2DC8682906AC0B0079E570 /* BitcoinUnitSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2DC8672906AC0B0079E570 /* BitcoinUnitSelector.swift */; }; DC2DC86A2906AC620079E570 /* FiatCurrencySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2DC8692906AC620079E570 /* FiatCurrencySelector.swift */; }; DC2F431427B6972C0006FCC4 /* SwapInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431327B6972C0006FCC4 /* SwapInView.swift */; }; - DC2F431627B6983B0006FCC4 /* CopyOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431527B6983B0006FCC4 /* CopyOptionsSheet.swift */; }; - DC2F431827B698E20006FCC4 /* ShareOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431727B698E20006FCC4 /* ShareOptionsSheet.swift */; }; + DC2F431627B6983B0006FCC4 /* CopyShareOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431527B6983B0006FCC4 /* CopyShareOptionsSheet.swift */; }; DC2F431A27B699800006FCC4 /* ModifyInvoiceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */; }; DC32FB3529A3D3FE009912AC /* XpcManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC32FB3429A3D3FE009912AC /* XpcManager.swift */; }; DC33369826BAF721000E3F49 /* ShortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33369726BAF721000E3F49 /* ShortSheet.swift */; }; @@ -144,8 +143,10 @@ DC4CF3CE2BE96C36003A957F /* DisablePinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */; }; DC4CF3D02BEA8C13003A957F /* EditPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */; }; DC5567452C2F1A6900008E11 /* ContactsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5567442C2F1A6900008E11 /* ContactsList.swift */; }; + DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C42C541E5C00DCB5BF /* Experimental.swift */; }; DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; + DC5631CA2C597B8600DCB5BF /* SourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C92C597B8600DCB5BF /* SourceInfo.swift */; }; DC59377127516297003B4B53 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC5A935329846044004F19FD /* FileHandle+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5A935229846043004F19FD /* FileHandle+Async.swift */; }; DC5CA4ED28F83C3B0048A737 /* DrainWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5CA4EC28F83C3B0048A737 /* DrainWalletView.swift */; }; @@ -501,8 +502,7 @@ DC2DC8672906AC0B0079E570 /* BitcoinUnitSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinUnitSelector.swift; sourceTree = ""; }; DC2DC8692906AC620079E570 /* FiatCurrencySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiatCurrencySelector.swift; sourceTree = ""; }; DC2F431327B6972C0006FCC4 /* SwapInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInView.swift; sourceTree = ""; }; - DC2F431527B6983B0006FCC4 /* CopyOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyOptionsSheet.swift; sourceTree = ""; }; - DC2F431727B698E20006FCC4 /* ShareOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareOptionsSheet.swift; sourceTree = ""; }; + DC2F431527B6983B0006FCC4 /* CopyShareOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyShareOptionsSheet.swift; sourceTree = ""; }; DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyInvoiceSheet.swift; sourceTree = ""; }; DC32FB3429A3D3FE009912AC /* XpcManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XpcManager.swift; sourceTree = ""; }; DC33369726BAF721000E3F49 /* ShortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortSheet.swift; sourceTree = ""; }; @@ -553,7 +553,9 @@ DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisablePinView.swift; sourceTree = ""; }; DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPinView.swift; sourceTree = ""; }; DC5567442C2F1A6900008E11 /* ContactsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsList.swift; sourceTree = ""; }; + DC5631C42C541E5C00DCB5BF /* Experimental.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experimental.swift; sourceTree = ""; }; DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Manager.swift"; sourceTree = ""; }; + DC5631C92C597B8600DCB5BF /* SourceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceInfo.swift; sourceTree = ""; }; DC59377027516296003B4B53 /* Sequence+Sum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Sum.swift"; sourceTree = ""; }; DC5A935229846043004F19FD /* FileHandle+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileHandle+Async.swift"; sourceTree = ""; }; DC5CA4EC28F83C3B0048A737 /* DrainWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrainWalletView.swift; sourceTree = ""; }; @@ -1017,8 +1019,8 @@ DC98D3972AF2AE41005BD177 /* ReceiveView.swift */, DC3FDCAE2C3306AB002C5931 /* LightningDualView.swift */, DC2F431327B6972C0006FCC4 /* SwapInView.swift */, - DC2F431527B6983B0006FCC4 /* CopyOptionsSheet.swift */, - DC2F431727B698E20006FCC4 /* ShareOptionsSheet.swift */, + DC5631C92C597B8600DCB5BF /* SourceInfo.swift */, + DC2F431527B6983B0006FCC4 /* CopyShareOptionsSheet.swift */, DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */, DC370A882B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift */, DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */, @@ -1274,6 +1276,7 @@ DCFAEFC72A72F46D00330088 /* wallet */, DCFFAADC2900218B004E3C11 /* channels */, 53BEF0A8669F9379E4E4596F /* logs */, + DC5631C42C541E5C00DCB5BF /* Experimental.swift */, ); path = advanced; sourceTree = ""; @@ -1794,7 +1797,7 @@ DC4CF3C62BE59E4B003A957F /* TextTracking.swift in Sources */, DC63BDF729AEB30C0067A361 /* BackgroundPaymentsConfig.swift in Sources */, DC67E40B27F3798600496C04 /* AnimatedMenu.swift in Sources */, - DC2F431627B6983B0006FCC4 /* CopyOptionsSheet.swift in Sources */, + DC2F431627B6983B0006FCC4 /* CopyShareOptionsSheet.swift in Sources */, DCACF6FE2566D0BA0009B01E /* GenericPasswordConvertible.swift in Sources */, DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */, DC33369826BAF721000E3F49 /* ShortSheet.swift in Sources */, @@ -1914,7 +1917,6 @@ DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DC63BDF429AE44380067A361 /* NotificationsManager.swift in Sources */, DC118BFA27B44F840080BBAC /* TipSliderSheet.swift in Sources */, - DC2F431827B698E20006FCC4 /* ShareOptionsSheet.swift in Sources */, DC6F04252C38807300627B4F /* PhotosManager.swift in Sources */, DC72C33425A51AAC008A927A /* CurrencyPrefs.swift in Sources */, DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */, @@ -1936,6 +1938,7 @@ DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */, DCFC72042862237400D6B293 /* Asserts.swift in Sources */, DC1771B42ABC99CE00B286C7 /* WebsiteLinkPopover.swift in Sources */, + DC5631CA2C597B8600DCB5BF /* SourceInfo.swift in Sources */, DCCD046127EE045C007D57A5 /* DetailsView.swift in Sources */, DC6CF35B2938F32E001837EE /* ListBackgroundColor.swift in Sources */, DC641C82282188E700862DCD /* Utils+CurrencyPrefs.swift in Sources */, @@ -2015,6 +2018,7 @@ DC5CA4ED28F83C3B0048A737 /* DrainWalletView.swift in Sources */, DC784A112B31EA180018DC4A /* LiquidityAdsView.swift in Sources */, DC6D26E329E76557006A7814 /* AnimatedClock.swift in Sources */, + DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */, DCF8E3AC2BC4968D009299EE /* LowMinerFeeWarning.swift in Sources */, DCAC9FC329675E1A0098D769 /* NavigationWrapper.swift in Sources */, DC65D86428E2F7D700686355 /* ResetWalletView_Action.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/AppDelegate.swift b/phoenix-ios/phoenix-ios/AppDelegate.swift index ae51b79a1..88138c31d 100644 --- a/phoenix-ios/phoenix-ios/AppDelegate.swift +++ b/phoenix-ios/phoenix-ios/AppDelegate.swift @@ -193,7 +193,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { // Called when the user discards a scene session. // If any sessions were discarded while the application was not running, // this will be called shortly after application:didFinishLaunchingWithOptions. diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index d53bee33d..bbc03a60a 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -428,6 +428,7 @@ }, "(Bitcoin address)" : { "comment" : "Type of text being copied", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -998,6 +999,9 @@ } } } + }, + "(image)" : { + }, "(inclusive)" : { "comment" : "translate: inclusive", @@ -1126,6 +1130,7 @@ } }, "(QR code)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1273,6 +1278,9 @@ } } } + }, + "(text)" : { + }, "(This is your address - derived from your seed. You alone possess your seed.)" : { "localizations" : { @@ -1741,6 +1749,16 @@ } } }, + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + } + } + }, "%@ days" : { "extractionState" : "manual", "localizations" : { @@ -7822,6 +7840,9 @@ } } } + }, + "BIP353 DNS Address" : { + }, "Bitcoin" : { "localizations" : { @@ -9791,6 +9812,9 @@ } } } + }, + "Claim my address" : { + }, "claimed amount" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -11569,8 +11593,12 @@ } } }, + "Copied image to pasteboard!" : { + "comment" : "Toast message" + }, "Copied QR code image to pasteboard!" : { "comment" : "Toast message", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11813,6 +11841,7 @@ } }, "Copy Image (QR code)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11891,8 +11920,12 @@ } } } + }, + "Copy options" : { + }, "Copy Text" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11933,6 +11966,7 @@ } }, "Copy Text (bitcoin address)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11973,6 +12007,7 @@ } }, "Copy Text (lightning invoice)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -16287,6 +16322,9 @@ } } } + }, + "Experimental" : { + }, "Expired" : { "localizations" : { @@ -18205,6 +18243,9 @@ } } }, + "Full URI" : { + "comment" : "Type of text being copied" + }, "Funding failure" : { "localizations" : { "ar" : { @@ -19200,6 +19241,9 @@ }, "How to use" : { + }, + "Human-readable address" : { + "comment" : "Type of text being copied" }, "I have saved my recovery phrase somewhere safe." : { "localizations" : { @@ -22753,7 +22797,7 @@ } }, "Lightning invoice" : { - "extractionState" : "stale", + "comment" : "Type of text being copied", "localizations" : { "ar" : { "stringUnit" : { @@ -25472,6 +25516,9 @@ } } } + }, + "No address yet..." : { + }, "No available channels. Please check your internet connection." : { "localizations" : { @@ -27709,6 +27756,9 @@ } } }, + "Payment code" : { + "comment" : "Type of text being copied" + }, "payment description" : { "extractionState" : "stale", "localizations" : { @@ -33599,6 +33649,7 @@ } }, "Share Image (QR code)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -33637,8 +33688,12 @@ } } } + }, + "Share options" : { + }, "Share Text" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -33679,6 +33734,7 @@ } }, "Share Text (bitcoin address)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -33713,6 +33769,7 @@ } }, "Share Text (lightning invoice)" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -37487,6 +37544,9 @@ } } } + }, + "The request timed out. Please check your internet connection and try again." : { + }, "The scanned QR code is not a bitcoin address" : { "comment" : "Error message - parsing bitcoin address", @@ -38548,6 +38608,9 @@ } } } + }, + "This is a human-readable address for your Bolt12 payment request." : { + }, "This is a swap address. It is not controlled by your wallet. On-chain deposits sent to this address will be converted to Lightning channels." : { "extractionState" : "manual", @@ -42624,6 +42687,9 @@ } } } + }, + "Want a prettier address? Use third-party services, or self-host the address!" : { + }, "Warning" : { "localizations" : { @@ -44648,6 +44714,9 @@ } } } + }, + "You need at least one channel to claim your address. Try adding funds to your wallet and try again." : { + }, "You scanned a bitcoin address. Phoenix currently only supports sending Lightning payments. You can use a third-party service to make the offchain->onchain swap." : { "comment" : "Error message - scanning lightning invoice", diff --git a/phoenix-ios/phoenix-ios/SceneDelegate.swift b/phoenix-ios/phoenix-ios/SceneDelegate.swift index 0e8f4669b..ab7748bc9 100644 --- a/phoenix-ios/phoenix-ios/SceneDelegate.swift +++ b/phoenix-ios/phoenix-ios/SceneDelegate.swift @@ -275,8 +275,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Issue #282 - Face ID remains enabled between app installs. // Items stored in the iOS keychain remain persisted between iOS installs. - // So we clear the flag here. - AppSecurity.shared.setSoftBiometrics(enabled: false) { _ in } + // So we need to clear the flag here. + // + // We have the same problem with the Custom PIN. + // And also the Bip353Address. + // So we perform a standard wallet reset (which clears all values). + AppSecurity.shared.resetWallet() } else { // The user has a wallet. (UI may or may not be locked.) diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs.swift b/phoenix-ios/phoenix-ios/prefs/Prefs.swift index b2fabbf60..92ef45ab6 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs.swift @@ -12,7 +12,6 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) fileprivate enum Key: String { case theme case defaultPaymentDescription - case showChannelsRemoteBalance case recentTipPercents case isNewWallet case invoiceExpirationDays @@ -28,6 +27,7 @@ fileprivate enum Key: String { } fileprivate enum KeyDeprecated: String { + case showChannelsRemoteBalance case recentPaymentSeconds case maxFees } @@ -78,11 +78,6 @@ class Prefs { set { defaults.defaultPaymentDescription = newValue } } - var showChannelsRemoteBalance: Bool { - get { defaults.showChannelsRemoteBalance } - set { defaults.showChannelsRemoteBalance = newValue } - } - var invoiceExpirationDays: Int { get { defaults.invoiceExpirationDays } set { defaults.invoiceExpirationDays = newValue } @@ -250,7 +245,6 @@ class Prefs { // - Key.theme: App feels weird when this changes unexpectedly. defaults.removeObject(forKey: Key.defaultPaymentDescription.rawValue) - defaults.removeObject(forKey: Key.showChannelsRemoteBalance.rawValue) defaults.removeObject(forKey: Key.recentTipPercents.rawValue) defaults.removeObject(forKey: Key.isNewWallet.rawValue) defaults.removeObject(forKey: Key.invoiceExpirationDays.rawValue) @@ -318,11 +312,6 @@ extension UserDefaults { set { setValue(newValue, forKey: Key.defaultPaymentDescription.rawValue) } } - @objc fileprivate var showChannelsRemoteBalance: Bool { - get { bool(forKey: Key.showChannelsRemoteBalance.rawValue) } - set { set(newValue, forKey: Key.showChannelsRemoteBalance.rawValue) } - } - @objc fileprivate var invoiceExpirationDays: Int { get { integer(forKey: Key.invoiceExpirationDays.rawValue) } set { set(newValue, forKey: Key.invoiceExpirationDays.rawValue) } diff --git a/phoenix-ios/phoenix-ios/security/AppSecurity.swift b/phoenix-ios/phoenix-ios/security/AppSecurity.swift index c5ecc553a..fa0ec1135 100644 --- a/phoenix-ios/phoenix-ios/security/AppSecurity.swift +++ b/phoenix-ios/phoenix-ios/security/AppSecurity.swift @@ -601,6 +601,10 @@ class AppSecurity { return enabled } + // -------------------------------------------------------------------------------- + // MARK: Custom PIN + // -------------------------------------------------------------------------------- + public func setCustomPin( pin : String?, completion : @escaping (_ error: Error?) -> Void @@ -796,6 +800,58 @@ class AppSecurity { return invalidPin } + // -------------------------------------------------------------------------------- + // MARK: BIP353 Address + // -------------------------------------------------------------------------------- + + public func setBip353Address( + _ address: String + ) -> Result { + + let keychain = GenericPasswordStore() + let account = keychain_accountName_bip353Address + let accessGroup = privateAccessGroup() + + do { + var mixins = [String: Any]() + mixins[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + + try keychain.storeKey( address, + account: account, + accessGroup: accessGroup, + mixins: mixins) + + return .success + } catch { + log.error("keychain.storeKey(account: bip353Address): error: \(error)") + return .failure(error) + } + } + + public func getBip353Address() -> String? { + + let keychain = GenericPasswordStore() + let account = keychain_accountName_bip353Address + let accessGroup = privateAccessGroup() + + var addr: String? = nil + do { + let value: String? = try keychain.readKey( + account : account, + accessGroup : accessGroup + ) + + if let value { + addr = value + } + + } catch { + log.error("keychain.readKey(account: bip353Address): error: \(error)") + } + + return addr + } + // -------------------------------------------------------------------------------- // MARK: Biometrics // -------------------------------------------------------------------------------- @@ -1033,11 +1089,13 @@ class AppSecurity { let fm = FileManager.default let securityJsonUrl = SharedSecurity.shared.securityJsonUrl - do { - try fm.removeItem(at: securityJsonUrl) - log.error("Deleted file security.json") - } catch { - log.error("Unable to delete security.json: \(error)") + if fm.fileExists(atPath: securityJsonUrl.path) { + do { + try fm.removeItem(at: securityJsonUrl) + log.info("Deleted file security.json") + } catch { + log.error("Unable to delete security.json: \(error)") + } } let keychain = GenericPasswordStore() @@ -1077,11 +1135,31 @@ class AppSecurity { account: keychain_accountName_softBiometrics, accessGroup: privateAccessGroup() ) - log.error("Deleted keychain item: act(softBiometrics) grp(private)") + log.info("Deleted keychain item: act(softBiometrics) grp(private)") } catch { log.error("Unable to delete keychain item: act(softBiometrics) grp(private): \(error)") } + do { + try keychain.deleteKey( + account: keychain_accountName_customPin, + accessGroup: privateAccessGroup() + ) + log.info("Deleted keychain item: act(customPin) grp(private)") + } catch { + log.error("Unable to delete keychain item: act(customPin) grp(private): \(error)") + } + + do { + try keychain.deleteKey( + account: keychain_accountName_bip353Address, + accessGroup: privateAccessGroup() + ) + log.info("Deleted keychain item: act(bip353Address) grp(private)") + } catch { + log.error("Unable to delete keychain item: act(bip353Address) grp(private): \(error)") + } + publishEnabledSecurity(EnabledSecurity()) } diff --git a/phoenix-ios/phoenix-ios/security/KeychainConstants.swift b/phoenix-ios/phoenix-ios/security/KeychainConstants.swift index 084f1c936..5d082e503 100644 --- a/phoenix-ios/phoenix-ios/security/KeychainConstants.swift +++ b/phoenix-ios/phoenix-ios/security/KeychainConstants.swift @@ -5,3 +5,4 @@ let keychain_accountName_softBiometrics = "biometrics" let keychain_accountName_passcodeFallback = "passcodeFallback" let keychain_accountName_customPin = "customPin" let keychain_accountName_invalidPin = "invalidPin" +let keychain_accountName_bip353Address = "bip353Address" diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 8f5ae3230..8ca6162b8 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -30,6 +30,7 @@ fileprivate enum NavLinkTag: String { case WalletInfo case ChannelsConfiguration case LogsConfiguration + case Experimental // Danger Zone case DrainWallet case ResetWallet @@ -81,6 +82,7 @@ fileprivate struct ConfigurationList: View { @Namespace var linkID_WalletInfo @Namespace var linkID_ChannelsConfiguration @Namespace var linkID_LogsConfiguration + @Namespace var linkID_Experimental @Namespace var linkID_DrainWallet @Namespace var linkID_ResetWallet @Namespace var linkID_ForceCloseChannels @@ -348,6 +350,19 @@ fileprivate struct ConfigurationList: View { } } .id(linkID_LogsConfiguration) + + if hasWallet { + navLink(.Experimental) { + Label { Text("Experimental") } icon: { + if #available(iOS 17, *) { + Image(systemName: "flask") + } else { + Image(systemName: "testtube.2") + } + } + } + .id(linkID_Experimental) + } } // } @@ -436,6 +451,7 @@ fileprivate struct ConfigurationList: View { case .WalletInfo : WalletInfoView(popTo: popTo) case .ChannelsConfiguration : ChannelsConfigurationView() case .LogsConfiguration : LogsConfigurationView() + case .Experimental : Experimental() // Danger Zone case .DrainWallet : DrainWalletView(popTo: popTo) case .ResetWallet : ResetWalletView() @@ -623,6 +639,7 @@ fileprivate struct ConfigurationList: View { case .WalletInfo : return linkID_WalletInfo case .ChannelsConfiguration : return linkID_ChannelsConfiguration case .LogsConfiguration : return linkID_LogsConfiguration + case .Experimental : return linkID_Experimental case .DrainWallet : return linkID_DrainWallet case .ResetWallet : return linkID_ResetWallet diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift new file mode 100644 index 000000000..5d17ce9b0 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift @@ -0,0 +1,248 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "Experimental" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct Experimental: View { + + @State var address: String? = AppSecurity.shared.getBip353Address() + @State var isClaiming: Bool = false + + enum ClaimError: Error { + case noChannels + case timeout + } + @State var claimError: ClaimError? = nil + + @State var claimIndex: Int = 0 + + @StateObject var toast = Toast() + + @Environment(\.colorScheme) var colorScheme + + @ViewBuilder + var body: some View { + + ZStack { + content() + toast.view() + } + .navigationTitle("Experimental") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func content() -> some View { + + List { + section_bip353() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + } + + @ViewBuilder + func section_bip353() -> some View { + + Section { + + VStack(alignment: HorizontalAlignment.leading, spacing: 24) { + Label { + section_bip353_info() + } icon: { + Image(systemName: "at") + } + + if address == nil { + section_bip353_claim() + } + + if let err = claimError { + section_bip353_error(err) + } + } + + } /* Section.*/ header: { + + Text("BIP353 DNS Address") + + } // + } + + @ViewBuilder + func section_bip353_info() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + + if let address { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(address) + Spacer(minLength: 0) + Button { + copyText(address) + } label: { + Image(systemName: "square.on.square") + } + } + .font(.headline) + + } else { + Text("No address yet...") + .font(.headline) + } + + Text( + """ + This is a human-readable address for your Bolt12 payment request. + """ + ) + .font(.subheadline) + .foregroundColor(.secondary) + + if address != nil { + Text( + """ + Want a prettier address? Use third-party services, or self-host the address! + """ + ) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 8) + } + + } // + } + + @ViewBuilder + func section_bip353_claim() -> some View { + + ZStack(alignment: Alignment.leading) { + + if isClaiming { + Label { + Text("") + } icon: { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + } + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 10) + + Button { + claimButtonTapped() + } label: { + Text("Claim my address") + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(isClaiming) + + Spacer(minLength: 10) + } // + } + } + + @ViewBuilder + func section_bip353_error(_ err: ClaimError) -> some View { + + Label { + switch err { + case .noChannels: + Text( + """ + You need at least one channel to claim your address. \ + Try adding funds to your wallet and try again. + """ + ) + + case .timeout: + Text( + """ + The request timed out. \ + Please check your internet connection and try again. + """ + ) + } + } icon: { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.appNegative) + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func claimButtonTapped() { + log.trace("claimButtonTapped") + + let channels = Biz.business.peerManager.channelsValue() + guard !channels.isEmpty else { + claimError = .noChannels + return + } + + guard + let peer = Biz.business.peerManager.peerStateValue(), + !isClaiming + else { + return + } + + isClaiming = true + claimError = nil + + let idx = claimIndex + let finish = {(result: Result) in + + guard self.claimIndex == idx else { + return + } + self.claimIndex += 1 + self.isClaiming = false + + switch result { + case .success(let addr): + self.address = addr + self.claimError = nil + let _ = AppSecurity.shared.setBip353Address(addr) + + case .failure(let err): + self.claimError = err + } + } + + Task { @MainActor in + do { + let addr = try await peer.requestAddress(languageSubtag: "en") + finish(.success(addr)) + + } catch { + finish(.failure(.timeout)) + } + } + + Task { @MainActor in + try await Task.sleep(seconds: 5) + finish(.failure(.timeout)) + } + } + + func copyText(_ text: String) -> Void { + log.trace("copyText()") + + UIPasteboard.general.string = text + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite, + style: .chrome + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift deleted file mode 100644 index badf55d39..000000000 --- a/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI - -/// Sheet content with buttons: -/// -/// Copy Text (Lightning invoice) -/// Copy Image (QR code) -/// -struct CopyOptionsSheet: View { - - let textType: String - let copyText: () -> Void - let copyImage: () -> Void - - @EnvironmentObject var smartModalState: SmartModalState - - @ViewBuilder - var body: some View { - - VStack { - - Button { - smartModalState.close { - copyText() - } - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - Image(systemName: "square.on.square") - .imageScale(.medium) - Text("Copy Text") - Spacer() - Text(textType) - .font(.footnote) - .foregroundColor(.secondary) - } - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 16) - .contentShape(Rectangle()) // make Spacer area tappable - } - .buttonStyle( - ScaleButtonStyle( - cornerRadius: 16, - borderStroke: Color.appAccent - ) - ) - .padding(.bottom, 8) - - Button { - smartModalState.close { - copyImage() - } - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - Image(systemName: "square.on.square") - .imageScale(.medium) - Text("Copy Image") - Spacer() - Text("(QR code)") - .font(.footnote) - .foregroundColor(.secondary) - } - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 16) - .contentShape(Rectangle()) // make Spacer area tappable - } - .buttonStyle( - ScaleButtonStyle( - cornerRadius: 16, - borderStroke: Color.appAccent - ) - ) - } - .padding(.all) - } -} diff --git a/phoenix-ios/phoenix-ios/views/receive/CopyShareOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/CopyShareOptionsSheet.swift new file mode 100644 index 000000000..e8d5339d9 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/receive/CopyShareOptionsSheet.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Sheet content with buttons: +/// +/// Copy: Lightning invoice (text) +/// Copy: QR code (image) +/// +struct CopyShareOptionsSheet: View { + + enum ActionType { + case copy + case share + } + + let type: ActionType + let sources: [SourceInfo] + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + title() + ForEach(sources.indices, id: \.self) { idx in + button(sources[idx]) + } + } // + .padding(.all) + } + + @ViewBuilder + func title() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 4) + switch type { + case .copy: + Text("Copy") + case .share: + Text("Share") + } + Spacer(minLength: 4) + } + .font(.title2) + } + + @ViewBuilder + func button(_ source: SourceInfo) -> some View { + + Button { + smartModalState.close { + source.callback() + } + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + + Group { + switch type { + case .copy: + Image(systemName: "square.on.square") + case .share: + Image(systemName: "square.and.arrow.up") + } + } + .imageScale(.medium) + .font(.title3) + .padding(.trailing, 4) + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text(source.title) + .font(source.isDefault ? Font.body.bold() : Font.body) + + if let subtitle = source.subtitle { + Text(subtitle) + .font(.footnote) + .lineLimit(1) + .truncationMode(.tail) + .foregroundColor(.secondary) + } + } + + Spacer() + + Group { + switch source.type { + case .text: + Text("(text)") + case .image: + Text("(image)") + } + } + .font(.footnote) + .foregroundColor(.secondary) + } + .padding([.top, .bottom], 8) + .padding([.leading, .trailing], 16) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle( + ScaleButtonStyle( + cornerRadius: 16, + borderStroke: Color.appAccent + ) + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift index f2c028ecc..ca3087ded 100644 --- a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift @@ -27,11 +27,13 @@ struct LightningDualView: View { } @State var activeType: LightningType = .bolt11_invoice - enum ReceiveViewSheet { - case sharingUrl(url: String) - case sharingImg(img: UIImage) + @State var bip353Address: String? = nil + + enum ActiveSheet { + case sharingText(text: String) + case sharingImage(image: UIImage) } - @State var activeSheet: ReceiveViewSheet? = nil + @State var activeSheet: ActiveSheet? = nil @State var notificationPermissions = NotificationsManager.shared.permissions.value @@ -112,14 +114,14 @@ struct LightningDualView: View { set: { if !$0 { activeSheet = nil }} )) { switch activeSheet! { - case .sharingUrl(let sharingUrl): + case .sharingText(let text): - let items: [Any] = [sharingUrl] + let items: [Any] = [text] ActivityView(activityItems: items, applicationActivities: nil) - case .sharingImg(let sharingImg): + case .sharingImage(let image): - let items: [Any] = [sharingImg] + let items: [Any] = [image] ActivityView(activityItems: items, applicationActivities: nil) } // @@ -174,7 +176,7 @@ struct LightningDualView: View { .padding(.horizontal) } - qrCodeInfo() + detailedInfo() .padding(.horizontal, 20) .padding(.vertical) @@ -258,18 +260,20 @@ struct LightningDualView: View { @ViewBuilder func qrCodeView() -> some View { - if let qrCodeImage = qrCode.image { + if let qrCodeCgImage = qrCode.cgImage, + let qrCodeImage = qrCode.image + { qrCodeImage .resizable() .aspectRatio(contentMode: .fit) .contextMenu { Button { - copyImageToPasteboard() + copyImageToPasteboard(qrCodeCgImage) } label: { Text("Copy") } Button { - shareImageToSystem() + shareImageToSystem(qrCodeCgImage) } label: { Text("Share") } @@ -285,10 +289,10 @@ struct LightningDualView: View { .accessibilityLabel("QR code") .accessibilityHint("Lightning QR code") .accessibilityAction(named: "Copy Image") { - copyImageToPasteboard() + copyImageToPasteboard(qrCodeCgImage) } .accessibilityAction(named: "Share Image") { - shareImageToSystem() + shareImageToSystem(qrCodeCgImage) } .accessibilityAction(named: "Full Screen") { showFullScreenQRCode() @@ -310,7 +314,7 @@ struct LightningDualView: View { } @ViewBuilder - func qrCodeInfo() -> some View { + func detailedInfo() -> some View { VStack(alignment: .center, spacing: 10) { @@ -319,15 +323,28 @@ struct LightningDualView: View { .font(.footnote) .foregroundColor(.secondary) - Text(invoiceDescription()) + invoiceDescriptionView() .lineLimit(1) .font(.footnote) .foregroundColor(.secondary) } else { - addressView() - .font(.footnote) - .foregroundColor(.secondary) + + if let address = bip353Address { + bip353AddressView(address) + .lineLimit(2) + .multilineTextAlignment(.center) + .font(.footnote) + .foregroundColor(.secondary) + + } else { + + offerAddressView() + .lineLimit(1) + .truncationMode(.middle) + .font(.footnote) + .foregroundColor(.secondary) + } } } } @@ -358,16 +375,42 @@ struct LightningDualView: View { } @ViewBuilder - func addressView() -> some View { + func invoiceDescriptionView() -> some View { + + if let m = mvi.model as? Receive.Model_Generated { + if let desc = m.desc, desc.count > 0 { + Text(desc) + } else { + Text("no description", comment: "placeholder: invoice is description-less") + } + } else { + Text("...") + } + } + + @ViewBuilder + func bip353AddressView(_ address: String) -> some View { + + let bAddress = "₿\(address)" + + Text("\(Image(systemName: "bitcoinsign.circle")) \(address)") + .contextMenu { + Button { + copyTextToPasteboard(bAddress) + } label: { + Text("Copy") + } + } + } + + @ViewBuilder + func offerAddressView() -> some View { if let offerStr = qrCode.value { Text(offerStr) - .lineLimit(2) - .multilineTextAlignment(.center) - .truncationMode(.middle) .contextMenu { Button { - didTapCopyButton() + copyTextToPasteboard(offerStr) } label: { Text("Copy") } @@ -443,20 +486,11 @@ struct LightningDualView: View { width: 20, height: 20, xOffset: 0, yOffset: 0 ) { - // using simultaneousGesture's below - } - .disabled(!(mvi.model is Receive.Model_Generated)) - .simultaneousGesture(LongPressGesture().onEnded { _ in - didLongPressCopyButton() - }) - .simultaneousGesture(TapGesture().onEnded { - didTapCopyButton() - }) - .accessibilityAction(named: "Copy Text (lightning invoice)") { - copyTextToPasteboard() + showCopyOptionsSheet() } - .accessibilityAction(named: "Copy Image (QR code)") { - copyImageToPasteboard() + .disabled(qrCode.value == nil) + .accessibilityAction(named: "Copy options") { + showCopyOptionsSheet() } } @@ -469,20 +503,11 @@ struct LightningDualView: View { width: 21, height: 21, xOffset: 0, yOffset: -1 ) { - // using simultaneousGesture's below - } - .disabled(!(mvi.model is Receive.Model_Generated)) - .simultaneousGesture(LongPressGesture().onEnded { _ in - didLongPressShareButton() - }) - .simultaneousGesture(TapGesture().onEnded { - didTapShareButton() - }) - .accessibilityAction(named: "Share Text (lightning invoice)") { - shareTextToSystem() + showShareOptionsSheet() } - .accessibilityAction(named: "Share Image (QR code)") { - shareImageToSystem() + .disabled(qrCode.value == nil) + .accessibilityAction(named: "Share options") { + showShareOptionsSheet() } } @@ -774,41 +799,101 @@ struct LightningDualView: View { } } - func didTapCopyButton() { - log.trace("didTapCopyButton()") + func showCopyOptionsSheet() { + log.trace("showCopyOptionsSheet()") - copyTextToPasteboard() + showCopyShareOptionsSheet(.copy) } - func didLongPressCopyButton() { - log.trace("didLongPressCopyButton()") + func showShareOptionsSheet() { + log.trace("showShareOptionsSheet()") - smartModalState.display(dismissable: true) { - - CopyOptionsSheet( - textType: textType(), - copyText: { copyTextToPasteboard() }, - copyImage: { copyImageToPasteboard() } - ) - } + showCopyShareOptionsSheet(.share) } - func didTapShareButton() -> Void { - log.trace("didTapShareButton()") + func showCopyShareOptionsSheet(_ type: CopyShareOptionsSheet.ActionType) { + log.trace("showCopyShareOptionsSheet(_)") - shareTextToSystem() - } - - func didLongPressShareButton() -> Void { - log.trace("didLongPressShareButton()") + let exportText = { (text: String) -> () -> Void in + switch type { + case .copy : return { copyTextToPasteboard(text) } + case .share : return { shareTextToSystem(text) } + } + } + let exportImage = { (img: CGImage) -> () -> Void in + switch type { + case .copy : return { copyImageToPasteboard(img) } + case .share : return { shareImageToSystem(img) } + } + } - smartModalState.display(dismissable: true) { + var sources: [SourceInfo] = [] + switch activeType { + case .bolt11_invoice: + if let invoiceText = qrCode.value { + sources.append(SourceInfo( + type: .text, + isDefault: true, + title: String(localized: "Lightning invoice", comment: "Type of text being copied"), + subtitle: invoiceText, + callback: exportText(invoiceText) + )) + } + if let invoiceImage = qrCode.cgImage { + sources.append(SourceInfo( + type: .image, + isDefault: false, + title: String(localized: "QR code", comment: "Type of image being copied"), + subtitle: nil, + callback: exportImage(invoiceImage) + )) + } - ShareOptionsSheet( - textType: textType(), - shareText: { shareTextToSystem() }, - shareImage: { shareImageToSystem() } - ) + case .bolt12_offer: + if let address = bip353Address { + let bAddress = "₿\(address)" // this will probably confuse users, but it's in the spec + sources.append(SourceInfo( + type: .text, + isDefault: true, + title: String(localized: "Human-readable address", comment: "Type of text being copied"), + subtitle: bAddress, + callback: exportText(bAddress) + )) + } + if let offerText = qrCode.value { + sources.append(SourceInfo( + type: .text, + isDefault: false, + title: String(localized: "Payment code", comment: "Type of text being copied"), + subtitle: offerText, + callback: exportText(offerText) + )) + } + if let offerText = qrCode.value { + let uri = "bitcoin:?lno=\(offerText)" + sources.append(SourceInfo( + type: .text, + isDefault: false, + title: String(localized: "Full URI", comment: "Type of text being copied"), + subtitle: uri, + callback: exportText(uri) + )) + } + if let offerImage = qrCode.cgImage { + sources.append(SourceInfo( + type: .image, + isDefault: false, + title: String(localized: "QR code", comment: "Type of image being copied"), + subtitle: nil, + callback: exportImage(offerImage) + )) + } + } // + + if !sources.isEmpty { + smartModalState.display(dismissable: true) { + CopyShareOptionsSheet(type: type, sources: sources) + } } } @@ -846,6 +931,10 @@ struct LightningDualView: View { qrCode.clear() } + if bip353Address == nil { + bip353Address = AppSecurity.shared.getBip353Address() + } + case .bolt12_offer: // Switching to Bolt 11 invoice activeType = .bolt11_invoice @@ -890,50 +979,39 @@ struct LightningDualView: View { // MARK: Utilities // -------------------------------------------------- - func copyTextToPasteboard() -> Void { - log.trace("copyTextToPasteboard()") + func copyTextToPasteboard(_ text: String) { + log.trace("copyTextToPasteboard(_)") - if let qrCodeValue = qrCode.value { - UIPasteboard.general.string = qrCodeValue - toast.pop( - NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), - colorScheme: colorScheme.opposite, - style: .chrome - ) - } + UIPasteboard.general.string = text + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite, + style: .chrome + ) } - func copyImageToPasteboard() -> Void { - log.trace("copyImageToPasteboard()") + func copyImageToPasteboard(_ cgImage: CGImage) { + log.trace("copyImageToPasteboard(_)") - if let qrCodeCgImage = qrCode.cgImage { - let uiImg = UIImage(cgImage: qrCodeCgImage) - UIPasteboard.general.image = uiImg - toast.pop( - NSLocalizedString("Copied QR code image to pasteboard!", comment: "Toast message"), - colorScheme: colorScheme.opposite - ) - } + let uiImg = UIImage(cgImage: cgImage) + UIPasteboard.general.image = uiImg + toast.pop( + NSLocalizedString("Copied image to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) } - func shareTextToSystem() -> Void { - log.trace("shareTextToSystem()") + func shareTextToSystem(_ text: String) { + log.trace("shareTextToSystem(_)") - if let qrCodeValue = qrCode.value { - withAnimation { - let url = "lightning:\(qrCodeValue)" - activeSheet = ReceiveViewSheet.sharingUrl(url: url) - } - } + activeSheet = ActiveSheet.sharingText(text: text) } - func shareImageToSystem() -> Void { - log.trace("shareImageToSystem()") + func shareImageToSystem(_ cgImage: CGImage) { + log.trace("shareImageToSystem(_)") - if let qrCodeCgImage = qrCode.cgImage { - let uiImg = UIImage(cgImage: qrCodeCgImage) - activeSheet = ReceiveViewSheet.sharingImg(img: uiImg) - } + let uiImg = UIImage(cgImage: cgImage) + activeSheet = ActiveSheet.sharingImage(image: uiImg) } } diff --git a/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift deleted file mode 100644 index 0e5e31ac4..000000000 --- a/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI - -/// Sheet content with buttons: -/// -/// Share Text (Lightning invoice) -/// Share Image (QR code) -/// -struct ShareOptionsSheet: View { - - let textType: String - let shareText: () -> Void - let shareImage: () -> Void - - @EnvironmentObject var smartModalState: SmartModalState - - @ViewBuilder - var body: some View { - - VStack { - - Button { - smartModalState.close { - shareText() - } - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - Image(systemName: "square.and.arrow.up") - .imageScale(.medium) - Text("Share Text") - Spacer() - Text(verbatim: textType) - .font(.footnote) - .foregroundColor(.secondary) - } - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 16) - .contentShape(Rectangle()) // make Spacer area tappable - } - .buttonStyle( - ScaleButtonStyle( - cornerRadius: 16, - borderStroke: Color.appAccent - ) - ) - .padding(.bottom, 8) - - Button { - smartModalState.close { - shareImage() - } - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - Image(systemName: "square.and.arrow.up") - .imageScale(.medium) - Text("Share Image") - Spacer() - Text("(QR code)") - .font(.footnote) - .foregroundColor(.secondary) - } - .padding([.top, .bottom], 8) - .padding([.leading, .trailing], 16) - .contentShape(Rectangle()) // make Spacer area tappable - } - .buttonStyle( - ScaleButtonStyle( - cornerRadius: 16, - borderStroke: Color.appAccent - ) - ) - } - .padding(.all) - } -} - diff --git a/phoenix-ios/phoenix-ios/views/receive/SourceInfo.swift b/phoenix-ios/phoenix-ios/views/receive/SourceInfo.swift new file mode 100644 index 000000000..8c212de00 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/receive/SourceInfo.swift @@ -0,0 +1,14 @@ +import Foundation + +enum SourceType { + case text + case image +} + +struct SourceInfo { + let type: SourceType + let isDefault: Bool + let title: String + let subtitle: String? + let callback: () -> Void +} diff --git a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift index f40acfc92..8191bacf3 100644 --- a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift @@ -22,11 +22,6 @@ enum SwapInAddressType: CustomStringConvertible{ struct SwapInView: View { - enum ReceiveViewSheet { - case sharingUrl(url: String) - case sharingImg(img: UIImage) - } - @ObservedObject var toast: Toast @State var swapInAddress: String? = nil @@ -34,7 +29,11 @@ struct SwapInView: View { @StateObject var qrCode = QRCode() - @State var activeSheet: ReceiveViewSheet? = nil + enum ActiveSheet { + case sharingText(text: String) + case sharingImage(image: UIImage) + } + @State var activeSheet: ActiveSheet? = nil let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() @@ -116,14 +115,14 @@ struct SwapInView: View { set: { if !$0 { activeSheet = nil }} )) { switch activeSheet! { - case .sharingUrl(let sharingUrl): + case .sharingText(let text): - let items: [Any] = [sharingUrl] + let items: [Any] = [text] ActivityView(activityItems: items, applicationActivities: nil) - case .sharingImg(let sharingImg): + case .sharingImage(let image): - let items: [Any] = [sharingImg] + let items: [Any] = [image] ActivityView(activityItems: items, applicationActivities: nil) } // @@ -162,22 +161,20 @@ struct SwapInView: View { @ViewBuilder func qrCodeView() -> some View { - if let address = swapInAddress, - let qrCodeValue = qrCode.value, - qrCodeValue.caseInsensitiveCompare(address) == .orderedSame, - let qrCodeImage = qrCode.image + if let qrCodeCgImage = qrCode.cgImage, + let qrCodeImage = qrCode.image { qrCodeImage .resizable() .aspectRatio(contentMode: .fit) .contextMenu { Button(action: { - copyImageToPasteboard() + copyImageToPasteboard(qrCodeCgImage) }) { Text("Copy") } Button(action: { - shareImageToSystem() + shareImageToSystem(qrCodeCgImage) }) { Text("Share") } @@ -187,10 +184,10 @@ struct SwapInView: View { .accessibilityLabel("QR code") .accessibilityHint("Bitcoin address") .accessibilityAction(named: "Copy Image") { - copyImageToPasteboard() + copyImageToPasteboard(qrCodeCgImage) } .accessibilityAction(named: "Share Image") { - shareImageToSystem() + shareImageToSystem(qrCodeCgImage) } } else { @@ -219,7 +216,7 @@ struct SwapInView: View { .multilineTextAlignment(.center) .contextMenu { Button { - didTapCopyButton() + copyTextToPasteboard(btcAddr) } label: { Text("Copy") } @@ -276,27 +273,18 @@ struct SwapInView: View { @ViewBuilder func copyButton() -> some View { - + actionButton( text: NSLocalizedString("copy", comment: "button label - try to make it short"), image: Image(systemName: "square.on.square"), width: 20, height: 20, xOffset: 0, yOffset: 0 ) { - // using simultaneousGesture's below + showCopyOptionsSheet() } .disabled(swapInAddress == nil) - .simultaneousGesture(LongPressGesture().onEnded { _ in - didLongPressCopyButton() - }) - .simultaneousGesture(TapGesture().onEnded { - didTapCopyButton() - }) - .accessibilityAction(named: "Copy Text (bitcoin address)") { - copyTextToPasteboard() - } - .accessibilityAction(named: "Copy Image (QR code)") { - copyImageToPasteboard() + .accessibilityAction(named: "Copy options") { + showCopyOptionsSheet() } } @@ -309,20 +297,11 @@ struct SwapInView: View { width: 21, height: 21, xOffset: 0, yOffset: -1 ) { - // using simultaneousGesture's below + showShareOptionsSheet() } .disabled(swapInAddress == nil) - .simultaneousGesture(LongPressGesture().onEnded { _ in - didLongPressShareButton() - }) - .simultaneousGesture(TapGesture().onEnded { - didTapShareButton() - }) - .accessibilityAction(named: "Share Text (bitcoin address)") { - shareTextToSystem() - } - .accessibilityAction(named: "Share Image (QR code)") { - shareImageToSystem() + .accessibilityAction(named: "Share options") { + showShareOptionsSheet() } } @@ -436,41 +415,58 @@ struct SwapInView: View { // MARK: Actions // -------------------------------------------------- - func didTapCopyButton() -> Void { - log.trace("didTapCopyButton()") + func showCopyOptionsSheet() { + log.trace("showCopyOptionsSheet()") - copyTextToPasteboard() + showCopyShareOptionsSheet(.copy) } - func didLongPressCopyButton() -> Void { - log.trace("didLongPressCopyButton()") + func showShareOptionsSheet() { + log.trace("showShareOptionsSheet()") - smartModalState.display(dismissable: true) { - - CopyOptionsSheet( - textType: String(localized: "(Bitcoin address)", comment: "Type of text being copied"), - copyText: { copyTextToPasteboard() }, - copyImage: { copyImageToPasteboard() } - ) - } + showCopyShareOptionsSheet(.share) } - func didTapShareButton() { - log.trace("didTapShareButton()") + func showCopyShareOptionsSheet(_ type: CopyShareOptionsSheet.ActionType) { + log.trace("showCopyShareOptionsSheet(_)") - shareTextToSystem() - } - - func didLongPressShareButton() { - log.trace("didLongPressShareButton()") + let exportText = { (text: String) -> () -> Void in + switch type { + case .copy : return { copyTextToPasteboard(text) } + case .share : return { shareTextToSystem(text) } + } + } + let exportImage = { (img: CGImage) -> () -> Void in + switch type { + case .copy : return { copyImageToPasteboard(img) } + case .share : return { shareImageToSystem(img) } + } + } - smartModalState.display(dismissable: true) { - - ShareOptionsSheet( - textType: NSLocalizedString("(Bitcoin address)", comment: "Type of text being copied"), - shareText: { shareTextToSystem() }, - shareImage: { shareImageToSystem() } - ) + var sources: [SourceInfo] = [] + if let address = qrCode.value { + sources.append(SourceInfo( + type: .text, + isDefault: true, + title: String(localized: "Bitcoin address", comment: "Type of text being copied"), + subtitle: address, + callback: exportText(address) + )) + } + if let cgImage = qrCode.cgImage { + sources.append(SourceInfo( + type: .image, + isDefault: false, + title: String(localized: "QR code", comment: "Type of image being copied"), + subtitle: nil, + callback: exportImage(cgImage) + )) + } + + if !sources.isEmpty { + smartModalState.display(dismissable: true) { + CopyShareOptionsSheet(type: type, sources: sources) + } } } @@ -478,7 +474,6 @@ struct SwapInView: View { log.trace("didTapEditButton()") smartModalState.display(dismissable: true) { - BtcAddrOptionsSheet(swapInAddressType: $swapInAddressType) } } @@ -487,54 +482,37 @@ struct SwapInView: View { // MARK: Utilities // -------------------------------------------------- - func copyTextToPasteboard() -> Void { - log.trace("copyTextToPasteboard()") + func copyTextToPasteboard(_ text: String) { + log.trace("copyTextToPasteboard(_)") - if let address = swapInAddress { - UIPasteboard.general.string = address - toast.pop( - NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), - colorScheme: colorScheme.opposite - ) - } + UIPasteboard.general.string = text + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) } - func copyImageToPasteboard() -> Void { - log.trace("copyImageToPasteboard()") + func copyImageToPasteboard(_ cgImage: CGImage) { + log.trace("copyImageToPasteboard(_)") - if let address = swapInAddress, - let qrCodeValue = qrCode.value, - qrCodeValue.caseInsensitiveCompare(address) == .orderedSame, - let qrCodeCgImage = qrCode.cgImage - { - let uiImg = UIImage(cgImage: qrCodeCgImage) - UIPasteboard.general.image = uiImg - toast.pop( - NSLocalizedString("Copied QR code image to pasteboard!", comment: "Toast message"), - colorScheme: colorScheme.opposite - ) - } + let uiImg = UIImage(cgImage: cgImage) + UIPasteboard.general.image = uiImg + toast.pop( + NSLocalizedString("Copied image to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) } - func shareTextToSystem() { - log.trace("shareTextToSystem()") + func shareTextToSystem(_ text: String) { + log.trace("shareTextToSystem(_)") - if let address = swapInAddress { - let url = "bitcoin:\(address)" - activeSheet = ReceiveViewSheet.sharingUrl(url: url) - } + activeSheet = ActiveSheet.sharingText(text: text) } - func shareImageToSystem() { + func shareImageToSystem(_ cgImage: CGImage) { log.trace("shareImageToSystem()") - if let address = swapInAddress, - let qrCodeValue = qrCode.value, - qrCodeValue.caseInsensitiveCompare(address) == .orderedSame, - let qrCodeCgImage = qrCode.cgImage - { - let uiImg = UIImage(cgImage: qrCodeCgImage) - activeSheet = ReceiveViewSheet.sharingImg(img: uiImg) - } + let uiImg = UIImage(cgImage: cgImage) + activeSheet = ActiveSheet.sharingImage(image: uiImg) } }