rename example
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user