Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Bitkit/BitkitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
195 changes: 195 additions & 0 deletions Bitkit/Managers/PushNotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import SwiftUI

enum PushNotificationError: Error {
case deviceTokenNotAvailable
case paymentExpired
case nodeNotReady
case processingFailed(String)
}

final class PushNotificationManager: ObservableObject {
static let shared = PushNotificationManager()
@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() {
Expand Down Expand Up @@ -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 {
Expand Down
Loading