package com.bitcointxoko.gudariwallet.service import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.util.Log import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.WsPaymentMessage import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.util.NotificationConstants import com.bitcointxoko.gudariwallet.util.NotificationHelper import com.bitcointxoko.gudariwallet.util.WalletConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener private const val TAG = "WalletNotifService" /** * Foreground service that owns the single wallet-level WebSocket connection. * * Responsibilities: * - Keeps one WebSocket open to wss://{baseUrl}/api/v1/ws/{invoiceKey} * - Posts user-facing notifications for all payment events (in-app or external) * - Emits payment events to [paymentEvents] SharedFlow so the ViewModel can * update the UI when the app is foregrounded — zero extra network cost * - Reconnects with exponential backoff on failure * - Survives process kill via START_REDELIVER_INTENT * * Lifetime: starts once after onboarding completes, stops only on logout. * Never started or stopped by invoice creation. */ class WalletNotificationService : Service() { private val paymentCache by lazy { PaymentCacheRepository(applicationContext) } // ── Companion: shared state accessible by ViewModel ─────────────────────── companion object { /** * Payment events emitted by the service. * The ViewModel collects this flow to update UI state when the app is * in the foreground. replay=0 means no stale events are delivered to * late collectors — a payment that arrived while the app was closed * is communicated via the notification, not via this flow. */ private val _paymentEvents = MutableSharedFlow(replay = 0) val paymentEvents: SharedFlow = _paymentEvents.asSharedFlow() /** Convenience wrapper — use instead of constructing the Intent manually. */ fun start(context: Context) { context.startForegroundService(Intent(context, WalletNotificationService::class.java)) } /** Convenience wrapper — stops the service and closes the WebSocket. */ fun stop(context: Context) { context.stopService(Intent(context, WalletNotificationService::class.java)) } } /** * Represents a payment event received from the wallet-level WebSocket. * Emitted to [paymentEvents] for in-process UI updates. */ data class PaymentEvent( val walletBalance: Long?, val amountSats: Long, val memo: String?, val isOutgoing: Boolean, val paymentHash: String ) // ── Service internals ───────────────────────────────────────────────────── // SupervisorJob ensures one failing coroutine doesn't cancel the others. private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var webSocket: WebSocket? = null private var reconnectJob: Job? = null private var reconnectAttempts = 0 private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS private val gson = GsonProvider.gson // ── Lifecycle ───────────────────────────────────────────────────────────── override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Step 1: Call startForeground immediately — Android requires this within // 5 seconds of startForegroundService() or the app is ANR'd. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { startForeground( NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this) ) } // Step 2: Open the WebSocket. If already open (e.g. service restarted // by Android after a kill), close the old one first. serviceScope.launch { openWebSocket() } // START_REDELIVER_INTENT: if Android kills the service under memory // pressure, it will restart it and re-deliver the last intent. // This ensures the service always has credentials to reconnect. return START_REDELIVER_INTENT } override fun onDestroy() { super.onDestroy() Log.d(TAG, "Service destroyed — closing WebSocket") reconnectJob?.cancel() webSocket?.close(WalletConstants.WS_CLOSE_NORMAL, "Service stopped") webSocket = null serviceScope.cancel() } // onBind returns null — this is a started service, not a bound service. // Communication with the ViewModel happens via the companion SharedFlow. override fun onBind(intent: Intent?): IBinder? = null // ── WebSocket ───────────────────────────────────────────────────────────── private suspend fun openWebSocket() { val secrets = EncryptedSecretStore(applicationContext) val invoiceKey = secrets.invoiceKey() val baseUrl = secrets.baseUrl() if (invoiceKey.isBlank() || baseUrl.isBlank()) { Log.e(TAG, "Credentials not available — cannot open WebSocket") stopSelf() return } val wsUrl = baseUrl .replace("https://", "wss://") .replace("http://", "ws://") .trimEnd('/') + "/api/v1/ws/${invoiceKey}" Log.d(TAG, "Opening WebSocket: $wsUrl") val request = Request.Builder().url(wsUrl).build() webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { Log.d(TAG, "WebSocket connected") // Reset backoff on successful connection reconnectAttempts = 0 currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS } override fun onMessage(webSocket: WebSocket, text: String) { Log.d(TAG, "WebSocket message: $text") handleMessage(text) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket failure (attempt $reconnectAttempts): ${t.message}") scheduleReconnect() } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket closing: $code $reason") webSocket.close(WalletConstants.WS_CLOSE_NORMAL, null) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket closed: $code $reason") // Only reconnect on unexpected closes (not our own WS_CLOSE_NORMAL) if (code != WalletConstants.WS_CLOSE_NORMAL) { scheduleReconnect() } } }) } private fun handleMessage(text: String) { val msg = runCatching { gson.fromJson(text, WsPaymentMessage::class.java) }.getOrNull() ?: return val payment = msg.payment ?: return // Only act on successful payments — ignore pending/failed events if (payment.status != "success") return val amountSats = kotlin.math.abs(payment.amount) / WalletConstants.MSAT_PER_SAT val isOutgoing = payment.amount < 0 // Post notification — works whether app is foreground or background if (isOutgoing) { NotificationHelper.notifyPaymentSent( context = applicationContext, amountSats = amountSats, paymentHash = payment.paymentHash ) } else { NotificationHelper.notifyPaymentReceived( context = applicationContext, amountSats = amountSats, memo = payment.memo, paymentHash = payment.paymentHash ) } // Emit to SharedFlow — ViewModel collects this to update UI in real time // when the app is foregrounded. Fire-and-forget; no suspension needed. serviceScope.launch { _paymentEvents.emit( PaymentEvent( walletBalance = msg.walletBalance, amountSats = amountSats, memo = payment.memo, isOutgoing = isOutgoing, paymentHash = payment.paymentHash ) ) } // Persist the incoming payment directly to DB // so the cache is fresh even if the History screen isn't open serviceScope.launch { paymentCache.upsertPayment( PaymentRecord( checkingId = payment.checkingId, paymentHash = payment.paymentHash, amountMsat = payment.amount, feeMsat = payment.fee, memo = payment.memo, time = payment.time, status = payment.status, bolt11 = payment.bolt11, pending = false, // we only act on status == "success" preimage = payment.preimage, extra = payment.extra ) ) } } private fun scheduleReconnect() { if (reconnectAttempts >= WalletConstants.WS_MAX_ATTEMPTS) { Log.e(TAG, "WebSocket gave up after ${WalletConstants.WS_MAX_ATTEMPTS} attempts — service will wait for next start") // Do not stop the service — it will reconnect on next onStartCommand // (e.g. when the app is brought to foreground and MainActivity calls start()) return } val backoff = currentBackoffMs reconnectAttempts++ currentBackoffMs = (currentBackoffMs * 2).coerceAtMost(WalletConstants.WS_MAX_BACKOFF_MS) Log.d(TAG, "Reconnecting in ${backoff}ms (attempt $reconnectAttempts of ${WalletConstants.WS_MAX_ATTEMPTS})") reconnectJob?.cancel() reconnectJob = serviceScope.launch { delay(backoff) openWebSocket() } } }