diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index c6086e98b..80a92d367 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -53,6 +53,7 @@ struct AppScene: View { // Run app data migrations before any feature code loads migrated state AppDataMigrations.run() + PaykitFeatureFlags.enforceBuildAvailability() _app = StateObject(wrappedValue: AppViewModel(sheetViewModel: sheetViewModel, navigationViewModel: navigationViewModel)) _sheets = StateObject(wrappedValue: sheetViewModel) @@ -146,13 +147,21 @@ struct AppScene: View { .environment(keyboardManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { - Task { try? await contactsManager.loadContacts(for: pk) } + Task { + try? await contactsManager.loadContacts(for: pk) + if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { + await retryPendingPaykitEndpointRemoval() + } + } } else if authState == .idle { contactsManager.reset() } } .onReceive(contactsManager.$contacts) { contacts in - guard wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return } + guard PaykitFeatureFlags.isUIEnabled, + wallet.walletExists == true, + pubkyProfile.authState == .authenticated + else { return } let publicKeys = contacts.map(\.publicKey) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) @@ -383,6 +392,9 @@ struct AppScene: View { do { try await wallet.start() try await activity.syncLdkNodePayments() + if !PaykitFeatureFlags.isUIEnabled { + await retryPendingPaykitEndpointRemoval() + } // Start watching pending orders after wallet is ready await blocktank.startWatchingPendingOrders(transferViewModel: transfer) @@ -544,6 +556,10 @@ struct AppScene: View { app.markAppStatusInit() BackupService.shared.startObservingBackups() Task { + if !PaykitFeatureFlags.isUIEnabled { + await retryPendingPaykitEndpointRemoval() + } + guard PaykitFeatureFlags.isUIEnabled else { return } await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() await PrivatePaykitService.shared.prepareSavedContacts( contactsManager.contacts.map(\.publicKey), @@ -576,20 +592,26 @@ struct AppScene: View { await clearDeliveredNotifications() await LightningService.shared.reconnectPeers() try? await wallet.sync() - await PrivatePaykitService.shared.retryPendingEndpointRemoval( - wallet: wallet, - savedPublicKeys: contactsManager.contacts.map(\.publicKey) - ) + await retryPendingPaykitEndpointRemoval() await wallet.refreshPublicPaykitEndpointsOnForeground() - await PrivatePaykitService.shared.refreshSavedContactEndpoints( - for: contactsManager.contacts.map(\.publicKey), - wallet: wallet - ) + if PaykitFeatureFlags.isUIEnabled { + await PrivatePaykitService.shared.refreshSavedContactEndpoints( + for: contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } } } } } + private func retryPendingPaykitEndpointRemoval() async { + await PrivatePaykitService.shared.retryPendingEndpointRemoval( + wallet: wallet, + savedPublicKeys: contactsManager.contacts.map(\.publicKey) + ) + } + /// Removes all delivered notifications from Notification Center so the app can handle them when opened. private func clearDeliveredNotifications() async { let center = UNUserNotificationCenter.current() diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift index c3945ec15..64ec2abfe 100644 --- a/Bitkit/Components/Activity/ActivityList.swift +++ b/Bitkit/Components/Activity/ActivityList.swift @@ -2,12 +2,18 @@ import BitkitCore import SwiftUI struct ActivityList: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager let viewType: ActivityViewType + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + enum ActivityViewType { case all case lightning @@ -31,7 +37,7 @@ struct ActivityList: View { ActivityRow( item: item, feeEstimates: feeEstimatesManager.estimates, - contact: item.contact(in: contactsManager.contacts) + contact: isPaykitUIActive ? item.contact(in: contactsManager.contacts) : nil ) } .accessibilityIdentifier("Activity-\(index)") diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index 8d962d98b..1dfd44758 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -1,6 +1,8 @@ import SwiftUI struct Header: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager @@ -10,6 +12,10 @@ struct Header: View { /// Binding to widgets edit state; used when showWidgetEditButton is true. @Binding var isEditingWidgets: Bool + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + init(showWidgetEditButton: Bool = false, isEditingWidgets: Binding = .constant(false)) { self.showWidgetEditButton = showWidgetEditButton _isEditingWidgets = isEditingWidgets @@ -17,7 +23,9 @@ struct Header: View { var body: some View { HStack(alignment: .center, spacing: 0) { - profileButton + if isPaykitUIActive { + profileButton + } Spacer() @@ -65,7 +73,6 @@ struct Header: View { .padding(.trailing, 10) } - @ViewBuilder private var profileButton: some View { Button { if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 7b38f3b2c..7591fad56 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -172,8 +172,13 @@ struct Suggestions: View { @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @State private var showShareSheet = false + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + /// Which suggestion cards to show. /// Up to 4 for current wallet state, in priority order; completed and dismissed are skipped. /// In widget preview: 2 fixed cards. @@ -183,6 +188,7 @@ struct Suggestions: View { settings: SettingsViewModel, suggestionsManager: SuggestionsManager, pubkyProfile: PubkyProfileManager? = nil, + isPaykitUIEnabled: Bool = PaykitFeatureFlags.isUIEnabled, isPreview: Bool = false ) -> [SuggestionCardData] { if isPreview { @@ -199,6 +205,7 @@ struct Suggestions: View { var result: [SuggestionCardData] = [] for id in orderedIds { guard let card = cardsById[id] else { continue } + if !isPaykitUIEnabled, card.isPaykitCard { continue } if isCardCompleted(card, app: app, settings: settings, pubkyProfile: pubkyProfile) { continue } if suggestionsManager.isDismissed(card.id) { continue } result.append(card) @@ -229,6 +236,7 @@ struct Suggestions: View { settings: settings, suggestionsManager: suggestionsManager, pubkyProfile: pubkyProfile, + isPaykitUIEnabled: isPaykitUIActive, isPreview: isPreview ) } @@ -273,6 +281,7 @@ struct Suggestions: View { } private func onItemTap(_ card: SuggestionCardData) { + if card.isPaykitCard, !PaykitFeatureFlags.isUIEnabled { return } var route: Route? switch card.action { @@ -322,6 +331,12 @@ struct Suggestions: View { } } +private extension SuggestionCardData { + var isPaykitCard: Bool { + action == .profile + } +} + #Preview { VStack { Suggestions() diff --git a/Bitkit/FeatureFlags/PaykitFeatureFlags.swift b/Bitkit/FeatureFlags/PaykitFeatureFlags.swift new file mode 100644 index 000000000..728a49982 --- /dev/null +++ b/Bitkit/FeatureFlags/PaykitFeatureFlags.swift @@ -0,0 +1,37 @@ +import Foundation + +enum PaykitFeatureFlags { + static let uiEnabledKey = "paykitUiEnabled" + + static var isUIAvailable: Bool { + #if FEATURE_PAYKIT_UI_DISABLED + false + #else + true + #endif + } + + static var isUIEnabled: Bool { + isUIAvailable && UserDefaults.standard.bool(forKey: uiEnabledKey) + } + + static func enforceBuildAvailability() { + let defaults = UserDefaults.standard + let hasPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) || + defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) || + defaults.bool(forKey: "hasConfirmedPublicPaykitEndpoints") || + !(defaults.string(forKey: "publicPaykitBolt11") ?? "").isEmpty + + guard !isUIEnabled, hasPublishedState else { return } + + defaults.set(false, forKey: uiEnabledKey) + defaults.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.removeObject(forKey: "publicPaykitBolt11") + defaults.removeObject(forKey: "publicPaykitBolt11PaymentHash") + defaults.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + + PrivatePaykitService.setContactSharingCleanupPending(true) + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 570b8e26f..4f7433cc9 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,6 +1,8 @@ import SwiftUI struct MainNavView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject private var app: AppViewModel @Environment(CameraManager.self) private var cameraManager @EnvironmentObject private var contactsManager: ContactsManager @@ -16,6 +18,10 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + // Delay constants for clipboard processing private static let nodeReadyDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds private static let statePropagationDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds @@ -247,6 +253,15 @@ struct MainNavView: View { Logger.info("Received deeplink: \(url.absoluteString)") if let callback = PubkyRingAuthCallback.parse(url: url) { + guard isPaykitUIActive else { + app.toast( + type: .error, + title: t("profile__auth_error_title"), + description: t("other__qr_error_text") + ) + return + } + let handlingResult = await pubkyProfile.handleAuthCallback(callback) switch handlingResult { @@ -368,7 +383,9 @@ struct MainNavView: View { // Profile & Contacts case .contacts: - if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { + if !isPaykitUIActive { + ComingSoonScreen() + } else if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { pubkyInitializationErrorView(message: initializationErrorMessage) } else if app.hasSeenContactsIntro || !contactsManager.contacts.isEmpty { if !pubkyProfile.isInitialized { @@ -383,12 +400,18 @@ struct MainNavView: View { } else { ContactsIntroView() } - case .contactsIntro: ContactsIntroView() - case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey) - case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey) - case let .assignActivityContact(activityId): AssignActivityContactView(activityId: activityId) + case .contactsIntro: + if isPaykitUIActive { ContactsIntroView() } else { ComingSoonScreen() } + case let .contactDetail(publicKey): + if isPaykitUIActive { ContactDetailView(publicKey: publicKey) } else { paykitDisabledRedirectView } + case let .contactActivity(publicKey): + if isPaykitUIActive { ContactActivityView(publicKey: publicKey) } else { paykitDisabledRedirectView } + case let .assignActivityContact(activityId): + if isPaykitUIActive { AssignActivityContactView(activityId: activityId) } else { paykitDisabledRedirectView } case .contactImportOverview: - if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { + if !isPaykitUIActive { + paykitDisabledRedirectView + } else if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) } else if let profile = contactsManager.pendingImportProfile { ContactImportOverviewView( @@ -399,15 +422,21 @@ struct MainNavView: View { missingPendingImportView(fallbackRoute: .payContacts) } case .contactImportSelect: - if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { + if !isPaykitUIActive { + paykitDisabledRedirectView + } else if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) } else { ContactImportSelectView(contacts: contactsManager.pendingImportContacts) } - case let .addContact(publicKey): AddContactView(publicKey: publicKey) - case let .editContact(publicKey): EditContactView(publicKey: publicKey) + case let .addContact(publicKey): + if isPaykitUIActive { AddContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } + case let .editContact(publicKey): + if isPaykitUIActive { EditContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } case .profile: - if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { + if !isPaykitUIActive { + ComingSoonScreen() + } else if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { pubkyInitializationErrorView(message: initializationErrorMessage) } else if !pubkyProfile.isInitialized { pubkyLoadingView @@ -418,11 +447,16 @@ struct MainNavView: View { } else { ProfileIntroView() } - case .profileIntro: ProfileIntroView() - case .pubkyChoice: PubkyChoiceView() - case .createProfile: CreateProfileView() - case .editProfile: EditProfileView() - case .payContacts: PayContactsView() + case .profileIntro: + if isPaykitUIActive { ProfileIntroView() } else { ComingSoonScreen() } + case .pubkyChoice: + if isPaykitUIActive { PubkyChoiceView() } else { paykitDisabledRedirectView } + case .createProfile: + if isPaykitUIActive { CreateProfileView() } else { paykitDisabledRedirectView } + case .editProfile: + if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView } + case .payContacts: + if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView } // Shop case .shopIntro: ShopIntro() @@ -451,7 +485,8 @@ struct MainNavView: View { case .widgetsSettings: WidgetsSettingsScreen() case .notifications: NotificationsSettings() case .notificationsIntro: NotificationsIntro() - case .paymentPreference: PaymentPreferenceView() + case .paymentPreference: + if isPaykitUIActive { PaymentPreferenceView() } else { paykitDisabledRedirectView } // Security settings case .changePin: ChangePinScreen() @@ -498,6 +533,13 @@ struct MainNavView: View { } } + private var paykitDisabledRedirectView: some View { + Color.customBlack + .task { + navigation.reset() + } + } + private func handleClipboard() { Task { @MainActor in guard let uri = UIPasteboard.general.string else { diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index ab6fae68f..a0caae8fb 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -96,7 +96,8 @@ extension PrivatePaykitService { @MainActor func canPublishPrivateEndpoints(wallet: WalletViewModel) async -> Bool { - guard UserDefaults.standard.bool(forKey: Self.publishingEnabledKey), + guard PaykitFeatureFlags.isUIEnabled, + UserDefaults.standard.bool(forKey: Self.publishingEnabledKey), UIApplication.shared.applicationState == .active, wallet.walletExists == true, wallet.nodeLifecycleState == .running, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8d43f0474..276d90cc9 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -472,6 +472,15 @@ extension AppViewModel { handleNodeUri(url) case let .pubkyAuth(data: authUrl): + guard PaykitFeatureFlags.isUIEnabled else { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } handlePubkyAuthApproval(authUrl) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) @@ -664,8 +673,14 @@ extension AppViewModel { if PubkyPublicKeyFormat.normalized(normalized) != nil { guard currentSequence == manualEntryValidationSequence else { return } - manualEntryValidationResult = .valid - isManualEntryInputValid = true + if PaykitFeatureFlags.isUIEnabled { + manualEntryValidationResult = .valid + isManualEntryInputValid = true + } else { + manualEntryValidationResult = .invalid + isManualEntryInputValid = false + showValidationErrorToast(for: .invalid) + } return } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index b7670abcc..fdadd996d 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -129,6 +129,10 @@ func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? { } func resolvePubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? { + guard PaykitFeatureFlags.isUIEnabled else { + return nil + } + guard let normalizedKey = PubkyPublicKeyFormat.normalized(input) else { return nil } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index afd49b015..29ed9d8ea 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -204,6 +204,7 @@ class SettingsViewModel: NSObject, ObservableObject { quickpayAmount = 5 enableNotifications = false enableNotificationsAmount = false + UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 58aec99f5..f6681de68 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -59,11 +59,16 @@ class WalletViewModel: ObservableObject { private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60 private static let paykitChannelUsabilityRefreshDelay: UInt64 = 5_000_000_000 + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + private let lightningService: LightningService private let coreService: CoreService private let electrumConfigService: ElectrumConfigService @@ -209,9 +214,13 @@ class WalletViewModel: ObservableObject { ) case let .paymentReceived(_, paymentHash, _, _): self.bolt11 = "" - self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) + if self.isPaykitUIActive { + self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) + } Task { - await PrivatePaykitService.shared.handleReceivedPayment(paymentHash: paymentHash, wallet: self) + if self.isPaykitUIActive { + await PrivatePaykitService.shared.handleReceivedPayment(paymentHash: paymentHash, wallet: self) + } await self.refreshAndSyncState() try? await self.refreshBip21() } @@ -234,7 +243,9 @@ class WalletViewModel: ObservableObject { await self.refreshAndSyncState() await self.handleChannelClosed(channelId: channelId, reason: reason) try? await self.refreshBip21() - await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(wallet: self, reason: "channel-closed refresh") + if self.isPaykitUIActive { + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(wallet: self, reason: "channel-closed refresh") + } } // MARK: Onchain Transaction Events @@ -242,25 +253,31 @@ class WalletViewModel: ObservableObject { case let .onchainTransactionReceived(_, details): Task { await self.refreshAndSyncState() - await PrivatePaykitService.shared.handleOnchainActivity( - receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), - wallet: self - ) + if self.isPaykitUIActive { + await PrivatePaykitService.shared.handleOnchainActivity( + receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), + wallet: self + ) + } } case let .onchainTransactionConfirmed(_, _, _, _, details): Task { await self.refreshAndSyncState() - await PrivatePaykitService.shared.handleOnchainActivity( - receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), - wallet: self - ) + if self.isPaykitUIActive { + await PrivatePaykitService.shared.handleOnchainActivity( + receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), + wallet: self + ) + } } case .onchainTransactionReplaced, .onchainTransactionReorged, .onchainTransactionEvicted: Task { await self.refreshAndSyncState() - await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) + if self.isPaykitUIActive { + await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) + } } // MARK: Sync Events @@ -479,8 +496,10 @@ class WalletViewModel: ObservableObject { isSyncingWallet = false syncState() - await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) - await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) + if isPaykitUIActive { + await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) + await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) + } } /// Sends bitcoin to an on-chain address @@ -1009,6 +1028,10 @@ class WalletViewModel: ObservableObject { includeOnchain: Bool = true, includeLightning: Bool = true ) async throws -> (onchainAddress: String, bolt11: String) { + guard isPaykitUIActive else { + return ("", "") + } + let publicOnchainAddress = includeOnchain ? try await refreshReusableOnchainAddress() : "" if includeLightning, hasUsableChannels { @@ -1035,7 +1058,7 @@ class WalletViewModel: ObservableObject { } func refreshPublicPaykitEndpointsOnForeground() async { - guard sharesPublicPaykitEndpoints else { return } + guard isPaykitUIActive, sharesPublicPaykitEndpoints else { return } do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) @@ -1045,6 +1068,8 @@ class WalletViewModel: ObservableObject { } private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { + guard isPaykitUIActive else { return } + do { try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true) } catch { @@ -1056,6 +1081,8 @@ class WalletViewModel: ObservableObject { await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning) + guard isPaykitUIActive else { return } + if sharesPublicPaykitEndpoints { do { if hasUsableChannels { @@ -1171,7 +1198,7 @@ class WalletViewModel: ObservableObject { // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) - if sharesPublicPaykitEndpoints { + if isPaykitUIActive, sharesPublicPaykitEndpoints { do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) } catch { diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index fea1bcf9d..22d3357d9 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -11,6 +11,12 @@ struct HomeWidgetsView: View { @Binding var isEditingWidgets: Bool + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + private var bottomPadding: CGFloat { // Keep the calculator widget fully scrollable above the keyboard. let inset = keyboard.height + ScreenLayout.bottomSpacing @@ -22,7 +28,13 @@ struct HomeWidgetsView: View { widgets.savedWidgets.filter { widget in if widget.type != .suggestions { return true } if isEditingWidgets { return true } - return !Suggestions.visibleCards(wallet: wallet, app: app, settings: settings, suggestionsManager: suggestionsManager).isEmpty + return !Suggestions.visibleCards( + wallet: wallet, + app: app, + settings: settings, + suggestionsManager: suggestionsManager, + isPaykitUIEnabled: isPaykitUIActive + ).isEmpty } } diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 0e3544b1d..1bf95277a 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -2,6 +2,11 @@ import SwiftUI import UIKit struct DevSettingsView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false + @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false + @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false + @EnvironmentObject var app: AppViewModel @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @@ -9,6 +14,8 @@ struct DevSettingsView: View { @EnvironmentObject var session: SessionManager @EnvironmentObject var wallet: WalletViewModel + @State private var showPaykitWarning = false + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__dev_title")) @@ -46,6 +53,29 @@ struct DevSettingsView: View { SettingsRow(title: "Orders") } + if PaykitFeatureFlags.isUIAvailable { + SettingsSectionHeader("PAYKIT") + .padding(.top, 16) + + SettingsRow( + title: "Enable Paykit UI", + rightIcon: nil, + toggle: Binding( + get: { isPaykitUIEnabled }, + set: { enabled in + if enabled { + showPaykitWarning = true + } else { + Task { + await disablePaykitUI() + } + } + } + ), + testIdentifier: "PaykitUiToggle" + ) + } + Button { Task { do { @@ -147,6 +177,59 @@ struct DevSettingsView: View { } } .navigationBarHidden(true) + .alert("Enable Paykit UI?", isPresented: $showPaykitWarning) { + Button("Cancel", role: .cancel) {} + Button("Enable") { + if PaykitFeatureFlags.isUIAvailable { + isPaykitUIEnabled = true + app.toast(type: .success, title: "Paykit UI enabled", accessibilityIdentifier: "PaykitUiEnabledToast") + } + } + } message: { + Text("Paykit features are still experimental and may not work reliably until supporting homeserver changes are deployed.") + } + } + + @MainActor + private func disablePaykitUI() async { + isPaykitUIEnabled = false + hasConfirmedPublicPaykitEndpoints = false + sharesPrivatePaykitEndpoints = false + sharesPublicPaykitEndpoints = false + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + + var cleanupError: Error? + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + } catch { + cleanupError = error + Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") + } + + do { + try await PrivatePaykitService.shared.removePublishedEndpoints() + } catch { + if cleanupError == nil { + cleanupError = error + } + Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") + } + + if let cleanupError { + PrivatePaykitService.setContactSharingCleanupPending(true) + app.toast( + type: .error, + title: "Paykit UI disabled", + description: cleanupError.localizedDescription, + accessibilityIdentifier: "PaykitUiDisabledToast" + ) + return + } + + PrivatePaykitService.setContactSharingCleanupPending(false) + app.toast(type: .success, title: "Paykit UI disabled", accessibilityIdentifier: "PaykitUiDisabledToast") } } diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 92727e6df..ac80c34fd 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI struct GeneralSettingsView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true @@ -11,6 +12,10 @@ struct GeneralSettingsView: View { @EnvironmentObject var tagManager: TagManager @StateObject private var languageManager = LanguageManager.shared + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { @@ -77,7 +82,7 @@ struct GeneralSettingsView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("TransactionSpeedSettings") - if pubkyProfile.isAuthenticated { + if isPaykitUIActive, pubkyProfile.isAuthenticated { NavigationLink(value: Route.paymentPreference) { SettingsRow( title: t("settings__adv__payment_preference"), diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 9107b5bc3..0f3d9668d 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -4,6 +4,8 @@ import SwiftUI struct ActivityItemView: View { let item: Activity + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject var activityList: ActivityListViewModel @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @@ -18,6 +20,10 @@ struct ActivityItemView: View { @State private var boostTxDoesExist: [String: Bool] = [:] // Maps boostTxId -> doesExist @State private var isCpfpChild: Bool = false + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + init(item: Activity) { self.item = item _viewModel = StateObject(wrappedValue: ActivityItemViewModel(item: item)) @@ -101,7 +107,8 @@ struct ActivityItemView: View { } private var assignedContact: PubkyContact? { - viewModel.activity.contact(in: contactsManager.contacts) + guard isPaykitUIActive else { return nil } + return viewModel.activity.contact(in: contactsManager.contacts) } private var navigationTitle: String { @@ -508,21 +515,23 @@ struct ActivityItemView: View { private var buttons: some View { VStack(spacing: 16) { HStack(spacing: 16) { - CustomButton( - title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, - icon: Image(assignedContact == nil ? "user-plus" : "user-minus") - .foregroundColor(accentColor), - shouldExpand: true - ) { - if assignedContact == nil { - navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) - } else { - Task { - await detachContact() + if isPaykitUIActive { + CustomButton( + title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, + icon: Image(assignedContact == nil ? "user-plus" : "user-minus") + .foregroundColor(accentColor), + shouldExpand: true + ) { + if assignedContact == nil { + navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) + } else { + Task { + await detachContact() + } } } + .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") } - .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") CustomButton( title: t("wallet__activity_tag"), size: .small, diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 88f606fcb..926b4fb56 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -2,6 +2,8 @@ import BitkitCore import SwiftUI struct ActivityLatest: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject private var activity: ActivityListViewModel @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var contactsManager: ContactsManager @@ -10,6 +12,10 @@ struct ActivityLatest: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var wallet: WalletViewModel + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + private var shouldShowBanner: Bool { wallet.balanceInTransferToSavings > 0 || wallet.balanceInTransferToSpending > 0 } @@ -64,7 +70,7 @@ struct ActivityLatest: View { ActivityRow( item: item, feeEstimates: feeEstimatesManager.estimates, - contact: item.contact(in: contactsManager.contacts) + contact: isPaykitUIActive ? item.contact(in: contactsManager.contacts) : nil ) } .accessibilityIdentifier("ActivityShort-\(index)") diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index e087f9dbd..2d4997e9d 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -2,6 +2,8 @@ import PhotosUI import SwiftUI struct SendOptionsView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject var app: AppViewModel @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel @@ -15,6 +17,10 @@ struct SendOptionsView: View { @Binding var navigationPath: [SendRoute] @State private var selectedItem: PhotosPickerItem? + private var isPaykitUIActive: Bool { + PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled + } + var body: some View { VStack(spacing: 0) { SheetHeader(title: t("wallet__send_bitcoin")) @@ -104,7 +110,7 @@ struct SendOptionsView: View { } func handleContact() { - navigationPath.append(.contact) + navigationPath.append(isPaykitUIActive ? .contact : .comingSoon) } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 6fc4750ee..3fc3d4469 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -3,6 +3,7 @@ import SwiftUI enum SendRoute: Hashable { case options case contact + case comingSoon case manual case amount case utxoSelection @@ -294,6 +295,8 @@ struct SendSheet: View { SendOptionsView(navigationPath: $navigationPath) case .contact: SendContactSelectView(navigationPath: $navigationPath) + case .comingSoon: + SendComingSoonView() case .manual: SendEnterManuallyView(navigationPath: $navigationPath) case .amount: @@ -335,3 +338,35 @@ struct SendSheet: View { } } } + +private struct SendComingSoonView: View { + @EnvironmentObject private var sheets: SheetViewModel + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("coming_soon__nav_title"), showBackButton: true) + + VStack(alignment: .leading, spacing: 0) { + Image("stopwatch") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 48) + + DisplayText(t("coming_soon__headline")) + .frame(maxWidth: .infinity, alignment: .leading) + + BodyMText(t("coming_soon__description")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 14) + + CustomButton(title: t("coming_soon__button")) { + sheets.hideSheet() + } + .padding(.top, 32) + } + } + .sheetBackground() + .padding(.horizontal, 16) + } +} diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 86256d958..758e16501 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -4,6 +4,16 @@ import XCTest @MainActor final class ContactsManagerTests: XCTestCase { + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + super.tearDown() + } + func testPubkyPublicKeyFormatNormalizesPrefixedAndUnprefixedKeys() { let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let prefixedKey = "pubky\(rawKey)" @@ -260,6 +270,7 @@ final class ContactsManagerTests: XCTestCase { } func testResolvePastedPubkyRouteReturnsProfileForOwnKey() { + enablePaykitUIForRouteTests() let ownPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" XCTAssertEqual( @@ -269,6 +280,7 @@ final class ContactsManagerTests: XCTestCase { } func testResolvePastedPubkyRouteReturnsContactDetailForExistingContact() { + enablePaykitUIForRouteTests() let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" XCTAssertEqual( @@ -282,6 +294,7 @@ final class ContactsManagerTests: XCTestCase { } func testResolvePastedPubkyRouteTrimsClipboardInput() { + enablePaykitUIForRouteTests() let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" XCTAssertEqual( @@ -295,6 +308,7 @@ final class ContactsManagerTests: XCTestCase { } func testResolvePastedPubkyRouteReturnsAddContactForUnknownKey() { + enablePaykitUIForRouteTests() let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" XCTAssertEqual( @@ -308,6 +322,8 @@ final class ContactsManagerTests: XCTestCase { } func testResolvePastedPubkyRouteReturnsNilForInvalidInput() { + enablePaykitUIForRouteTests() + XCTAssertNil( resolvePastedPubkyRoute( input: "not-a-pubky", @@ -317,6 +333,22 @@ final class ContactsManagerTests: XCTestCase { ) } + func testResolvePastedPubkyRouteReturnsNilWhenPaykitUIIsDisabled() { + let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertNil( + resolvePastedPubkyRoute( + input: contactKey, + ownPublicKey: nil, + contacts: [makeContact(publicKey: contactKey)] + ) + ) + } + + private func enablePaykitUIForRouteTests() { + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + } + private func makeProfile(publicKey: String) -> Bitkit.PubkyProfile { Bitkit.PubkyProfile( publicKey: publicKey, diff --git a/changelog.d/next/556.changed.md b/changelog.d/next/556.changed.md new file mode 100644 index 000000000..2ac5fd45c --- /dev/null +++ b/changelog.d/next/556.changed.md @@ -0,0 +1 @@ +Hide experimental Paykit profile, contacts, and contact payment controls behind a developer setting.