From 15f1a146f399c3ce8219eab3a1665ed9e8bea22a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Jan 2026 17:39:09 +0100 Subject: [PATCH 1/2] refactor: remove ldk-node start from extension The notification service extension now follows a lightweight approach that avoids iOS memory (~24MB) and time (~30s) constraints by not starting the LDK node in the extension. Changes: - NotificationService now only decrypts payload and displays time-sensitive notifications with urgency messaging - Added IncomingPaymentInfo model to persist payment data between extension and main app via App Group UserDefaults - Main app now processes incoming payments when it becomes active, with priority handling for node startup and LSP peer connection - Notifications are auto-removed after payment expiry window (2 minutes) - AppViewModel clears pending payments when payment is received --- Bitkit/Managers/PushNotificationManager.swift | 195 +++++ Bitkit/Models/IncomingPaymentInfo.swift | 162 ++++ Bitkit/Utilities/ScenePhase.swift | 36 +- Bitkit/Utilities/StateLocker.swift | 17 + Bitkit/ViewModels/AppViewModel.swift | 6 + BitkitNotification/NotificationService.swift | 699 ++++++++---------- 6 files changed, 740 insertions(+), 375 deletions(-) create mode 100644 Bitkit/Models/IncomingPaymentInfo.swift diff --git a/Bitkit/Managers/PushNotificationManager.swift b/Bitkit/Managers/PushNotificationManager.swift index 5c9dd186..c10c2cca 100644 --- a/Bitkit/Managers/PushNotificationManager.swift +++ b/Bitkit/Managers/PushNotificationManager.swift @@ -3,6 +3,9 @@ import SwiftUI enum PushNotificationError: Error { case deviceTokenNotAvailable + case paymentExpired + case nodeNotReady + case processingFailed(String) } final class PushNotificationManager: ObservableObject { @@ -10,6 +13,12 @@ final class PushNotificationManager: ObservableObject { @Published var deviceToken: String? = nil @Published var authorizationStatus: UNAuthorizationStatus = .notDetermined + /// Currently processing incoming payment, if any + @Published var pendingPaymentInfo: IncomingPaymentInfo? = nil + + /// Whether we're currently processing an incoming payment + @Published var isProcessingPayment: Bool = false + private init() {} func requestPermission() { @@ -90,6 +99,192 @@ final class PushNotificationManager: ObservableObject { func handleNotification(_ userInfo: [AnyHashable: Any]) { Logger.debug("📩 Notification received: \(userInfo)") + + // Check if this notification has an associated incoming payment + if let paymentId = userInfo["incomingPaymentId"] as? String { + Logger.info("📩 Notification tapped for payment: \(paymentId)") + // The incoming payment info should already be saved by the extension + // It will be picked up when the app becomes active + } + } + + // MARK: - Incoming Payment Processing + + /// Checks for and processes any pending incoming payments saved by the notification extension. + /// This should be called when the app becomes active. + /// - Returns: The incoming payment info if one was found and is still valid + @discardableResult + func checkForPendingPayment() -> IncomingPaymentInfo? { + guard let paymentInfo = IncomingPaymentInfo.load() else { + Logger.debug("📩 No pending incoming payment found") + return nil + } + + Logger.info("📩 Found pending payment: type=\(paymentInfo.paymentType.rawValue), state=\(paymentInfo.state.rawValue)") + + // Check if already processed or expired + if paymentInfo.state == .completed || paymentInfo.state == .failed { + Logger.debug("📩 Payment already processed, clearing") + IncomingPaymentInfo.clear() + return nil + } + + if paymentInfo.isExpired { + Logger.warn("📩 Payment expired, clearing") + var expired = paymentInfo + expired.updateState(.expired) + IncomingPaymentInfo.clear() + return nil + } + + DispatchQueue.main.async { + self.pendingPaymentInfo = paymentInfo + } + + return paymentInfo + } + + /// Processes an incoming payment by ensuring the node is running and connected to the LSP. + /// This is the main entry point for handling payments after the user opens the app. + /// - Parameter paymentInfo: The incoming payment info to process + /// - Parameter walletViewModel: The wallet view model for node lifecycle management + func processIncomingPayment(_ paymentInfo: IncomingPaymentInfo, walletViewModel: WalletViewModel) async { + Logger.info("📩 Processing incoming payment: \(paymentInfo.id)") + + guard !paymentInfo.isExpired else { + Logger.warn("📩 Payment expired before processing could start") + await markPaymentState(.expired) + return + } + + await MainActor.run { + isProcessingPayment = true + } + + var info = paymentInfo + info.updateState(.processing) + + do { + // Step 1: Ensure node is running + Logger.debug("📩 Step 1: Ensuring node is running...") + try await ensureNodeRunning(walletViewModel: walletViewModel) + + // Step 2: Connect to LSP peer if specified + if let lspId = paymentInfo.lspId { + Logger.debug("📩 Step 2: Connecting to LSP peer...") + try await connectToLspPeer(lspId: lspId) + } + + // Step 3: Handle specific payment types + Logger.debug("📩 Step 3: Handling payment type \(paymentInfo.paymentType.rawValue)...") + try await handlePaymentType(paymentInfo) + + // Payment processing initiated successfully + // The actual payment completion will be signaled by LDK events + Logger.info("📩 Payment processing initiated successfully") + + } catch { + Logger.error("📩 Payment processing failed: \(error)") + await markPaymentState(.failed) + } + + await MainActor.run { + isProcessingPayment = false + } + } + + /// Clears the pending payment after successful processing + func clearPendingPayment() { + IncomingPaymentInfo.clear() + DispatchQueue.main.async { + self.pendingPaymentInfo = nil + self.isProcessingPayment = false + } + Logger.debug("📩 Cleared pending payment") + } + + /// Marks the payment as completed (called when payment is received via LDK event) + func markPaymentCompleted() { + Task { + await markPaymentState(.completed) + clearPendingPayment() + } + } + + // MARK: - Private Processing Helpers + + private func markPaymentState(_ state: IncomingPaymentInfo.ProcessingState) async { + if var info = pendingPaymentInfo { + info.updateState(state) + await MainActor.run { + pendingPaymentInfo = info + } + } + } + + private func ensureNodeRunning(walletViewModel: WalletViewModel) async throws { + // Check if node is already running + if walletViewModel.nodeLifecycleState == .running { + Logger.debug("📩 Node already running") + return + } + + // Wait for node to be ready + Logger.debug("📩 Waiting for node to start...") + try await waitForNodeToBeReady(timeout: 60) + } + + private func connectToLspPeer(lspId: String) async throws { + // Find the peer in trusted peers list + guard let peer = Env.trustedLnPeers.first(where: { $0.nodeId == lspId }) else { + Logger.warn("📩 LSP \(lspId) not found in trusted peers") + return // Not a fatal error, node might already be connected + } + + // Check if already connected + if let peers = LightningService.shared.peers, peers.contains(where: { $0.nodeId == lspId }) { + Logger.debug("📩 Already connected to LSP") + return + } + + // Connect to the peer + Logger.debug("📩 Connecting to LSP: \(lspId)") + try await LightningService.shared.connectPeer(peer: peer) + + // Wait for connection to be established + let maxWaitTime: TimeInterval = 10.0 + let pollInterval: TimeInterval = 0.5 + let startTime = Date() + + while Date().timeIntervalSince(startTime) < maxWaitTime { + if let peers = LightningService.shared.peers, peers.contains(where: { $0.nodeId == lspId }) { + Logger.debug("📩 Successfully connected to LSP") + return + } + try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + } + + Logger.warn("📩 Timeout waiting for LSP connection, continuing anyway") + } + + private func handlePaymentType(_ paymentInfo: IncomingPaymentInfo) async throws { + switch paymentInfo.paymentType { + case .orderPaymentConfirmed: + // Handle channel opening + if let orderId = paymentInfo.orderId { + Logger.debug("📩 Opening channel for order: \(orderId)") + _ = try await CoreService.shared.blocktank.open(orderId: orderId) + } + case .incomingHtlc, .cjitPaymentArrived: + // These are handled automatically by LDK node events once connected + Logger.debug("📩 Payment will be processed by LDK events") + case .mutualClose: + // Channel close is handled by LDK + Logger.debug("📩 Channel close will be processed by LDK") + case .wakeToTimeout, .unknown: + // Generic wake - just ensure node is running + Logger.debug("📩 Node running, events will be processed") + } } func sendTestNotification() async throws { diff --git a/Bitkit/Models/IncomingPaymentInfo.swift b/Bitkit/Models/IncomingPaymentInfo.swift new file mode 100644 index 00000000..f3f7ec1d --- /dev/null +++ b/Bitkit/Models/IncomingPaymentInfo.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Represents an incoming payment notification that needs to be processed by the app. +/// This is saved by the notification service extension and picked up by the main app when it becomes active. +struct IncomingPaymentInfo: Codable, Equatable { + /// The type of incoming payment notification + enum PaymentType: String, Codable { + case incomingHtlc + case cjitPaymentArrived + case orderPaymentConfirmed + case mutualClose + case wakeToTimeout + case unknown + + init(from blocktankType: String) { + self = PaymentType(rawValue: blocktankType) ?? .unknown + } + } + + /// Current processing state of the incoming payment + enum ProcessingState: String, Codable { + case pending // Waiting for user to open app + case processing // App is processing (node starting, connecting peer, etc.) + case completed // Payment successfully received + case expired // Payment window expired + case failed // Processing failed + } + + let id: String + let paymentType: PaymentType + let paymentHash: String? + let orderId: String? + let lspId: String? + let amountMsat: UInt64? + let receivedAt: Date + let expiresAt: Date + var state: ProcessingState + + /// Human-readable description for the notification + var notificationTitle: String { + switch paymentType { + case .incomingHtlc, .cjitPaymentArrived: + return "Incoming Payment" + case .orderPaymentConfirmed: + return "Spending Balance Ready" + case .mutualClose: + return "Channel Closing" + case .wakeToTimeout: + return "Payment Pending" + case .unknown: + return "Notification" + } + } + + /// Urgency message for the notification body + var notificationBody: String { + switch paymentType { + case .incomingHtlc, .cjitPaymentArrived: + return "Open Bitkit now to receive your payment" + case .orderPaymentConfirmed: + return "Open Bitkit now to complete setup" + case .mutualClose: + return "Your spending balance is being transferred" + case .wakeToTimeout: + return "Open Bitkit to process pending payment" + case .unknown: + return "Open Bitkit to continue" + } + } + + /// Default expiry duration for incoming payments (2 minutes) + /// This accounts for HTLC timeout constraints + static let defaultExpiryDuration: TimeInterval = 2 * 60 + + /// Storage key for app group UserDefaults + private static let storageKey = "incomingPaymentInfo" + + /// App group UserDefaults for sharing between app and extension + private static let appGroupUserDefaults = UserDefaults(suiteName: "group.bitkit") + + /// Creates a new incoming payment info with auto-generated ID and expiry + init( + paymentType: PaymentType, + paymentHash: String? = nil, + orderId: String? = nil, + lspId: String? = nil, + amountMsat: UInt64? = nil, + expiryDuration: TimeInterval = IncomingPaymentInfo.defaultExpiryDuration + ) { + self.id = UUID().uuidString + self.paymentType = paymentType + self.paymentHash = paymentHash + self.orderId = orderId + self.lspId = lspId + self.amountMsat = amountMsat + self.receivedAt = Date() + self.expiresAt = Date().addingTimeInterval(expiryDuration) + self.state = .pending + } + + /// Whether this payment has expired + var isExpired: Bool { + Date() > expiresAt + } + + /// Time remaining until expiry in seconds + var timeRemaining: TimeInterval { + max(0, expiresAt.timeIntervalSinceNow) + } + + // MARK: - Persistence + + /// Saves the incoming payment info to shared storage + func save() { + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(self) + Self.appGroupUserDefaults?.set(data, forKey: Self.storageKey) + Self.appGroupUserDefaults?.synchronize() + } catch { + // Note: Logger may not be available in extension, use os_log + print("IncomingPaymentInfo: Failed to save: \(error)") + } + } + + /// Loads the incoming payment info from shared storage + static func load() -> IncomingPaymentInfo? { + guard let data = appGroupUserDefaults?.data(forKey: storageKey) else { + return nil + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var info = try decoder.decode(IncomingPaymentInfo.self, from: data) + + // Update state if expired + if info.isExpired && info.state == .pending { + info.state = .expired + info.save() + } + + return info + } catch { + print("IncomingPaymentInfo: Failed to load: \(error)") + return nil + } + } + + /// Clears the incoming payment info from shared storage + static func clear() { + appGroupUserDefaults?.removeObject(forKey: storageKey) + appGroupUserDefaults?.synchronize() + } + + /// Updates the state and saves + mutating func updateState(_ newState: ProcessingState) { + state = newState + save() + } +} diff --git a/Bitkit/Utilities/ScenePhase.swift b/Bitkit/Utilities/ScenePhase.swift index 69c70129..8b0b242f 100644 --- a/Bitkit/Utilities/ScenePhase.swift +++ b/Bitkit/Utilities/ScenePhase.swift @@ -8,6 +8,7 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var blocktank: BlocktankViewModel + @EnvironmentObject var pushManager: PushNotificationManager let sleepTime: UInt64 = 500_000_000 // 0.5 seconds @@ -41,17 +42,28 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { Logger.debug("Ended background task on app becoming active") } - if let transaction = ReceivedTxSheetDetails.load() { - // Background extension received a transaction + // Check for pending incoming payment from notification extension + // This takes priority over other processing + let pendingPayment = pushManager.checkForPendingPayment() + + // Also check for legacy received tx sheets + if pendingPayment == nil, let transaction = ReceivedTxSheetDetails.load() { ReceivedTxSheetDetails.clear() sheets.showSheet(.receivedTx, data: transaction) } + // Start node (priority for pending payments) do { try await startNodeIfNeeded() } catch { Logger.error(error, context: "Failed to start LN") } + + // Process incoming payment after node is running + if let payment = pendingPayment { + await handleIncomingPayment(payment) + } + Task { await currency.refresh() } @@ -64,6 +76,26 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { } } + /// Handles an incoming payment with priority processing + private func handleIncomingPayment(_ paymentInfo: IncomingPaymentInfo) async { + Logger.info("📩 Handling incoming payment: \(paymentInfo.paymentType.rawValue)") + + // If payment is expired, clear it and show appropriate message + if paymentInfo.isExpired { + Logger.warn("📩 Payment expired, showing toast") + pushManager.clearPendingPayment() + app.toast( + type: .error, + title: "Payment Expired", + description: "The payment window has closed. Ask the sender to try again." + ) + return + } + + // Process the payment + await pushManager.processIncomingPayment(paymentInfo, walletViewModel: wallet) + } + func stopNodeIfNeeded() async throws { if wallet.nodeLifecycleState == .stopped || wallet.nodeLifecycleState == .stopping { return diff --git a/Bitkit/Utilities/StateLocker.swift b/Bitkit/Utilities/StateLocker.swift index 72a1ea36..3a5cd2dd 100644 --- a/Bitkit/Utilities/StateLocker.swift +++ b/Bitkit/Utilities/StateLocker.swift @@ -1,5 +1,22 @@ import Foundation +// MARK: - StateLocker +// +// This utility manages cross-process locking for LDK node operations. +// +// NOTE: As of the push notification refactor (pnwd_001/pnwd_002), the notification +// service extension no longer starts the LDK node. Instead, it only decrypts the +// notification payload and displays an urgent notification. The main app handles +// all Lightning operations when the user opens the app. +// +// This means StateLocker is now primarily used for: +// 1. Preventing the app from starting the node while it's still stopping +// 2. Coordinating between foreground and background states within the main app +// +// Future consideration (pnwd_005): The background timing logic could be optimized +// to maximize the time the app stays alive in background while ensuring proper +// node shutdown. This would reduce node restart times when switching between apps. + enum StateLockerError: Error, Equatable { case alreadyLocked(processName: String) case differentEnvironmentLocked diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a3b878af..c6acab15 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -392,6 +392,9 @@ extension AppViewModel { await CoreService.shared.activity.markActivityAsSeen(id: paymentId) } + // Clear any pending incoming payment from push notification + PushNotificationManager.shared.markPaymentCompleted() + await MainActor.run { sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .lightning, sats: amountMsat / 1000)) } @@ -400,6 +403,9 @@ extension AppViewModel { // Only relevant for channels to external nodes break case .channelReady(let channelId, userChannelId: _, counterpartyNodeId: _, fundingTxo: _): + // Clear any pending incoming payment (for channel opening or CJIT) + PushNotificationManager.shared.markPaymentCompleted() + if let channel = lightningService.channels?.first(where: { $0.channelId == channelId }) { Task { let cjitOrder = try await CoreService.shared.blocktank.getCjit(channel: channel) diff --git a/BitkitNotification/NotificationService.swift b/BitkitNotification/NotificationService.swift index 07f7ac88..8feaa3c2 100644 --- a/BitkitNotification/NotificationService.swift +++ b/BitkitNotification/NotificationService.swift @@ -1,459 +1,412 @@ import BitkitCore -import LDKNode +import Foundation import os.log import UserNotifications +/// Lightweight notification service extension that handles incoming push notifications. +/// +/// IMPORTANT: This extension does NOT start the LDK node due to iOS memory (~24MB) and time (~30s) constraints. +/// Instead, it: +/// 1. Decrypts the notification payload +/// 2. Displays a time-sensitive notification with urgency messaging +/// 3. Saves payment info for the main app to process when opened +/// +/// The main app handles actual Lightning payment processing when the user opens it. class NotificationService: UNNotificationServiceExtension { - let walletIndex = 0 // Assume first wallet for now - var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? - var receiveTime: CFAbsoluteTime? - var nodeStartedTime: CFAbsoluteTime? - var lightningEventTime: CFAbsoluteTime? - var nodeStopTime: CFAbsoluteTime? - - var notificationType: BlocktankNotificationType? - var notificationPayload: [String: Any]? + /// Request identifier for scheduled removal + private var notificationIdentifier: String? - private lazy var notificationLogger: OSLog = { + private lazy var logger: OSLog = { let bundleID = Bundle.main.bundleIdentifier ?? "to.bitkit.notification" return OSLog(subsystem: bundleID, category: "NotificationService") }() - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - os_log("🚨 Push received! %{public}@", log: notificationLogger, type: .error, request.identifier) - os_log("🔔 UserInfo: %{public}@", log: notificationLogger, type: .error, request.content.userInfo) - - receiveTime = CFAbsoluteTimeGetCurrent() + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + os_log("🔔 Push received: %{public}@", log: logger, type: .info, request.identifier) self.contentHandler = contentHandler + self.notificationIdentifier = request.identifier bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - guard !StateLocker.isLocked(.lightning) else { - os_log("🔔 LDK-node process already locked, app likely in foreground", log: notificationLogger, type: .error) - return + Task { + await processNotification(request) } + } - Task { - // Ensure lock is released even if task is cancelled or errors occur - defer { - // Fallback: unlock in case stop() wasn't called - try? StateLocker.unlock(.lightning) - os_log("🔔 Task cleanup: ensured lock release", log: notificationLogger, type: .error) - } + /// Processes the incoming notification + private func processNotification(_ request: UNNotificationRequest) async { + // Try to decrypt the payload + let paymentInfo = await decryptAndParsePayload(request) - do { - try await self.decryptPayload(request) - os_log("🔔 Decryption successful. Type: %{public}@", log: notificationLogger, type: .error, self.notificationType?.rawValue ?? "nil") - } catch { - // Don't cancel the notification if this fails, rather let the node spin up and handle any potential events - os_log( - "🔔 Failed to decrypt notification payload: %{public}@", - log: notificationLogger, - type: .error, - error.localizedDescription - ) - } + if let paymentInfo { + // Save payment info for main app to pick up + paymentInfo.save() + os_log("🔔 Saved payment info: type=%{public}@, id=%{public}@", log: logger, type: .info, paymentInfo.paymentType.rawValue, paymentInfo.id) - do { - try await LightningService.shared.setup(walletIndex: self.walletIndex) - try await LightningService.shared.start { event in - self.lightningEventTime = CFAbsoluteTimeGetCurrent() - self.handleLdkEvent(event: event) - } - - self.nodeStartedTime = CFAbsoluteTimeGetCurrent() - os_log("🔔 Lightning node started successfully", log: notificationLogger, type: .error) - } catch { - self.bestAttemptContent?.title = "Lightning Error" - self.bestAttemptContent?.body = error.localizedDescription - - os_log( - "🔔 NotificationService: Failed to setup node in notification service: %{public}@", - log: notificationLogger, - type: .error, - error.localizedDescription - ) - self.dumpLdkLogs() - self.deliver() - } + // Configure notification with urgency messaging + configureNotificationContent(for: paymentInfo) - // Once node is started, handle the manual channel opening if needed - if self.notificationType == .orderPaymentConfirmed { - guard let orderId = notificationPayload?["orderId"] as? String else { - os_log("🔔 NotificationService: Missing orderId", log: notificationLogger, type: .error) - return - } - - guard let lspId = notificationPayload?["lspId"] as? String else { - os_log("🔔 NotificationService: Missing lspId", log: notificationLogger, type: .error) - return - } - - os_log( - "🔔 NotificationService: Open channel request for order %{public}@ with LSP %{public}@", - log: notificationLogger, - type: .error, - orderId, - lspId - ) - - do { - // First, ensure we're connected to the LSP peer - try await self.ensurePeerConnected(lspId: lspId) - - // Now open the channel - let order = try await CoreService.shared.blocktank.open(orderId: orderId) - os_log("🔔 NotificationService: Channel opened for order %{public}@", log: notificationLogger, type: .error, order.id) - } catch { - logError(error, context: "Failed to open channel") - - self.bestAttemptContent?.title = "Spending Balance Setup Ready" - self.bestAttemptContent?.body = "Tap to finalize transfer to spending" - - self.deliver() - } - } + // Schedule notification removal after expiry + scheduleNotificationRemoval(after: paymentInfo.timeRemaining, identifier: notificationIdentifier ?? request.identifier) + } else { + // Fallback: show generic notification if decryption fails + configureFallbackNotification() } - } - func decryptPayload(_ request: UNNotificationRequest) async throws { - guard let aps = request.content.userInfo["aps"] as? AnyObject else { - os_log("🔔 Failed to decrypt payload: missing aps payload", log: notificationLogger, type: .error) - return - } + deliver() + } - guard let alert = aps["alert"] as? AnyObject, - let payload = alert["payload"] as? AnyObject, + /// Decrypts and parses the notification payload + private func decryptAndParsePayload(_ request: UNNotificationRequest) async -> IncomingPaymentInfo? { + guard let aps = request.content.userInfo["aps"] as? [String: Any], + let alert = aps["alert"] as? [String: Any], + let payload = alert["payload"] as? [String: Any], let cipher = payload["cipher"] as? String, let iv = payload["iv"] as? String, let publicKey = payload["publicKey"] as? String, let tag = payload["tag"] as? String else { - os_log("🔔 Failed to decrypt payload: missing details", log: notificationLogger, type: .error) - return + os_log("🔔 Missing payload structure in notification", log: logger, type: .error) + return nil } guard let ciphertext = Data(base64Encoded: cipher) else { - os_log("🔔 Failed to decrypt payload: failed to decode cipher", log: notificationLogger, type: .error) - return + os_log("🔔 Failed to decode cipher", log: logger, type: .error) + return nil } - guard let privateKey = try Keychain.load(key: .pushNotificationPrivateKey) else { - os_log("🔔 Failed to decrypt payload: missing pushNotificationPrivateKey", log: notificationLogger, type: .error) - return + guard let privateKey = try? Keychain.load(key: .pushNotificationPrivateKey) else { + os_log("🔔 Missing pushNotificationPrivateKey in keychain", log: logger, type: .error) + return nil } - let password = try Crypto.generateSharedSecret(privateKey: privateKey, nodePubkey: publicKey, derivationName: "bitkit-notifications") - let decrypted = try Crypto.decrypt(.init(cipher: ciphertext, iv: iv.hexaData, tag: tag.hexaData), secretKey: password) + do { + let password = try Crypto.generateSharedSecret( + privateKey: privateKey, + nodePubkey: publicKey, + derivationName: "bitkit-notifications" + ) + + let decrypted = try Crypto.decrypt( + .init(cipher: ciphertext, iv: iv.hexaData, tag: tag.hexaData), + secretKey: password + ) - os_log("🔔 Decrypted payload: %{public}@", log: notificationLogger, type: .error, String(data: decrypted, encoding: .utf8) ?? "") + os_log("🔔 Payload decrypted successfully", log: logger, type: .info) - guard let jsonData = try JSONSerialization.jsonObject(with: decrypted, options: []) as? [String: Any] else { - os_log("🔔 Failed to decrypt payload: failed to convert decrypted data to utf8", log: notificationLogger, type: .error) - return + return parseDecryptedPayload(decrypted) + } catch { + os_log("🔔 Decryption failed: %{public}@", log: logger, type: .error, error.localizedDescription) + return nil } + } - guard let payload = jsonData["payload"] as? [String: Any] else { - os_log("🔔 Failed to decrypt payload: missing payload", log: notificationLogger, type: .error) - return + /// Parses the decrypted JSON payload into IncomingPaymentInfo + private func parseDecryptedPayload(_ data: Data) -> IncomingPaymentInfo? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + os_log("🔔 Failed to parse decrypted JSON", log: logger, type: .error) + return nil } - guard let typeStr = jsonData["type"] as? String, let type = BlocktankNotificationType(rawValue: typeStr) else { - os_log("🔔 Failed to decrypt payload: missing type", log: notificationLogger, type: .error) - return + guard let typeStr = json["type"] as? String else { + os_log("🔔 Missing 'type' in decrypted payload", log: logger, type: .error) + return nil } - notificationType = type - notificationPayload = payload + let payload = json["payload"] as? [String: Any] + + let paymentType = IncomingPaymentInfo.PaymentType(from: typeStr) + let paymentHash = payload?["paymentHash"] as? String + let orderId = payload?["orderId"] as? String + let lspId = payload?["lspId"] as? String + let amountMsat = payload?["amountMsat"] as? UInt64 + + os_log( + "🔔 Parsed payload: type=%{public}@, paymentHash=%{public}@", + log: logger, + type: .info, + typeStr, + paymentHash ?? "nil" + ) + + return IncomingPaymentInfo( + paymentType: paymentType, + paymentHash: paymentHash, + orderId: orderId, + lspId: lspId, + amountMsat: amountMsat + ) } - /// Listen for LDK events and if the event matches the notification type then deliver the notification - /// - Parameter event - func handleLdkEvent(event: Event) { - os_log("🔔 New LDK event: %{public}@", log: notificationLogger, type: .error, String(describing: event)) - - switch event { - case let .paymentReceived(_, paymentHash, amountMsat, _): - // For incomingHtlc notifications, only show notification if payment hash matches - if notificationType == .incomingHtlc { - guard let expectedPaymentHash = notificationPayload?["paymentHash"] as? String else { - os_log("🔔 NotificationService: Missing paymentHash in notification payload", log: notificationLogger, type: .error) - return - } - - // Only process if this is the payment we're waiting for - guard paymentHash == expectedPaymentHash else { - os_log( - "🔔 NotificationService: Payment hash mismatch. Expected: %{public}@, Got: %{public}@", - log: notificationLogger, - type: .error, - expectedPaymentHash, - paymentHash - ) - return - } - } + /// Configures the notification content with urgency messaging + private func configureNotificationContent(for paymentInfo: IncomingPaymentInfo) { + guard let content = bestAttemptContent else { return } - let sats = amountMsat / 1000 - bestAttemptContent?.title = "Payment Received" - bestAttemptContent?.body = "₿ \(sats)" + content.title = paymentInfo.notificationTitle + content.body = paymentInfo.notificationBody + content.sound = .default - if notificationType == .incomingHtlc { - deliver() - } - case .channelPending: - bestAttemptContent?.title = "Spending Balance Ready" - bestAttemptContent?.body = "Pending" - // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel - case let .channelReady(channelId, _, _, _): - if notificationType == .cjitPaymentArrived { - bestAttemptContent?.title = "Payment Received" - bestAttemptContent?.body = "Your funds arrived in your spending balance" - - os_log("🔔 NotificationService: cjitPaymentArrived", log: notificationLogger, type: .error) - - if let channel = LightningService.shared.channels?.first(where: { $0.channelId == channelId }) { - os_log("🔔 NotificationService: Channel found", log: notificationLogger, type: .error) - let sats = channel.outboundCapacityMsat / 1000 + (channel.unspendablePunishmentReserve ?? 0) - bestAttemptContent?.title = "Payment Received" - bestAttemptContent?.body = "₿ \(sats)" - ReceivedTxSheetDetails(type: .lightning, sats: sats).save() // Save for UI to pick up - - // Add activity item for CJIT payment - Task { - do { - let cjitOrder = await CoreService.shared.blocktank.getCjit(channel: channel) - if let cjitOrder { - let now = UInt64(Date().timeIntervalSince1970) - - let ln = LightningActivity( - id: channel.fundingTxo?.txid.description ?? "", - txType: .received, - status: .succeeded, - value: sats, - fee: 0, - invoice: cjitOrder.invoice.request, - message: "", - timestamp: now, - preimage: nil, - createdAt: now, - updatedAt: nil, - seenAt: nil - ) - - try await CoreService.shared.activity.insert(.lightning(ln)) - os_log("🔔 NotificationService: Added CJIT activity item", log: notificationLogger, type: .error) - } - } catch { - os_log( - "🔔 NotificationService: Failed to add CJIT activity: %{public}@", - log: notificationLogger, - type: .error, - error.localizedDescription - ) - } - } - } - - deliver() - } else if notificationType == .orderPaymentConfirmed { - bestAttemptContent?.title = "Spending Balance Ready" - bestAttemptContent?.body = "Open Bitkit to start paying anyone, anywhere." - deliver() - } - case .channelClosed: - if notificationType == .mutualClose { - bestAttemptContent?.title = "Spending Balance Expired" - bestAttemptContent?.body = "Your funds moved from spending to savings" - deliver() - } else if notificationType == .orderPaymentConfirmed { - bestAttemptContent?.title = "Spending Balance Setup Failed" - bestAttemptContent?.body = "Please open Bitkit and try again" - deliver() - } - case .paymentSuccessful: - break - case .paymentClaimable: - break - case let .paymentFailed(_, _, reason): - bestAttemptContent?.title = "Payment Failed" - bestAttemptContent?.body = reason.debugDescription - - if notificationType == .wakeToTimeout { - deliver() - } - case .paymentForwarded: - break - - // MARK: New Onchain Transaction Events - - case let .onchainTransactionReceived(_, details): - // Show notification for incoming onchain transactions - if details.amountSats > 0 { - let sats = UInt64(abs(Int64(details.amountSats))) - bestAttemptContent?.title = "Payment Received" - bestAttemptContent?.body = "₿ \(sats) (unconfirmed)" - ReceivedTxSheetDetails(type: .onchain, sats: sats).save() // Save for UI to pick up - deliver() - } - case .onchainTransactionConfirmed: - // // Transaction confirmed - could show notification if it was previously unconfirmed - // if details.amountSats > 0 { - // let sats = UInt64(abs(Int64(details.amountSats))) - // bestAttemptContent?.title = "Payment Confirmed" - // bestAttemptContent?.body = "₿ \(sats) confirmed at block \(blockHeight)" - // deliver() - // } - break - case .onchainTransactionReplaced, .onchainTransactionReorged, .onchainTransactionEvicted: - // These events are less critical for notifications, but could be logged - os_log("🔔 Onchain transaction state changed: %{public}@", log: notificationLogger, type: .error, String(describing: event)) - - // MARK: Sync Events - - case .syncProgress, .syncCompleted: - // Sync events are not critical for notifications - break - - // MARK: Balance Events - - case .balanceChanged: - // Balance changes are handled by other events, not critical for notifications - break - - // MARK: Splice Events - - case .splicePending, .spliceFailed: - break + // Set time-sensitive interruption level for urgent notifications (iOS 15+) + if #available(iOS 15.0, *) { + content.interruptionLevel = .timeSensitive + content.relevanceScore = 1.0 } + + // Add category for potential custom actions + content.categoryIdentifier = "INCOMING_PAYMENT" + + // Store payment info ID in userInfo for the app to reference + var userInfo = content.userInfo + userInfo["incomingPaymentId"] = paymentInfo.id + userInfo["paymentType"] = paymentInfo.paymentType.rawValue + content.userInfo = userInfo + + os_log("🔔 Configured notification: title=%{public}@", log: logger, type: .info, content.title) } - func deliver() { - Task { - // Sleep to allow event to be processed - try? await Task.sleep(nanoseconds: 1_000_000_000) - try? await LightningService.shared.stop() - - self.nodeStopTime = CFAbsoluteTimeGetCurrent() - self.logPerformance() - - if let contentHandler, let bestAttemptContent { - contentHandler(bestAttemptContent) - os_log("🔔 Notification delivered successfully", log: notificationLogger, type: .error) - } else { - os_log("🔔 Missing contentHandler or bestAttemptContent", log: notificationLogger, type: .error) - } + /// Configures a fallback notification when decryption fails + private func configureFallbackNotification() { + guard let content = bestAttemptContent else { return } + + content.title = "Bitkit" + content.body = "Open Bitkit to check for new activity" + content.sound = .default + + if #available(iOS 15.0, *) { + content.interruptionLevel = .timeSensitive } - } - func logPerformance() { - guard let receiveTime else { return } - guard let nodeStartedTime else { return } + os_log("🔔 Using fallback notification content", log: logger, type: .info) + } - let nodeStartSeconds = Double(round(100 * (nodeStartedTime - receiveTime)) / 100) - os_log("⏱️ Node start time: %{public}f seconds", log: notificationLogger, type: .error, nodeStartSeconds) + /// Schedules the notification to be removed after expiry + /// This prevents stale notifications since the payment window has closed + private func scheduleNotificationRemoval(after delay: TimeInterval, identifier: String) { + guard delay > 0 else { + os_log("🔔 Payment already expired, removing immediately", log: logger, type: .info) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + return + } - guard let lightningEventTime else { return } + // Schedule removal slightly after expiry to ensure it's shown until the last moment + let removalDelay = delay + 5 // Add 5 seconds buffer - let lightningEventSeconds = Double(round(100 * (lightningEventTime - nodeStartedTime)) / 100) - os_log("⏱️ Lightning event time: %{public}f seconds from node startup", log: notificationLogger, type: .error, lightningEventSeconds) + os_log("🔔 Scheduling notification removal in %.0f seconds", log: logger, type: .info, removalDelay) - guard let nodeStopTime else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + removalDelay) { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + os_log("🔔 Removed expired notification: %{public}@", log: self.logger, type: .info, identifier) + } + } - let nodeStopSeconds = Double(round(100 * (nodeStopTime - lightningEventTime)) / 100) - os_log("⏱️ Node stop time: %{public}f seconds from lightning event", log: notificationLogger, type: .error, nodeStopSeconds) + /// Delivers the notification to the user + private func deliver() { + if let contentHandler, let content = bestAttemptContent { + contentHandler(content) + os_log("🔔 Notification delivered", log: logger, type: .info) + } else { + os_log("🔔 Failed to deliver: missing handler or content", log: logger, type: .error) + } } - func dumpLdkLogs() { - let dir = Env.ldkStorage(walletIndex: walletIndex) - let fileURL = dir.appendingPathComponent("ldk_node_latest.log") + override func serviceExtensionTimeWillExpire() { + os_log("🔔 Extension time expiring, delivering best attempt", log: logger, type: .info) - do { - let text = try String(contentsOf: fileURL, encoding: .utf8) - let lines = text.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - os_log("📋 LDK-NODE LOG (last 20 lines):", log: notificationLogger, type: .error) - for line in lines.suffix(20) { - os_log("📋 %{public}@", log: notificationLogger, type: .error, line) + // Deliver whatever we have before the system terminates us + if let contentHandler, let content = bestAttemptContent { + // If we haven't configured the content yet, use fallback + if content.title.isEmpty { + configureFallbackNotification() } - } catch { - os_log("🔔 Failed to load LDK log file: %{public}@", log: notificationLogger, type: .error, error.localizedDescription) + contentHandler(content) } } +} - override func serviceExtensionTimeWillExpire() { - os_log("🔔 NotificationService: Delivering notification before timeout", log: notificationLogger, type: .error) +// MARK: - Keychain Access (Shared with main app via App Group) - // Try to stop node and release lock before termination - Task { - try? await LightningService.shared.stop() - } +/// Minimal Keychain access for the notification extension +/// Mirrors the main app's Keychain class but only includes what's needed +private enum KeychainEntryType { + case pushNotificationPrivateKey - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler, let bestAttemptContent { - contentHandler(bestAttemptContent) + var storageKey: String { + switch self { + case .pushNotificationPrivateKey: + return "push_notification_private_key" } } +} + +private class Keychain { + /// Keychain access group shared between app and extension + private static let keychainGroup = "KYH47R284B.to.bitkit" + + class func load(key: KeychainEntryType) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.storageKey, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: keychainGroup, + ] - /// Ensures the peer is connected before attempting to open a channel - /// - Parameter lspId: The LSP node ID to connect to - private func ensurePeerConnected(lspId: String) async throws { - // Find the peer in trusted peers list - guard let peer = Env.trustedLnPeers.first(where: { $0.nodeId == lspId }) else { - os_log("🔔 NotificationService: LSP %{public}@ not found in trusted peers", log: notificationLogger, type: .error, lspId) - throw AppError(message: "LSP not found in trusted peers", debugMessage: "LSP ID: \(lspId)") + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecItemNotFound { + return nil } - // Check if already connected - if let peers = LightningService.shared.peers, peers.contains(where: { $0.nodeId == lspId }) { - os_log("🔔 NotificationService: Already connected to LSP %{public}@", log: notificationLogger, type: .error, lspId) - return + guard status == noErr else { + throw KeychainError.failedToLoad } - // Connect to the peer - os_log("🔔 NotificationService: Connecting to LSP %{public}@ at %{public}@", log: notificationLogger, type: .error, lspId, peer.address) - try await LightningService.shared.connectPeer(peer: peer) + return dataTypeRef as? Data + } - // Wait for connection to be established (with timeout) - let maxWaitTime: TimeInterval = 10.0 - let pollInterval: TimeInterval = 0.5 - let startTime = Date() + enum KeychainError: Error { + case failedToLoad + } +} - while Date().timeIntervalSince(startTime) < maxWaitTime { - if let peers = LightningService.shared.peers, peers.contains(where: { $0.nodeId == lspId }) { - os_log("🔔 NotificationService: Successfully connected to LSP %{public}@", log: notificationLogger, type: .error, lspId) - return - } +// MARK: - IncomingPaymentInfo (Shared with main app via App Group) + +/// Represents an incoming payment notification that needs to be processed by the app. +/// NOTE: This must be kept in sync with the main app's IncomingPaymentInfo model. +private struct IncomingPaymentInfo: Codable { + enum PaymentType: String, Codable { + case incomingHtlc + case cjitPaymentArrived + case orderPaymentConfirmed + case mutualClose + case wakeToTimeout + case unknown + + init(from blocktankType: String) { + self = PaymentType(rawValue: blocktankType) ?? .unknown + } + } - try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + enum ProcessingState: String, Codable { + case pending + case processing + case completed + case expired + case failed + } + + let id: String + let paymentType: PaymentType + let paymentHash: String? + let orderId: String? + let lspId: String? + let amountMsat: UInt64? + let receivedAt: Date + let expiresAt: Date + var state: ProcessingState + + var notificationTitle: String { + switch paymentType { + case .incomingHtlc, .cjitPaymentArrived: + return "Incoming Payment" + case .orderPaymentConfirmed: + return "Spending Balance Ready" + case .mutualClose: + return "Channel Closing" + case .wakeToTimeout: + return "Payment Pending" + case .unknown: + return "Notification" } + } - // Timeout - check one more time - if let peers = LightningService.shared.peers, peers.contains(where: { $0.nodeId == lspId }) { - os_log( - "🔔 NotificationService: Successfully connected to LSP %{public}@ (after timeout check)", - log: notificationLogger, - type: .error, - lspId - ) - return + var notificationBody: String { + switch paymentType { + case .incomingHtlc, .cjitPaymentArrived: + return "Open Bitkit now to receive your payment" + case .orderPaymentConfirmed: + return "Open Bitkit now to complete setup" + case .mutualClose: + return "Your spending balance is being transferred" + case .wakeToTimeout: + return "Open Bitkit to process pending payment" + case .unknown: + return "Open Bitkit to continue" } + } - os_log("🔔 NotificationService: Timeout waiting for LSP connection %{public}@", log: notificationLogger, type: .error, lspId) - throw AppError(message: "Failed to connect to LSP", debugMessage: "Timeout after \(maxWaitTime)s waiting for peer \(lspId)") + static let defaultExpiryDuration: TimeInterval = 2 * 60 + + private static let storageKey = "incomingPaymentInfo" + private static let appGroupUserDefaults = UserDefaults(suiteName: "group.bitkit") + + init( + paymentType: PaymentType, + paymentHash: String? = nil, + orderId: String? = nil, + lspId: String? = nil, + amountMsat: UInt64? = nil, + expiryDuration: TimeInterval = IncomingPaymentInfo.defaultExpiryDuration + ) { + self.id = UUID().uuidString + self.paymentType = paymentType + self.paymentHash = paymentHash + self.orderId = orderId + self.lspId = lspId + self.amountMsat = amountMsat + self.receivedAt = Date() + self.expiresAt = Date().addingTimeInterval(expiryDuration) + self.state = .pending } - /// Logs comprehensive error details - private func logError(_ error: Error, context: String) { - os_log( - "❌ %{public}@: %{public}@", - log: notificationLogger, - type: .error, - context, - String(describing: error) - ) + var timeRemaining: TimeInterval { + max(0, expiresAt.timeIntervalSinceNow) + } + + func save() { + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(self) + Self.appGroupUserDefaults?.set(data, forKey: Self.storageKey) + Self.appGroupUserDefaults?.synchronize() + } catch { + print("IncomingPaymentInfo: Failed to save: \(error)") + } + } +} + +// MARK: - String Extension for Hex Conversion + +private extension String { + var hexaData: Data { + var data = Data() + var hex = self + + // Remove any spaces or non-hex characters + hex = hex.replacingOccurrences(of: " ", with: "") + + // Ensure even number of characters + if hex.count % 2 != 0 { + hex = "0" + hex + } + + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + if let byte = UInt8(hex[index ..< nextIndex], radix: 16) { + data.append(byte) + } + index = nextIndex + } + + return data } } From cdd8259b7a249751e5588dc826e5b148842a055a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 11 Jan 2026 23:34:03 +0100 Subject: [PATCH 2/2] feat: incoming payment sheet --- Bitkit/BitkitApp.swift | 22 ++ Bitkit/MainNavView.swift | 8 + Bitkit/Utilities/ScenePhase.swift | 16 +- Bitkit/ViewModels/SheetViewModel.swift | 14 ++ .../Views/Transfer/IncomingPaymentView.swift | 233 ++++++++++++++++++ 5 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 Bitkit/Views/Transfer/IncomingPaymentView.swift diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index f16d8603..e1355381 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -14,6 +14,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { { UNUserNotificationCenter.current().delegate = self + // Register notification categories for custom actions + registerNotificationCategories() + // Check notification authorization status at launch and re-register with APN if granted UNUserNotificationCenter.current().getNotificationSettings { settings in if settings.authorizationStatus == .authorized { @@ -44,6 +47,25 @@ class AppDelegate: NSObject, UIApplicationDelegate { func applicationWillTerminate(_ application: UIApplication) { try? StateLocker.unlock(.lightning) } + + // MARK: - Notification Categories + + private func registerNotificationCategories() { + let openAction = UNNotificationAction( + identifier: "OPEN_NOW", + title: "Open Now", + options: [.foreground] + ) + + let incomingPayment = UNNotificationCategory( + identifier: "INCOMING_PAYMENT", + actions: [openAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + UNUserNotificationCenter.current().setNotificationCategories([incomingPayment]) + } } // MARK: - Push Notifications diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 5320ecf9..f43e3430 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -72,6 +72,14 @@ struct MainNavView: View { ) { config in HighBalanceSheet(config: config) } + .sheet( + item: $sheets.incomingPaymentSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in IncomingPaymentSheet(config: config) + } .sheet( item: $sheets.lnurlAuthSheetItem, onDismiss: { diff --git a/Bitkit/Utilities/ScenePhase.swift b/Bitkit/Utilities/ScenePhase.swift index 8b0b242f..1f2be479 100644 --- a/Bitkit/Utilities/ScenePhase.swift +++ b/Bitkit/Utilities/ScenePhase.swift @@ -59,9 +59,9 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { Logger.error(error, context: "Failed to start LN") } - // Process incoming payment after node is running + // Show incoming payment UI after node is running if let payment = pendingPayment { - await handleIncomingPayment(payment) + handleIncomingPayment(payment) } Task { @@ -76,8 +76,8 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { } } - /// Handles an incoming payment with priority processing - private func handleIncomingPayment(_ paymentInfo: IncomingPaymentInfo) async { + /// Handles an incoming payment by showing the dedicated UI + private func handleIncomingPayment(_ paymentInfo: IncomingPaymentInfo) { Logger.info("📩 Handling incoming payment: \(paymentInfo.paymentType.rawValue)") // If payment is expired, clear it and show appropriate message @@ -86,14 +86,14 @@ private struct HandleLightningStateOnScenePhaseChange: ViewModifier { pushManager.clearPendingPayment() app.toast( type: .error, - title: "Payment Expired", - description: "The payment window has closed. Ask the sender to try again." + title: tTodo("Payment Expired"), + description: tTodo("The payment window has closed. Ask the sender to try again.") ) return } - // Process the payment - await pushManager.processIncomingPayment(paymentInfo, walletViewModel: wallet) + // Show dedicated incoming payment UI (processing happens inside the sheet) + sheets.showSheet(.incomingPayment, data: paymentInfo) } func stopNodeIfNeeded() async throws { diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 529b76b5..c47cf8e7 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -9,6 +9,7 @@ enum SheetID: String, CaseIterable { case forgotPin case gift case highBalance + case incomingPayment case lnurlAuth case lnurlWithdraw case notifications @@ -191,6 +192,19 @@ class SheetViewModel: ObservableObject { } } + var incomingPaymentSheetItem: IncomingPaymentSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .incomingPayment else { return nil } + guard let paymentInfo = config.data as? IncomingPaymentInfo else { return nil } + return IncomingPaymentSheetItem(paymentInfo: paymentInfo) + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var lnurlAuthSheetItem: LnurlAuthSheetItem? { get { guard let config = activeSheetConfiguration, config.id == .lnurlAuth else { return nil } diff --git a/Bitkit/Views/Transfer/IncomingPaymentView.swift b/Bitkit/Views/Transfer/IncomingPaymentView.swift new file mode 100644 index 00000000..8b1b5e33 --- /dev/null +++ b/Bitkit/Views/Transfer/IncomingPaymentView.swift @@ -0,0 +1,233 @@ +import SwiftUI + +// MARK: - Sheet Item + +struct IncomingPaymentSheetItem: SheetItem { + let id: SheetID = .incomingPayment + let size: SheetSize = .large + let paymentInfo: IncomingPaymentInfo +} + +// MARK: - Sheet Wrapper + +struct IncomingPaymentSheet: View { + let config: IncomingPaymentSheetItem + + var body: some View { + Sheet(item: config) { + IncomingPaymentView(paymentInfo: config.paymentInfo) + } + } +} + +// MARK: - Main View + +/// Shows progress while completing an incoming Lightning payment. +/// Triggered when user opens app from payment notification. +struct IncomingPaymentView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var pushManager: PushNotificationManager + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + let paymentInfo: IncomingPaymentInfo + + @State private var state: IncomingState = .connecting + + enum IncomingState { + case connecting + case completing + case completed(sats: UInt64) + case expired + case failed(String) + } + + var body: some View { + VStack(spacing: 0) { + Spacer() + + stateContent + + Spacer() + + actionButton + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await processPayment() + } + } + + // MARK: - State Content + + @ViewBuilder + private var stateContent: some View { + switch state { + case .connecting: + loadingContent( + title: tTodo("Connecting"), + subtitle: tTodo("Connecting to Lightning network...") + ) + + case .completing: + loadingContent( + title: tTodo("Receiving"), + subtitle: tTodo("Completing payment...") + ) + + case let .completed(sats): + successContent(sats: sats) + + case .expired: + errorContent( + icon: "exclamationmark.triangle", + iconColor: .orange, + title: tTodo("Payment Expired"), + subtitle: tTodo("Ask sender to retry") + ) + + case let .failed(message): + errorContent( + icon: "xmark.circle", + iconColor: .red, + title: tTodo("Processing Failed"), + subtitle: message + ) + } + } + + private func loadingContent(title: String, subtitle: String) -> some View { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + .tint(.purpleAccent) + + VStack(spacing: 8) { + DisplayText(title, accentColor: .purpleAccent) + .multilineTextAlignment(.center) + + BodyMText(subtitle) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 16) + } + + private func successContent(sats: UInt64) -> some View { + VStack(spacing: 24) { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 128, height: 128) + + VStack(spacing: 8) { + DisplayText(tTodo("Payment Received"), accentColor: .purpleAccent) + .multilineTextAlignment(.center) + + BodyMText(tTodo("Received \(sats.formatted()) sats")) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 16) + } + + private func errorContent(icon: String, iconColor: Color, title: String, subtitle: String) -> some View { + VStack(spacing: 24) { + Image(systemName: icon) + .font(.system(size: 64)) + .foregroundColor(iconColor) + + VStack(spacing: 8) { + DisplayText(title) + .multilineTextAlignment(.center) + + BodyMText(subtitle) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 16) + } + + // MARK: - Action Button + + @ViewBuilder + private var actionButton: some View { + switch state { + case .connecting, .completing: + // No button while processing + EmptyView() + + case .completed: + CustomButton(title: localizedRandom("common__ok_random")) { + sheets.hideSheet() + } + + case .expired: + CustomButton(title: tTodo("Close")) { + sheets.hideSheet() + } + + case .failed: + VStack(spacing: 12) { + CustomButton(title: tTodo("Retry")) { + Task { + await processPayment() + } + } + + CustomButton(title: tTodo("Close"), style: .outline) { + sheets.hideSheet() + } + } + } + } + + // MARK: - Payment Processing + + private func processPayment() async { + // Check expiry first + guard !paymentInfo.isExpired else { + state = .expired + pushManager.clearPendingPayment() + return + } + + state = .connecting + + // Process via manager (starts node, connects peer, etc.) + await pushManager.processIncomingPayment(paymentInfo, walletViewModel: wallet) + + // Wait for completion signal with timeout (30s) + state = .completing + for _ in 0 ..< 60 { + try? await Task.sleep(nanoseconds: 500_000_000) + if pushManager.pendingPaymentInfo == nil { + let sats = (paymentInfo.amountMsat ?? 0) / 1000 + state = .completed(sats: sats) + Haptics.notify(.success) + return + } + } + + state = .failed(tTodo("Payment processing timed out")) + } +} + +// MARK: - Previews + +#Preview("Connecting") { + IncomingPaymentSheet( + config: IncomingPaymentSheetItem( + paymentInfo: IncomingPaymentInfo( + paymentType: .incomingHtlc, + amountMsat: 100_000_000 + ) + ) + ) + .environmentObject(AppViewModel()) + .environmentObject(PushNotificationManager.shared) + .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) + .preferredColorScheme(.dark) +}