Compare commits

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">
<words>
<w>Aead</w>
<w>Bech</w>
<w>Bitrefill</w>
<w>Español</w>
<w>Nbits</w>
<w>Nostr</w>
<w>Tink</w>
<w>amboss</w>
<w>bech</w>
<w>bitcointxoko</w>
<w>btcpay</w>
<w>cltv</w>
<w>getnwckey</w>
<w>gudari</w>
<w>gudariwallet</w>
<w>hkdf</w>
<w>inflight</w>
<w>keyauth</w>
<w>lnaddr</w>
<w>lnaddress</w>
<w>lnbc</w>
<w>lnbits</w>
<w>lnurlp</w>
<w>lnurlw</w>
<w>msat</w>
<w>msats</w>
<w>nostr</w>
<w>nwccacherepository</w>
<w>satoshi</w>
<w>satoshis</w>
<w>sats</w>
<w>snackbar</w>
<w>snull</w>
<w>tink</w>
<w>walletconnect</w>
</words>
</dictionary>
</component>
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -18,7 +18,7 @@ import kotlinx.serialization.json.jsonPrimitive
* - a JSON array ["Thanks!"]
* - null
*
* Mirrors the behaviour of the old Gson FlexibleStringAdapter.
* Mirrors the behavior of the old Gson FlexibleStringAdapter.
*/
object FlexibleStringSerializer : KSerializer<String?> {
@@ -3,7 +3,7 @@ package com.bitcointxoko.gudariwallet.api
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,
* and WebSocket message parsing — must use this instance so that
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -13,7 +12,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
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() }
private fun buildApi(): LNbitsApi {
@@ -251,7 +251,7 @@ data class SuccessAction(
* - a plain string "Thanks!"
* - 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.
*
* Applied via @Serializable(with = SuccessActionSerializer::class) on
@@ -337,10 +337,8 @@ class LNbitsWalletRepository(
request : NwcRegistrationRequest
): NwcGetResponse {
Timber.d(
"NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} " +
"expires_at=${request.expires_at} " +
"budgets=${request.budgets.size}"
"%snull", "NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} "
)
val result = api.registerNwcKey(
adminKey = secrets.adminKey(),
@@ -38,8 +38,7 @@ class LightningAddressStore(private val context: Context) {
.map { prefs ->
prefs[KEY_ADDRESS].also { address ->
_currentAddress = address
Timber.d("LNADDR [CACHE ] read from cache: " +
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
}
suspend fun isCacheStale(): Boolean {
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
val age = System.currentTimeMillis() - fetchedAt
val isStale = address == null || age > LNADDRESS_TTL_MS
Timber.d("LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " +
if (isStale) "→ cache stale, will fetch from API"
Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
else "→ cache fresh, skipping API call")
isStale
}
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
return null
}
val response = deserialise(entity.responseJson) ?: run {
val response = deserialize(entity.responseJson) ?: run {
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key)
return null
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) }
}
private fun deserialise(jsonString: String): LnurlScanResponse? =
private fun deserialize(jsonString: String): LnurlScanResponse? =
runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull()
}
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings
import kotlin.String
@LyricistStrings(languageTag = "en", default = true)
val EnHomeStrings = AppStrings(
@@ -55,6 +54,8 @@ val EnHomeStrings = AppStrings(
details = "Details",
enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" },
today = "today",
yesterday = "yesterday",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Send",
@@ -377,6 +378,9 @@ val EnHomeStrings = AppStrings(
connectionRowExpires = "Expires",
connectionRowPubkey = "Public key",
connectionNeverUsed = "Never",
lastUsedToday = "Last used today",
lastUsedYesterday = "Last used yesterday",
lastUsedDate = "Last used %s",
connectionNoExpiry = "Never",
connectionExpired = { date -> "Expired · $date" },
connectionSectionPermissions = "Permissions",
@@ -398,6 +402,10 @@ val EnHomeStrings = AppStrings(
permSignMessage = "Sign messages",
permPay = "Send payments",
permInvoice = "Create invoices",
permLookup = "Lookup invoice status",
permHistory = "Read transaction history",
permBalance = "Read wallet balance",
permInfo = "Read account info",
permLookupHistory = "Lookup status & transaction history",
permBalanceInfo = "Read balance & account info",
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.",
revokeDialogConfirm = "Revoke",
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
import cafe.adriel.lyricist.LyricistStrings
import com.bitcointxoko.gudariwallet.i18n.NwcStrings
@LyricistStrings(languageTag = "es")
val EsHomeStrings = AppStrings(
@@ -55,6 +54,8 @@ val EsHomeStrings = AppStrings(
details = "Detalles",
enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" },
today = "hoy",
yesterday = "ayer",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar",
@@ -378,6 +379,9 @@ val EsHomeStrings = AppStrings(
connectionRowExpires = "Vence",
connectionRowPubkey = "Clave pública",
connectionNeverUsed = "Nunca",
lastUsedToday = "Usada hoy",
lastUsedYesterday = "Usada ayer",
lastUsedDate = "Usada %s",
connectionNoExpiry = "Sin vencimiento",
connectionExpired = { date -> "Vencido · $date" },
connectionSectionPermissions = "Permisos",
@@ -399,6 +403,10 @@ val EsHomeStrings = AppStrings(
permSignMessage = "Firmar mensajes",
permPay = "Enviar pagos",
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",
permBalanceInfo = "Ver saldo e información de cuenta",
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.",
revokeDialogConfirm = "Revocar",
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",
enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" },
today = "gaur",
yesterday = "atzo",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali",
@@ -374,6 +376,9 @@ val EuStrings = AppStrings(
connectionSectionDetails = "Xehetasunak",
connectionRowCreated = "Konektatuta",
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",
connectionRowPubkey = "Gako publikoa",
connectionNeverUsed = "Inoiz ez",
@@ -398,6 +403,10 @@ val EuStrings = AppStrings(
permSignMessage = "Mezuak sinatu",
permPay = "Ordainketak bidali",
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",
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
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.",
revokeDialogConfirm = "Baliogabetu",
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 enterValidAmount : String,
val satsUnit : (sats: Long) -> String,
val today : String,
val yesterday : String,
// ── SendScreen — general ──────────────────────────────────────────────────
val send : String,
@@ -381,6 +383,9 @@ data class NwcStrings(
val connectionRowExpires : String,
val connectionRowPubkey : 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,
/** "Expired · {date}" */
val connectionExpired : (date: String) -> String,
@@ -404,12 +409,16 @@ data class NwcStrings(
val permLookupInvoice : String,
val permListTransactions : String,
val permSignMessage : String,
val permPay : String, // "Send payments"
val permInvoice : String, // "Create invoices"
val permLookupHistory: String, // "Lookup status & transaction history"
val permBalanceInfo : String, // "Read balance & account info"
val permSendPayments: String,
val permCreateInvoices: String,
val permPay : String, // "Send payments"
val permInvoice : String, // "Create invoices"
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 permSendPayments : String,
val permCreateInvoices : String,
val permLookupInvoiceStatus: String,
val permReadTransactionHistory: String,
val permReadWalletBalance: String,
@@ -418,4 +427,56 @@ data class NwcStrings(
val revokeDialogBody : String,
val revokeDialogConfirm : 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
import android.content.Context
import timber.log.Timber
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.migrations.SharedPreferencesMigration
import androidx.datastore.migrations.SharedPreferencesView
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.File
import androidx.core.content.edit
/**
* Singleton DataStore for encrypted credentials.
@@ -27,8 +22,6 @@ import androidx.core.content.edit
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
*/
object CredentialsDataStore {
private const val TAG = "CredentialsDataStore"
@Volatile
private var instance: DataStore<Credentials>? = null
@@ -25,8 +25,8 @@ class CredentialsSerializer(
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
result
} catch (e: Exception) {
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e)
throw CorruptionException("Cannot read/decrypt credentials DataStore", e)
Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
throw CorruptionException("Cannot read/decrypt credentials DataStore")
}
}
@@ -37,12 +37,10 @@ class CredentialsSerializer(
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
encryptingStream.close()
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " +
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
} catch (e: Exception) {
Timber.e("Encryption/write failed", e)
Timber.e("Encryption/write failed")
throw e
}
}
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
Timber.i("StreamingAead primitive acquired successfully")
}
} 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
}
}
@@ -41,7 +41,7 @@ class NodeAliasService(
repo.put(pubkey, alias)
// 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias)
_aliases.value += (pubkey to alias)
Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"")
return alias
@@ -30,6 +30,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "WalletNotifService"
@@ -271,7 +272,7 @@ class WalletNotificationService : Service() {
reconnectJob?.cancel()
reconnectJob = serviceScope.launch {
delay(backoff)
delay(backoff.milliseconds)
openWebSocket()
}
}
@@ -71,6 +71,7 @@ import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── Tab destinations ──────────────────────────────────────────────────────────
// Note: label and icon are now resolved at the call site via LocalAppStrings,
@@ -205,7 +206,7 @@ fun WalletScreen(
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
delay(8_000.milliseconds)
vm.dismissClipboardOffer()
}
}
@@ -215,7 +216,7 @@ fun WalletScreen(
LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) {
delay(8_000)
delay(8_000.milliseconds)
nfcVm.dismissNfcOffer()
}
}
@@ -460,7 +461,7 @@ fun WalletScreen(
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey) }
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) {
@@ -490,7 +491,7 @@ fun WalletScreen(
budgets = state.budgets,
onBack = { navController.popBackStack() },
onRevoke = {
nwcVm.deleteConnection(pubkey)
nwcVm.deleteConnection(pubkey, strings)
navController.popBackStack()
}
)
@@ -13,7 +13,6 @@ import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
@@ -75,11 +75,10 @@ class BalanceViewModel(
runCatching { repo.getBalance() }
.onSuccess { balance ->
val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats
when {
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
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})")
when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
}
_balanceState.value = BalanceState.Success(
sats = sats,
@@ -1,9 +1,8 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
/**
* Manages screen brightness for QR-display screens.
@@ -12,12 +11,12 @@ import androidx.compose.ui.platform.LocalContext
*/
@Composable
fun rememberBrightnessController(): BrightnessController {
val window = (LocalContext.current as? Activity)?.window
val window = (LocalActivity.current)?.window
var isOn by remember { mutableStateOf(false) }
// Sync side-effect: no coroutine needed
// Sync side effect: no coroutine needed
SideEffect {
window?.attributes = window?.attributes?.apply {
window?.attributes = window.attributes?.apply {
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
@@ -25,7 +24,7 @@ fun rememberBrightnessController(): BrightnessController {
DisposableEffect(Unit) {
onDispose {
window?.attributes = window?.attributes?.apply {
window?.attributes = window.attributes?.apply {
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -220,7 +220,7 @@ internal fun FilterBottomSheet(
) {
Text(
currentFilter.minCreatedAt
?.let { formatEpochShort(it) }
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateFrom_label // ← "From"
)
}
@@ -231,7 +231,7 @@ internal fun FilterBottomSheet(
) {
Text(
currentFilter.maxCreatedAt
?.let { formatEpochShort(it) }
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateTo_label // ← "To"
)
}
@@ -464,13 +464,13 @@ private fun ActiveFilterChips(
null -> when {
minCreatedAt != null && maxCreatedAt != null ->
strings.history.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt),
formatEpochShort(maxCreatedAt)
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
)
minCreatedAt != null ->
strings.history.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
else ->
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
}
}
InputChip(
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel"
@@ -156,7 +157,7 @@ class HistoryViewModel(
viewModelScope.launch {
combine(
_filter.map { it.searchQuery }.debounce(300),
_filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter
) { _, f -> f }
.collect { f ->
@@ -164,10 +165,8 @@ class HistoryViewModel(
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
_roomResults.value = null
} else {
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
"amt=${f.minAmountSat}${f.maxAmountSat} " +
"date=${f.minCreatedAt}${f.maxCreatedAt}")
Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"statuses=${f.statuses} dir=${f.direction} types=${f.types} ")
_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.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -234,7 +233,11 @@ private fun PaymentDetailContent(
// ── Basic info ───────────────────────────────────────────────────────
item {
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(
strings.history.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
@@ -78,7 +78,7 @@ internal fun PaymentRow(
)
Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time)
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
}
Text(
text = formattedTime,
@@ -159,8 +159,7 @@ class PaymentSyncManager(
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
if (reachedOverlap || reachedEnd) {
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " +
if (reachedOverlap) "stopped at known record" else "reached end of history")
Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
break
}
@@ -77,13 +77,12 @@ class NdefHceService : HostApduService() {
val ins = apdu[1]
return when {
return when (ins) {
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
ins == 0xA4.toByte() -> handleSelect(apdu)
0xA4.toByte() -> handleSelect(apdu)
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
ins == 0xB0.toByte() -> handleReadBinary(apdu)
0xB0.toByte() -> handleReadBinary(apdu)
else -> SW_UNKNOWN
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
}
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
}
if (rawMessages != null && rawMessages.isNotEmpty()) {
if (!rawMessages.isNullOrEmpty()) {
val message = rawMessages[0] as NdefMessage
val uri = extractUri(message)
if (uri != null) {
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
}
if (tag != null) {
viewModelScope.launch(Dispatchers.IO) {
@@ -16,6 +16,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.strings
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -41,6 +43,16 @@ private class AddConnectionState {
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 ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@@ -58,6 +70,7 @@ internal fun NwcAddConnectionSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val state = remember { AddConnectionState() }
if (state.showDatePicker) {
val initialMillis = state.expiry
.atZone(ZoneId.systemDefault())
@@ -81,10 +94,10 @@ internal fun NwcAddConnectionSheet(
.withDayOfMonth(picked.dayOfMonth)
}
state.showDatePicker = false
}) { Text("OK") }
}) { Text(strings.nwc.ok) }
},
dismissButton = {
TextButton(onClick = { state.showDatePicker = false }) { Text("Cancel") }
TextButton(onClick = { state.showDatePicker = false }) { Text(strings.nwc.cancel) }
}
) { DatePicker(state = datePickerState) }
}
@@ -105,10 +118,10 @@ internal fun NwcAddConnectionSheet(
.withSecond(0)
.withNano(0)
state.showTimePicker = false
}) { Text("OK") }
}) { Text(strings.nwc.ok) }
},
dismissButton = {
TextButton(onClick = { state.showTimePicker = false }) { Text("Cancel") }
TextButton(onClick = { state.showTimePicker = false }) { Text(strings.nwc.cancel) }
},
text = { TimePicker(state = timePickerState) }
)
@@ -127,7 +140,7 @@ internal fun NwcAddConnectionSheet(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New connection",
text = strings.nwc.newConnection,
style = MaterialTheme.typography.titleMedium
)
@@ -135,7 +148,7 @@ internal fun NwcAddConnectionSheet(
OutlinedTextField(
value = state.description,
onValueChange = { state.description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
label = { Text(strings.nwc.labelHint) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -145,7 +158,7 @@ internal fun NwcAddConnectionSheet(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Expires", style = MaterialTheme.typography.bodyMedium)
Text(text = strings.nwc.expires, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
if (!state.neverExpires) {
@@ -158,7 +171,7 @@ internal fun NwcAddConnectionSheet(
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date",
contentDescription = strings.nwc.pickDate,
modifier = Modifier.size(16.dp)
)
}
@@ -173,7 +186,7 @@ internal fun NwcAddConnectionSheet(
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pick time",
contentDescription = strings.nwc.pickTime,
modifier = Modifier.size(16.dp)
)
}
@@ -185,7 +198,7 @@ internal fun NwcAddConnectionSheet(
checked = state.neverExpires,
onCheckedChange = { state.neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
Text(text = strings.nwc.never, style = MaterialTheme.typography.bodyMedium)
}
Row(
@@ -193,7 +206,7 @@ internal fun NwcAddConnectionSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Budgets",
text = strings.nwc.budgets,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -206,11 +219,11 @@ internal fun NwcAddConnectionSheet(
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add budget",
contentDescription = strings.nwc.addBudget,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add budget")
Text(strings.nwc.addBudget)
}
}
@@ -230,7 +243,7 @@ internal fun NwcAddConnectionSheet(
// ── Permissions ──────────────────────────────────────────────────
Text(
text = "Permissions",
text = strings.nwc.permissions,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -251,11 +264,11 @@ internal fun NwcAddConnectionSheet(
}
)
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 ─────────────────────────────────────────────────
Button(
onClick = {
@@ -263,7 +276,7 @@ internal fun NwcAddConnectionSheet(
else state.expiryEpochSeconds() // #10
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
state.description.trim().ifBlank { "Unnamed" },
state.description.trim().ifBlank { unnamed },
state.selectedPermissions.toList(),
expiresAtSecs,
validBudgets
@@ -280,7 +293,7 @@ internal fun NwcAddConnectionSheet(
)
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 ────────────────────────────────────────────────────────────────
@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)
@Composable
private fun BudgetRow(
@@ -313,7 +335,7 @@ private fun BudgetRow(
OutlinedTextField(
value = draft.amountSats,
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
label = { Text("Sats") },
label = { Text(strings.nwc.sats) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).height(fieldHeight)
@@ -325,10 +347,10 @@ private fun BudgetRow(
modifier = Modifier.weight(1f).height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.label,
value = draft.refreshWindow.displayLabel(strings),
onValueChange = {},
readOnly = true,
label = { Text("Resets") },
label = { Text(strings.nwc.resets) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
@@ -343,7 +365,7 @@ private fun BudgetRow(
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.label) },
text = { Text(window.displayLabel(strings)) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
@@ -359,7 +381,7 @@ private fun BudgetRow(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove budget",
contentDescription = strings.nwc.removeBudget,
tint = MaterialTheme.colorScheme.error
)
}
@@ -1,5 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.strings
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 java.time.Instant
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
internal fun NwcConnectionRow(
connection : NwcGetResponse,
isDeleting : Boolean,
onDeleteConfirm : () -> Unit,
connection: NwcGetResponse,
isDeleting: Boolean,
onDeleteConfirm: () -> Unit,
onConnectionClick: () -> Unit,
modifier : Modifier = Modifier
modifier: Modifier = Modifier
) {
val key = connection.data
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
@@ -35,7 +41,7 @@ internal fun NwcConnectionRow(
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
// 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
},
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
@@ -47,7 +53,7 @@ internal fun NwcConnectionRow(
var dialogTriggered by remember { mutableStateOf(false) }
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) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting
@@ -84,7 +90,7 @@ internal fun NwcConnectionRow(
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete connection",
contentDescription = strings.nwc.deleteConnection,
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
)
}
@@ -110,7 +116,7 @@ internal fun NwcConnectionRow(
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = key.description.ifBlank { "Unnamed connection" },
text = key.description.ifBlank { strings.nwc.unnamedConnection },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -120,9 +126,15 @@ internal fun NwcConnectionRow(
MaterialTheme.colorScheme.onSurface
)
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 = if (key.last_used <= 0L) "Never used"
else "Last used ${formatEpoch(key.last_used)}",
text = if (key.last_used <= 0L) strings.nwc.neverUsed
else lastUsedText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -134,7 +146,7 @@ internal fun NwcConnectionRow(
contentColor = semantic.onErrorContainer
) {
Text(
text = "Expired",
text = strings.nwc.expired,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
@@ -92,8 +92,6 @@ fun ConnectionDetailScreen(
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
@@ -218,13 +216,13 @@ private fun ConnectionDetailContent(
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt)
value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
)
DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed)
formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
else
strings.nwc.connectionNeverUsed // ← "Never"
)
@@ -234,9 +232,13 @@ private fun ConnectionDetailContent(
value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
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
)
@@ -383,7 +385,7 @@ private fun BudgetCard(budget: BudgetData) {
// Created at
DetailRow(
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 = permission.label(strings), // already localised
text = permission.label(strings), // already localized
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
@@ -5,17 +5,16 @@ import com.bitcointxoko.gudariwallet.api.NwcNewBudget
// ── Available permissions ────────────────────────────────────────────
internal data class NwcPermission(
val serverKey : String, // what we send in the permissions array
val displayLabel : String, // shown in the UI
val default : Boolean // pre-checked by default
)
internal val ALL_PERMISSIONS = listOf(
NwcPermission("pay", "Send payments", default = true),
NwcPermission("invoice", "Create invoices", default = false),
NwcPermission("lookup", "Lookup invoice status", default = false),
NwcPermission("history", "Read transaction history", default = false),
NwcPermission("balance", "Read wallet balance", default = false),
NwcPermission("info", "Read account info", default = false)
NwcPermission("pay", default = true),
NwcPermission("invoice", default = false),
NwcPermission("lookup", default = false),
NwcPermission("history", default = false),
NwcPermission("balance", default = false),
NwcPermission("info", default = false)
)
// ── Budget ────────────────────────────────────────────────────────────
@@ -24,12 +23,12 @@ internal val ALL_PERMISSIONS = listOf(
* Transient UI state for a single budget row in the "Add connection" sheet.
* Converted to [NwcNewBudget] on submit.
*/
enum class BudgetRefreshWindow(val label: String, val seconds: Int) {
DAILY ("Daily", 86_400),
WEEKLY ("Weekly", 604_800),
MONTHLY ("Monthly", 2_592_000),
YEARLY ("Yearly", 31_536_000),
NEVER ("Never", 0);
enum class BudgetRefreshWindow(val seconds: Int) {
DAILY (86_400),
WEEKLY (604_800),
MONTHLY (2_592_000),
YEARLY (31_536_000),
NEVER (0);
}
data class BudgetDraft(
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
@@ -29,20 +30,21 @@ internal fun NwcPairingUrlDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connection created") },
title = { Text(strings.nwc.connectionCreated) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
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,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val strings = strings
QrDisplayCard(
modifier = Modifier.fillMaxWidth(),
content = url,
contentDescription = "NWC pairing QR code",
clipLabel = "NWC connection string",
shareTitle = "Share NWC connection string",
contentDescription = strings.nwc.nwcPairingQrCode,
clipLabel = strings.nwc.nwcConnectionString,
shareTitle = strings.nwc.shareNwcConnectionString,
textToCopy = url,
brightnessOn = brightness.isOn,
onToggleBrightness = brightness.toggle,
@@ -52,7 +54,7 @@ internal fun NwcPairingUrlDialog(
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
try {
context.startActivity(
Intent.createChooser(intent, "Open in app")
Intent.createChooser(intent, strings.nwc.openInApp)
)
} catch (e: ActivityNotFoundException) {
onNoAppFound() // #17: no Toast — caller handles it
@@ -63,7 +65,7 @@ internal fun NwcPairingUrlDialog(
},
confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") }
TextButton(onClick = onDismiss) { Text(strings.nwc.done) }
}
)
}
@@ -1,5 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -13,8 +15,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NwcScreen(
@@ -30,8 +34,13 @@ fun NwcScreen(
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
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) {
if (pairingUrl != null) {
@@ -42,17 +51,17 @@ fun NwcScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Wallet Connect") },
title = { Text(strings.nwc.walletConnect) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.nwc.back)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add connection")
Icon(Icons.Default.Add, contentDescription = strings.nwc.addConnection)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
@@ -102,7 +111,7 @@ fun NwcScreen(
connection,
isDeleting,
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
onConnectionClick = { onConnectionClick(connection.data.pubkey) }
{ onConnectionClick(connection.data.pubkey) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
@@ -125,7 +134,8 @@ fun NwcScreen(
description = description,
permissions = permissions,
expiresAt = expiresAt,
budgets = budgets
budgets = budgets,
strings = strings
)
},
onDismiss = { viewModel.closeAddSheet() }
@@ -134,12 +144,13 @@ fun NwcScreen(
// ── Pairing URL dialog ─────────────────────────────────────────────────────
pairingUrl?.let { url ->
val strings = strings
NwcPairingUrlDialog(
url = url,
onDismiss = { viewModel.clearPairingUrl() },
onNoAppFound = {
scope.launch {
snackbarHostState.showSnackbar("No compatible app found")
snackbarHostState.showSnackbar(strings.nwc.noCompatibleAppFound)
}
}
)
@@ -149,15 +160,15 @@ fun NwcScreen(
pendingDeletePubkey?.let {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Remove connection?") },
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") },
title = { Text(strings.nwc.removeConnection) },
text = { Text(strings.nwc.removeConnectionDetail) },
confirmButton = {
TextButton(
onClick = { viewModel.confirmDelete() }
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
onClick = { viewModel.confirmDelete(strings) }
) { Text(strings.nwc.remove, color = MaterialTheme.colorScheme.error) }
},
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.Modifier
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.strings
// ── Empty state ──────────────────────────────────────────────────────
@@ -21,10 +22,10 @@ internal fun NwcEmptyContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("No connections yet", style = MaterialTheme.typography.titleMedium)
Text(strings.nwc.noConnectionsYet, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Tap + to connect an app via Nostr Wallet Connect.",
strings.nwc.tapToAddHint,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -32,7 +33,7 @@ internal fun NwcEmptyContent(
Button(onClick = onAddNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Add connection")
Text(strings.nwc.addConnection)
}
}
}
@@ -51,7 +52,7 @@ internal fun NwcErrorContent(
verticalArrangement = Arrangement.Center
) {
Text(
"Something went wrong",
strings.nwc.somethingWentWrong,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
@@ -62,6 +63,6 @@ internal fun NwcErrorContent(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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.ViewModelProvider
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.NwcNewBudget
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.toDomain
import com.bitcointxoko.gudariwallet.data.toDomain
import com.bitcointxoko.gudariwallet.data.toGetResponse
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.Job
@@ -95,18 +97,14 @@ class NwcViewModel(
fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey }
fun cancelDelete() { _pendingDeletePubkey.value = null }
// confirmDelete() calls the existing deleteConnection() then clears the pending key
fun confirmDelete() {
_pendingDeletePubkey.value?.let { deleteConnection(it) }
fun confirmDelete(strings: AppStrings) {
_pendingDeletePubkey.value?.let { deleteConnection(it, strings) }
_pendingDeletePubkey.value = null
}
init {
loadConnections(includeExpired = true)
}
// ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false) {
fun loadConnections(includeExpired: Boolean = false, strings: AppStrings) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
isSyncing = true
@@ -154,7 +152,7 @@ class NwcViewModel(
isSyncing = false
if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to load connections"
e.message ?: strings.nwc.failedToLoadConnections
)
} else {
Timber.w("[$TAG] network failed but cache is showing — non-fatal")
@@ -172,7 +170,8 @@ class NwcViewModel(
description : String,
permissions : List<String>,
expiresAt : Long = 0L,
budgets : List<NwcNewBudget> = emptyList()
budgets : List<NwcNewBudget> = emptyList(),
strings : AppStrings
) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
viewModelScope.launch {
@@ -213,7 +212,7 @@ class NwcViewModel(
}
.onFailure { e ->
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 {
_isCreating.value = false
@@ -234,7 +233,7 @@ class NwcViewModel(
* and the detail screen (revoke button). Sets [_isDeleting] for the duration
* 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)}")
viewModelScope.launch {
_isDeleting.value = true
@@ -254,7 +253,7 @@ class NwcViewModel(
.onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to delete connection"
e.message ?: strings.nwc.failedToDeleteConnection
)
}
} 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)}")
// 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")
if (_connectionDetailState.value !is ConnectionDetailState.Success) {
_connectionDetailState.value = ConnectionDetailState.Error(
e.message ?: "Failed to load connection"
e.message ?: strings.nwc.failedToLoadConnection
)
} else {
Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)")
@@ -265,7 +265,7 @@ fun NotificationsPage(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
// Mirror the BatteryPage tint behaviour
// Mirror the BatteryPage tint behavior
tint = if (alreadyGranted)
MaterialTheme.colorScheme.primary
else
@@ -300,7 +300,7 @@ fun NotificationsPage(
}
}
// ── Page 5: Battery optimisation ──────────────────────────────────────────────
// ── Page 5: Battery optimization ──────────────────────────────────────────────
@Composable
fun BatteryPage(strings: AppStrings) {
val context = LocalContext.current
@@ -13,11 +13,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch
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.fiatLabel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun ReceiveScreen(
@@ -636,7 +637,7 @@ private fun ReceiveInvoiceContent(
}
LaunchedEffect(state.expiresAt) {
while (secondsLeft > 0L) {
delay(1_000L)
delay(1_000L.milliseconds)
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
.coerceAtLeast(0L)
}
@@ -37,6 +37,7 @@ import kotlinx.coroutines.delay
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun Bolt11ConfirmCard(
@@ -117,7 +118,7 @@ private fun ConfirmCardContent(
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(expiresAt) {
while (true) {
delay(1_000L)
delay(1_000L.milliseconds)
now = Instant.now()
}
}
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui.send
import timber.log.Timber
import com.bitcointxoko.gudariwallet.data.WalletRepository
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
class PaymentPoller(private val repo: WalletRepository) {
@@ -19,7 +20,7 @@ class PaymentPoller(private val repo: WalletRepository) {
maxAttempts : Int = 120,
): PollResult {
repeat(maxAttempts) { attempt ->
delay(intervalMs)
delay(intervalMs.milliseconds)
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
@@ -37,6 +37,7 @@ import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.heavyClick
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun SendScreen(
@@ -218,7 +219,7 @@ fun SendScreen(
var slowPayment by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(10_000L)
delay(10_000L.milliseconds)
slowPayment = true
}
@@ -175,7 +175,7 @@ class SendViewModel(
}
}
private suspend fun emitPaymentSent(
private fun emitPaymentSent(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
@@ -1,10 +1,14 @@
package com.bitcointxoko.gudariwallet.util
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
// ── Epoch formatters ──────────────────────────────────────────────────────────
//
@@ -13,45 +17,98 @@ import java.time.format.FormatStyle
// class-load time and become stale after a timezone/locale change).
/**
* "dd MMM" e.g. "09 Jun"
* Suitable for compact list labels.
* Returns "Today", "Yesterday", or null if neither applies.
* Extracted so all three formatters share the same logic without duplication.
*/
fun formatEpochShort(epochSeconds: Long): String =
DateTimeFormatter.ofPattern("dd MMM")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds))
enum class RelativeDay { TODAY, YESTERDAY }
private fun relativeDay(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): RelativeDay? {
val date = instant.atZone(zone).toLocalDate()
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
* as [rawTime] fallback (for payments not yet persisted through Room).
*/
fun formatTimestamp(createdAt: Long?, rawTime: String): String {
val formatter = DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(ZoneId.systemDefault())
if (createdAt != null && createdAt > 1_000_000_000L) {
return formatter.format(Instant.ofEpochSecond(createdAt))
fun formatTimestamp(createdAt: Long?, rawTime: String, todayLabel: String, yesterdayLabel: String): String {
val zone = ZoneId.systemDefault()
val timeFmt = DateTimeFormatter.ofPattern("HH:mm").withZone(zone)
val fullFmt = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm").withZone(zone)
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 {
formatter.format(Instant.ofEpochSecond(rawTime.toLong()))
format(Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime))
format(java.time.OffsetDateTime.parse(rawTime).toInstant())
}.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
* values (e.g. 0L = "never expires") before calling this.
*/
fun formatEpoch(epochSeconds: Long): String =
fun formatEpoch(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String =
try {
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds))
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)
.withZone(zone)
.format(instant)
}
raw.lowercase().replaceFirstChar { it.titlecase(Locale.getDefault()) }
} catch (e: Exception) {
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.
*
* 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)
*/
fun extractPaymentUri(intent: Intent?): String? {
@@ -40,7 +40,7 @@ object LnurlMetadataParser {
/**
* Returns the display label for a LNURL payment target.
* 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 =
if ('@' in lnurl) lnurl else domain