rename example

This commit is contained in:
2026-06-01 03:09:19 +02:00
parent 7f3d14a764
commit b5473bfafc
59 changed files with 261 additions and 182 deletions
@@ -0,0 +1,184 @@
package com.bitcointxoko.gudariwallet.service
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "NodeAliasService"
class NodeAliasService(
private val httpClient : OkHttpClient,
private val context : Context
) {
// ── Persistence ───────────────────────────────────────────────────────────
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.ALIAS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// ── In-memory alias cache ─────────────────────────────────────────────────
private data class CachedAlias(val alias: String, val fetchedAt: Long)
// Type token for Gson deserialisation
private val cacheType = object : TypeToken<Map<String, CachedAlias>>() {}.type
private val cache = ConcurrentHashMap<String, CachedAlias>()
// ── Shared alias StateFlow — all screens collect this ─────────────────────
private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap())
val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow()
// ── Init — load persisted cache from SharedPreferences ────────────────────
init {
loadPersistedCache()
}
private fun loadPersistedCache() {
val json = prefs.getString(WalletConstants.ALIAS_PREFS_KEY, null) ?: run {
Log.d(TAG, "ALIAS [PERSIST ] No persisted cache found — starting fresh")
return
}
runCatching {
val persisted: Map<String, CachedAlias> = gson.fromJson(json, cacheType)
val now = System.currentTimeMillis()
var loaded = 0
var dropped = 0
persisted.forEach { (pubkey, entry) ->
val ageMs = now - entry.fetchedAt
if (ageMs < WalletConstants.ALIAS_PERSIST_TTL_MS) {
cache[pubkey] = entry
loaded++
} else {
dropped++
Log.d(TAG, "ALIAS [PERSIST ] Dropped stale entry for $pubkey (age ${ageMs / 3_600_000}h)")
}
}
// Seed the StateFlow with everything we loaded
_aliases.value = cache.mapValues { it.value.alias }
Log.d(TAG, "ALIAS [PERSIST ] Loaded $loaded entries, dropped $dropped stale entries")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to deserialise cache — starting fresh: ${e.message}")
}
}
private fun persistCache() {
runCatching {
val json = gson.toJson(cache)
prefs.edit().putString(WalletConstants.ALIAS_PREFS_KEY, json).apply()
Log.d(TAG, "ALIAS [PERSIST ] Wrote ${cache.size} entries to SharedPreferences")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to write cache: ${e.message}")
}
}
// ── Public API ────────────────────────────────────────────────────────────
suspend fun resolve(pubkey: String): String? {
val now = System.currentTimeMillis()
cache[pubkey]?.let { entry ->
if (now - entry.fetchedAt < WalletConstants.ALIAS_CACHE_TTL_MS) {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${(now - entry.fetchedAt) / 1000}s)")
return entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${(now - entry.fetchedAt) / 1000}s) — refetching")
}
} ?: Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey — fetching from network")
val alias = fetchFromNetwork(pubkey)
if (alias == null) {
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey — no alias from mempool or 1ml")
return null
}
// Update in-memory cache
cache[pubkey] = CachedAlias(alias = alias, fetchedAt = System.currentTimeMillis())
// Publish to shared StateFlow
_aliases.value = _aliases.value + (pubkey to alias)
// Persist to SharedPreferences
persistCache()
Log.d(TAG, "ALIAS [API FETCHED] $pubkey\"$alias\"")
return alias
}
/** Clears in-memory cache, StateFlow, and persisted SharedPreferences entry. */
fun clearPersistedCache() {
cache.clear()
_aliases.value = emptyMap()
prefs.edit().remove(WalletConstants.ALIAS_PREFS_KEY).apply()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
// ── Private helpers ───────────────────────────────────────────────────────
private suspend fun fetchFromNetwork(pubkey: String): String? = withContext(Dispatchers.IO) {
Log.d(TAG, "ALIAS [TRYING ] mempool.space for $pubkey")
val mempoolResult = fetchFromMempool(pubkey)
if (mempoolResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] mempool.space resolved $pubkey\"$mempoolResult\"")
return@withContext mempoolResult
}
Log.d(TAG, "ALIAS [TRYING ] 1ml.com fallback for $pubkey")
val mlResult = fetchFrom1ml(pubkey)
if (mlResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] 1ml.com resolved $pubkey\"$mlResult\"")
}
mlResult
}
private fun fetchFromMempool(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://mempool.space/api/v1/lightning/nodes/$pubkey")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "mempool.space lookup failed for $pubkey: ${e.message}")
null
}
}
private fun fetchFrom1ml(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://1ml.com/node/$pubkey/json")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "1ml.com lookup failed for $pubkey: ${e.message}")
null
}
}
}
@@ -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()
}
}
}