Files
gudari/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt
T
2026-06-12 18:15:04 +02:00

279 lines
12 KiB
Kotlin

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 timber.log.Timber
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
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
import kotlin.time.Duration.Companion.milliseconds
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<PaymentEvent>(replay = 0)
val paymentEvents: SharedFlow<PaymentEvent> = _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 json = JsonProvider.json
// ── 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.UPSIDE_DOWN_CAKE) { // API 34
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else 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()
Timber.d("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()) {
Timber.e("Credentials not available — cannot open WebSocket")
stopSelf()
return
}
val wsUrl = baseUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + "/api/v1/ws/${invoiceKey}"
Timber.d("Opening WebSocket: $wsUrl")
val request = Request.Builder().url(wsUrl).build()
webSocket = LNbitsHttpClient.instance.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("WebSocket connected")
// Reset backoff on successful connection
reconnectAttempts = 0
currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
}
override fun onMessage(webSocket: WebSocket, text: String) {
Timber.d("WebSocket message: $text")
handleMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Timber.e("WebSocket failure (attempt $reconnectAttempts): ${t.message}")
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Timber.d("WebSocket closing: $code $reason")
webSocket.close(WalletConstants.WS_CLOSE_NORMAL, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Timber.d("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 {
json.decodeFromString(WsPaymentMessage.serializer(), text)
}.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) {
Timber.e("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)
Timber.d("Reconnecting in ${backoff}ms (attempt $reconnectAttempts of ${WalletConstants.WS_MAX_ATTEMPTS})")
reconnectJob?.cancel()
reconnectJob = serviceScope.launch {
delay(backoff.milliseconds)
openWebSocket()
}
}
}