283 lines
11 KiB
Kotlin
283 lines
11 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 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<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 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()
|
|
}
|
|
}
|
|
} |