rename example
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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.WsPaymentMessage
|
||||
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 com.google.gson.Gson
|
||||
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() {
|
||||
|
||||
// ── 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 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.
|
||||
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 fun openWebSocket() {
|
||||
val secrets = EncryptedSecretStore(applicationContext)
|
||||
|
||||
if (secrets.invoiceKey.isBlank() || secrets.baseUrl.isBlank()) {
|
||||
Log.e(TAG, "Credentials not available — cannot open WebSocket")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val wsUrl = secrets.baseUrl
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
.trimEnd('/') + "/api/v1/ws/${secrets.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(
|
||||
amountSats = amountSats,
|
||||
memo = payment.memo,
|
||||
isOutgoing = isOutgoing,
|
||||
paymentHash = payment.paymentHash
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user