6 Commits

Author SHA1 Message Date
rasputin 181cf56239 localisation: nwc 2026-06-12 20:51:30 +02:00
rasputin 262d5f068d localisation: nwc 2026-06-12 20:50:36 +02:00
rasputin 201242c797 enhancement: show relative days 2026-06-12 19:05:28 +02:00
rasputin 8c84eca8eb refactor: make linter happy 2026-06-12 18:23:18 +02:00
rasputin 4e22f4d3e9 refactor: make linter happy 2026-06-12 18:20:26 +02:00
rasputin c155f43870 refactor: make linter happy 2026-06-12 18:15:04 +02:00
50 changed files with 556 additions and 248 deletions
+17
View File
@@ -2,23 +2,40 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>Aead</w> <w>Aead</w>
<w>Bech</w>
<w>Bitrefill</w> <w>Bitrefill</w>
<w>Español</w>
<w>Nbits</w> <w>Nbits</w>
<w>Nostr</w> <w>Nostr</w>
<w>Tink</w> <w>Tink</w>
<w>amboss</w> <w>amboss</w>
<w>bech</w>
<w>bitcointxoko</w>
<w>btcpay</w>
<w>cltv</w> <w>cltv</w>
<w>getnwckey</w>
<w>gudari</w>
<w>gudariwallet</w>
<w>hkdf</w> <w>hkdf</w>
<w>inflight</w> <w>inflight</w>
<w>keyauth</w>
<w>lnaddr</w>
<w>lnaddress</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w> <w>lnbits</w>
<w>lnurlp</w> <w>lnurlp</w>
<w>lnurlw</w>
<w>msat</w> <w>msat</w>
<w>msats</w> <w>msats</w>
<w>nostr</w> <w>nostr</w>
<w>nwccacherepository</w>
<w>satoshi</w>
<w>satoshis</w> <w>satoshis</w>
<w>sats</w> <w>sats</w>
<w>snackbar</w> <w>snackbar</w>
<w>snull</w>
<w>tink</w>
<w>walletconnect</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
import android.Manifest import android.Manifest
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -18,7 +18,7 @@ import kotlinx.serialization.json.jsonPrimitive
* - a JSON array ["Thanks!"] * - a JSON array ["Thanks!"]
* - null * - null
* *
* Mirrors the behaviour of the old Gson FlexibleStringAdapter. * Mirrors the behavior of the old Gson FlexibleStringAdapter.
*/ */
object FlexibleStringSerializer : KSerializer<String?> { object FlexibleStringSerializer : KSerializer<String?> {
@@ -3,7 +3,7 @@ package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
* Single source of truth for the app's Json instance. * Single source of truth for the app's JSON instance.
* *
* All serialization/deserialization — Retrofit, DataStore caches, * All serialization/deserialization — Retrofit, DataStore caches,
* and WebSocket message parsing — must use this instance so that * and WebSocket message parsing — must use this instance so that
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -13,7 +12,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class LNbitsClient(secrets: SecretStore) { class LNbitsClient(secrets: SecretStore) {
@@ -38,8 +36,6 @@ class LNbitsClient(secrets: SecretStore) {
} }
} }
// Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient get() = LNbitsHttpClient.instance
val api: LNbitsApi by lazy { buildApi() } val api: LNbitsApi by lazy { buildApi() }
private fun buildApi(): LNbitsApi { private fun buildApi(): LNbitsApi {
@@ -251,7 +251,7 @@ data class SuccessAction(
* - a plain string "Thanks!" * - a plain string "Thanks!"
* - null * - null
* *
* kotlinx.serialization's default deserialiser throws when it sees a * kotlinx.serialization's default deserializer throws when it sees a
* string where it expects {. This serializer handles all three cases. * string where it expects {. This serializer handles all three cases.
* *
* Applied via @Serializable(with = SuccessActionSerializer::class) on * Applied via @Serializable(with = SuccessActionSerializer::class) on
@@ -337,10 +337,8 @@ class LNbitsWalletRepository(
request : NwcRegistrationRequest request : NwcRegistrationRequest
): NwcGetResponse { ): NwcGetResponse {
Timber.d( Timber.d(
"NWC [REGISTER ] pubkey=${pubkey.take(16)}" + "%snull", "NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} " + "permissions=${request.permissions} "
"expires_at=${request.expires_at} " +
"budgets=${request.budgets.size}"
) )
val result = api.registerNwcKey( val result = api.registerNwcKey(
adminKey = secrets.adminKey(), adminKey = secrets.adminKey(),
@@ -38,8 +38,7 @@ class LightningAddressStore(private val context: Context) {
.map { prefs -> .map { prefs ->
prefs[KEY_ADDRESS].also { address -> prefs[KEY_ADDRESS].also { address ->
_currentAddress = address _currentAddress = address
Timber.d("LNADDR [CACHE ] read from cache: " + Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
} }
suspend fun isCacheStale(): Boolean { suspend fun isCacheStale(): Boolean {
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
val age = System.currentTimeMillis() - fetchedAt val age = System.currentTimeMillis() - fetchedAt
val isStale = address == null || age > LNADDRESS_TTL_MS val isStale = address == null || age > LNADDRESS_TTL_MS
Timber.d("LNADDR [TTL ] cached=${address != null} " + Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " + "age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
if (isStale) "→ cache stale, will fetch from API"
else "→ cache fresh, skipping API call") else "→ cache fresh, skipping API call")
isStale isStale
} }
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
return null return null
} }
val response = deserialise(entity.responseJson) ?: run { val response = deserialize(entity.responseJson) ?: run {
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry") Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key) evict(key)
return null return null
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) } runCatching { dao.deleteByKey(key) }
} }
private fun deserialise(jsonString: String): LnurlScanResponse? = private fun deserialize(jsonString: String): LnurlScanResponse? =
runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) } runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull() .getOrNull()
} }
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.i18n package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings import cafe.adriel.lyricist.LyricistStrings
import kotlin.String
@LyricistStrings(languageTag = "en", default = true) @LyricistStrings(languageTag = "en", default = true)
val EnHomeStrings = AppStrings( val EnHomeStrings = AppStrings(
@@ -55,6 +54,8 @@ val EnHomeStrings = AppStrings(
details = "Details", details = "Details",
enterValidAmount = "Enter a valid amount", enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "today",
yesterday = "yesterday",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Send", send = "Send",
@@ -377,6 +378,9 @@ val EnHomeStrings = AppStrings(
connectionRowExpires = "Expires", connectionRowExpires = "Expires",
connectionRowPubkey = "Public key", connectionRowPubkey = "Public key",
connectionNeverUsed = "Never", connectionNeverUsed = "Never",
lastUsedToday = "Last used today",
lastUsedYesterday = "Last used yesterday",
lastUsedDate = "Last used %s",
connectionNoExpiry = "Never", connectionNoExpiry = "Never",
connectionExpired = { date -> "Expired · $date" }, connectionExpired = { date -> "Expired · $date" },
connectionSectionPermissions = "Permissions", connectionSectionPermissions = "Permissions",
@@ -398,6 +402,10 @@ val EnHomeStrings = AppStrings(
permSignMessage = "Sign messages", permSignMessage = "Sign messages",
permPay = "Send payments", permPay = "Send payments",
permInvoice = "Create invoices", permInvoice = "Create invoices",
permLookup = "Lookup invoice status",
permHistory = "Read transaction history",
permBalance = "Read wallet balance",
permInfo = "Read account info",
permLookupHistory = "Lookup status & transaction history", permLookupHistory = "Lookup status & transaction history",
permBalanceInfo = "Read balance & account info", permBalanceInfo = "Read balance & account info",
permSendPayments = "Send payments", permSendPayments = "Send payments",
@@ -410,6 +418,57 @@ val EnHomeStrings = AppStrings(
revokeDialogBody = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.", revokeDialogBody = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
revokeDialogConfirm = "Revoke", revokeDialogConfirm = "Revoke",
revokeDialogDismiss = "Cancel", revokeDialogDismiss = "Cancel",
refreshDaily = "Daily",
refreshWeekly = "Weekly",
refreshMonthly = "Monthly",
refreshYearly = "Yearly",
refreshNever = "Never",
newConnection = "New connection",
labelHint = "Label (e.g. Amethyst, Bitrefill)",
expires = "Expires",
pickDate = "Pick date",
pickTime = "Pick time",
never = "Never",
budgets = "Budgets",
addBudget = "Add budget",
removeBudget = "Remove budget",
permissions = "Permissions",
unnamed = "Unnamed",
creating = "Creating…",
createConnection = "Create connection",
ok = "OK",
sats = "Sats",
resets = "Resets",
deleteConnection = "Delete connection",
unnamedConnection = "Unnamed connection",
neverUsed = "Never used",
expired = "Expired",
connectionCreated = "Connection created",
scanOrCopyHint = "Scan or copy this connection string into your app. It will not be shown again.",
nwcPairingQrCode = "NWC pairing QR code",
nwcConnectionString = "NWC connection string",
shareNwcConnectionString = "Share NWC connection string",
openInApp = "Open in app",
walletConnect = "Wallet Connect",
addConnection = "Add connection",
noCompatibleAppFound = "No compatible app found",
removeConnection = "Remove connection?",
removeConnectionDetail = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
remove = "Remove",
noConnectionsYet = "No connections yet",
tapToAddHint = "Tap + to connect an app via Nostr Wallet Connect.",
somethingWentWrong = "Something went wrong",
retry = "Retry",
failedToLoadConnections = "Failed to load connections",
failedToCreateConnection = "Failed to create connection",
failedToDeleteConnection = "Failed to delete connection",
failedToLoadConnection = "Failed to load connection",
) )
) )
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.i18n package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings import cafe.adriel.lyricist.LyricistStrings
import com.bitcointxoko.gudariwallet.i18n.NwcStrings
@LyricistStrings(languageTag = "es") @LyricistStrings(languageTag = "es")
val EsHomeStrings = AppStrings( val EsHomeStrings = AppStrings(
@@ -55,6 +54,8 @@ val EsHomeStrings = AppStrings(
details = "Detalles", details = "Detalles",
enterValidAmount = "Introduce una cantidad válida", enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "hoy",
yesterday = "ayer",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar", send = "Enviar",
@@ -378,6 +379,9 @@ val EsHomeStrings = AppStrings(
connectionRowExpires = "Vence", connectionRowExpires = "Vence",
connectionRowPubkey = "Clave pública", connectionRowPubkey = "Clave pública",
connectionNeverUsed = "Nunca", connectionNeverUsed = "Nunca",
lastUsedToday = "Usada hoy",
lastUsedYesterday = "Usada ayer",
lastUsedDate = "Usada %s",
connectionNoExpiry = "Sin vencimiento", connectionNoExpiry = "Sin vencimiento",
connectionExpired = { date -> "Vencido · $date" }, connectionExpired = { date -> "Vencido · $date" },
connectionSectionPermissions = "Permisos", connectionSectionPermissions = "Permisos",
@@ -399,6 +403,10 @@ val EsHomeStrings = AppStrings(
permSignMessage = "Firmar mensajes", permSignMessage = "Firmar mensajes",
permPay = "Enviar pagos", permPay = "Enviar pagos",
permInvoice = "Crear facturas", permInvoice = "Crear facturas",
permLookup = "Consultar estado de facturas",
permHistory = "Leer historial de transacciones",
permBalance = "Leer saldo del monedero",
permInfo = "Leer información de cuenta",
permLookupHistory = "Consultar estado e historial de transacciones", permLookupHistory = "Consultar estado e historial de transacciones",
permBalanceInfo = "Ver saldo e información de cuenta", permBalanceInfo = "Ver saldo e información de cuenta",
permSendPayments = "Enviar pagos", permSendPayments = "Enviar pagos",
@@ -411,5 +419,56 @@ val EsHomeStrings = AppStrings(
revokeDialogBody = "Esto revocará permanentemente esta clave Nostr Wallet Connect. Cualquier app que la use perderá el acceso.", revokeDialogBody = "Esto revocará permanentemente esta clave Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
revokeDialogConfirm = "Revocar", revokeDialogConfirm = "Revocar",
revokeDialogDismiss = "Cancelar", revokeDialogDismiss = "Cancelar",
refreshDaily = "Diario",
refreshWeekly = "Semanal",
refreshMonthly = "Mensual",
refreshYearly = "Anual",
refreshNever = "Nunca",
newConnection = "Nueva conexión",
labelHint = "Etiqueta (p.ej. Amethyst, Bitrefill)",
expires = "Caduca",
pickDate = "Elegir fecha",
pickTime = "Elegir hora",
never = "Nunca",
budgets = "Presupuestos",
addBudget = "Añadir presupuesto",
removeBudget = "Eliminar presupuesto",
permissions = "Permisos",
unnamed = "Sin nombre",
creating = "Creando…",
createConnection = "Crear conexión",
ok = "OK",
sats = "Sats",
resets = "Se reinicia",
deleteConnection = "Eliminar conexión",
unnamedConnection = "Conexión sin nombre",
neverUsed = "Nunca usada",
expired = "Caducada",
connectionCreated = "Conexión creada",
scanOrCopyHint = "Escanea o copia esta cadena de conexión en tu app. No se volverá a mostrar.",
nwcPairingQrCode = "Código QR de emparejamiento NWC",
nwcConnectionString = "Cadena de conexión NWC",
shareNwcConnectionString = "Compartir cadena de conexión NWC",
openInApp = "Abrir en app",
walletConnect = "Wallet Connect",
addConnection = "Añadir conexión",
noCompatibleAppFound = "No se encontró una app compatible",
removeConnection = "¿Eliminar conexión?",
removeConnectionDetail = "Esto revocará permanentemente esta clave de Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
remove = "Eliminar",
noConnectionsYet = "Aún no hay conexiones",
tapToAddHint = "Toca + para conectar una app vía Nostr Wallet Connect.",
somethingWentWrong = "Algo salió mal",
retry = "Reintentar",
failedToLoadConnections = "Error al cargar las conexiones",
failedToCreateConnection = "Error al crear la conexión",
failedToDeleteConnection = "Error al eliminar la conexión",
failedToLoadConnection = "Error al cargar la conexión",
) )
) )
@@ -54,6 +54,8 @@ val EuStrings = AppStrings(
details = "Xehetasunak", details = "Xehetasunak",
enterValidAmount = "Sartu zenbateko baliogarri bat", enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" }, satsUnit = { sats -> "$sats sat" },
today = "gaur",
yesterday = "atzo",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali", send = "Bidali",
@@ -374,6 +376,9 @@ val EuStrings = AppStrings(
connectionSectionDetails = "Xehetasunak", connectionSectionDetails = "Xehetasunak",
connectionRowCreated = "Konektatuta", connectionRowCreated = "Konektatuta",
connectionRowLastUsed = "Azken erabilera", connectionRowLastUsed = "Azken erabilera",
lastUsedToday = "Duela gutxi erabilia", // "used recently" (idiomatic for today)
lastUsedYesterday = "Atzo erabilia", // "used yesterday"
lastUsedDate = "Duela %s erabilia", // "used [date] ago"
connectionRowExpires = "Iraungitze-data", connectionRowExpires = "Iraungitze-data",
connectionRowPubkey = "Gako publikoa", connectionRowPubkey = "Gako publikoa",
connectionNeverUsed = "Inoiz ez", connectionNeverUsed = "Inoiz ez",
@@ -398,6 +403,10 @@ val EuStrings = AppStrings(
permSignMessage = "Mezuak sinatu", permSignMessage = "Mezuak sinatu",
permPay = "Ordainketak bidali", permPay = "Ordainketak bidali",
permInvoice = "Fakturak sortu", permInvoice = "Fakturak sortu",
permLookup = "Kontsultatu faktura-egoera",
permHistory = "Irakurri transakzio-historiala",
permBalance = "Irakurri zorroaren saldoa",
permInfo = "Irakurri kontu-informazioa",
permLookupHistory = "Egoera eta transakzio-historia kontsultatu", permLookupHistory = "Egoera eta transakzio-historia kontsultatu",
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi", permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
permSendPayments = "Ordainketak bidali", permSendPayments = "Ordainketak bidali",
@@ -410,5 +419,56 @@ val EuStrings = AppStrings(
revokeDialogBody = "Honek Nostr Wallet Connect gako hau behin betiko baliogabetuko du. Erabiltzen duen edozein aplikaziok sarbidea galduko du.", revokeDialogBody = "Honek Nostr Wallet Connect gako hau behin betiko baliogabetuko du. Erabiltzen duen edozein aplikaziok sarbidea galduko du.",
revokeDialogConfirm = "Baliogabetu", revokeDialogConfirm = "Baliogabetu",
revokeDialogDismiss = "Utzi", revokeDialogDismiss = "Utzi",
refreshDaily = "Egunero",
refreshWeekly = "Astero",
refreshMonthly = "Hilabetero",
refreshYearly = "Urtero",
refreshNever = "Inoiz ez",
newConnection = "Konexio berria",
labelHint = "Etiketa (adb. Amethyst, Bitrefill)",
expires = "Iraungitzen da",
pickDate = "Aukeratu data",
pickTime = "Aukeratu ordua",
never = "Inoiz ez",
budgets = "Aurrekontuak",
addBudget = "Gehitu aurrekontua",
removeBudget = "Kendu aurrekontua",
permissions = "Baimenak",
unnamed = "Izengabea",
creating = "Sortzen…",
createConnection = "Sortu konexioa",
ok = "Ados",
sats = "Sats",
resets = "Berrezarri",
deleteConnection = "Ezabatu konexioa",
unnamedConnection = "Izengabeko konexioa",
neverUsed = "Inoiz erabili gabea",
expired = "Iraungitua",
connectionCreated = "Konexioa sortuta",
scanOrCopyHint = "Eskaneatu edo kopiatu konexio-kate hau zure app-ean. Ez da berriro erakutsiko.",
nwcPairingQrCode = "NWC parekatzeko QR kodea",
nwcConnectionString = "NWC konexio-katea",
shareNwcConnectionString = "Partekatu NWC konexio-katea",
openInApp = "Ireki app-ean",
walletConnect = "Wallet Connect",
addConnection = "Gehitu konexioa",
noCompatibleAppFound = "Ez da app bateragarririk aurkitu",
removeConnection = "Kendu konexioa?",
removeConnectionDetail = "Honek Nostr Wallet Connect gako hau behin betiko indargabetuko du. Erabiltzen duen edozein app-ek sarbidea galduko du.",
remove = "Kendu",
noConnectionsYet = "Oraindik konexiorik ez",
tapToAddHint = "Sakatu + app bat Nostr Wallet Connect bidez konektatzeko.",
somethingWentWrong = "Zerbait gaizki atera da",
retry = "Saiatu berriro",
failedToLoadConnections = "Huts egin du konexioak kargatzean",
failedToCreateConnection = "Huts egin du konexioa sortzean",
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
failedToLoadConnection = "Huts egin du konexioa kargatzean",
) )
) )
@@ -63,6 +63,8 @@ data class AppStrings(
val details : String, val details : String,
val enterValidAmount : String, val enterValidAmount : String,
val satsUnit : (sats: Long) -> String, val satsUnit : (sats: Long) -> String,
val today : String,
val yesterday : String,
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
val send : String, val send : String,
@@ -381,6 +383,9 @@ data class NwcStrings(
val connectionRowExpires : String, val connectionRowExpires : String,
val connectionRowPubkey : String, val connectionRowPubkey : String,
val connectionNeverUsed : String, val connectionNeverUsed : String,
val lastUsedToday : String, // e.g. "Last used today"
val lastUsedYesterday : String, // e.g. "Last used yesterday"
val lastUsedDate : String, // e.g. "Last used %s" — %s is a formatted date
val connectionNoExpiry : String, val connectionNoExpiry : String,
/** "Expired · {date}" */ /** "Expired · {date}" */
val connectionExpired : (date: String) -> String, val connectionExpired : (date: String) -> String,
@@ -406,10 +411,14 @@ data class NwcStrings(
val permSignMessage : String, val permSignMessage : String,
val permPay : String, // "Send payments" val permPay : String, // "Send payments"
val permInvoice : String, // "Create invoices" val permInvoice : String, // "Create invoices"
val permLookupHistory: String, // "Lookup status & transaction history" val permLookup : String,
val permHistory : String,
val permBalance : String,
val permInfo : String,
val permLookupHistory : String, // "Lookup status & transaction history"
val permBalanceInfo : String, // "Read balance & account info" val permBalanceInfo : String, // "Read balance & account info"
val permSendPayments: String, val permSendPayments : String,
val permCreateInvoices: String, val permCreateInvoices : String,
val permLookupInvoiceStatus: String, val permLookupInvoiceStatus: String,
val permReadTransactionHistory: String, val permReadTransactionHistory: String,
val permReadWalletBalance: String, val permReadWalletBalance: String,
@@ -418,4 +427,56 @@ data class NwcStrings(
val revokeDialogBody : String, val revokeDialogBody : String,
val revokeDialogConfirm : String, val revokeDialogConfirm : String,
val revokeDialogDismiss : String, val revokeDialogDismiss : String,
) val refreshDaily : String,
val refreshWeekly : String,
val refreshMonthly : String,
val refreshYearly : String,
val refreshNever : String,
// New Connection
val newConnection : String,
val labelHint : String,
val expires : String,
val pickDate : String,
val pickTime : String,
val never : String,
val budgets : String,
val addBudget : String,
val removeBudget : String,
val permissions : String,
val unnamed : String,
val creating : String,
val createConnection : String,
val ok : String,
val sats : String,
val resets : String,
val deleteConnection : String,
val unnamedConnection : String,
val neverUsed : String,
val expired : String,
val connectionCreated : String,
val scanOrCopyHint : String,
val nwcPairingQrCode : String,
val nwcConnectionString : String,
val shareNwcConnectionString : String,
val openInApp : String,
val walletConnect : String,
val addConnection : String,
val noCompatibleAppFound : String,
val removeConnection : String,
val removeConnectionDetail: String,
val remove : String,
val noConnectionsYet : String,
val tapToAddHint : String,
val somethingWentWrong : String,
val retry : String,
val failedToLoadConnections : String,
val failedToCreateConnection : String,
val failedToDeleteConnection : String,
val failedToLoadConnection : String,
)
@@ -1,20 +1,15 @@
package com.bitcointxoko.gudariwallet.security package com.bitcointxoko.gudariwallet.security
import android.content.Context import android.content.Context
import timber.log.Timber
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.migrations.SharedPreferencesMigration import androidx.datastore.migrations.SharedPreferencesMigration
import androidx.datastore.migrations.SharedPreferencesView
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.File import java.io.File
import androidx.core.content.edit
/** /**
* Singleton DataStore for encrypted credentials. * Singleton DataStore for encrypted credentials.
@@ -27,8 +22,6 @@ import androidx.core.content.edit
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded. * [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
*/ */
object CredentialsDataStore { object CredentialsDataStore {
private const val TAG = "CredentialsDataStore"
@Volatile @Volatile
private var instance: DataStore<Credentials>? = null private var instance: DataStore<Credentials>? = null
@@ -25,8 +25,8 @@ class CredentialsSerializer(
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}") Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
result result
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e) Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
throw CorruptionException("Cannot read/decrypt credentials DataStore", e) throw CorruptionException("Cannot read/decrypt credentials DataStore")
} }
} }
@@ -37,12 +37,10 @@ class CredentialsSerializer(
val encryptingStream = streamingAead.newEncryptingStream(output, AAD) val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
encryptingStream.close() encryptingStream.close()
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " + Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " + "hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Encryption/write failed", e) Timber.e("Encryption/write failed")
throw e throw e
} }
} }
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
Timber.i("StreamingAead primitive acquired successfully") Timber.i("StreamingAead primitive acquired successfully")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable", e) Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable")
throw e throw e
} }
} }
@@ -41,7 +41,7 @@ class NodeAliasService(
repo.put(pubkey, alias) repo.put(pubkey, alias)
// 4. Publish to StateFlow // 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias) _aliases.value += (pubkey to alias)
Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"") Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"")
return alias return alias
@@ -30,6 +30,7 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "WalletNotifService" private const val TAG = "WalletNotifService"
@@ -271,7 +272,7 @@ class WalletNotificationService : Service() {
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = serviceScope.launch { reconnectJob = serviceScope.launch {
delay(backoff) delay(backoff.milliseconds)
openWebSocket() openWebSocket()
} }
} }
@@ -71,6 +71,7 @@ import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── Tab destinations ────────────────────────────────────────────────────────── // ── Tab destinations ──────────────────────────────────────────────────────────
// Note: label and icon are now resolved at the call site via LocalAppStrings, // Note: label and icon are now resolved at the call site via LocalAppStrings,
@@ -205,7 +206,7 @@ fun WalletScreen(
LaunchedEffect(clipboardOffer) { LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) { if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000) delay(8_000.milliseconds)
vm.dismissClipboardOffer() vm.dismissClipboardOffer()
} }
} }
@@ -215,7 +216,7 @@ fun WalletScreen(
LaunchedEffect(nfcOffer) { LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) { if (nfcOffer is NfcOfferState.Detected) {
delay(8_000) delay(8_000.milliseconds)
nfcVm.dismissNfcOffer() nfcVm.dismissNfcOffer()
} }
} }
@@ -460,7 +461,7 @@ fun WalletScreen(
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle() val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey) } LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } } DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) { when (val state = connectionState) {
@@ -490,7 +491,7 @@ fun WalletScreen(
budgets = state.budgets, budgets = state.budgets,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onRevoke = { onRevoke = {
nwcVm.deleteConnection(pubkey) nwcVm.deleteConnection(pubkey, strings)
navController.popBackStack() navController.popBackStack()
} }
) )
@@ -13,7 +13,6 @@ import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
@@ -75,10 +75,9 @@ class BalanceViewModel(
runCatching { repo.getBalance() } runCatching { repo.getBalance() }
.onSuccess { balance -> .onSuccess { balance ->
val sats = balance.sats val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
when { null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete") sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
prev == sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})") else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
} }
_balanceState.value = BalanceState.Success( _balanceState.value = BalanceState.Success(
@@ -1,9 +1,8 @@
package com.bitcointxoko.gudariwallet.ui.common package com.bitcointxoko.gudariwallet.ui.common
import android.app.Activity
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
/** /**
* Manages screen brightness for QR-display screens. * Manages screen brightness for QR-display screens.
@@ -12,12 +11,12 @@ import androidx.compose.ui.platform.LocalContext
*/ */
@Composable @Composable
fun rememberBrightnessController(): BrightnessController { fun rememberBrightnessController(): BrightnessController {
val window = (LocalContext.current as? Activity)?.window val window = (LocalActivity.current)?.window
var isOn by remember { mutableStateOf(false) } var isOn by remember { mutableStateOf(false) }
// Sync side-effect: no coroutine needed // Sync side effect: no coroutine needed
SideEffect { SideEffect {
window?.attributes = window?.attributes?.apply { window?.attributes = window.attributes?.apply {
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} }
@@ -25,7 +24,7 @@ fun rememberBrightnessController(): BrightnessController {
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
window?.attributes = window?.attributes?.apply { window?.attributes = window.attributes?.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} }
} }
@@ -14,7 +14,6 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -220,7 +220,7 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.minCreatedAt currentFilter.minCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateFrom_label // ← "From" ?: strings.history.filterDateFrom_label // ← "From"
) )
} }
@@ -231,7 +231,7 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.maxCreatedAt currentFilter.maxCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateTo_label // ← "To" ?: strings.history.filterDateTo_label // ← "To"
) )
} }
@@ -464,13 +464,13 @@ private fun ActiveFilterChips(
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.history.filterDateRange( // ← "XY" strings.history.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt), formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt) formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
) )
minCreatedAt != null -> minCreatedAt != null ->
strings.history.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X" strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
else -> else ->
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
} }
} }
InputChip( InputChip(
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel" private const val TAG = "HistoryViewModel"
@@ -156,7 +157,7 @@ class HistoryViewModel(
viewModelScope.launch { viewModelScope.launch {
combine( combine(
_filter.map { it.searchQuery }.debounce(300), _filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter _filter
) { _, f -> f } ) { _, f -> f }
.collect { f -> .collect { f ->
@@ -164,10 +165,8 @@ class HistoryViewModel(
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
_roomResults.value = null _roomResults.value = null
} else { } else {
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " + Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " + "statuses=${f.statuses} dir=${f.direction} types=${f.types} ")
"amt=${f.minAmountSat}${f.maxAmountSat} " +
"date=${f.minCreatedAt}${f.maxCreatedAt}")
_roomResults.value = paymentCache.queryAll(f.searchQuery, f) _roomResults.value = paymentCache.queryAll(f.searchQuery, f)
} }
} }
@@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -234,7 +233,11 @@ private fun PaymentDetailContent(
// ── Basic info ─────────────────────────────────────────────────────── // ── Basic info ───────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details" DetailSection(title = strings.history.detailSectionDetails) { // ← "Details"
DetailRow(strings.history.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date" DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date"
payment.createdAt,
payment.time,
strings.today,
strings.yesterday))
DetailRow( DetailRow(
strings.history.detailRowMemo, // ← "Memo" strings.history.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
@@ -78,7 +78,7 @@ internal fun PaymentRow(
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) { val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time) formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
} }
Text( Text(
text = formattedTime, text = formattedTime,
@@ -159,8 +159,7 @@ class PaymentSyncManager(
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
if (reachedOverlap || reachedEnd) { if (reachedOverlap || reachedEnd) {
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " + Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
if (reachedOverlap) "stopped at known record" else "reached end of history")
break break
} }
@@ -77,13 +77,12 @@ class NdefHceService : HostApduService() {
val ins = apdu[1] val ins = apdu[1]
return when { return when (ins) {
// ── SELECT (INS = 0xA4) ────────────────────────────────────────── // ── SELECT (INS = 0xA4) ──────────────────────────────────────────
ins == 0xA4.toByte() -> handleSelect(apdu) 0xA4.toByte() -> handleSelect(apdu)
// ── READ BINARY (INS = 0xB0) ───────────────────────────────────── // ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
ins == 0xB0.toByte() -> handleReadBinary(apdu) 0xB0.toByte() -> handleReadBinary(apdu)
else -> SW_UNKNOWN else -> SW_UNKNOWN
}.also { Timber.d("APDU OUT: ${it.toHex()}") } }.also { Timber.d("APDU OUT: ${it.toHex()}") }
} }
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
} }
if (rawMessages != null && rawMessages.isNotEmpty()) { if (!rawMessages.isNullOrEmpty()) {
val message = rawMessages[0] as NdefMessage val message = rawMessages[0] as NdefMessage
val uri = extractUri(message) val uri = extractUri(message)
if (uri != null) { if (uri != null) {
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
} }
if (tag != null) { if (tag != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -16,6 +16,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcNewBudget import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.strings
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -41,6 +43,16 @@ private class AddConnectionState {
expiry.atZone(ZoneId.systemDefault()).toEpochSecond() expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
} }
private fun NwcPermission.displayLabel(strings: AppStrings): String = when (serverKey) {
"pay" -> strings.nwc.permPay
"invoice" -> strings.nwc.permInvoice
"lookup" -> strings.nwc.permLookup
"history" -> strings.nwc.permHistory
"balance" -> strings.nwc.permBalance
"info" -> strings.nwc.permInfo
else -> serverKey
}
// ── Sheet ───────────────────────────────────────────────────────────────────── // ── Sheet ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -58,6 +70,7 @@ internal fun NwcAddConnectionSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val state = remember { AddConnectionState() } val state = remember { AddConnectionState() }
if (state.showDatePicker) { if (state.showDatePicker) {
val initialMillis = state.expiry val initialMillis = state.expiry
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
@@ -81,10 +94,10 @@ internal fun NwcAddConnectionSheet(
.withDayOfMonth(picked.dayOfMonth) .withDayOfMonth(picked.dayOfMonth)
} }
state.showDatePicker = false state.showDatePicker = false
}) { Text("OK") } }) { Text(strings.nwc.ok) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { state.showDatePicker = false }) { Text("Cancel") } TextButton(onClick = { state.showDatePicker = false }) { Text(strings.nwc.cancel) }
} }
) { DatePicker(state = datePickerState) } ) { DatePicker(state = datePickerState) }
} }
@@ -105,10 +118,10 @@ internal fun NwcAddConnectionSheet(
.withSecond(0) .withSecond(0)
.withNano(0) .withNano(0)
state.showTimePicker = false state.showTimePicker = false
}) { Text("OK") } }) { Text(strings.nwc.ok) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { state.showTimePicker = false }) { Text("Cancel") } TextButton(onClick = { state.showTimePicker = false }) { Text(strings.nwc.cancel) }
}, },
text = { TimePicker(state = timePickerState) } text = { TimePicker(state = timePickerState) }
) )
@@ -127,7 +140,7 @@ internal fun NwcAddConnectionSheet(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "New connection", text = strings.nwc.newConnection,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
@@ -135,7 +148,7 @@ internal fun NwcAddConnectionSheet(
OutlinedTextField( OutlinedTextField(
value = state.description, value = state.description,
onValueChange = { state.description = it }, onValueChange = { state.description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") }, label = { Text(strings.nwc.labelHint) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -145,7 +158,7 @@ internal fun NwcAddConnectionSheet(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(text = "Expires", style = MaterialTheme.typography.bodyMedium) Text(text = strings.nwc.expires, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (!state.neverExpires) { if (!state.neverExpires) {
@@ -158,7 +171,7 @@ internal fun NwcAddConnectionSheet(
icon = { icon = {
Icon( Icon(
imageVector = Icons.Default.CalendarToday, imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date", contentDescription = strings.nwc.pickDate,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -173,7 +186,7 @@ internal fun NwcAddConnectionSheet(
icon = { icon = {
Icon( Icon(
imageVector = Icons.Default.Schedule, imageVector = Icons.Default.Schedule,
contentDescription = "Pick time", contentDescription = strings.nwc.pickTime,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -185,7 +198,7 @@ internal fun NwcAddConnectionSheet(
checked = state.neverExpires, checked = state.neverExpires,
onCheckedChange = { state.neverExpires = it } onCheckedChange = { state.neverExpires = it }
) )
Text(text = "Never", style = MaterialTheme.typography.bodyMedium) Text(text = strings.nwc.never, style = MaterialTheme.typography.bodyMedium)
} }
Row( Row(
@@ -193,7 +206,7 @@ internal fun NwcAddConnectionSheet(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Budgets", text = strings.nwc.budgets,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -206,11 +219,11 @@ internal fun NwcAddConnectionSheet(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = "Add budget", contentDescription = strings.nwc.addBudget,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Add budget") Text(strings.nwc.addBudget)
} }
} }
@@ -230,7 +243,7 @@ internal fun NwcAddConnectionSheet(
// ── Permissions ────────────────────────────────────────────────── // ── Permissions ──────────────────────────────────────────────────
Text( Text(
text = "Permissions", text = strings.nwc.permissions,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -251,11 +264,11 @@ internal fun NwcAddConnectionSheet(
} }
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium) Text(text = perm.displayLabel(strings), style = MaterialTheme.typography.bodyMedium)
} }
} }
} }
val unnamed = strings.nwc.unnamed
// ── Create button ───────────────────────────────────────────────── // ── Create button ─────────────────────────────────────────────────
Button( Button(
onClick = { onClick = {
@@ -263,7 +276,7 @@ internal fun NwcAddConnectionSheet(
else state.expiryEpochSeconds() // #10 else state.expiryEpochSeconds() // #10
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() } val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
onCreate( onCreate(
state.description.trim().ifBlank { "Unnamed" }, state.description.trim().ifBlank { unnamed },
state.selectedPermissions.toList(), state.selectedPermissions.toList(),
expiresAtSecs, expiresAtSecs,
validBudgets validBudgets
@@ -280,7 +293,7 @@ internal fun NwcAddConnectionSheet(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text(if (isCreating) "Creating…" else "Create connection") Text(if (isCreating) strings.nwc.creating else strings.nwc.createConnection)
} }
} }
} }
@@ -288,6 +301,15 @@ internal fun NwcAddConnectionSheet(
// ── Budget row ──────────────────────────────────────────────────────────────── // ── Budget row ────────────────────────────────────────────────────────────────
@Composable
fun BudgetRefreshWindow.displayLabel(strings: AppStrings): String = when (this) {
BudgetRefreshWindow.DAILY -> strings.nwc.refreshDaily
BudgetRefreshWindow.WEEKLY -> strings.nwc.refreshWeekly
BudgetRefreshWindow.MONTHLY -> strings.nwc.refreshMonthly
BudgetRefreshWindow.YEARLY -> strings.nwc.refreshYearly
BudgetRefreshWindow.NEVER -> strings.nwc.refreshNever
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun BudgetRow( private fun BudgetRow(
@@ -313,7 +335,7 @@ private fun BudgetRow(
OutlinedTextField( OutlinedTextField(
value = draft.amountSats, value = draft.amountSats,
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) }, onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
label = { Text("Sats") }, label = { Text(strings.nwc.sats) },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).height(fieldHeight) modifier = Modifier.weight(1f).height(fieldHeight)
@@ -325,10 +347,10 @@ private fun BudgetRow(
modifier = Modifier.weight(1f).height(fieldHeight) modifier = Modifier.weight(1f).height(fieldHeight)
) { ) {
OutlinedTextField( OutlinedTextField(
value = draft.refreshWindow.label, value = draft.refreshWindow.displayLabel(strings),
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Resets") }, label = { Text(strings.nwc.resets) },
trailingIcon = { trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
}, },
@@ -343,7 +365,7 @@ private fun BudgetRow(
) { ) {
BudgetRefreshWindow.entries.forEach { window -> BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(window.label) }, text = { Text(window.displayLabel(strings)) },
onClick = { onClick = {
onChange(draft.copy(refreshWindow = window)) onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false dropdownExpanded = false
@@ -359,7 +381,7 @@ private fun BudgetRow(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Remove budget", contentDescription = strings.nwc.removeBudget,
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
@@ -1,5 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.nwc package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -14,18 +16,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatEpoch import com.bitcointxoko.gudariwallet.util.formatEpochShort
import com.bitcointxoko.gudariwallet.util.isToday
import com.bitcointxoko.gudariwallet.util.isYesterday
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable @Composable
internal fun NwcConnectionRow( internal fun NwcConnectionRow(
connection : NwcGetResponse, connection: NwcGetResponse,
isDeleting : Boolean, isDeleting: Boolean,
onDeleteConfirm : () -> Unit, onDeleteConfirm: () -> Unit,
onConnectionClick: () -> Unit, onConnectionClick: () -> Unit,
modifier : Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val key = connection.data val key = connection.data
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
@@ -35,7 +41,7 @@ internal fun NwcConnectionRow(
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { confirmValueChange = {
// Always reject — we never want the row to stay dismissed. // Always reject — we never want the row to stay dismissed.
// The dialog is triggered via LaunchedEffect on targetValue below. // The dialogue is triggered via LaunchedEffect on targetValue below.
false false
}, },
positionalThreshold = { totalDistance -> totalDistance * 0.4f } positionalThreshold = { totalDistance -> totalDistance * 0.4f }
@@ -47,7 +53,7 @@ internal fun NwcConnectionRow(
var dialogTriggered by remember { mutableStateOf(false) } var dialogTriggered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Fires when the swipe crosses the threshold — trigger confirm dialog // Fires when the swipe crosses the threshold — trigger confirm dialogue
LaunchedEffect(dismissState.targetValue) { LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting && !isDeleting
@@ -84,7 +90,7 @@ internal fun NwcConnectionRow(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Delete connection", contentDescription = strings.nwc.deleteConnection,
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha) tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
) )
} }
@@ -110,7 +116,7 @@ internal fun NwcConnectionRow(
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = key.description.ifBlank { "Unnamed connection" }, text = key.description.ifBlank { strings.nwc.unnamedConnection },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -120,9 +126,15 @@ internal fun NwcConnectionRow(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val lastUsedText = when {
key.last_used <= 0L -> strings.nwc.neverUsed
isToday(key.last_used) -> strings.nwc.lastUsedToday
isYesterday(key.last_used) -> strings.nwc.lastUsedYesterday
else -> strings.nwc.lastUsedDate.format(formatEpochShort(key.last_used, strings.today, strings.yesterday))
}
Text( Text(
text = if (key.last_used <= 0L) "Never used" text = if (key.last_used <= 0L) strings.nwc.neverUsed
else "Last used ${formatEpoch(key.last_used)}", else lastUsedText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -134,7 +146,7 @@ internal fun NwcConnectionRow(
contentColor = semantic.onErrorContainer contentColor = semantic.onErrorContainer
) { ) {
Text( Text(
text = "Expired", text = strings.nwc.expired,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
) )
@@ -92,8 +92,6 @@ fun ConnectionDetailScreen(
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -218,13 +216,13 @@ private fun ConnectionDetailContent(
DetailRow( DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected" label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt) value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
) )
DetailRow( DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used" label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0) value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed) formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
else else
strings.nwc.connectionNeverUsed // ← "Never" strings.nwc.connectionNeverUsed // ← "Never"
) )
@@ -234,9 +232,13 @@ private fun ConnectionDetailContent(
value = when { value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never" connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025" isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
formatEpoch(connection.expiresAt) formatEpoch(connection.expiresAt, strings.today, strings.yesterday)
)
else -> formatEpoch(
connection.expiresAt,
strings.today,
strings.yesterday
) )
else -> formatEpoch(connection.expiresAt)
}, },
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null // valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
) )
@@ -383,7 +385,7 @@ private fun BudgetCard(budget: BudgetData) {
// Created at // Created at
DetailRow( DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Created" label = strings.nwc.connectionRowCreated, // ← "Created"
value = formatEpoch(budget.createdAt) value = formatEpoch(budget.createdAt, strings.today, strings.yesterday)
) )
} }
} }
@@ -415,7 +417,7 @@ private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings)
) )
} }
Text( Text(
text = permission.label(strings), // already localised text = permission.label(strings), // already localized
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -5,17 +5,16 @@ import com.bitcointxoko.gudariwallet.api.NwcNewBudget
// ── Available permissions ──────────────────────────────────────────── // ── Available permissions ────────────────────────────────────────────
internal data class NwcPermission( internal data class NwcPermission(
val serverKey : String, // what we send in the permissions array val serverKey : String, // what we send in the permissions array
val displayLabel : String, // shown in the UI
val default : Boolean // pre-checked by default val default : Boolean // pre-checked by default
) )
internal val ALL_PERMISSIONS = listOf( internal val ALL_PERMISSIONS = listOf(
NwcPermission("pay", "Send payments", default = true), NwcPermission("pay", default = true),
NwcPermission("invoice", "Create invoices", default = false), NwcPermission("invoice", default = false),
NwcPermission("lookup", "Lookup invoice status", default = false), NwcPermission("lookup", default = false),
NwcPermission("history", "Read transaction history", default = false), NwcPermission("history", default = false),
NwcPermission("balance", "Read wallet balance", default = false), NwcPermission("balance", default = false),
NwcPermission("info", "Read account info", default = false) NwcPermission("info", default = false)
) )
// ── Budget ──────────────────────────────────────────────────────────── // ── Budget ────────────────────────────────────────────────────────────
@@ -24,12 +23,12 @@ internal val ALL_PERMISSIONS = listOf(
* Transient UI state for a single budget row in the "Add connection" sheet. * Transient UI state for a single budget row in the "Add connection" sheet.
* Converted to [NwcNewBudget] on submit. * Converted to [NwcNewBudget] on submit.
*/ */
enum class BudgetRefreshWindow(val label: String, val seconds: Int) { enum class BudgetRefreshWindow(val seconds: Int) {
DAILY ("Daily", 86_400), DAILY (86_400),
WEEKLY ("Weekly", 604_800), WEEKLY (604_800),
MONTHLY ("Monthly", 2_592_000), MONTHLY (2_592_000),
YEARLY ("Yearly", 31_536_000), YEARLY (31_536_000),
NEVER ("Never", 0); NEVER (0);
} }
data class BudgetDraft( data class BudgetDraft(
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
@@ -29,20 +30,21 @@ internal fun NwcPairingUrlDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Connection created") }, title = { Text(strings.nwc.connectionCreated) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = "Scan or copy this connection string into your app. It will not be shown again.", text = strings.nwc.scanOrCopyHint,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val strings = strings
QrDisplayCard( QrDisplayCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
content = url, content = url,
contentDescription = "NWC pairing QR code", contentDescription = strings.nwc.nwcPairingQrCode,
clipLabel = "NWC connection string", clipLabel = strings.nwc.nwcConnectionString,
shareTitle = "Share NWC connection string", shareTitle = strings.nwc.shareNwcConnectionString,
textToCopy = url, textToCopy = url,
brightnessOn = brightness.isOn, brightnessOn = brightness.isOn,
onToggleBrightness = brightness.toggle, onToggleBrightness = brightness.toggle,
@@ -52,7 +54,7 @@ internal fun NwcPairingUrlDialog(
val intent = Intent(Intent.ACTION_VIEW, url.toUri()) val intent = Intent(Intent.ACTION_VIEW, url.toUri())
try { try {
context.startActivity( context.startActivity(
Intent.createChooser(intent, "Open in app") Intent.createChooser(intent, strings.nwc.openInApp)
) )
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
onNoAppFound() // #17: no Toast — caller handles it onNoAppFound() // #17: no Toast — caller handles it
@@ -63,7 +65,7 @@ internal fun NwcPairingUrlDialog(
}, },
confirmButton = {}, // actions live inside QrDisplayCard confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") } TextButton(onClick = onDismiss) { Text(strings.nwc.done) }
} }
) )
} }
@@ -1,5 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.nwc package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -13,8 +15,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NwcScreen( fun NwcScreen(
@@ -30,8 +34,13 @@ fun NwcScreen(
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle() val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val strings = LocalAppStrings.current
val refresh = { viewModel.loadConnections(includeExpired = true) } val refresh = { viewModel.loadConnections(includeExpired = true, strings) }
LaunchedEffect(Unit) {
viewModel.loadConnections(includeExpired = true, strings)
}
LaunchedEffect(pairingUrl) { LaunchedEffect(pairingUrl) {
if (pairingUrl != null) { if (pairingUrl != null) {
@@ -42,17 +51,17 @@ fun NwcScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Wallet Connect") }, title = { Text(strings.nwc.walletConnect) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.nwc.back)
} }
} }
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { viewModel.openAddSheet() }) { FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add connection") Icon(Icons.Default.Add, contentDescription = strings.nwc.addConnection)
} }
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
@@ -102,7 +111,7 @@ fun NwcScreen(
connection, connection,
isDeleting, isDeleting,
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) }, onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
onConnectionClick = { onConnectionClick(connection.data.pubkey) } { onConnectionClick(connection.data.pubkey) }
) )
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
@@ -125,7 +134,8 @@ fun NwcScreen(
description = description, description = description,
permissions = permissions, permissions = permissions,
expiresAt = expiresAt, expiresAt = expiresAt,
budgets = budgets budgets = budgets,
strings = strings
) )
}, },
onDismiss = { viewModel.closeAddSheet() } onDismiss = { viewModel.closeAddSheet() }
@@ -134,12 +144,13 @@ fun NwcScreen(
// ── Pairing URL dialog ───────────────────────────────────────────────────── // ── Pairing URL dialog ─────────────────────────────────────────────────────
pairingUrl?.let { url -> pairingUrl?.let { url ->
val strings = strings
NwcPairingUrlDialog( NwcPairingUrlDialog(
url = url, url = url,
onDismiss = { viewModel.clearPairingUrl() }, onDismiss = { viewModel.clearPairingUrl() },
onNoAppFound = { onNoAppFound = {
scope.launch { scope.launch {
snackbarHostState.showSnackbar("No compatible app found") snackbarHostState.showSnackbar(strings.nwc.noCompatibleAppFound)
} }
} }
) )
@@ -149,15 +160,15 @@ fun NwcScreen(
pendingDeletePubkey?.let { pendingDeletePubkey?.let {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.cancelDelete() }, onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Remove connection?") }, title = { Text(strings.nwc.removeConnection) },
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") }, text = { Text(strings.nwc.removeConnectionDetail) },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { viewModel.confirmDelete() } onClick = { viewModel.confirmDelete(strings) }
) { Text("Remove", color = MaterialTheme.colorScheme.error) } ) { Text(strings.nwc.remove, color = MaterialTheme.colorScheme.error) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) { Text("Cancel") } TextButton(onClick = { viewModel.cancelDelete() }) { Text(strings.nwc.cancel) }
} }
) )
} }
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.strings
// ── Empty state ────────────────────────────────────────────────────── // ── Empty state ──────────────────────────────────────────────────────
@@ -21,10 +22,10 @@ internal fun NwcEmptyContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text("No connections yet", style = MaterialTheme.typography.titleMedium) Text(strings.nwc.noConnectionsYet, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Tap + to connect an app via Nostr Wallet Connect.", strings.nwc.tapToAddHint,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -32,7 +33,7 @@ internal fun NwcEmptyContent(
Button(onClick = onAddNew) { Button(onClick = onAddNew) {
Icon(Icons.Default.Add, contentDescription = null) Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Add connection") Text(strings.nwc.addConnection)
} }
} }
} }
@@ -51,7 +52,7 @@ internal fun NwcErrorContent(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
"Something went wrong", strings.nwc.somethingWentWrong,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -62,6 +63,6 @@ internal fun NwcErrorContent(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onRetry) { Text("Retry") } OutlinedButton(onClick = onRetry) { Text(strings.nwc.retry) }
} }
} }
@@ -3,14 +3,16 @@ package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcNewBudget import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.toDomain
import com.bitcointxoko.gudariwallet.data.toDomain import com.bitcointxoko.gudariwallet.data.toDomain
import com.bitcointxoko.gudariwallet.data.toGetResponse import com.bitcointxoko.gudariwallet.data.toGetResponse
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -95,18 +97,14 @@ class NwcViewModel(
fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey } fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey }
fun cancelDelete() { _pendingDeletePubkey.value = null } fun cancelDelete() { _pendingDeletePubkey.value = null }
// confirmDelete() calls the existing deleteConnection() then clears the pending key // confirmDelete() calls the existing deleteConnection() then clears the pending key
fun confirmDelete() { fun confirmDelete(strings: AppStrings) {
_pendingDeletePubkey.value?.let { deleteConnection(it) } _pendingDeletePubkey.value?.let { deleteConnection(it, strings) }
_pendingDeletePubkey.value = null _pendingDeletePubkey.value = null
} }
init {
loadConnections(includeExpired = true)
}
// ── Load ────────────────────────────────────────────────────────────────── // ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false) { fun loadConnections(includeExpired: Boolean = false, strings: AppStrings) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)") Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
isSyncing = true isSyncing = true
@@ -154,7 +152,7 @@ class NwcViewModel(
isSyncing = false isSyncing = false
if (_uiState.value !is NwcUiState.Success) { if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Error( _uiState.value = NwcUiState.Error(
e.message ?: "Failed to load connections" e.message ?: strings.nwc.failedToLoadConnections
) )
} else { } else {
Timber.w("[$TAG] network failed but cache is showing — non-fatal") Timber.w("[$TAG] network failed but cache is showing — non-fatal")
@@ -172,7 +170,8 @@ class NwcViewModel(
description : String, description : String,
permissions : List<String>, permissions : List<String>,
expiresAt : Long = 0L, expiresAt : Long = 0L,
budgets : List<NwcNewBudget> = emptyList() budgets : List<NwcNewBudget> = emptyList(),
strings : AppStrings
) { ) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions") Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
viewModelScope.launch { viewModelScope.launch {
@@ -213,7 +212,7 @@ class NwcViewModel(
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] createConnection failed") Timber.e(e, "[$TAG] createConnection failed")
_uiState.value = NwcUiState.Error(e.message ?: "Failed to create connection") _uiState.value = NwcUiState.Error(e.message ?: strings.nwc.failedToCreateConnection)
} }
} finally { } finally {
_isCreating.value = false _isCreating.value = false
@@ -234,7 +233,7 @@ class NwcViewModel(
* and the detail screen (revoke button). Sets [_isDeleting] for the duration * and the detail screen (revoke button). Sets [_isDeleting] for the duration
* so the list screen can disable row interactions while in flight. * so the list screen can disable row interactions while in flight.
*/ */
fun deleteConnection(pubkey: String) { fun deleteConnection(pubkey: String, strings: AppStrings) {
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}") Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch { viewModelScope.launch {
_isDeleting.value = true _isDeleting.value = true
@@ -254,7 +253,7 @@ class NwcViewModel(
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}") Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
_uiState.value = NwcUiState.Error( _uiState.value = NwcUiState.Error(
e.message ?: "Failed to delete connection" e.message ?: strings.nwc.failedToDeleteConnection
) )
} }
} finally { } finally {
@@ -264,7 +263,7 @@ class NwcViewModel(
} }
fun loadConnectionDetail(pubkey: String) { fun loadConnectionDetail(pubkey: String, strings: AppStrings) {
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}") Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}")
// Cancel any subscription from a previous key — prevents stale Flow // Cancel any subscription from a previous key — prevents stale Flow
@@ -303,7 +302,7 @@ class NwcViewModel(
Timber.e(e, "[$TAG] failed to load connection detail") Timber.e(e, "[$TAG] failed to load connection detail")
if (_connectionDetailState.value !is ConnectionDetailState.Success) { if (_connectionDetailState.value !is ConnectionDetailState.Success) {
_connectionDetailState.value = ConnectionDetailState.Error( _connectionDetailState.value = ConnectionDetailState.Error(
e.message ?: "Failed to load connection" e.message ?: strings.nwc.failedToLoadConnection
) )
} else { } else {
Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)") Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)")
@@ -265,7 +265,7 @@ fun NotificationsPage(
imageVector = Icons.Default.Notifications, imageVector = Icons.Default.Notifications,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(72.dp), modifier = Modifier.size(72.dp),
// Mirror the BatteryPage tint behaviour // Mirror the BatteryPage tint behavior
tint = if (alreadyGranted) tint = if (alreadyGranted)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else else
@@ -300,7 +300,7 @@ fun NotificationsPage(
} }
} }
// ── Page 5: Battery optimisation ────────────────────────────────────────────── // ── Page 5: Battery optimization ──────────────────────────────────────────────
@Composable @Composable
fun BatteryPage(strings: AppStrings) { fun BatteryPage(strings: AppStrings) {
val context = LocalContext.current val context = LocalContext.current
@@ -13,11 +13,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -1,29 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.receive
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessHigh
import androidx.compose.material.icons.filled.BrightnessLow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.bitcointxoko.gudariwallet.LocalAppStrings
@Composable
internal fun BrightnessToggleButton(
isOn : Boolean,
onToggle: () -> Unit
) {
val strings = LocalAppStrings.current
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isOn) Icons.Filled.BrightnessHigh
else Icons.Filled.BrightnessLow,
contentDescription = if (isOn) strings.receive.brightnessReduce // ← "Reduce brightness"
else strings.receive.brightnessBoost, // ← "Boost brightness"
tint = if (isOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -64,6 +64,7 @@ import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.util.buildUpTaps import com.bitcointxoko.gudariwallet.util.buildUpTaps
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun ReceiveScreen( fun ReceiveScreen(
@@ -636,7 +637,7 @@ private fun ReceiveInvoiceContent(
} }
LaunchedEffect(state.expiresAt) { LaunchedEffect(state.expiresAt) {
while (secondsLeft > 0L) { while (secondsLeft > 0L) {
delay(1_000L) delay(1_000L.milliseconds)
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L) secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
.coerceAtLeast(0L) .coerceAtLeast(0L)
} }
@@ -37,6 +37,7 @@ import kotlinx.coroutines.delay
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
internal fun Bolt11ConfirmCard( internal fun Bolt11ConfirmCard(
@@ -117,7 +118,7 @@ private fun ConfirmCardContent(
var now by remember { mutableStateOf(Instant.now()) } var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(expiresAt) { LaunchedEffect(expiresAt) {
while (true) { while (true) {
delay(1_000L) delay(1_000L.milliseconds)
now = Instant.now() now = Instant.now()
} }
} }
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui.send
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
class PaymentPoller(private val repo: WalletRepository) { class PaymentPoller(private val repo: WalletRepository) {
@@ -19,7 +20,7 @@ class PaymentPoller(private val repo: WalletRepository) {
maxAttempts : Int = 120, maxAttempts : Int = 120,
): PollResult { ): PollResult {
repeat(maxAttempts) { attempt -> repeat(maxAttempts) { attempt ->
delay(intervalMs) delay(intervalMs.milliseconds)
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
@@ -37,6 +37,7 @@ import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.heavyClick import com.bitcointxoko.gudariwallet.util.heavyClick
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun SendScreen( fun SendScreen(
@@ -218,7 +219,7 @@ fun SendScreen(
var slowPayment by remember { mutableStateOf(false) } var slowPayment by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(10_000L) delay(10_000L.milliseconds)
slowPayment = true slowPayment = true
} }
@@ -175,7 +175,7 @@ class SendViewModel(
} }
} }
private suspend fun emitPaymentSent( private fun emitPaymentSent(
paymentHash : String, paymentHash : String,
amountSats : Long, amountSats : Long,
feeSats : Long?, feeSats : Long?,
@@ -1,10 +1,14 @@
package com.bitcointxoko.gudariwallet.util package com.bitcointxoko.gudariwallet.util
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber import timber.log.Timber
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale
// ── Epoch formatters ────────────────────────────────────────────────────────── // ── Epoch formatters ──────────────────────────────────────────────────────────
// //
@@ -13,45 +17,98 @@ import java.time.format.FormatStyle
// class-load time and become stale after a timezone/locale change). // class-load time and become stale after a timezone/locale change).
/** /**
* "dd MMM" e.g. "09 Jun" * Returns "Today", "Yesterday", or null if neither applies.
* Suitable for compact list labels. * Extracted so all three formatters share the same logic without duplication.
*/ */
fun formatEpochShort(epochSeconds: Long): String = enum class RelativeDay { TODAY, YESTERDAY }
DateTimeFormatter.ofPattern("dd MMM") private fun relativeDay(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): RelativeDay? {
.withZone(ZoneId.systemDefault()) val date = instant.atZone(zone).toLocalDate()
.format(Instant.ofEpochSecond(epochSeconds)) val today = LocalDate.now(zone)
return when (date) {
today -> RelativeDay.TODAY
today.minusDays(1) -> RelativeDay.YESTERDAY
else -> null
}
}
/** /**
* "dd MMM yyyy, HH:mm" e.g. "09 Jun 2025, 14:32" * "Today" / "Yesterday" / "dd MMM" e.g. "Today", "Yesterday", "09 Jun"
* Suitable for compact list labels.
*/
fun formatEpochShort(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String {
val instant = Instant.ofEpochSecond(epochSeconds)
val zone = ZoneId.systemDefault()
return when (relativeDay(instant, zone)) {
RelativeDay.TODAY -> todayLabel
RelativeDay.YESTERDAY -> yesterdayLabel
null -> DateTimeFormatter.ofPattern("dd MMM").withZone(zone).format(instant)
}
}
/**
* "Today, HH:mm" / "Yesterday, HH:mm" / "dd MMM yyyy, HH:mm" e.g. "Today, 14:32"
* Accepts either a numeric epoch-seconds value or an ISO offset-date-time string * Accepts either a numeric epoch-seconds value or an ISO offset-date-time string
* as [rawTime] fallback (for payments not yet persisted through Room). * as [rawTime] fallback (for payments not yet persisted through Room).
*/ */
fun formatTimestamp(createdAt: Long?, rawTime: String): String { fun formatTimestamp(createdAt: Long?, rawTime: String, todayLabel: String, yesterdayLabel: String): String {
val formatter = DateTimeFormatter val zone = ZoneId.systemDefault()
.ofPattern("dd MMM yyyy, HH:mm") val timeFmt = DateTimeFormatter.ofPattern("HH:mm").withZone(zone)
.withZone(ZoneId.systemDefault()) val fullFmt = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm").withZone(zone)
if (createdAt != null && createdAt > 1_000_000_000L) {
return formatter.format(Instant.ofEpochSecond(createdAt)) fun format(instant: Instant): String {
return when (relativeDay(instant, zone)) {
RelativeDay.TODAY -> "$todayLabel, ${timeFmt.format(instant)}"
RelativeDay.YESTERDAY -> "$yesterdayLabel, ${timeFmt.format(instant)}"
null -> fullFmt.format(instant)
} }
}
if (createdAt != null && createdAt > 1_000_000_000L) return format(Instant.ofEpochSecond(createdAt))
return runCatching { return runCatching {
formatter.format(Instant.ofEpochSecond(rawTime.toLong())) format(Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching { }.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime)) format(java.time.OffsetDateTime.parse(rawTime).toInstant())
}.getOrDefault(rawTime) }.getOrDefault(rawTime)
} }
/** /**
* Localized SHORT date-time e.g. "6/9/25, 2:32 PM" (locale-dependent). * "Today, 2:32 PM" / "Yesterday, 2:32 PM" / localized SHORT date-time (locale-dependent).
* Returns "" for out-of-range epoch values; callers should handle sentinel * Returns "" for out-of-range epoch values; callers should handle sentinel
* values (e.g. 0L = "never expires") before calling this. * values (e.g. 0L = "never expires") before calling this.
*/ */
fun formatEpoch(epochSeconds: Long): String = fun formatEpoch(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String =
try { try {
DateTimeFormatter val zone = ZoneId.systemDefault()
val instant = Instant.ofEpochSecond(epochSeconds)
val raw: String = when (relativeDay(instant, zone)) {
RelativeDay.TODAY -> "$todayLabel, ${
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone).format(instant)
}"
RelativeDay.YESTERDAY -> "$yesterdayLabel, ${
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone).format(instant)
}"
null -> DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.SHORT) .ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault()) .withZone(zone)
.format(Instant.ofEpochSecond(epochSeconds)) .format(instant)
}
raw.lowercase().replaceFirstChar { it.titlecase(Locale.getDefault()) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds") Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds")
"" ""
} }
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun epochToLocalDate(epochSeconds: Long): LocalDate =
LocalDate.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault())
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
internal fun isToday(epochSeconds: Long): Boolean =
epochToLocalDate(epochSeconds) == LocalDate.now(ZoneId.systemDefault())
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
internal fun isYesterday(epochSeconds: Long): Boolean =
epochToLocalDate(epochSeconds) == LocalDate.now(ZoneId.systemDefault()).minusDays(1)
@@ -23,7 +23,7 @@ object IntentUtils {
* Returns a raw payment URI string if the intent carries one, else null. * Returns a raw payment URI string if the intent carries one, else null.
* *
* Handles: * Handles:
* - ACTION_VIEW with a recognised scheme (BTCPay "Pay in wallet", browser links) * - ACTION_VIEW with a recognized scheme (BTCPay "Pay in wallet", browser links)
* - ACTION_SEND with text/plain (share sheet from another app) * - ACTION_SEND with text/plain (share sheet from another app)
*/ */
fun extractPaymentUri(intent: Intent?): String? { fun extractPaymentUri(intent: Intent?): String? {
@@ -40,7 +40,7 @@ object LnurlMetadataParser {
/** /**
* Returns the display label for a LNURL payment target. * Returns the display label for a LNURL payment target.
* If [lnurl] is a Lightning Address (contains '@'), returns it as-is. * If [lnurl] is a Lightning Address (contains '@'), returns it as-is.
* Otherwise returns the [domain] of the LNURL endpoint. * Otherwise, returns the [domain] of the LNURL endpoint.
*/ */
fun payingToLabel(lnurl: String, domain: String): String = fun payingToLabel(lnurl: String, domain: String): String =
if ('@' in lnurl) lnurl else domain if ('@' in lnurl) lnurl else domain