49 Commits

Author SHA1 Message Date
rasputin f2d6aeb13b test: add checksum and signature validation tests to Bolt11Decoder 2026-06-12 17:32:23 +02:00
rasputin 91dc24d1e1 feat: add checksum and signature validation to Bolt11Decoder 2026-06-12 17:08:35 +02:00
rasputin 321c3d9c55 test: add util tests 2026-06-12 16:14:08 +02:00
rasputin 4756681a2a refactor: fix build.gradle.kts lint errors 2026-06-12 13:50:12 +02:00
rasputin cba4a740a0 refactor: target SDK 37, upgrade dependencies 2026-06-12 13:40:34 +02:00
rasputin 8baa9ad398 refactor: upgrade okhttp and loggingInterceptor 2026-06-12 13:09:30 +02:00
rasputin bd0c8e477b refactor: upgrade composeBom 2026-06-12 13:08:20 +02:00
rasputin 25855ad7b3 refactor: upgrade kotlin and ksp 2026-06-12 13:06:53 +02:00
rasputin 3c834efb20 refactor: upgrade tink and protobuf 2026-06-12 13:01:10 +02:00
rasputin 2edd30ac21 refactor: downgrade biometric to stable 2026-06-12 12:52:56 +02:00
rasputin 8e20959bbb refactor: update non-breaking dependencies 2026-06-12 12:51:07 +02:00
rasputin 16e955e553 refactor: make LNbitsHttpClient.instance internal 2026-06-12 12:25:30 +02:00
rasputin 4694b6d44e refactor: clean up LNbitsApi 2026-06-12 12:14:01 +02:00
rasputin aaadd0d73f refactor: code smell runBlocking inside an OkHttp Interceptor 2026-06-12 12:03:36 +02:00
rasputin 98bc466edd refactor: disable backup 2026-06-12 11:38:48 +02:00
rasputin 23e92ea8db refactor: remove permissions used for testing 2026-06-12 11:34:52 +02:00
rasputin cad54c69e7 feat: nwc caching 2026-06-12 11:29:45 +02:00
rasputin 25d13b5a0b refactor: i18n HistoryStrings 2026-06-11 23:37:16 +02:00
rasputin 868869fd88 refactor: i18n HistoryStrings 2026-06-11 23:34:48 +02:00
rasputin b053da6a05 refactor: i18n ReceiveStrings 2026-06-11 23:25:54 +02:00
rasputin 78eb034327 fix: send payment shows as failed even when succeeded 2026-06-11 20:34:00 +02:00
rasputin a2754a7168 add macrobenchmark 2026-06-11 11:13:39 +02:00
rasputin 787acff1e8 add leakcanary 2026-06-11 01:33:31 +02:00
rasputin 39c31f96a8 fix: r8 broke workmanager 2026-06-11 01:22:48 +02:00
rasputin e242c7b5d0 refactor: migrate to kotlinx serialization, eliminate gson dependency 2026-06-10 23:06:11 +02:00
rasputin d1c3722781 refactor: r8 build optmisations 2026-06-10 21:49:52 +02:00
rasputin 3c9b480a9e refactor: extract common ui components 2026-06-10 19:30:44 +02:00
rasputin b90fdca83a refactor: UnitWheelPicker to ui/common 2026-06-10 19:19:42 +02:00
rasputin 8405f33c47 refactor: BrightnessController and QrDisplayCard to ui/common 2026-06-10 19:05:53 +02:00
rasputin e71a0ffdb9 refactor: DateTimeUtils and PaymentUtils 2026-06-10 18:48:16 +02:00
rasputin 2bc92fca06 refactor: observe pairingUrl state 2026-06-10 18:38:15 +02:00
rasputin a121d40bbd refactor: add connections sheet 2026-06-10 18:34:08 +02:00
rasputin e5f8156ecf refactor: extract BrightnessUtils, use snackbar instead of toast 2026-06-10 18:18:14 +02:00
rasputin 9ba408e157 refactor: fix loading flash 2026-06-10 17:32:50 +02:00
rasputin f9ddebac8a refactor: wrap with try finally 2026-06-10 16:54:53 +02:00
rasputin 8be9d8ea43 refactor: extract add connection sheet from nwc 2026-06-10 16:52:37 +02:00
rasputin af59b385e6 refactor: extract pairing dialogue from nwc 2026-06-10 16:44:48 +02:00
rasputin a22e5e4628 refactor: extract connection row from nwc 2026-06-10 16:39:28 +02:00
rasputin 28b366d81f refactor: extract states from nwc 2026-06-10 16:32:09 +02:00
rasputin 08a76e00d9 refactor: extract models and utils from nwc 2026-06-10 16:29:11 +02:00
rasputin 237cc74519 feat: improve nwc qr code ux 2026-06-10 16:08:38 +02:00
rasputin 2ef9a52e6e fix: okhttp url interceptor 2026-06-10 15:47:04 +02:00
rasputin 05a31070e8 fix: nwc ui 2026-06-10 12:40:02 +02:00
rasputin 2ff28720f8 fix: nwc revoke alert 2026-06-09 18:51:54 +02:00
rasputin 4a2b66dcf2 feat: nwc details 2026-06-09 18:17:32 +02:00
rasputin 7fad5ec9dd feat: nwc 2026-06-09 15:48:38 +02:00
rasputin 2490ea2f75 fix: adminKey ignored 2026-06-09 00:46:14 +02:00
rasputin 767d7d19cf fix: production build config 2026-06-08 23:05:18 +02:00
rasputin 36d04f5c87 fix: production build config 2026-06-08 23:05:04 +02:00
115 changed files with 7158 additions and 1942 deletions
+2 -35
View File
@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z"> <DropdownSelection timestamp="2026-06-11T09:22:11.580091Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
@@ -13,40 +13,7 @@
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="getAll_baseline()"> <SelectionState runConfigName="macrobenchmark">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="PaymentDaoBenchmark">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="concurrentReadWrite()">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="checkJournalMode()">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z"> <DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
+6
View File
@@ -2,17 +2,23 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>Aead</w> <w>Aead</w>
<w>Bitrefill</w>
<w>Nbits</w> <w>Nbits</w>
<w>Nostr</w>
<w>Tink</w> <w>Tink</w>
<w>amboss</w> <w>amboss</w>
<w>cltv</w>
<w>hkdf</w> <w>hkdf</w>
<w>inflight</w> <w>inflight</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w> <w>lnbits</w>
<w>lnurlp</w> <w>lnurlp</w>
<w>msat</w> <w>msat</w>
<w>msats</w>
<w>nostr</w>
<w>satoshis</w> <w>satoshis</w>
<w>sats</w> <w>sats</w>
<w>snackbar</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>
+1
View File
@@ -10,6 +10,7 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/macrobenchmark" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>
+55 -23
View File
@@ -1,4 +1,9 @@
import com.google.protobuf.gradle.* import com.google.protobuf.gradle.*
import java.util.Properties
val localProps = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
configurations.all { configurations.all {
resolutionStrategy { resolutionStrategy {
@@ -12,49 +17,64 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.protobuf) alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
namespace = "com.bitcointxoko.gudariwallet" namespace = "com.bitcointxoko.gudariwallet"
compileSdk { compileSdk = 37
version = release(36) { splits {
minorApiLevel = 1 abi {
isEnable = true
reset() // Clear all defaults
include("arm64-v8a") // Specify desired ABIs
isUniversalApk = false
} }
} }
defaultConfig { defaultConfig {
applicationId = "com.bitcointxoko.gudariwallet" applicationId = "com.bitcointxoko.gudariwallet"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
androidResources {
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @Suppress("UnstableApiUsage")
localeFilters += setOf("en", "es", "eu")
}
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "ACTIVITY-MISSING"
} }
testBuildType = "benchmark" signingConfigs {
create("release") {
storeFile = file(localProps["KEYSTORE_PATH"] as String)
storePassword = localProps["KEYSTORE_PASSWORD"] as String
keyAlias = localProps["KEY_ALIAS"] as String
keyPassword = localProps["KEY_PASSWORD"] as String
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true // Enables R8/ProGuard shrinking
isShrinkResources = true // Removes unused resources
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("release")
} }
create("benchmark") { create("benchmark") {
initWith(buildTypes.getByName("release")) initWith(buildTypes.getByName("release"))
isMinifyEnabled = false // benchmarks must not be minified
//noinspection NotShrinkingResources
isShrinkResources = false // or results are meaningless
signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment
installation {
enableBaselineProfile = false // ← disables profile embedding for this build type only
}
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -67,13 +87,17 @@ android {
} }
} }
getByName("debug") { getByName("debug") {
java.srcDirs("build/generated/ksp/debug/kotlin") java.directories.add("build/generated/ksp/debug/kotlin")
}
getByName("test") {
java.srcDirs("src/test/java")
kotlin.srcDirs("src/test/java")
} }
getByName("release") { getByName("release") {
java.srcDirs("build/generated/ksp/release/kotlin") java.directories.add("build/generated/ksp/release/kotlin")
} }
getByName("benchmark") { getByName("benchmark") {
java.srcDirs("build/generated/ksp/benchmark/kotlin") java.directories.add("build/generated/ksp/benchmark/kotlin")
} }
} }
} }
@@ -82,6 +106,7 @@ androidComponents {
onVariants(selector().withBuildType("benchmark")) { variant -> onVariants(selector().withBuildType("benchmark")) { variant ->
variant.packaging.resources.excludes.add("baseline-prof.txt") variant.packaging.resources.excludes.add("baseline-prof.txt")
variant.packaging.resources.excludes.add("baseline.profm") variant.packaging.resources.excludes.add("baseline.profm")
variant.packaging.resources.excludes.add("**/*.profm")
} }
} }
@@ -98,11 +123,12 @@ dependencies {
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.mockk)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.benchmark.junit4) androidTestImplementation(libs.androidx.benchmark.junit4)
androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -110,9 +136,9 @@ dependencies {
// Networking // Networking
implementation(libs.retrofit2.retrofit) implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson) implementation(libs.converter.kotlinx.serialization)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.logging.interceptor) debugImplementation(libs.logging.interceptor)
// Coroutines // Coroutines
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -141,7 +167,7 @@ dependencies {
implementation(libs.androidx.biometric) implementation(libs.androidx.biometric)
// JSON // JSON
implementation(libs.gson) implementation(libs.kotlinx.serialization.json)
// icons // icons
implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material.icons.extended)
@@ -151,7 +177,6 @@ dependencies {
//Navigation //Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
// animations // animations
@@ -159,6 +184,8 @@ dependencies {
// secp256k1 // secp256k1
implementation(libs.secp256k1.kmp.android) implementation(libs.secp256k1.kmp.android)
testImplementation(libs.secp256k1.kmp.jvm)
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.15.0")
// lyricist // lyricist
implementation(libs.lyricist) implementation(libs.lyricist)
@@ -168,6 +195,11 @@ dependencies {
// timber // timber
implementation(libs.timber) implementation(libs.timber)
// leakcanary
debugImplementation(libs.leakcanary.android)
testImplementation("org.json:json:20240303")
} }
protobuf { protobuf {
+145 -17
View File
@@ -1,21 +1,149 @@
# Add project specific ProGuard rules here. # ──────────────────────────────────────────────────────────────────
# You can control the set of applied configuration files using the # Protobuf lite
# proguardFiles setting in build.gradle. # ──────────────────────────────────────────────────────────────────
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
# ──────────────────────────────────────────────────────────────────
# Google Tink 1.13.0
# ──────────────────────────────────────────────────────────────────
-keepclassmembers class * extends com.google.crypto.tink.shaded.protobuf.GeneratedMessageLite {
<fields>;
}
-keep class com.google.crypto.tink.KeyTemplates { *; }
-keep class com.google.crypto.tink.StreamingAead { *; }
-keep class com.google.crypto.tink.KeysetHandle { *; }
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager { *; }
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder { *; }
-keep class com.google.crypto.tink.streamingaead.StreamingAeadConfig { *; }
-keep class com.google.crypto.tink.streamingaead.AesGcmHkdfStreamingKeyManager { *; }
-keep class com.google.crypto.tink.streamingaead.StreamingAeadWrapper { *; }
-keep class com.google.crypto.tink.subtle.AesGcmHkdfStreaming { *; }
-dontwarn com.google.crypto.tink.**
# ──────────────────────────────────────────────────────────────────
# kotlinx.serialization
# The compiler plugin generates a $serializer inner class for every
# @Serializable class. R8 must not remove or rename these they are
# looked up by name at runtime via SerializersModule.
# ──────────────────────────────────────────────────────────────────
-keepattributes *Annotation*, InnerClasses
-keep class kotlinx.serialization.SerializersKt { *; }
-dontnote kotlinx.serialization.**
-dontwarn kotlinx.serialization.**
# Keep generated serializers for all @Serializable classes in your app
-keepclassmembers class com.bitcointxoko.gudariwallet.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep the $serializer companion objects themselves
-keep class com.bitcointxoko.gudariwallet.**$$serializer { *; }
# Required for sealed class polymorphic serialization
-keepclassmembers @kotlinx.serialization.Serializable class * {
*** Companion;
*** INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# ──────────────────────────────────────────────────────────────────
# Retrofit 3.0.0
# ──────────────────────────────────────────────────────────────────
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
-keep,allowobfuscation,allowshrinking class * extends kotlin.coroutines.CoroutineContext$Element { *; }
# ──────────────────────────────────────────────────────────────────
# OkHttp 5.x / Okio
# ──────────────────────────────────────────────────────────────────
-keep class okhttp3.internal.publicsuffix.PublicSuffixDatabase { *; }
-keepnames class okhttp3.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-dontwarn org.codehaus.mojo.animal_sniffer.*
# ──────────────────────────────────────────────────────────────────
# secp256k1-kmp (JNI)
# ──────────────────────────────────────────────────────────────────
-keep class fr.acinq.secp256k1.** { *; }
-keepclasseswithmembernames class * {
native <methods>;
}
# ──────────────────────────────────────────────────────────────────
# ZXing 4.3.0
# #
# For more details, see # ZXing uses reflection to instantiate Reader subclasses by class name.
# http://developer.android.com/guide/developing/tools/proguard.html # Only the classes your app actually exercises need to be kept.
# A Lightning wallet only needs QR code scanning (QR_CODE format).
# ──────────────────────────────────────────────────────────────────
# If your project uses WebView with JS, uncomment the following # Core entry points used directly via the API
# and specify the fully qualified class name to the JavaScript interface -keep class com.google.zxing.BarcodeFormat { *; }
# class: -keep class com.google.zxing.DecodeHintType { *; }
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -keep class com.google.zxing.Result { *; }
# public *; -keep class com.google.zxing.ResultMetadataType { *; }
#} -keep class com.google.zxing.MultiFormatReader { *; }
# Uncomment this to preserve the line number information for # QR code reader instantiated reflectively by MultiFormatReader
# debugging stack traces. -keep class com.google.zxing.qrcode.QRCodeReader { *; }
#-keepattributes SourceFile,LineNumberTable -keep class com.google.zxing.qrcode.decoder.** { *; }
-keep class com.google.zxing.qrcode.detector.** { *; }
# If you keep the line number information, uncomment this to # QR code generator
# hide the original source file name. -keep class com.google.zxing.qrcode.QRCodeWriter { *; }
#-renamesourcefileattribute SourceFile
# Common infrastructure used by all readers
-keep class com.google.zxing.common.** { *; }
# Suppress warnings for unused format readers (1D barcodes, DataMatrix, etc.)
# that are in the jar but not needed by a wallet app
-dontwarn com.google.zxing.**
# ──────────────────────────────────────────────────────────────────
# Room 2.7.1
# ──────────────────────────────────────────────────────────────────
-keep @androidx.room.Entity class *
-keep @androidx.room.Dao interface *
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.**
# ──────────────────────────────────────────────────────────────────
# WorkManager 2.9.1
# ──────────────────────────────────────────────────────────────────
-keep class * extends androidx.work.InputMerger { *; }
-keep class * extends androidx.work.Worker { *; }
-keep class * extends androidx.work.CoroutineWorker { *; }
-keep class * extends androidx.work.ListenableWorker { *; }
-keep class * extends androidx.work.WorkerFactory { *; }
# Keep specific classes explicitly as a safety net
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker { *; }
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker$Companion { *; }
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorkerFactory { *; }
# ──────────────────────────────────────────────────────────────────
# Kotlin Coroutines 1.11.0
# ──────────────────────────────────────────────────────────────────
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-dontwarn kotlinx.coroutines.**
-dontwarn kotlinx.coroutines.debug.*
# ──────────────────────────────────────────────────────────────────
# Lyricist 1.8.0
# ──────────────────────────────────────────────────────────────────
-keep class com.bitcointxoko.gudariwallet.**Strings { *; }
-keep class com.bitcointxoko.gudariwallet.**Translations { *; }
# ──────────────────────────────────────────────────────────────────
# Timber 5.0.1
# ──────────────────────────────────────────────────────────────────
-dontwarn org.jetbrains.annotations.**
# ──────────────────────────────────────────────────────────────────
# Stack traces upload mapping.txt to Play Console / Crashlytics
# ──────────────────────────────────────────────────────────────────
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+26
View File
@@ -0,0 +1,26 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.bitcointxoko.gudariwallet",
"variantName": "release",
"elements": [
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "0.1.0",
"outputFile": "app-arm64-v8a-release.apk"
}
],
"elementType": "File",
"minSdkVersionForDexing": 26
}
+10 -3
View File
@@ -10,16 +10,23 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" /> <uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" /> <uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<queries>
<!-- Allows resolveActivity() to see apps that handle the NWC URI scheme -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="nostr+walletconnect" />
</intent>
</queries>
<application <application
android:name=".GudariWalletApplication" android:name=".GudariWalletApplication"
android:allowBackup="true" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() { get() {
val secrets = EncryptedSecretStore(this) val secrets = EncryptedSecretStore(this)
val api = LNbitsClient.create(secrets) val client = LNbitsClient(secrets)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
val paymentCache = PaymentCacheRepository(this) val paymentCache = PaymentCacheRepository(this)
val syncStore = HistoricalSyncStore(this) val syncStore = HistoricalSyncStore(this)
return Configuration.Builder() return Configuration.Builder()
@@ -29,7 +29,7 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NotificationHelper.createChannels(this) // moved from MainActivity NotificationHelper.createChannels(this)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }
@@ -1,9 +1,16 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter import kotlinx.serialization.ExperimentalSerializationApi
import com.google.gson.stream.JsonReader import kotlinx.serialization.KSerializer
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonWriter import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
/** /**
* LNbits returns `comment` in PaymentExtra as either: * LNbits returns `comment` in PaymentExtra as either:
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
* - a JSON array ["Thanks!"] * - a JSON array ["Thanks!"]
* - null * - null
* *
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter * Mirrors the behaviour of the old Gson FlexibleStringAdapter.
* on the field for extra safety.
*/ */
class FlexibleStringAdapter : TypeAdapter<String?>() { object FlexibleStringSerializer : KSerializer<String?> {
override fun write(out: JsonWriter, value: String?) { override val descriptor: SerialDescriptor =
if (value == null) out.nullValue() else out.value(value) buildClassSerialDescriptor("FlexibleString")
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: String?) {
if (value == null) encoder.encodeNull()
else encoder.encodeString(value)
} }
override fun read(reader: JsonReader): String? { override fun deserialize(decoder: Decoder): String? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
JsonToken.BEGIN_ARRAY -> { is JsonNull -> null
val parts = mutableListOf<String>() is JsonPrimitive -> element.content // plain string → as-is
reader.beginArray() is JsonArray -> element // array → join elements
while (reader.hasNext()) { .mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
when (reader.peek()) { .joinToString(", ")
JsonToken.NULL -> reader.nextNull() .ifEmpty { null }
else -> parts.add(reader.nextString()) else -> null
}
}
reader.endArray()
parts.joinToString(", ").ifEmpty { null }
}
else -> reader.nextString()
} }
} }
} }
@@ -1,21 +0,0 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.Gson
import com.google.gson.GsonBuilder
/**
* Single source of truth for the app's Gson instance.
*
* All serialization/deserialization — Retrofit, SharedPreferences caches,
* and WebSocket message parsing — must use this instance so that registered
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
* everywhere.
*
* To add a new adapter: register it here once. It will automatically apply
* to every deserialization path in the app.
*/
object GsonProvider {
val gson: Gson = GsonBuilder()
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
.create()
}
@@ -0,0 +1,24 @@
package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.json.Json
/**
* 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
* configuration is applied consistently everywhere.
*
* Custom type serializers (e.g. [SuccessActionSerializer],
* [FlexibleStringSerializer]) are registered directly on the model
* classes via @Serializable(with = ...) so no explicit registration
* is needed here.
*/
object JsonProvider {
val json: Json = Json {
ignoreUnknownKeys = true // tolerate extra fields from the API
isLenient = true // accept unquoted/malformed JSON from some endpoints
explicitNulls = false // omit null fields when serializing requests
encodeDefaults = true
}
}
@@ -1,9 +1,11 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -13,7 +15,7 @@ interface LNbitsApi {
/** GET /api/v1/wallet — returns balance and wallet info */ /** GET /api/v1/wallet — returns balance and wallet info */
@GET("api/v1/wallet") @GET("api/v1/wallet")
suspend fun getWallet( suspend fun getWallet(
@Header("X-Api-Key") apiKey: String @Header("X-Api-Key") invoiceKey: String
): WalletResponse ): WalletResponse
/** POST /api/v1/payments — creates a BOLT-11 invoice */ /** POST /api/v1/payments — creates a BOLT-11 invoice */
@@ -42,10 +44,10 @@ interface LNbitsApi {
): PayInvoiceResponse ): PayInvoiceResponse
/** GET /api/v1/payments — returns payment history */ /** GET /api/v1/payments — returns payment history */
@GET("api/v1/payments") // @GET("api/v1/payments")
suspend fun getPayments( // suspend fun getPayments(
@Header("X-Api-Key") apiKey: String // @Header("X-Api-Key") apiKey: String
): List<WsPayment> // ): List<WsPayment>
/** GET /api/v1/payments/paginated — returns bulk payment history */ /** GET /api/v1/payments/paginated — returns bulk payment history */
@GET("api/v1/payments/paginated") @GET("api/v1/payments/paginated")
@@ -144,4 +146,61 @@ interface LNbitsApi {
*/ */
@GET("api/v1/currencies") @GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String> suspend fun getSupportedCurrencies(): List<String>
// ── NWC Provider ──────────────────────────────────────────────────────────
@GET("nwcprovider/api/v1/permissions")
suspend fun getNwcPermissions(
@Header("X-Api-Key") invoiceKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/nwc")
suspend fun getNwcKeys(
@Header("X-Api-Key") adminKey: String,
@Query("include_expired") includeExpired: Boolean = false
): List<NwcGetResponse>
@GET("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun getNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Query("include_expired") includeExpired: Boolean = false,
@Query("refresh_last_used") refreshLastUsed: Boolean = false
): NwcGetResponse
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun registerNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Body request: NwcRegistrationRequest
): NwcGetResponse
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun deleteNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String
)
@GET("nwcprovider/api/v1/pairing/{secret}")
suspend fun getNwcPairingUrl(
@Header("X-Api-Key") invoiceKey: String,
@Path("secret") secret: String
): String
@GET("nwcprovider/api/v1/config")
suspend fun getNwcConfig(
@Header("X-Api-Key") adminKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/config/{key}")
suspend fun getNwcConfigValue(
@Header("X-Api-Key") adminKey: String,
@Path("key") key: String
): Map<String, Any>
@POST("nwcprovider/api/v1/config")
suspend fun setNwcConfig(
@Header("X-Api-Key") adminKey: String,
@Body config: Map<String, Any>
): Map<String, Any>
} }
@@ -1,87 +1,92 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants 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.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
object LNbitsClient { class LNbitsClient(secrets: SecretStore) {
// Scope lives as long as this client — SupervisorJob so one failure
// doesn't cancel the URL-watching coroutine.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Cached in memory. Starts blank; populated as soon as DataStore emits.
private val cachedBaseUrl = AtomicReference("")
init {
scope.launch {
// Suspend until DataStore has emitted at least once.
// This primes cachedBaseUrl before any request can realistically fire.
val initial = secrets.credentials.first()
cachedBaseUrl.set(initial.baseUrl)
// Then keep it updated reactively for the lifetime of this client.
secrets.credentials.collect { credentials ->
cachedBaseUrl.set(credentials.baseUrl)
}
}
}
// Single shared client — used by both Retrofit and WebSocket // Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder() val httpClient: OkHttpClient get() = LNbitsHttpClient.instance
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) val api: LNbitsApi by lazy { buildApi() }
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build()
fun create(secrets: SecretStore): LNbitsApi { private fun buildApi(): LNbitsApi {
// Auth header interceptor — reads invoice key per-request (unchanged)
val authInterceptor = Interceptor { chain ->
val key = runBlocking { secrets.invoiceKey() }
chain.proceed(
chain.request().newBuilder()
.header("X-Api-Key", key)
.build()
)
}
// Dynamic base URL interceptor — rewrites the placeholder host to the
// real base URL at request time, not at Retrofit construction time.
// This means credentials only need to be in DataStore by the time the
// first HTTP request fires, not at app startup.
val dynamicBaseUrlInterceptor = Interceptor { chain -> val dynamicBaseUrlInterceptor = Interceptor { chain ->
val realBaseUrl = runBlocking { secrets.baseUrl() } val originalRequest = chain.request()
.ifBlank { "https://placeholder.invalid" } val originalUrl = originalRequest.url
.trimEnd('/')
val realHttpUrl = realBaseUrl.toHttpUrl() if (originalUrl.host != "placeholder.invalid") {
return@Interceptor chain.proceed(originalRequest)
}
val raw = cachedBaseUrl.get().ifBlank { null }
?: throw IllegalStateException(
"LNbits base URL is not configured. " +
"Complete onboarding before making API calls."
)
val realHttpUrl = raw.trimEnd('/').toHttpUrl()
val originalUrl = chain.request().url
val newUrl = originalUrl.newBuilder() val newUrl = originalUrl.newBuilder()
.scheme(realHttpUrl.scheme) .scheme(realHttpUrl.scheme)
.host(realHttpUrl.host) .host(realHttpUrl.host)
.port(realHttpUrl.port) .port(realHttpUrl.port)
.build() // Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
.encodedPath(
chain.proceed( realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
chain.request().newBuilder()
.url(newUrl)
.build()
) )
.build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
} }
val client = httpClient.newBuilder() val client = LNbitsHttpClient.instance.newBuilder()
.addInterceptor(authInterceptor)
.addInterceptor(dynamicBaseUrlInterceptor) .addInterceptor(dynamicBaseUrlInterceptor)
.apply {
// Logging goes LAST — after URL rewrite — so logs show the real URL
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build() .build()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit; .baseUrl("https://placeholder.invalid/")
// always overwritten by the interceptor // ↑ Structurally required: Retrofit validates that baseUrl is a
// well-formed absolute URL at construction time. The interceptor
// rewrites it before any real request leaves the device.
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .addConverterFactory(
JsonProvider.json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
)
)
.build() .build()
.create(LNbitsApi::class.java) .create(LNbitsApi::class.java)
} }
@@ -0,0 +1,18 @@
package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.util.WalletConstants
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
// internal = visible within this Gradle module only.
// Intentionally not private: WalletNotificationService accesses this
// directly because Android instantiates Services via reflection —
// constructor injection is not possible. All other consumers must use
// LNbitsClient.api instead.
object LNbitsHttpClient {
internal val instance: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.build()
}
@@ -1,133 +1,147 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.google.gson.TypeAdapter import kotlinx.serialization.KSerializer
import com.google.gson.annotations.JsonAdapter import kotlinx.serialization.SerialName
import com.google.gson.annotations.SerializedName import kotlinx.serialization.Serializable
import com.google.gson.stream.JsonReader import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import com.google.gson.stream.JsonWriter import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
// ── Wallet ──────────────────────────────────────────────────────────────────── // ── Wallet ────────────────────────────────────────────────────────────────────
@Serializable
data class WalletResponse( data class WalletResponse(
val id: String, val id: String = "",
val name: String, val name: String = "",
val balance: Long // millisatoshis val balance: Long = 0L // millisatoshis
) )
// ── Create invoice ──────────────────────────────────────────────────────────── // ── Create invoice ────────────────────────────────────────────────────────────
@Serializable
data class CreateInvoiceRequest( data class CreateInvoiceRequest(
val out: Boolean, val out: Boolean,
val amount: Long, // satoshis val amount: Long, // satoshis
val memo: String val memo: String
) )
@Serializable
data class CreateInvoiceResponse( data class CreateInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("payment_request") val paymentRequest: String, @SerialName("payment_request") val paymentRequest: String = "",
@SerializedName("expiry") val expiry : String? @SerialName("expiry") val expiry: String? = null
) )
// ── Pay invoice ─────────────────────────────────────────────────────────────── // ── Pay invoice ───────────────────────────────────────────────────────────────
@Serializable
data class PayInvoiceRequest( data class PayInvoiceRequest(
val out: Boolean, val out: Boolean,
val bolt11: String val bolt11: String
) )
@Serializable
data class PayInvoiceResponse( data class PayInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String = ""
) )
// ── Check payment ───────────────────────────────────────────────────────────── // ── Check payment ─────────────────────────────────────────────────────────────
@Serializable
data class PaymentStatusResponse( data class PaymentStatusResponse(
val paid: Boolean, val paid: Boolean = false,
val details: PaymentDetails? = null val details: PaymentDetails? = null
) )
@Serializable
data class PaymentDetails( data class PaymentDetails(
val amount: Long?, val amount: Long? = null,
val fee: Long?, val fee: Long? = null,
val memo: String? val memo: String? = null
) )
// ── Decode BOLT-11 ──────────────────────────────────────────────────────────── // ── Decode BOLT-11 ────────────────────────────────────────────────────────────
@Serializable
data class DecodeInvoiceRequest( data class DecodeInvoiceRequest(
val data: String val data: String
) )
@Serializable
data class DecodeInvoiceResponse( data class DecodeInvoiceResponse(
@SerializedName("payment_hash") val paymentHash : String? = null, @SerialName("payment_hash") val paymentHash: String? = null,
@SerializedName("amount_msat") val amountMsat : Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
@SerializedName("description") val description : String? = null, @SerialName("description") val description: String? = null,
@SerializedName("payee") val payee : String? = null, @SerialName("payee") val payee: String? = null,
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null @SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
) )
@Serializable
data class RouteHintHop( data class RouteHintHop(
@SerializedName("public_key") val publicKey : String, @SerialName("public_key") val publicKey: String = "",
@SerializedName("short_channel_id") val shortChannelId : String? = null, @SerialName("short_channel_id") val shortChannelId: String? = null,
@SerializedName("base_fee") val baseFeeMsat : Long = 0, @SerialName("base_fee") val baseFeeMsat: Long = 0,
@SerializedName("ppm_fee") val ppmFee : Long = 0, @SerialName("ppm_fee") val ppmFee: Long = 0,
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0 @SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
) )
// ── LNURL scan ──────────────────────────────────────────────────────────────── // ── LNURL scan ────────────────────────────────────────────────────────────────
@Serializable
data class LnurlScanResponse( data class LnurlScanResponse(
val tag: String?, val tag: String? = null,
// ── payRequest fields ──────────────────────────────────────────────────── // ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null, val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long? = null, @SerialName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long? = null, @SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null, val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int? = null, @SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null, @SerialName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null, val bolt11: String? = null,
@SerializedName("amount_msat") val amountMsat: Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache) val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ─────────────────────────────────────────────── // ── withdrawRequest fields ───────────────────────────────────────────────
val k1: String? = null, val k1: String? = null,
@SerializedName("defaultDescription") @SerialName("defaultDescription") val defaultDescription: String? = null,
val defaultDescription: String? = null, @SerialName("minWithdrawable") val minWithdrawable: Long? = null,
@SerializedName("minWithdrawable") @SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
val minWithdrawable : Long? = null, @SerialName("balanceCheck") val balanceCheck: String? = null,
@SerializedName("maxWithdrawable") @SerialName("currentBalance") val currentBalance: Long? = null,
val maxWithdrawable : Long? = null, // ── Error fields ──────────────────────────────────────────────────────────
@SerializedName("balanceCheck")
val balanceCheck : String? = null,
@SerializedName("currentBalance")
val currentBalance : Long? = null,
// ── Error fields ─────────────────────────────────────────────────────────
val status: String? = null, // "OK" or "ERROR" val status: String? = null, // "OK" or "ERROR"
val reason: String? = null // error message when status == "ERROR" val reason: String? = null // error message when status == "ERROR"
) )
// ── LNURL-pay callback response (LUD-06) ───────────────────────────────────── // ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
@Serializable
data class LnurlCallbackResponse( data class LnurlCallbackResponse(
val pr : String, // BOLT-11 invoice to pay val pr: String = "",
val routes : List<Any>? = null, val routes: List<JsonElement>? = null,
@SerializedName("successAction") @SerialName("successAction") val successAction: SuccessAction? = null,
val successAction : SuccessAction? = null,
// Error fields (server may return status=ERROR instead of pr)
val status: String? = null, val status: String? = null,
val reason: String? = null val reason: String? = null
) )
// ── LNURL pay ───────────────────────────────────────────────────────────────── // ── LNURL pay ─────────────────────────────────────────────────────────────────
@Serializable
data class LnurlPayRes( data class LnurlPayRes(
val tag: String?, val tag: String? = null,
val callback: String?, val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long?, @SerialName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long?, @SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String?, val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int?, @SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null @SerialName("nostrPubkey") val nostrPubkey: String? = null
) )
@Serializable
data class PayLnurlRequest( data class PayLnurlRequest(
val res: LnurlPayRes, val res: LnurlPayRes,
val lnurl: String, val lnurl: String,
@@ -135,41 +149,44 @@ data class PayLnurlRequest(
val comment: String? = null val comment: String? = null
) )
@Serializable
data class PayLnurlResponse( data class PayLnurlResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String = ""
) )
@Serializable
data class WithdrawCallbackResponse( data class WithdrawCallbackResponse(
val status : String, val status: String = "",
val reason: String? = null val reason: String? = null
) )
// ── LNURLp pay link (lightning address) ────────────────────────────────────── // ── LNURLp pay link (lightning address) ──────────────────────────────────────
@Serializable
data class LnurlpLink( data class LnurlpLink(
val id : String, val id: String = "",
val wallet : String, val wallet: String = "",
val username : String?, // the part before the @ in the lightning address val username: String? = null,
val description : String?, val description: String? = null,
val min : Long, @Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
val max : Long @Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
) )
// ── Payment history ─────────────────────────────────────────────────────────── // ── Payment history ───────────────────────────────────────────────────────────
@Immutable @Immutable
@Serializable
data class PaymentRecord( data class PaymentRecord(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String = "",
@SerializedName("amount") val amountMsat: Long, @SerialName("amount") val amountMsat: Long = 0L,
@SerializedName("fee") val feeMsat: Long, @SerialName("fee") val feeMsat: Long = 0L,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String? = null,
@SerializedName("time") val time: String, @SerialName("time") val time: String = "",
@SerializedName("status") val status: String, @SerialName("status") val status: String = "",
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String? = null,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean = false,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String? = null,
@SerializedName("extra") val extra: PaymentExtra?, @SerialName("extra") val extra: PaymentExtra? = null,
// Not from the API — populated only when loaded from Room via toDomain() // Not from the API — populated only when loaded from Room via toDomain()
val createdAt: Long? = null, val createdAt: Long? = null,
val pubkey: String? = null, val pubkey: String? = null,
@@ -207,23 +224,25 @@ data class PaymentRecord(
} }
@Immutable @Immutable
@Serializable
data class PaymentExtra( data class PaymentExtra(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String? = null,
@JsonAdapter(FlexibleStringAdapter::class) @Serializable(with = FlexibleStringSerializer::class)
val comment: String?, val comment: String? = null,
@SerializedName("success_action") val successAction: SuccessAction?, @SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
@SerializedName("wallet_id") val walletId: String?, val successAction: SuccessAction? = null,
@SerializedName("destination") val destination: String?, @SerialName("wallet_id") val walletId: String? = null,
@SerializedName("expires_at") val expiresAt: String? @SerialName("destination") val destination: String? = null,
@SerialName("expires_at") val expiresAt: String? = null
) )
@Immutable @Immutable
@Serializable(with = SuccessActionSerializer::class)
data class SuccessAction( data class SuccessAction(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String?,
@SerializedName("message") val message: String?, @SerialName("message") val message: String?,
@SerializedName("url") val url: String?, @SerialName("url") val url: String?,
@SerializedName("description") val description: String? @SerialName("description") val description: String?
) )
/** /**
@@ -232,93 +251,158 @@ data class SuccessAction(
* - a plain string "Thanks!" * - a plain string "Thanks!"
* - null * - null
* *
* Gson's default deserialiser throws IllegalStateException when it sees a * kotlinx.serialization's default deserialiser throws when it sees a
* string where it expects {. This adapter handles all three cases. * string where it expects {. This serializer handles all three cases.
* *
* Registered centrally in [GsonProvider] — applies to all deserialization * Applied via @Serializable(with = SuccessActionSerializer::class) on
* paths automatically. * the SuccessAction class and on PaymentExtra.successAction.
*/ */
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() { object SuccessActionSerializer : KSerializer<SuccessAction?> {
override fun write(out: JsonWriter, value: SuccessAction?) { override val descriptor: SerialDescriptor =
if (value == null) { out.nullValue(); return } buildClassSerialDescriptor("SuccessAction")
out.beginObject()
out.name("tag"); out.value(value.tag) override fun serialize(encoder: Encoder, value: SuccessAction?) {
out.name("message"); out.value(value.message) val jsonEncoder = encoder as JsonEncoder
out.name("url"); out.value(value.url) if (value == null) {
out.name("description"); out.value(value.description) jsonEncoder.encodeJsonElement(JsonNull)
out.endObject() } else {
jsonEncoder.encodeJsonElement(
JsonObject(
mapOf(
"tag" to JsonPrimitive(value.tag),
"message" to JsonPrimitive(value.message),
"url" to JsonPrimitive(value.url),
"description" to JsonPrimitive(value.description)
)
)
)
}
} }
override fun read(reader: JsonReader): SuccessAction? { override fun deserialize(decoder: Decoder): SuccessAction? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonNull -> null
is JsonPrimitive -> {
// Plain string — treat as a message-type success action // Plain string — treat as a message-type success action
JsonToken.STRING -> { val text = element.jsonPrimitive.content
val text = reader.nextString()
if (text.isBlank()) null if (text.isBlank()) null
else SuccessAction(tag = "message", message = text, url = null, description = null) else SuccessAction(tag = "message", message = text, url = null, description = null)
} }
is JsonObject -> {
// Normal object SuccessAction(
JsonToken.BEGIN_OBJECT -> { tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
reader.beginObject() message = element.jsonObject["message"]?.jsonPrimitive?.content,
var tag: String? = null url = element.jsonObject["url"]?.jsonPrimitive?.content,
var message: String? = null description = element.jsonObject["description"]?.jsonPrimitive?.content
var url: String? = null )
var description: String? = null }
while (reader.hasNext()) { else -> null
when (reader.nextName()) {
"tag" -> tag = reader.nextString()
"message" -> message = reader.nextString()
"url" -> url = reader.nextString()
"description" -> description = reader.nextString()
else -> reader.skipValue()
} }
} }
reader.endObject()
SuccessAction(tag = tag, message = message, url = url, description = description)
} }
else -> { reader.skipValue(); null } @Serializable
}
}
}
data class PaymentDetailResponse( data class PaymentDetailResponse(
val paid: Boolean, val paid: Boolean,
val details: PaymentRecord? val details: PaymentRecord?
) )
// ── WebSocket payment message ───────────────────────────────────────────────── // ── WebSocket payment message ─────────────────────────────────────────────────
@Serializable
data class WsPaymentMessage( data class WsPaymentMessage(
@SerializedName("wallet_balance") val walletBalance: Long?, @SerialName("wallet_balance") val walletBalance: Long? = null,
val payment: WsPayment? val payment: WsPayment? = null
) )
@Serializable
data class WsPayment( data class WsPayment(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String = "",
@SerializedName("amount") val amount: Long, @SerialName("amount") val amount: Long = 0L,
@SerializedName("fee") val fee: Long, @SerialName("fee") val fee: Long = 0L,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String? = null,
@SerializedName("time") val time: String, @SerialName("time") val time: String = "",
@SerializedName("status") val status: String, @SerialName("status") val status: String = "",
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String? = null,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean = false,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String? = null,
@SerializedName("extra") val extra: PaymentExtra? @SerialName("extra") val extra: PaymentExtra? = null
) )
// ── Fiat rate ─────────────────────────────────────────────────────────────────
// ── Fiat rate ──────────────────────────────────────────────────────────────── @Serializable
data class FiatRateResponse( data class FiatRateResponse(
val rate: Double, // sat to fiat rate val rate: Double = 0.0, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency val price: Double = 0.0 // BTC price in the requested fiat currency
) )
// ── History sync ───────────────────────────────────────────────────────────── // ── History sync ─────────────────────────────────────────────────────────────
@Serializable
data class PaginatedPaymentsResponse( data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>, @SerialName("data") val data: List<PaymentRecord> = emptyList(),
@SerializedName("total") val total: Int @SerialName("total") val total: Int = 0
) )
// ── NWC Provider models ───────────────────────────────────────────────────────
@Serializable
data class NwcKey(
val pubkey: String = "",
val wallet: String = "",
val description: String = "",
val expires_at: Long = 0L,
val permissions: String = "",
val created_at: Long = 0L,
val last_used: Long = 0L
)
@Serializable
data class NwcBudget(
val id: Int = 0,
val pubkey: String = "",
val budget_msats: Long = 0L,
val refresh_window: Int = 0,
val created_at: Long = 0L,
val used_budget_msats: Long = 0L
)
@Serializable
data class NwcGetResponse(
val data: NwcKey = NwcKey(),
val budgets: List<NwcBudget> = emptyList()
)
// Request models — no defaults needed (you control the fields, never deserializing)
@Serializable
data class NwcNewBudget(
val budget_msats: Long,
val refresh_window: Int,
val created_at: Long = System.currentTimeMillis() / 1000L
)
@Serializable
data class NwcRegistrationRequest(
val permissions: List<String>,
val description: String,
val expires_at: Long,
val budgets: List<NwcNewBudget> = emptyList()
)
/**
* Deserializes a JSON number that may arrive as either an integer (1)
* or a float (1.0) into a Long, truncating any fractional part.
*/
object LongAsDoubleSerializer : KSerializer<Long> {
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
override fun deserialize(decoder: Decoder): Long {
val jsonDecoder = decoder as JsonDecoder
return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonPrimitive -> element.content.toDouble().toLong()
else -> 0L
}
}
}
@@ -1,10 +1,12 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
// ── mempool.space ───────────────────────────────────────────────────────────── // ── mempool.space ─────────────────────────────────────────────────────────────
@Serializable
data class MempoolNodeResponse( data class MempoolNodeResponse(
val alias: String?, val alias: String?,
val public_key: String? val public_key: String?
@@ -17,6 +19,7 @@ interface MempoolApi {
// ── 1ml.com ─────────────────────────────────────────────────────────────────── // ── 1ml.com ───────────────────────────────────────────────────────────────────
@Serializable
data class OneMlNodeResponse( data class OneMlNodeResponse(
val alias: String?, val alias: String?,
val pub_key: String? val pub_key: String?
@@ -1,8 +1,9 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -17,17 +18,20 @@ class NodeAliasClient {
.readTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS)
.build() .build()
private val converter = JsonProvider.json
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
private val mempoolApi: MempoolApi = Retrofit.Builder() private val mempoolApi: MempoolApi = Retrofit.Builder()
.baseUrl("https://mempool.space/") .baseUrl("https://mempool.space/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(MempoolApi::class.java) .create(MempoolApi::class.java)
private val oneMlApi: OneMlApi = Retrofit.Builder() private val oneMlApi: OneMlApi = Retrofit.Builder()
.baseUrl("https://1ml.com/") .baseUrl("https://1ml.com/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(OneMlApi::class.java) .create(OneMlApi::class.java)
@@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import java.io.IOException import java.io.IOException
private const val TAG = "FiatDataStore" private const val TAG = "FiatDataStore"
@@ -27,7 +28,7 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
class FiatDataStore(context: Context) { class FiatDataStore(context: Context) {
private val dataStore = context.applicationContext.fiatDataStore private val dataStore = context.applicationContext.fiatDataStore
private val gson = Gson() private val json = JsonProvider.json
// ── Key factories ───────────────────────────────────────────────────────── // ── Key factories ─────────────────────────────────────────────────────────
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
} else throw e } else throw e
} }
.first() .first()
val json = prefs[currenciesKey] ?: return null val jsonString = prefs[currenciesKey] ?: return null
return runCatching { return runCatching {
val type = object : TypeToken<List<String>>() {}.type val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
val list: List<String> = gson.fromJson(json, type)
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)") Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
list list
}.getOrElse { e -> }.getOrElse { e ->
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
if (currencies.isEmpty()) return if (currencies.isEmpty()) return
runCatching { runCatching {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[currenciesKey] = gson.toJson(currencies) prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
} }
}.onFailure { e -> }.onFailure { e ->
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}") Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
@@ -286,4 +286,133 @@ class LNbitsWalletRepository(
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId) return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
} }
// ── NWC Provider ───────────────────────────────────────────────────────────
/**
* Returns the map of supported NWC permission names.
*/
override suspend fun getNwcPermissions(): Map<String, Any> {
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
val result = api.getNwcPermissions(secrets.invoiceKey())
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
return result
}
/**
* Returns all NWC connection keys for the current wallet.
*/
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
return result
}
/**
* Returns a single NWC connection key by its public key.
*/
override suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean,
refreshLastUsed : Boolean
): NwcGetResponse {
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}")
val result = api.getNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey,
includeExpired = includeExpired,
refreshLastUsed = refreshLastUsed
)
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
return result
}
/**
* Registers / creates a new NWC connection key.
* Requires admin key.
*/
override suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse {
Timber.d(
"NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} " +
"expires_at=${request.expires_at} " +
"budgets=${request.budgets.size}"
)
val result = api.registerNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey,
request = request
)
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
return result
}
/**
* Deletes an NWC connection key by its public key.
* Requires admin key.
*/
override suspend fun deleteNwcKey(pubkey: String) {
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}")
api.deleteNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey
)
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
}
/**
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
*/
override suspend fun getNwcPairingUrl(secret: String): String {
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}")
val url = api.getNwcPairingUrl(
invoiceKey = secrets.invoiceKey(),
secret = secret
)
Timber.i("NWC [PAIRING ] pairing URL received ✓")
return url
}
/**
* Returns all extension configuration settings.
* Requires admin key.
*/
override suspend fun getNwcConfig(): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching all config settings")
val result = api.getNwcConfig(secrets.adminKey())
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
return result
}
/**
* Returns a single configuration value by key.
* Requires admin key.
*/
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching config key='$key'")
val result = api.getNwcConfigValue(
adminKey = secrets.adminKey(),
key = key
)
Timber.i("NWC [CONFIG ] received value for key='$key'")
return result
}
/**
* Sets / updates one or more configuration values.
* Requires admin key.
*/
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
val result = api.setNwcConfig(
adminKey = secrets.adminKey(),
config = config
)
Timber.i("NWC [CONFIG SET] config updated ✓")
return result
}
} }
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
import android.content.Context import android.content.Context
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
class LnurlCacheRepository(context: Context) { class LnurlCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).lnurlCacheDao() private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
private val gson = GsonProvider.gson private val json = JsonProvider.json
// Fast in-memory path — avoids a DB round-trip for keys already loaded // Fast in-memory path — avoids a DB round-trip for keys already loaded
// this session. Mirrors the pattern used in PaymentCacheRepository. // this session. Mirrors the pattern used in PaymentCacheRepository.
@@ -68,7 +68,7 @@ class LnurlCacheRepository(context: Context) {
} }
val entity = LnurlCacheEntity( val entity = LnurlCacheEntity(
key = key, key = key,
responseJson = gson.toJson(response), responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
savedAt = System.currentTimeMillis() savedAt = System.currentTimeMillis()
) )
runCatching { runCatching {
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) } runCatching { dao.deleteByKey(key) }
} }
private fun deserialise(json: String): LnurlScanResponse? = private fun deserialise(jsonString: String): LnurlScanResponse? =
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) } runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull() .getOrNull()
} }
@@ -0,0 +1,137 @@
package com.bitcointxoko.gudariwallet.data
import android.app.Application
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcKey
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.NwcBudgetEntity
import com.bitcointxoko.gudariwallet.data.db.NwcKeyDao
import com.bitcointxoko.gudariwallet.data.db.NwcKeyEntity
import kotlinx.coroutines.flow.Flow
class NwcCacheRepository(app: Application) {
private val dao: NwcKeyDao = AppDatabase.getInstance(app).nwcKeyDao()
// ── Observe (for ViewModels to collect) ───────────────────────────
fun observeKeys(): Flow<List<NwcKeyEntity>> = dao.observeAll()
fun observeKey(pubkey: String): Flow<NwcKeyEntity?> = dao.observeKey(pubkey)
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>> =
dao.observeBudgets(pubkey)
// ── Sync (called after network fetches) ───────────────────────────
// One-shot synchronous read — for startup population only
suspend fun getKeysOnce(): List<NwcGetResponse> =
dao.getAllKeys().map { it.toDomain().toGetResponse() }
/**
* Smart sync for the full key list.
*
* - New keys (not yet cached) → full insert
* - Existing keys → update last_used only (all other fields are immutable)
* - Keys present in cache but absent from server response → evict
*
* Never overwrites immutable fields on existing rows.
*/
suspend fun syncKeys(responses: List<NwcGetResponse>) {
val incoming = responses.associateBy { it.data.pubkey }
val cached = dao.getAllPubkeys().toSet()
dao.syncTransaction {
val newPubkeys = incoming.keys - cached
newPubkeys.forEach { pubkey ->
val response = incoming.getValue(pubkey)
dao.insertKey(response.data.toEntity())
dao.insertBudgets(response.budgets.map { it.toEntity() })
}
val existingPubkeys = incoming.keys intersect cached
existingPubkeys.forEach { pubkey ->
dao.updateLastUsed(pubkey, incoming.getValue(pubkey).data.last_used)
}
val removedPubkeys = cached - incoming.keys
if (removedPubkeys.isNotEmpty()) {
dao.deleteKeys(removedPubkeys)
}
}
}
/**
* Write a single key response into the cache.
* Used after registerNwcKey() — inserts the new key exactly once.
* IGNORE conflict strategy on the DAO ensures this is a no-op if
* somehow called twice for the same pubkey.
*/
suspend fun saveKey(response: NwcGetResponse) {
dao.insertKey(response.data.toEntity())
dao.insertBudgets(response.budgets.map { it.toEntity() })
}
/**
* Update only last_used for a single key.
* Called after getNwcKey(refreshLastUsed = true) on the detail screen.
*/
suspend fun refreshLastUsed(pubkey: String, lastUsed: Long) {
dao.updateLastUsed(pubkey, lastUsed)
}
/**
* Evict a key from the cache.
* Called after a successful deleteNwcKey() network call.
* CASCADE on the FK removes associated budgets automatically.
*/
suspend fun deleteKey(pubkey: String) {
dao.deleteKey(pubkey)
}
}
// ── Mappers ───────────────────────────────────────────────────────────
// Kept in this file — they exist solely to serve NwcCacheRepository.
// If mappers grow (e.g. reverse domain→entity direction is needed),
// extract to NwcEntityMappers.kt.
private fun NwcKey.toEntity(): NwcKeyEntity = NwcKeyEntity(
pubkey = pubkey,
wallet = wallet,
description = description,
expiresAt = expires_at,
permissions = permissions,
createdAt = created_at,
lastUsed = last_used,
cachedAt = System.currentTimeMillis() / 1000L
)
private fun NwcBudget.toEntity(): NwcBudgetEntity = NwcBudgetEntity(
id = id,
pubkey = pubkey,
budgetMsats = budget_msats,
refreshWindow = refresh_window,
createdAt = created_at,
usedBudgetMsats = used_budget_msats
)
fun NwcKeyEntity.toDomain(): NwcKey = NwcKey(
pubkey = pubkey,
wallet = wallet,
description = description,
expires_at = expiresAt,
permissions = permissions,
created_at = createdAt,
last_used = lastUsed
)
fun NwcBudgetEntity.toDomain(): NwcBudget = NwcBudget(
id = id,
pubkey = pubkey,
budget_msats = budgetMsats,
refresh_window = refreshWindow,
created_at = createdAt,
used_budget_msats = usedBudgetMsats
)
fun NwcKey.toGetResponse() = NwcGetResponse(data = this, budgets = emptyList())
@@ -109,9 +109,8 @@ class PaymentCacheRepository(context: Context) {
minCreatedAt = filter.minCreatedAt, minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt maxCreatedAt = filter.maxCreatedAt
).map { it.toDomain() }.also { ).map { it.toDomain() }.also {
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} " + "amt=${filter.minAmountSat}${filter.maxAmountSat} ")
"date=${filter.minCreatedAt}${filter.maxCreatedAt}${it.size} rows")
} }
} }
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.data package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
@@ -41,4 +43,20 @@ interface WalletRepository {
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String> suspend fun getSupportedCurrencies(): List<String>
suspend fun getNwcPermissions(): Map<String, Any>
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean = false,
refreshLastUsed : Boolean = false
): NwcGetResponse
suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse
suspend fun deleteNwcKey(pubkey: String)
suspend fun getNwcPairingUrl(secret: String): String
suspend fun getNwcConfig(): Map<String, Any>
suspend fun getNwcConfigValue(key: String): Map<String, Any>
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
} }
@@ -13,8 +13,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PaymentRecordEntity::class, PaymentRecordEntity::class,
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
NwcKeyEntity::class,
NwcBudgetEntity::class
], ],
version = 6, version = 7,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -23,6 +25,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
@@ -86,6 +89,41 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS nwc_keys (
pubkey TEXT NOT NULL PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
expires_at INTEGER NOT NULL,
permissions TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
cached_at INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS nwc_budgets (
id INTEGER NOT NULL PRIMARY KEY,
pubkey TEXT NOT NULL,
budget_msats INTEGER NOT NULL,
refresh_window INTEGER NOT NULL,
created_at INTEGER NOT NULL,
used_budget_msats INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES nwc_keys(pubkey)
ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_nwc_budgets_pubkey
ON nwc_budgets(pubkey)
""".trimIndent())
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -93,7 +131,14 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "gudari_wallet.db"
) )
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7
)
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -0,0 +1,33 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "nwc_budgets",
foreignKeys = [ForeignKey(
entity = NwcKeyEntity::class,
parentColumns = ["pubkey"],
childColumns = ["pubkey"],
onDelete = ForeignKey.CASCADE // auto-cleans budgets when key is deleted
)],
indices = [Index("pubkey")]
)
data class NwcBudgetEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Int,
@ColumnInfo(name = "pubkey")
val pubkey: String,
@ColumnInfo(name = "budget_msats")
val budgetMsats: Long,
@ColumnInfo(name = "refresh_window")
val refreshWindow: Int,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "used_budget_msats")
val usedBudgetMsats: Long
)
@@ -0,0 +1,97 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface NwcKeyDao {
// ── Observe (UI layer subscriptions) ──────────────────────────────
/**
* Live stream of all cached keys, newest first.
* Room re-emits automatically on any write — this is the only
* channel the list screen needs to collect.
*/
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
fun observeAll(): Flow<List<NwcKeyEntity>>
/**
* Live stream of a single key — for the detail screen.
* Emits null if the key has been evicted (e.g. after a delete).
*/
@Query("SELECT * FROM nwc_keys WHERE pubkey = :pubkey")
fun observeKey(pubkey: String): Flow<NwcKeyEntity?>
/**
* Live stream of budgets for a given key — for the detail screen.
* CASCADE delete on the parent ensures this automatically becomes
* empty when the parent key is deleted.
*/
@Query("SELECT * FROM nwc_budgets WHERE pubkey = :pubkey")
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>>
// ── One-shot reads (sync logic, not UI) ───────────────────────────
/**
* Lightweight membership check used by syncKeys() to determine
* which incoming keys are new vs. already cached.
* Returns only pubkeys — avoids loading full rows unnecessarily.
*/
@Query("SELECT pubkey FROM nwc_keys")
suspend fun getAllPubkeys(): List<String>
/**
* One-shot full read of all cached keys — used for instant startup
* population before the Flow subscription is active.
* Same query as observeAll() but returns a plain list instead of Flow.
*/
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
suspend fun getAllKeys(): List<NwcKeyEntity>
// ── Writes ────────────────────────────────────────────────────────
/**
* Insert a brand-new key. IGNORE on conflict means an existing row
* is never overwritten by this path — all immutable fields are safe.
* Used exclusively when a key is first seen (creation or first sync).
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertKey(key: NwcKeyEntity)
@Transaction
suspend fun syncTransaction(block: suspend () -> Unit) = block()
/**
* Insert budgets for a new key. Also IGNORE on conflict —
* budget fields are immutable post-creation.
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertBudgets(budgets: List<NwcBudgetEntity>)
/**
* Update ONLY last_used — the single mutable field on a key.
* This is the only update path that should ever run post-creation.
*/
@Query("UPDATE nwc_keys SET last_used = :lastUsed WHERE pubkey = :pubkey")
suspend fun updateLastUsed(pubkey: String, lastUsed: Long)
/**
* Evict a key by pubkey. The CASCADE foreign key on nwc_budgets
* automatically deletes all associated budget rows — no manual
* budget cleanup needed.
*/
@Query("DELETE FROM nwc_keys WHERE pubkey = :pubkey")
suspend fun deleteKey(pubkey: String)
/**
* Evict a set of keys in one statement — used by syncKeys() to
* remove server-deleted keys without issuing N individual deletes.
*/
@Query("DELETE FROM nwc_keys WHERE pubkey IN (:pubkeys)")
suspend fun deleteKeys(pubkeys: Collection<String>)
}
@@ -0,0 +1,26 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "nwc_keys")
data class NwcKeyEntity(
@PrimaryKey
@ColumnInfo(name = "pubkey")
val pubkey: String,
@ColumnInfo(name = "wallet")
val wallet: String,
@ColumnInfo(name = "description")
val description: String,
@ColumnInfo(name = "expires_at")
val expiresAt: Long,
@ColumnInfo(name = "permissions")
val permissions: String, // stored as comma-separated string
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "last_used")
val lastUsed: Long,
@ColumnInfo(name = "cached_at")
val cachedAt: Long // epoch-seconds, used for staleness check if desired
)
@@ -1,19 +1,19 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.GsonProvider
object PaymentConverters { object PaymentConverters {
private val gson = GsonProvider.gson private val json = JsonProvider.json
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun fromExtra(extra: PaymentExtra?): String? = fun fromExtra(extra: PaymentExtra?): String? =
extra?.let { gson.toJson(it) } extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun toExtra(json: String?): PaymentExtra? = fun toExtra(string: String?): PaymentExtra? =
json?.let { gson.fromJson(it, PaymentExtra::class.java) } string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
} }
@@ -1,12 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentMappers" private const val TAG = "PaymentMappers"
private val json = JsonProvider.json
// ── Parse helper — runs once at write time, never again ────────────────────── // ── Parse helper — runs once at write time, never again ──────────────────────
@@ -36,7 +36,7 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.toJson(it) }, extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
savedAt = savedAt, savedAt = savedAt,
pubkey = pubkey, pubkey = pubkey,
alias = alias alias = alias
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) }, extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
pubkey = pubkey, pubkey = pubkey,
alias = alias alias = alias
) )
@@ -56,61 +56,6 @@ val EnHomeStrings = AppStrings(
enterValidAmount = "Enter a valid amount", enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Receive",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning address QR code",
lightningAddressLabel = "Lightning Address",
shareLightningAddress = "Share Lightning Address",
lightningInvoiceQrCode = "Lightning invoice QR code",
invoiceLabel = "Invoice",
shareInvoice = "Share Invoice",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Amount (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Memo (optional)",
memoPlaceholder = "What's this for?",
creatingInvoice = "Creating invoice…",
createInvoice = "Create Invoice",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Waiting…",
invoiceExpired = "Invoice expired",
newInvoice = "New Invoice",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Withdraw from",
minimumSats = { min -> "Minimum is $min sats" },
maximumSats = { max -> "Maximum is $max sats" },
withdrawButton = "Withdraw",
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Payment Received!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reduce brightness",
brightnessBoost = "Boost brightness",
qrShare = "Share",
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Send", send = "Send",
paste = "Paste", paste = "Paste",
@@ -196,6 +141,113 @@ val EnHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" }, sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address", sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
onboarding = OnboardingStrings(
back = "Back",
next = "Next",
done = "Done",
title = "Connect to LNbits",
subtitle = "Enter your LNbits server URL and API keys.",
serverUrl = "Server URL",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Invoice Key (read-only)",
invoiceKeySubtitle = "Used to generate payment requests.",
pasteInvoiceKey = "Paste invoice key",
adminKey = "Admin Key (for sending)",
adminKeySubtitle = "Required to send payments.",
pasteAdminKey = "Paste admin key",
hideAdminKey = "Hide",
showAdminKey = "Show",
fieldsRequired = "All fields are required.",
invalidKeyFormat = "Invalid key format",
connect = "Connect",
notificationsTitle = "Stay in the loop",
notificationsSubtitle = "Get notified instantly when you receive a payment.",
notificationsAlreadyGranted = "You're all set - permissions have been granted.",
allowNotifications = "Allow Notifications",
notificationsNote = "You can change this later in system Settings.",
notificationsAutoGranted = "Notifications are enabled automatically on your device.",
batteryTitle = "Background activity",
batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.",
openSettings = "Open Settings",
batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.",
batteryAlreadyUnrestricted = "You're all set — no restrictions detected.",
paymentHistory = "Payment history",
paymentHistorySubtitle = "Download your past payments so they're available offline.",
syncNow = "Sync now",
skipForNow = "Skip for now",
syncScheduled = "Sync scheduled — will run when your device is online. You will get a notification when the sync is done. You can already start using the app.",
continueToApp = "Continue to app →",
connecting = "Connecting...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total payments" },
paymentsSynced = { total -> "$total payments synced"},
syncComplete = "✓ Sync complete",
syncFailed = "Sync failed. You can retry from Settings.",
scheduleForLater = "Schedule for later",
scheduleSync = "Schedule sync",
schedule = "Schedule",
cancel = "Cancel"
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Receive",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning address QR code",
lightningAddressLabel = "Lightning Address",
shareLightningAddress = "Share Lightning Address",
lightningInvoiceQrCode = "Lightning invoice QR code",
invoiceLabel = "Invoice",
shareInvoice = "Share Invoice",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Amount (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Memo (optional)",
memoPlaceholder = "What's this for?",
creatingInvoice = "Creating invoice…",
createInvoice = "Create Invoice",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Waiting…",
invoiceExpired = "Invoice expired",
newInvoice = "New Invoice",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Withdraw from",
minimumSats = { min -> "Minimum is $min sats" },
maximumSats = { max -> "Maximum is $max sats" },
withdrawButton = "Withdraw",
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Payment Received!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reduce brightness",
brightnessBoost = "Boost brightness",
qrShare = "Share",
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
openInApp = "Open in app",
noAppFound = "No compatible app found",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
),
history = HistoryStrings(
// ── HistoryScreen ───────────────────────────────────────────────────────── // ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Activity", historyTitle = "Activity",
historyClose = "Close", historyClose = "Close",
@@ -307,51 +359,57 @@ val EnHomeStrings = AppStrings(
paymentRowNoMemo = "No memo", paymentRowNoMemo = "No memo",
paymentRowPending = "Pending", paymentRowPending = "Pending",
paymentRowFailed = "Failed", paymentRowFailed = "Failed",
),
onboarding = OnboardingStrings( nwc = NwcStrings(
back = "Back", back = "Back",
next = "Next", next = "Next",
done = "Done", done = "Done",
title = "Connect to LNbits", cancel = "Cancel",
subtitle = "Enter your LNbits server URL and API keys.",
serverUrl = "Server URL",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Invoice Key (read-only)",
invoiceKeySubtitle = "Used to generate payment requests.",
pasteInvoiceKey = "Paste invoice key",
adminKey = "Admin Key (for sending)",
adminKeySubtitle = "Required to send payments.",
pasteAdminKey = "Paste admin key",
hideAdminKey = "Hide",
showAdminKey = "Show",
fieldsRequired = "All fields are required.",
connect = "Connect",
notificationsTitle = "Stay in the loop",
notificationsSubtitle = "Get notified instantly when you receive a payment.",
notificationsAlreadyGranted = "You're all set - permissions have been granted.",
allowNotifications = "Allow Notifications",
notificationsNote = "You can change this later in system Settings.",
notificationsAutoGranted = "Notifications are enabled automatically on your device.",
batteryTitle = "Background activity",
batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.",
openSettings = "Open Settings",
batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.",
batteryAlreadyUnrestricted = "You're all set — no restrictions detected.",
paymentHistory = "Payment history",
paymentHistorySubtitle = "Download your past payments so they're available offline.",
syncNow = "Sync now",
skipForNow = "Skip for now",
syncScheduled = "Sync scheduled — will run when your device is online. You will get a notification when the sync is done. You can already start using the app.",
continueToApp = "Continue to app →",
connecting = "Connecting...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total payments" },
paymentsSynced = { total -> "$total payments synced"},
syncComplete = "✓ Sync complete",
syncFailed = "Sync failed. You can retry from Settings.",
scheduleForLater = "Schedule for later",
scheduleSync = "Schedule sync",
schedule = "Schedule", schedule = "Schedule",
cancel = "Cancel"
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Connection",
connectionUnknownWallet = "Unknown app",
connectionSectionDetails = "Details",
connectionRowCreated = "Connected",
connectionRowLastUsed = "Last used",
connectionRowExpires = "Expires",
connectionRowPubkey = "Public key",
connectionNeverUsed = "Never",
connectionNoExpiry = "Never",
connectionExpired = { date -> "Expired · $date" },
connectionSectionPermissions = "Permissions",
connectionSectionBudgets = "Spending budgets",
connectionBudgetTitle = { window -> "$window budget" },
connectionBudgetUsed = { sats -> "Used: $sats sats" },
connectionBudgetTotal = { sats -> "of $sats sats" },
connectionRevoke = "Revoke connection",
connectionStatusActive = "Active",
connectionStatusExpired = "Expired",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pay invoices",
permGetBalance = "Read balance",
permGetInfo = "Read wallet info",
permMakeInvoice = "Create invoices",
permLookupInvoice = "Look up invoices",
permListTransactions = "View transaction history",
permSignMessage = "Sign messages",
permPay = "Send payments",
permInvoice = "Create invoices",
permLookupHistory = "Lookup status & transaction history",
permBalanceInfo = "Read balance & account info",
permSendPayments = "Send payments",
permCreateInvoices = "Create invoices",
permLookupInvoiceStatus = "Lookup status of invoice",
permReadTransactionHistory = "Read transaction history",
permReadWalletBalance = "Read wallet balance",
permReadAccountInfo = "Read account info",
revokeDialogTitle = "Revoke connection?",
revokeDialogBody = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
revokeDialogConfirm = "Revoke",
revokeDialogDismiss = "Cancel",
) )
) )
@@ -1,6 +1,7 @@
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,61 +56,6 @@ val EsHomeStrings = AppStrings(
enterValidAmount = "Introduce una cantidad válida", enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Recibir",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Código QR de dirección Lightning",
lightningAddressLabel = "Dirección Lightning",
shareLightningAddress = "Compartir dirección Lightning",
lightningInvoiceQrCode = "Código QR de factura Lightning",
invoiceLabel = "Factura",
shareInvoice = "Compartir factura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Cantidad (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Nota (opcional)",
memoPlaceholder = "¿Para qué es esto?",
creatingInvoice = "Creando factura…",
createInvoice = "Crear factura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Esperando…",
invoiceExpired = "Factura expirada",
newInvoice = "Nueva factura",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Retirar de",
minimumSats = { min -> "Mínimo es $min sats" },
maximumSats = { max -> "Máximo es $max sats" },
withdrawButton = "Retirar",
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "¡Pago recibido!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reducir brillo",
brightnessBoost = "Aumentar brillo",
qrShare = "Compartir",
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar", send = "Enviar",
paste = "Pegar", paste = "Pegar",
@@ -195,6 +141,113 @@ val EsHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" }, sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección", sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
onboarding = OnboardingStrings(
back = "Atrás",
next = "Siguiente",
done = "Hecho",
title = "Conectar a LNbits",
subtitle = "Introduce la URL de tu servidor LNbits y las claves de API.",
serverUrl = "URL del servidor",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Clave de factura (solo lectura)",
invoiceKeySubtitle = "Se usa para generar solicitudes de pago.",
pasteInvoiceKey = "Pegar clave de factura",
adminKey = "Clave de administrador (para enviar)",
adminKeySubtitle = "Necesaria para enviar pagos.",
pasteAdminKey = "Pegar clave de administrador",
hideAdminKey = "Ocultar",
showAdminKey = "Mostrar",
fieldsRequired = "Todos los campos son obligatorios.",
invalidKeyFormat = "Formato de clave no válido",
connect = "Conectar",
notificationsTitle = "Mantente al día",
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
notificationsAlreadyGranted = "Todo listo — los permisos han sido concedidos.",
allowNotifications = "Permitir notificaciones",
notificationsNote = "Puedes cambiar esto más tarde en los Ajustes del sistema.",
notificationsAutoGranted = "Las notificaciones se activan automáticamente en tu dispositivo.",
batteryTitle = "Actividad en segundo plano",
batterySubtitle = "Permite que Gudari Wallet funcione sin restricciones para que las notificaciones lleguen incluso cuando la aplicación esté cerrada.",
openSettings = "Abrir Ajustes",
batteryNote = "Pulsa 'Batería' → 'Sin restricciones'. Puedes omitir esto y cambiarlo más tarde.",
batteryAlreadyUnrestricted = "Todo listo — no se han detectado restricciones.",
paymentHistory = "Historial de transacciones",
paymentHistorySubtitle = "Descarga tus transacciones anteriores para tenerlas disponibles sin conexión.",
syncNow = "Sincronizar ahora",
skipForNow = "Omitir por ahora",
syncScheduled = "Sincronización programada — se ejecutará cuando tu dispositivo esté en línea. Recibirás una notificación cuando la sincronización haya finalizado. Ya puedes empezar a usar la aplicación.",
continueToApp = "Ir a la aplicación →",
connecting = "Conectando...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transacciones" },
paymentsSynced = { total -> "$total transacciones sincronizadas"},
syncComplete = "✓ Sincronización completada",
syncFailed = "Error en la sincronización. Puedes volver a intentarlo desde Ajustes.",
scheduleForLater = "Programar para más tarde",
scheduleSync = "Programar sincronización",
schedule = "Programar",
cancel = "Cancelar"
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Recibir",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Código QR de dirección Lightning",
lightningAddressLabel = "Dirección Lightning",
shareLightningAddress = "Compartir dirección Lightning",
lightningInvoiceQrCode = "Código QR de factura Lightning",
invoiceLabel = "Factura",
shareInvoice = "Compartir factura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Cantidad (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Nota (opcional)",
memoPlaceholder = "¿Para qué es esto?",
creatingInvoice = "Creando factura…",
createInvoice = "Crear factura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Esperando…",
invoiceExpired = "Factura expirada",
newInvoice = "Nueva factura",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Retirar de",
minimumSats = { min -> "Mínimo es $min sats" },
maximumSats = { max -> "Máximo es $max sats" },
withdrawButton = "Retirar",
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "¡Pago recibido!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reducir brillo",
brightnessBoost = "Aumentar brillo",
qrShare = "Compartir",
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
openInApp = "Abrir en app",
noAppFound = "No hay aplicación compatible",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
),
history = HistoryStrings(
// ── HistoryScreen ───────────────────────────────────────────────────────── // ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historial", historyTitle = "Historial",
historyClose = "Cerrar", historyClose = "Cerrar",
@@ -306,51 +359,57 @@ val EsHomeStrings = AppStrings(
paymentRowNoMemo = "Sin nota", paymentRowNoMemo = "Sin nota",
paymentRowPending = "Pendiente", paymentRowPending = "Pendiente",
paymentRowFailed = "Fallido", paymentRowFailed = "Fallido",
),
nwc = NwcStrings(
onboarding = OnboardingStrings( // ── Shared ────────────────────────────────────────────────────────────────
back = "Atrás", back = "Atrás",
next = "Siguiente", next = "Siguiente",
done = "Hecho", done = "Listo",
title = "Conectar a LNbits", cancel = "Cancelar",
subtitle = "Introduce la URL de tu servidor LNbits y las claves de API.",
serverUrl = "URL del servidor",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Clave de factura (solo lectura)",
invoiceKeySubtitle = "Se usa para generar solicitudes de pago.",
pasteInvoiceKey = "Pegar clave de factura",
adminKey = "Clave de administrador (para enviar)",
adminKeySubtitle = "Necesaria para enviar pagos.",
pasteAdminKey = "Pegar clave de administrador",
hideAdminKey = "Ocultar",
showAdminKey = "Mostrar",
fieldsRequired = "Todos los campos son obligatorios.",
connect = "Conectar",
notificationsTitle = "Mantente al día",
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
notificationsAlreadyGranted = "Todo listo — los permisos han sido concedidos.",
allowNotifications = "Permitir notificaciones",
notificationsNote = "Puedes cambiar esto más tarde en los Ajustes del sistema.",
notificationsAutoGranted = "Las notificaciones se activan automáticamente en tu dispositivo.",
batteryTitle = "Actividad en segundo plano",
batterySubtitle = "Permite que Gudari Wallet funcione sin restricciones para que las notificaciones lleguen incluso cuando la aplicación esté cerrada.",
openSettings = "Abrir Ajustes",
batteryNote = "Pulsa 'Batería' → 'Sin restricciones'. Puedes omitir esto y cambiarlo más tarde.",
batteryAlreadyUnrestricted = "Todo listo — no se han detectado restricciones.",
paymentHistory = "Historial de transacciones",
paymentHistorySubtitle = "Descarga tus transacciones anteriores para tenerlas disponibles sin conexión.",
syncNow = "Sincronizar ahora",
skipForNow = "Omitir por ahora",
syncScheduled = "Sincronización programada — se ejecutará cuando tu dispositivo esté en línea. Recibirás una notificación cuando la sincronización haya finalizado. Ya puedes empezar a usar la aplicación.",
continueToApp = "Ir a la aplicación →",
connecting = "Conectando...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transacciones" },
paymentsSynced = { total -> "$total transacciones sincronizadas"},
syncComplete = "✓ Sincronización completada",
syncFailed = "Error en la sincronización. Puedes volver a intentarlo desde Ajustes.",
scheduleForLater = "Programar para más tarde",
scheduleSync = "Programar sincronización",
schedule = "Programar", schedule = "Programar",
cancel = "Cancelar"
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Conexión",
connectionUnknownWallet = "App desconocida",
connectionSectionDetails = "Detalles",
connectionRowCreated = "Conectado",
connectionRowLastUsed = "Último uso",
connectionRowExpires = "Vence",
connectionRowPubkey = "Clave pública",
connectionNeverUsed = "Nunca",
connectionNoExpiry = "Sin vencimiento",
connectionExpired = { date -> "Vencido · $date" },
connectionSectionPermissions = "Permisos",
connectionSectionBudgets = "Presupuestos de gasto",
connectionBudgetTitle = { window -> "Presupuesto $window" },
connectionBudgetUsed = { sats -> "Usado: $sats sats" },
connectionBudgetTotal = { sats -> "de $sats sats" },
connectionRevoke = "Revocar conexión",
connectionStatusActive = "Activa",
connectionStatusExpired = "Vencida",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pagar facturas",
permGetBalance = "Ver saldo",
permGetInfo = "Ver información del monedero",
permMakeInvoice = "Crear facturas",
permLookupInvoice = "Consultar facturas",
permListTransactions = "Ver historial de transacciones",
permSignMessage = "Firmar mensajes",
permPay = "Enviar pagos",
permInvoice = "Crear facturas",
permLookupHistory = "Consultar estado e historial de transacciones",
permBalanceInfo = "Ver saldo e información de cuenta",
permSendPayments = "Enviar pagos",
permCreateInvoices = "Crear facturas",
permLookupInvoiceStatus = "Consultar estado de factura",
permReadTransactionHistory = "Leer historial de transacciones",
permReadWalletBalance = "Leer saldo del monedero",
permReadAccountInfo = "Leer información de cuenta",
revokeDialogTitle = "¿Revocar conexión?",
revokeDialogBody = "Esto revocará permanentemente esta clave Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
revokeDialogConfirm = "Revocar",
revokeDialogDismiss = "Cancelar",
) )
) )
@@ -55,61 +55,6 @@ val EuStrings = AppStrings(
enterValidAmount = "Sartu zenbateko baliogarri bat", enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" }, satsUnit = { sats -> "$sats sat" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Jaso",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Errorea",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning helbidearen QR kodea",
lightningAddressLabel = "Lightning helbidea",
shareLightningAddress = "Partekatu Lightning helbidea",
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
invoiceLabel = "Faktura",
shareInvoice = "Partekatu faktura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Zenbatekoa (sat)",
satsRange = { min, max -> "$min $max sat" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Oharra (aukerakoa)",
memoPlaceholder = "Zertarako da hau?",
creatingInvoice = "Faktura sortzen…",
createInvoice = "Sortu faktura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Zain…",
invoiceExpired = "Faktura iraungita",
newInvoice = "Faktura berria",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Atera hemendik:",
minimumSats = { min -> "Gutxienekoa $min sat da" },
maximumSats = { max -> "Gehienekoa $max sat da" },
withdrawButton = "Atera",
withdrawButtonAmount = { amount -> "Atera $amount sat" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Ordainketa jasota!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount sat" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Murriztu distira",
brightnessBoost = "Handitu distira",
qrShare = "Partekatu",
qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali", send = "Bidali",
paste = "Itsatsi", paste = "Itsatsi",
@@ -196,6 +141,113 @@ val EuStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" }, sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu", sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
onboarding = OnboardingStrings(
back = "Atzera",
next = "Hurrengoa",
done = "Eginda",
title = "Konektatu LNbits-era",
subtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.",
serverUrl = "Zerbitzariaren URLa",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Faktura gakoa (irakurtzeko soilik)",
invoiceKeySubtitle = "Ordainketa eskaerak sortzeko erabiltzen da.",
pasteInvoiceKey = "Itsatsi faktura gakoa",
adminKey = "Administrazio gakoa (bidaltzeko)",
adminKeySubtitle = "Beharrezkoa da transakzioak bidaltzeko.",
pasteAdminKey = "Itsatsi administrazio gakoa",
hideAdminKey = "Ezkutatu",
showAdminKey = "Erakutsi",
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
invalidKeyFormat = "Gako formatu baliogabea",
connect = "Konektatu",
notificationsTitle = "Egon eguneratuta",
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
notificationsAlreadyGranted = "Dena prest dago — baimenak emanda daude.",
allowNotifications = "Baimendu jakinarazpenak",
notificationsNote = "Hau geroago alda dezakezu sistemaren Ezarpenetan.",
notificationsAutoGranted = "Jakinarazpenak automatikoki gaituta daude zure gailuan.",
batteryTitle = "Atzeko planoko jarduera",
batterySubtitle = "Baimendu Gudari Wallet murrizketarik gabe funtzionatzea, jakinarazpenak aplikazioa itxita dagoenean ere irits daitezen.",
openSettings = "Ireki Ezarpenak",
batteryNote = "Sakatu 'Bateria' → 'Murrizketarik gabe'. Hau saltatu eta geroago alda dezakezu.",
batteryAlreadyUnrestricted = "Dena prest dago — ez da murrizketarik hauteman.",
paymentHistory = "Transakzioen historia",
paymentHistorySubtitle = "Deskargatu iraganeko transakzioak konexiorik gabe erabilgarri izateko.",
syncNow = "Sinkronizatu orain",
skipForNow = "Saltatu oraingoz",
syncScheduled = "Sinkronizazioa programatuta dago — gailua sarean dagoenean exekutatuko da. Sinkronizazioa amaitutakoan jakinarazpen bat jasoko duzu. Dagoeneko has zaitezke aplikazioa erabiltzen.",
continueToApp = "Joan aplikaziora →",
connecting = "Konektatzen...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transakzio" },
paymentsSynced = { total -> "$total transakzio sinkronizatuta"},
syncComplete = "✓ Sinkronizazioa osatuta",
syncFailed = "Sinkronizazioak huts egin du. Berriro saia zaitezke Ezarpenetatik.",
scheduleForLater = "Programatu geroago",
scheduleSync = "Programatu sinkronizazioa",
schedule = "Programatu",
cancel = "Utzi"
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Jaso",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Errorea",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning helbidearen QR kodea",
lightningAddressLabel = "Lightning helbidea",
shareLightningAddress = "Partekatu Lightning helbidea",
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
invoiceLabel = "Faktura",
shareInvoice = "Partekatu faktura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Zenbatekoa (sat)",
satsRange = { min, max -> "$min $max sat" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Oharra (aukerakoa)",
memoPlaceholder = "Zertarako da hau?",
creatingInvoice = "Faktura sortzen…",
createInvoice = "Sortu faktura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Zain…",
invoiceExpired = "Faktura iraungita",
newInvoice = "Faktura berria",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Atera hemendik:",
minimumSats = { min -> "Gutxienekoa $min sat da" },
maximumSats = { max -> "Gehienekoa $max sat da" },
withdrawButton = "Atera",
withdrawButtonAmount = { amount -> "Atera $amount sat" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Ordainketa jasota!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount sat" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Murriztu distira",
brightnessBoost = "Handitu distira",
qrShare = "Partekatu",
qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez",
openInApp = "Ireki aplikazioan",
noAppFound = "Ez da aurkitu aplikazio bateragarririk",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
),
history = HistoryStrings(
// ── HistoryScreen ───────────────────────────────────────────────────────── // ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historia", historyTitle = "Historia",
historyClose = "Itxi", historyClose = "Itxi",
@@ -306,51 +358,57 @@ val EuStrings = AppStrings(
paymentRowNoMemo = "Oharrik gabe", paymentRowNoMemo = "Oharrik gabe",
paymentRowPending = "Zain", paymentRowPending = "Zain",
paymentRowFailed = "Huts eginda", paymentRowFailed = "Huts eginda",
),
nwc = NwcStrings(
onboarding = OnboardingStrings( // ── Shared ────────────────────────────────────────────────────────────────
back = "Atzera", back = "Atzera",
next = "Hurrengoa", next = "Hurrengoa",
done = "Eginda", done = "Eginda",
title = "Konektatu LNbits-era", cancel = "Utzi",
subtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.",
serverUrl = "Zerbitzariaren URLa",
serverUrlHint = "https://bitcointxoko.org",
invoiceKey = "Faktura gakoa (irakurtzeko soilik)",
invoiceKeySubtitle = "Ordainketa eskaerak sortzeko erabiltzen da.",
pasteInvoiceKey = "Itsatsi faktura gakoa",
adminKey = "Administrazio gakoa (bidaltzeko)",
adminKeySubtitle = "Beharrezkoa da transakzioak bidaltzeko.",
pasteAdminKey = "Itsatsi administrazio gakoa",
hideAdminKey = "Ezkutatu",
showAdminKey = "Erakutsi",
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
connect = "Konektatu",
notificationsTitle = "Egon eguneratuta",
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
notificationsAlreadyGranted = "Dena prest dago — baimenak emanda daude.",
allowNotifications = "Baimendu jakinarazpenak",
notificationsNote = "Hau geroago alda dezakezu sistemaren Ezarpenetan.",
notificationsAutoGranted = "Jakinarazpenak automatikoki gaituta daude zure gailuan.",
batteryTitle = "Atzeko planoko jarduera",
batterySubtitle = "Baimendu Gudari Wallet murrizketarik gabe funtzionatzea, jakinarazpenak aplikazioa itxita dagoenean ere irits daitezen.",
openSettings = "Ireki Ezarpenak",
batteryNote = "Sakatu 'Bateria' → 'Murrizketarik gabe'. Hau saltatu eta geroago alda dezakezu.",
batteryAlreadyUnrestricted = "Dena prest dago — ez da murrizketarik hauteman.",
paymentHistory = "Transakzioen historia",
paymentHistorySubtitle = "Deskargatu iraganeko transakzioak konexiorik gabe erabilgarri izateko.",
syncNow = "Sinkronizatu orain",
skipForNow = "Saltatu oraingoz",
syncScheduled = "Sinkronizazioa programatuta dago — gailua sarean dagoenean exekutatuko da. Sinkronizazioa amaitutakoan jakinarazpen bat jasoko duzu. Dagoeneko has zaitezke aplikazioa erabiltzen.",
continueToApp = "Joan aplikaziora →",
connecting = "Konektatzen...",
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transakzio" },
paymentsSynced = { total -> "$total transakzio sinkronizatuta"},
syncComplete = "✓ Sinkronizazioa osatuta",
syncFailed = "Sinkronizazioak huts egin du. Berriro saia zaitezke Ezarpenetatik.",
scheduleForLater = "Programatu geroago",
scheduleSync = "Programatu sinkronizazioa",
schedule = "Programatu", schedule = "Programatu",
cancel = "Utzi"
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Konexioa",
connectionUnknownWallet = "App ezezaguna",
connectionSectionDetails = "Xehetasunak",
connectionRowCreated = "Konektatuta",
connectionRowLastUsed = "Azken erabilera",
connectionRowExpires = "Iraungitze-data",
connectionRowPubkey = "Gako publikoa",
connectionNeverUsed = "Inoiz ez",
connectionNoExpiry = "Iraungitze-datarik ez",
connectionExpired = { date -> "Iraungita · $date" },
connectionSectionPermissions = "Baimenak",
connectionSectionBudgets = "Gastu-aurrekontuak",
connectionBudgetTitle = { window -> "$window aurrekontua" },
connectionBudgetUsed = { sats -> "Erabilita: $sats sat" },
connectionBudgetTotal = { sats -> "$sats satetatik" },
connectionRevoke = "Konexioa baliogabetu",
connectionStatusActive = "Aktibo",
connectionStatusExpired = "Iraungita",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Fakturak ordaindu",
permGetBalance = "Saldoa ikusi",
permGetInfo = "Zorroko informazioa ikusi",
permMakeInvoice = "Fakturak sortu",
permLookupInvoice = "Fakturak kontsultatu",
permListTransactions = "Transakzio-historia ikusi",
permSignMessage = "Mezuak sinatu",
permPay = "Ordainketak bidali",
permInvoice = "Fakturak sortu",
permLookupHistory = "Egoera eta transakzio-historia kontsultatu",
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
permSendPayments = "Ordainketak bidali",
permCreateInvoices = "Fakturak sortu",
permLookupInvoiceStatus = "Fakturaren egoera kontsultatu",
permReadTransactionHistory = "Transakzio-historia irakurri",
permReadWalletBalance = "Zorroko saldoa irakurri",
permReadAccountInfo = "Kontuaren informazioa irakurri",
revokeDialogTitle = "Konexioa baliogabetu?",
revokeDialogBody = "Honek Nostr Wallet Connect gako hau behin betiko baliogabetuko du. Erabiltzen duen edozein aplikaziok sarbidea galduko du.",
revokeDialogConfirm = "Baliogabetu",
revokeDialogDismiss = "Utzi",
) )
) )
@@ -64,61 +64,6 @@ data class AppStrings(
val enterValidAmount : String, val enterValidAmount : String,
val satsUnit : (sats: Long) -> String, val satsUnit : (sats: Long) -> String,
// ── ReceiveScreen — general ───────────────────────────────────────────────
val receive : String,
// ── ReceiveScreen — error ─────────────────────────────────────────────────
val receiveError : String,
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
val lightningAddressQrCode : String,
val lightningAddressLabel : String,
val shareLightningAddress : String,
val lightningInvoiceQrCode : String,
val invoiceLabel : String,
val shareInvoice : String,
// ── ReceiveScreen — amount step ───────────────────────────────────────────
val amountInSats : String, // text field label
val satsRange : (min: Long, max: Long) -> String,
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
val memoOptional : String,
val memoPlaceholder : String,
val creatingInvoice : String,
val createInvoice : String,
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
val waiting : String,
val invoiceExpired : String,
val newInvoice : String,
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
val withdrawFrom : String,
val minimumSats : (min: Long) -> String,
val maximumSats : (max: Long) -> String,
val withdrawButton : String, // no fixed amount
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
// ── ReceiveScreen — success ───────────────────────────────────────────────
val paymentReceived : String,
// ── AmountDisplay ─────────────────────────────────────────────────────────
val amountDisplaySats : (amount: Long) -> String,
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
val brightnessReduce : String,
val brightnessBoost : String,
val qrShare : String,
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
val receiveVmWithdrawRejected : String,
val receiveVmWithdrawFailed : String,
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
val send : String, val send : String,
val paste : String, val paste : String,
@@ -204,6 +149,119 @@ data class AppStrings(
val sendVmUnsupportedType : (tag: String) -> String, val sendVmUnsupportedType : (tag: String) -> String,
val sendVmPaymentFailedRescan : String, val sendVmPaymentFailedRescan : String,
val onboarding: OnboardingStrings,
val receive: ReceiveStrings,
val history: HistoryStrings,
val nwc: NwcStrings
)
data class OnboardingStrings(
val title: String,
val subtitle: String,
val serverUrl: String,
val serverUrlHint: String,
val invoiceKey: String,
val invoiceKeySubtitle: String,
val pasteInvoiceKey: String,
val adminKey: String,
val adminKeySubtitle: String,
val pasteAdminKey: String,
val showAdminKey: String,
val hideAdminKey: String,
val connect: String,
val fieldsRequired: String,
val invalidKeyFormat: String,
val back: String,
val next: String,
val done: String,
val notificationsTitle: String,
val notificationsSubtitle: String,
val notificationsAlreadyGranted: String,
val allowNotifications: String,
val notificationsNote: String,
val notificationsAutoGranted: String,
val batteryTitle: String,
val batterySubtitle: String,
val openSettings: String,
val batteryNote: String,
val batteryAlreadyUnrestricted: String,
val paymentHistory: String,
val paymentHistorySubtitle: String,
val syncNow: String,
val skipForNow: String,
val syncScheduled: String,
val continueToApp: String,
val connecting: String,
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
val paymentsSynced: (total: Int) -> String,
val syncComplete: String,
val syncFailed: String,
val scheduleForLater: String,
val scheduleSync: String,
val schedule: String,
val cancel: String
)
data class ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
val receive : String,
// ── ReceiveScreen — error ─────────────────────────────────────────────────
val receiveError : String,
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
val lightningAddressQrCode : String,
val lightningAddressLabel : String,
val shareLightningAddress : String,
val lightningInvoiceQrCode : String,
val invoiceLabel : String,
val shareInvoice : String,
// ── ReceiveScreen — amount step ───────────────────────────────────────────
val amountInSats : String, // text field label
val satsRange : (min: Long, max: Long) -> String,
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
val memoOptional : String,
val memoPlaceholder : String,
val creatingInvoice : String,
val createInvoice : String,
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
val waiting : String,
val invoiceExpired : String,
val newInvoice : String,
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
val withdrawFrom : String,
val minimumSats : (min: Long) -> String,
val maximumSats : (max: Long) -> String,
val withdrawButton : String, // no fixed amount
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
// ── ReceiveScreen — success ───────────────────────────────────────────────
val paymentReceived : String,
// ── AmountDisplay ─────────────────────────────────────────────────────────
val amountDisplaySats : (amount: Long) -> String,
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
val brightnessReduce : String,
val brightnessBoost : String,
val qrShare : String,
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
val openInApp : String,
val noAppFound : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
val receiveVmWithdrawRejected : String,
val receiveVmWithdrawFailed : String,
)
data class HistoryStrings(
// ── HistoryScreen ───────────────────────────────────────────────────────── // ── HistoryScreen ─────────────────────────────────────────────────────────
val historyTitle : String, val historyTitle : String,
val historyClose : String, val historyClose : String,
@@ -303,52 +361,61 @@ data class AppStrings(
val paymentRowNoMemo : String, val paymentRowNoMemo : String,
val paymentRowPending : String, val paymentRowPending : String,
val paymentRowFailed : String, val paymentRowFailed : String,
val onboarding: OnboardingStrings
) )
data class OnboardingStrings( data class NwcStrings(
val title: String,
val subtitle: String, // ── Shared / navigation ───────────────────────────────────────────────────
val serverUrl: String,
val serverUrlHint: String,
val invoiceKey: String,
val invoiceKeySubtitle: String,
val pasteInvoiceKey: String,
val adminKey: String,
val adminKeySubtitle: String,
val pasteAdminKey: String,
val showAdminKey: String,
val hideAdminKey: String,
val connect: String,
val fieldsRequired: String,
val back : String, val back : String,
val next : String, val next : String,
val done : String, val done : String,
val notificationsTitle: String, val cancel : String,
val notificationsSubtitle: String,
val notificationsAlreadyGranted: String,
val allowNotifications: String,
val notificationsNote: String,
val notificationsAutoGranted: String,
val batteryTitle: String,
val batterySubtitle: String,
val openSettings: String,
val batteryNote: String,
val batteryAlreadyUnrestricted: String,
val paymentHistory: String,
val paymentHistorySubtitle: String,
val syncNow: String,
val skipForNow: String,
val syncScheduled: String,
val continueToApp: String,
val connecting: String,
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
val paymentsSynced: (total: Int) -> String,
val syncComplete: String,
val syncFailed: String,
val scheduleForLater: String,
val scheduleSync: String,
val schedule : String, val schedule : String,
val cancel: String
// ── Connection detail ─────────────────────────────────────────────────────
val connectionDetailTitle : String,
val connectionUnknownWallet : String,
val connectionSectionDetails : String,
val connectionRowCreated : String,
val connectionRowLastUsed : String,
val connectionRowExpires : String,
val connectionRowPubkey : String,
val connectionNeverUsed : String,
val connectionNoExpiry : String,
/** "Expired · {date}" */
val connectionExpired : (date: String) -> String,
val connectionSectionPermissions : String,
val connectionSectionBudgets : String,
/** "{window} budget" e.g. "Daily budget" */
val connectionBudgetTitle : (window: String) -> String,
/** "Used: {sats} sats" */
val connectionBudgetUsed : (sats: Long) -> String,
/** "of {sats} sats" */
val connectionBudgetTotal : (sats: Long) -> String,
val connectionRevoke : String,
val connectionStatusActive : String,
val connectionStatusExpired : String,
// ── NWC permission labels ─────────────────────────────────────────────────
val permPayInvoice : String,
val permGetBalance : String,
val permGetInfo : String,
val permMakeInvoice : String,
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 permLookupInvoiceStatus: String,
val permReadTransactionHistory: String,
val permReadWalletBalance: String,
val permReadAccountInfo: String,
val revokeDialogTitle : String,
val revokeDialogBody : String,
val revokeDialogConfirm : String,
val revokeDialogDismiss : String,
) )
@@ -1,8 +1,8 @@
package com.bitcointxoko.gudariwallet.i18n package com.bitcointxoko.gudariwallet.i18n
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) { fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
"success" -> statusSuccess "success" -> history.statusSuccess
"pending" -> statusPending "pending" -> history.statusPending
"failed" -> statusFailed "failed" -> history.statusFailed
else -> statusUnknown else -> history.statusUnknown
} }
@@ -7,8 +7,8 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
@@ -98,7 +98,7 @@ class WalletNotificationService : Service() {
private var reconnectAttempts = 0 private var reconnectAttempts = 0
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
private val gson = GsonProvider.gson private val json = JsonProvider.json
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -157,7 +157,7 @@ class WalletNotificationService : Service() {
val request = Request.Builder().url(wsUrl).build() val request = Request.Builder().url(wsUrl).build()
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() { webSocket = LNbitsHttpClient.instance.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("WebSocket connected") Timber.d("WebSocket connected")
@@ -193,7 +193,7 @@ class WalletNotificationService : Service() {
private fun handleMessage(text: String) { private fun handleMessage(text: String) {
val msg = runCatching { val msg = runCatching {
gson.fromJson(text, WsPaymentMessage::class.java) json.decodeFromString(WsPaymentMessage.serializer(), text)
}.getOrNull() ?: return }.getOrNull() ?: return
val payment = msg.payment ?: return val payment = msg.payment ?: return
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
fun HomeTab( fun HomeTab(
vm: WalletViewModel, vm: WalletViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onNwcClick : () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -50,7 +52,7 @@ fun HomeTab(
val isRefreshing = balanceState is BalanceState.Loading val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true || (balanceState as? BalanceState.Success)?.isRefreshing == true
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() }, onRefresh = { vm.refreshBalance() },
@@ -174,7 +176,9 @@ fun HomeTab(
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.75f
)
) )
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
Icon( Icon(
@@ -280,6 +284,21 @@ fun HomeTab(
} }
} }
} }
// ── NWC button — top-right corner ─────────────────────────────────
IconButton(
onClick = onNwcClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(
imageVector = Icons.Filled.Cable,
contentDescription = "Wallet Connect",
tint = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = 0.7f)
)
}
}
} }
// ── NEW: Language switcher composable ───────────────────────────────────────── // ── NEW: Language switcher composable ─────────────────────────────────────────
@@ -62,6 +62,10 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailScreen
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
@@ -81,6 +85,8 @@ private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner" private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history" private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
@Composable @Composable
fun WalletScreen( fun WalletScreen(
@@ -120,6 +126,9 @@ fun WalletScreen(
) )
} }
val historyVm: HistoryViewModel = viewModel(factory = historyFactory) val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
)
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -127,10 +136,14 @@ fun WalletScreen(
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER && currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY && currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER || currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL
// ── Notification tap ────────────────────────────────────────────────────── // ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value val hash = pendingPaymentHash.value
@@ -322,6 +335,11 @@ fun WalletScreen(
launchSingleTop = true launchSingleTop = true
} }
}, },
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
lyricist = lyricist lyricist = lyricist
) )
} }
@@ -415,6 +433,70 @@ fun WalletScreen(
} }
} }
} }
composable(
ROUTE_NWC,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
NwcScreen(
viewModel = nwcVm,
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onConnectionClick = { pubkey ->
navController.navigate("connection_detail/$pubkey") {
launchSingleTop = true
}
}
)
}
composable(
route = ROUTE_CONNECTION_DETAIL,
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) {
is ConnectionDetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ConnectionDetailState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
is ConnectionDetailState.Success -> {
ConnectionDetailScreen(
connection = state.connection,
budgets = state.budgets,
onBack = { navController.popBackStack() },
onRevoke = {
nwcVm.deleteConnection(pubkey)
navController.popBackStack()
}
)
}
}
}
} }
// ── Clipboard banner — floats over content, slides in from top ──── // ── Clipboard banner — floats over content, slides in from top ────
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -28,6 +29,7 @@ class WalletViewModel(
val repo : WalletRepository, val repo : WalletRepository,
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore, private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
@@ -11,7 +11,9 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore 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.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
@@ -24,17 +26,18 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app) val secrets = EncryptedSecretStore(app)
val api = LNbitsClient.create(secrets)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc) val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val poller = PaymentPoller(repo) val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app) val lnAddressStore = LightningAddressStore(app)
@@ -62,6 +65,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
repo, repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
nwcCache = nwcCache,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
fiatVm = fiatVm, fiatVm = fiatVm,
@@ -0,0 +1,41 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
@@ -0,0 +1,42 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.app.Activity
import android.view.WindowManager
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
/**
* Manages screen brightness for QR-display screens.
* Returns a [BrightnessController] with [isOn] state and a [toggle] callback.
* Restores system brightness unconditionally when it leaves composition.
*/
@Composable
fun rememberBrightnessController(): BrightnessController {
val window = (LocalContext.current as? Activity)?.window
var isOn by remember { mutableStateOf(false) }
// Sync side-effect: no coroutine needed
SideEffect {
window?.attributes = window?.attributes?.apply {
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
DisposableEffect(Unit) {
onDispose {
window?.attributes = window?.attributes?.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
}
// No remember needed — BrightnessController is a tiny data class,
// and this composable already recomposes only when isOn changes.
return BrightnessController(isOn = isOn, toggle = { isOn = !isOn })
}
data class BrightnessController(
val isOn : Boolean,
val toggle : () -> Unit
)
@@ -0,0 +1,48 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.content.ClipData
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -0,0 +1,93 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null, // ← from components
monospace : Boolean = false, // ← from history
copyable : Boolean = false, // ← from history
onCopy : (() -> Unit)? = null, // ← from history
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val baseStyle = MaterialTheme.typography.bodySmall
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = baseStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
style = baseStyle.copy(
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
textDecoration = textDecoration
),
color = valueColor,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -0,0 +1,32 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@@ -1,4 +1,4 @@
package com.bitcointxoko.gudariwallet.ui.receive package com.bitcointxoko.gudariwallet.ui.common
import android.content.ClipData import android.content.ClipData
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@@ -55,6 +56,22 @@ import kotlinx.coroutines.launch
* @param onToggleNfc Called when the NFC button is tapped. * @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired). * @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
*/ */
/**
* QR image followed by Share / Copy / Brightness / NFC / Open-in-app icon-button row.
*
* @param content What to encode into the QR code.
* @param contentDescription Accessibility label for the QR image.
* @param clipLabel Label used when writing to the clipboard.
* @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped.
* @param isNfcEmulating Current NFC emulation state (null = hide NFC button).
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive.
* @param onOpenUri When non-null, shows an "Open in app" icon button that
* fires this callback. Pass null (default) to hide it.
*/
@Composable @Composable
internal fun QrDisplayCard( internal fun QrDisplayCard(
modifier : Modifier = Modifier, modifier : Modifier = Modifier,
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
textToCopy : String, textToCopy : String,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, onToggleBrightness : () -> Unit,
isNfcEmulating : Boolean? = null, // null → no NFC button shown isNfcEmulating : Boolean? = null,
onToggleNfc : (() -> Unit)? = null, onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true, nfcEnabled : Boolean = true,
onOpenUri : (() -> Unit)? = null, // ← NEW
qrModifier : Modifier = Modifier.size(260.dp) qrModifier : Modifier = Modifier.size(260.dp)
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
// ── Action row: Share | Copy | Brightness | NFC ─────────────────────── // ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Share, imageVector = Icons.Filled.Share,
contentDescription = strings.qrShare, // ← "Share" contentDescription = strings.receive.qrShare,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ContentCopy, imageVector = Icons.Filled.ContentCopy,
contentDescription = strings.qrCopy, // ← "Copy" contentDescription = strings.receive.qrCopy,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
FilledTonalIconButton(onClick = onToggleBrightness) { FilledTonalIconButton(onClick = onToggleBrightness) {
Icon( Icon(
imageVector = Icons.Filled.WbSunny, imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness" contentDescription = if (brightnessOn) strings.receive.brightnessReduce
else strings.brightnessBoost, // ← "Boost brightness" else strings.receive.brightnessBoost,
tint = if (brightnessOn) MaterialTheme.colorScheme.primary tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
Icon( Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc imageVector = if (isNfcEmulating) Icons.Filled.Nfc
else Icons.Outlined.Nfc, else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing" contentDescription = if (isNfcEmulating) strings.receive.qrNfcStop
else strings.qrNfcStart, // ← "Share via NFC" else strings.receive.qrNfcStart,
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
// Open in app — only shown when caller passes onOpenUri
if (onOpenUri != null) {
IconButton(onClick = onOpenUri) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = strings.receive.openInApp,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} }
} }
} }
// ── QR generation ───────────────────────────────────────────────────────────── // ── QR generation ─────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512 private const val QR_BITMAP_SIZE = 512
@@ -0,0 +1,53 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.history.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.history.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.history.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.history.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
@@ -1,4 +1,4 @@
package com.bitcointxoko.gudariwallet.ui.components package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
@@ -1,106 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.components
import android.content.ClipData
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A two-column label/value row used throughout the send flow.
*/
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Row(
modifier = Modifier
.weight(0.65f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = valueColor,
style = LocalTextStyle.current.copy(textDecoration = textDecoration),
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailingContent?.invoke()
}
}
}
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -32,6 +32,7 @@ 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.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.util.formatEpochShort
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -61,14 +62,14 @@ internal fun FilterBottomSheet(
) { ) {
// ── Sheet title ─────────────────────────────────────────────────── // ── Sheet title ───────────────────────────────────────────────────
Text( Text(
text = strings.filterSheetTitle, // ← "Filter payments" text = strings.history.filterSheetTitle, // ← "Filter payments"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Direction ───────────────────────────────────────────────────── // ── Direction ─────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionDirection, // ← "Direction" text = strings.history.filterSectionDirection, // ← "Direction"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -76,9 +77,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DirectionFilter.entries.forEach { option -> DirectionFilter.entries.forEach { option ->
val label = when (option) { val label = when (option) {
DirectionFilter.ALL -> strings.filterDirectionAll // ← "All" DirectionFilter.ALL -> strings.history.filterDirectionAll // ← "All"
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming" DirectionFilter.INCOMING -> strings.history.filterDirectionIncoming // ← "Incoming"
} }
FilterChip( FilterChip(
selected = currentFilter.direction == option, selected = currentFilter.direction == option,
@@ -91,7 +92,7 @@ internal fun FilterBottomSheet(
// ── Status ──────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionStatus, // ← "Status" text = strings.history.filterSectionStatus, // ← "Status"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -104,9 +105,9 @@ internal fun FilterBottomSheet(
label = { label = {
Text( Text(
when (option) { when (option) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed" StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending" StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed" StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
} }
) )
} }
@@ -118,7 +119,7 @@ internal fun FilterBottomSheet(
if (availableTypes.isNotEmpty()) { if (availableTypes.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = strings.filterSectionType, // ← "Type" text = strings.history.filterSectionType, // ← "Type"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -137,7 +138,7 @@ internal fun FilterBottomSheet(
// ── Amount (sats) ───────────────────────────────────────────────── // ── Amount (sats) ─────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionAmount, // ← "Amount (sats)" text = strings.history.filterSectionAmount, // ← "Amount (sats)"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -158,8 +159,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = minText, value = minText,
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMin_label) }, // ← "Min" label = { Text(strings.history.filterAmountMin_label) }, // ← "Min"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats" suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -168,8 +169,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = maxText, value = maxText,
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMax_label) }, // ← "Max" label = { Text(strings.history.filterAmountMax_label) }, // ← "Max"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats" suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -179,7 +180,7 @@ internal fun FilterBottomSheet(
// ── Date ────────────────────────────────────────────────────────── // ── Date ──────────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionDate, // ← "Date" text = strings.history.filterSectionDate, // ← "Date"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -189,9 +190,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DatePreset.entries.forEach { preset -> DatePreset.entries.forEach { preset ->
val label = when (preset) { val label = when (preset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week" DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month" DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year" DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
} }
FilterChip( FilterChip(
selected = currentFilter.datePreset == preset, selected = currentFilter.datePreset == preset,
@@ -220,7 +221,7 @@ internal fun FilterBottomSheet(
Text( Text(
currentFilter.minCreatedAt currentFilter.minCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it) }
?: strings.filterDateFrom_label // ← "From" ?: strings.history.filterDateFrom_label // ← "From"
) )
} }
Text("", style = MaterialTheme.typography.bodyLarge) Text("", style = MaterialTheme.typography.bodyLarge)
@@ -231,7 +232,7 @@ internal fun FilterBottomSheet(
Text( Text(
currentFilter.maxCreatedAt currentFilter.maxCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it) }
?: strings.filterDateTo_label // ← "To" ?: strings.history.filterDateTo_label // ← "To"
) )
} }
} }
@@ -247,11 +248,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000) val epochSec = pickerState.selectedDateMillis?.div(1000)
onDateFilterSet(epochSec, currentFilter.maxCreatedAt) onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
showFromPicker = false showFromPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK" }) { Text(strings.history.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showFromPicker = false }) { TextButton(onClick = { showFromPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel" Text(strings.history.filterDialogCancel) // ← "Cancel"
} }
} }
) { DatePicker(state = pickerState) } ) { DatePicker(state = pickerState) }
@@ -269,11 +270,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L } val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
onDateFilterSet(currentFilter.minCreatedAt, epochSec) onDateFilterSet(currentFilter.minCreatedAt, epochSec)
showToPicker = false showToPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK" }) { Text(strings.history.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showToPicker = false }) { TextButton(onClick = { showToPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel" Text(strings.history.filterDialogCancel) // ← "Cancel"
} }
} }
) { DatePicker(state = pickerState) } ) { DatePicker(state = pickerState) }
@@ -1,185 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
// ── Amount + fiat column ─────────────────────────────────────────────────────
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
// ── Status chip ──────────────────────────────────────────────────────────────
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current // or however you access Lyricist strings
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Section card wrapper ─────────────────────────────────────────────────────
@Composable
internal fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
// ── Label / value row ────────────────────────────────────────────────────────
@Composable
internal fun DetailRow(
label : String,
value : String,
monospace : Boolean = false,
copyable : Boolean = false,
onCopy : (() -> Unit)? = null,
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = value,
style = if (monospace)
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
else
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
color = valueColor,
modifier = Modifier
.weight(1f, fill = false)
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── List screen ────────────────────────────────────────────────────────────── // ── List screen ──────────────────────────────────────────────────────────────
@@ -88,12 +89,12 @@ fun HistoryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(strings.historyTitle) }, // ← "History" title = { Text(strings.history.historyTitle) }, // ← "History"
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historyClose // ← "Close" contentDescription = strings.history.historyClose // ← "Close"
) )
} }
}, },
@@ -107,7 +108,7 @@ fun HistoryScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = strings.historySearchPayments // ← "Search payments" contentDescription = strings.history.historySearchPayments // ← "Search payments"
) )
} }
} }
@@ -117,7 +118,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) { IconButton(onClick = { filterSheetVisible = true }) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = strings.historyFilterPayments // ← "Filter payments" contentDescription = strings.history.historyFilterPayments // ← "Filter payments"
) )
} }
} }
@@ -190,7 +191,7 @@ fun HistoryScreen(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Button(onClick = { vm.refresh() }) {
Text(strings.historyRetry) // ← "Retry" Text(strings.history.historyRetry) // ← "Retry"
} }
} }
} }
@@ -209,7 +210,7 @@ fun HistoryScreen(
if (s.payments.isEmpty()) { if (s.payments.isEmpty()) {
Text( Text(
text = strings.historyNoPayments, // ← "No payments yet." text = strings.history.historyNoPayments, // ← "No payments yet."
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -309,13 +310,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) { IconButton(onClick = onInfoClick) {
Icon( Icon(
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
contentDescription = strings.historySearchHelp // ← "Search help" contentDescription = strings.history.historySearchHelp // ← "Search help"
) )
} }
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions" placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -336,7 +337,7 @@ private fun SearchBar(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ContentPaste, imageVector = Icons.Default.ContentPaste,
contentDescription = strings.historySearchPaste // ← "Paste" contentDescription = strings.history.historySearchPaste // ← "Paste"
) )
} }
IconButton( IconButton(
@@ -345,7 +346,7 @@ private fun SearchBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historySearchClear, // ← "Clear search" contentDescription = strings.history.historySearchClear, // ← "Clear search"
tint = if (query.isNotEmpty()) LocalContentColor.current tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
) )
@@ -381,15 +382,15 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (filter.direction) { when (filter.direction) {
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
else -> strings.filterDirectionIncoming // ← "Incoming" else -> strings.history.filterDirectionIncoming // ← "Incoming"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDirection, // ← "Clear direction filter" contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -402,16 +403,16 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (s) { when (s) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed" StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending" StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed" StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter" contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -425,7 +426,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveType, // ← "Remove type filter" contentDescription = strings.history.filterRemoveType, // ← "Remove type filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -434,11 +435,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) { if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when { val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null -> filter.minAmountSat != null && filter.maxAmountSat != null ->
strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats" strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats"
filter.minAmountSat != null -> filter.minAmountSat != null ->
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats" strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
else -> else ->
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats" strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
} }
InputChip( InputChip(
selected = true, selected = true,
@@ -447,7 +448,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearAmount, // ← "Clear amount filter" contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -457,19 +458,19 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) { if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) { val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week" DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month" DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year" DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.filterDateRange( // ← "XY" strings.history.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt), formatEpochShort(minCreatedAt),
formatEpochShort(maxCreatedAt) formatEpochShort(maxCreatedAt)
) )
minCreatedAt != null -> minCreatedAt != null ->
strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X" strings.history.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
else -> else ->
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
} }
} }
InputChip( InputChip(
@@ -479,7 +480,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDate, // ← "Clear date filter" contentDescription = strings.history.filterClearDate, // ← "Clear date filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -489,11 +490,11 @@ private fun ActiveFilterChips(
InputChip( InputChip(
selected = true, selected = true,
onClick = onClearSearch, onClick = onClearSearch,
label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX" label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historyClearSearch, // ← "Clear search" contentDescription = strings.history.historyClearSearch, // ← "Clear search"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -4,36 +4,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.isFailedStatus
// ── Pure helpers ───────────────────────────────────────────────────────────── import com.bitcointxoko.gudariwallet.util.isPendingStatus
internal fun formatEpochShort(epoch: Long): String =
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
.withZone(java.time.ZoneId.systemDefault())
.format(java.time.Instant.ofEpochSecond(epoch))
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
}
// Fallback: only reached for payments not yet written through Room
return runCatching {
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime))
}.getOrDefault(rawTime)
}
internal fun isPendingStatus(status: String): Boolean =
status.lowercase() in setOf("pending", "in_flight", "inflight")
internal fun isFailedStatus(status: String): Boolean =
status.lowercase() in setOf("failed", "error", "expired")
// ── Composable helpers ───────────────────────────────────────────────────────
@Composable @Composable
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color { internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
@@ -45,8 +45,14 @@ import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Detail screen ──────────────────────────────────────────────────────────── // ── Detail screen ────────────────────────────────────────────────────────────
@@ -86,15 +92,15 @@ fun PaymentDetailScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment" if (payment.isOutgoing) strings.history.detailOutgoingTitle // ← "Outgoing payment"
else strings.detailIncomingTitle // ← "Incoming payment" else strings.history.detailIncomingTitle // ← "Incoming payment"
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.detailBack // ← "Back" contentDescription = strings.history.detailBack // ← "Back"
) )
} }
} }
@@ -116,7 +122,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = strings.detailCouldNotLoad, // ← "Could not load full details" text = strings.history.detailCouldNotLoad, // ← "Could not load full details"
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
@@ -209,7 +215,7 @@ private fun PaymentDetailContent(
// Build the optional parts separately so the lambda stays clean // Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else "" val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else "" val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm" val feeLabel = strings.history.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
payment.feeSat, fiatPart, ppmPart payment.feeSat, fiatPart, ppmPart
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -227,17 +233,17 @@ private fun PaymentDetailContent(
// ── Basic info ─────────────────────────────────────────────────────── // ── Basic info ───────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.detailSectionDetails) { // ← "Details" DetailSection(title = strings.history.detailSectionDetails) { // ← "Details"
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date" DetailRow(strings.history.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date"
DetailRow( DetailRow(
strings.detailRowMemo, // ← "Memo" strings.history.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—" payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
) )
if (payment.extra?.tag != null) { if (payment.extra?.tag != null) {
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type" DetailRow(strings.history.detailRowType, payment.extra.tag) // ← "Type"
} }
if (payment.extra?.comment?.isNotBlank() == true) { if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment" DetailRow(strings.history.detailRowComment, payment.extra.comment) // ← "Comment"
} }
} }
} }
@@ -245,19 +251,19 @@ private fun PaymentDetailContent(
// ── Success action (LNURL) ─────────────────────────────────────────── // ── Success action (LNURL) ───────────────────────────────────────────
payment.extra?.successAction?.let { action -> payment.extra?.successAction?.let { action ->
item { item {
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action" DetailSection(title = strings.history.detailSectionSuccessAction) { // ← "Success Action"
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message" action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } // ← "Message"
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL" action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } // ← "URL"
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description" action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } // ← "Description"
} }
} }
} }
// ── Technical ──────────────────────────────────────────────────────── // ── Technical ────────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical" DetailSection(title = strings.history.detailSectionTechnical) { // ← "Technical"
DetailRow( DetailRow(
label = strings.detailRowPaymentHash, // ← "Payment Hash" label = strings.history.detailRowPaymentHash, // ← "Payment Hash"
value = payment.paymentHash, value = payment.paymentHash,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -271,7 +277,7 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = strings.detailRowPreimage, // ← "Preimage" label = strings.history.detailRowPreimage, // ← "Preimage"
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -288,7 +294,7 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = strings.detailRowDestination, // ← "Destination" label = strings.history.detailRowDestination, // ← "Destination"
value = label, value = label,
valueColor = MaterialTheme.colorScheme.primary, valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
@@ -296,29 +302,13 @@ private fun PaymentDetailContent(
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}, },
trailingContent = { trailingContent = {
IconButton( CopyIconButton(label = "node_id", value = pubkey)
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("node_id", pubkey))
)
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
) )
} }
if (!payment.bolt11.isNullOrBlank()) { if (!payment.bolt11.isNullOrBlank()) {
DetailRow( DetailRow(
label = strings.detailRowBolt11, // ← "BOLT11" label = strings.history.detailRowBolt11, // ← "BOLT11"
value = payment.bolt11, value = payment.bolt11,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -24,8 +24,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import com.bitcointxoko.gudariwallet.util.isFailedStatus
import com.bitcointxoko.gudariwallet.util.isPendingStatus
@Composable @Composable
internal fun PaymentRow( internal fun PaymentRow(
@@ -57,8 +61,8 @@ internal fun PaymentRow(
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent" contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
else strings.paymentRowReceivedCD, // ← "Received" else strings.history.paymentRowReceivedCD, // ← "Received"
tint = amountColor, tint = amountColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
@@ -67,7 +71,7 @@ internal fun PaymentRow(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } text = payment.memo?.takeIf { it.isNotBlank() }
?: strings.paymentRowNoMemo, // ← "No memo" ?: strings.history.paymentRowNoMemo, // ← "No memo"
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -82,8 +86,8 @@ internal fun PaymentRow(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val statusLabel = when { val statusLabel = when {
isPending -> strings.paymentRowPending isPending -> strings.history.paymentRowPending
isFailed -> strings.paymentRowFailed isFailed -> strings.history.paymentRowFailed
else -> null else -> null
} }
val statusColor = when { val statusColor = when {
@@ -33,45 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp) .padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
) { ) {
Text( Text(
text = strings.searchInfoTitle, // ← "What can I search for?" text = strings.history.searchInfoTitle, // ← "What can I search for?"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = strings.searchInfoSubtitle, // ← "Type any part of the following…" text = strings.history.searchInfoSubtitle, // ← "Type any part of the following…"
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoMemoTitle, // ← "Memo" title = strings.history.searchInfoMemoTitle, // ← "Memo"
description = strings.searchInfoMemoDesc description = strings.history.searchInfoMemoDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoInvoiceTitle, // ← "Invoice" title = strings.history.searchInfoInvoiceTitle, // ← "Invoice"
description = strings.searchInfoInvoiceDesc description = strings.history.searchInfoInvoiceDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoHashTitle, // ← "Hash" title = strings.history.searchInfoHashTitle, // ← "Hash"
description = strings.searchInfoHashDesc description = strings.history.searchInfoHashDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoPreimageTitle, // ← "Preimage" title = strings.history.searchInfoPreimageTitle, // ← "Preimage"
description = strings.searchInfoPreimageDesc description = strings.history.searchInfoPreimageDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoNodeAliasTitle, // ← "Node alias" title = strings.history.searchInfoNodeAliasTitle, // ← "Node alias"
description = strings.searchInfoNodeAliasDesc description = strings.history.searchInfoNodeAliasDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)" title = strings.history.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
description = strings.searchInfoNodeIdDesc description = strings.history.searchInfoNodeIdDesc
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = strings.searchInfoFooter, // ← "You can paste a full value…" text = strings.history.searchInfoFooter, // ← "You can paste a full value…"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -0,0 +1,368 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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 java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
@Stable
private class AddConnectionState {
var description by mutableStateOf("")
var selectedPermissions by mutableStateOf(
ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
)
var neverExpires by mutableStateOf(false)
var expiry: LocalDateTime by mutableStateOf(
LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
)
var showDatePicker by mutableStateOf(false)
var showTimePicker by mutableStateOf(false)
var nextBudgetId by mutableIntStateOf(0)
var budgets by mutableStateOf(emptyList<BudgetDraft>())
// Converts the LocalDateTime expiry to a Unix epoch in seconds
fun expiryEpochSeconds(): Long =
expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (
description : String,
permissions : List<String>,
expiresAt : Long,
budgets : List<NwcNewBudget>
) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val state = remember { AddConnectionState() }
if (state.showDatePicker) {
val initialMillis = state.expiry
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialMillis
)
DatePickerDialog(
onDismissRequest = { state.showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val picked = java.time.Instant
.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
state.expiry = state.expiry
.withYear(picked.year)
.withMonth(picked.monthValue)
.withDayOfMonth(picked.dayOfMonth)
}
state.showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { state.showDatePicker = false }) { Text("Cancel") }
}
) { DatePicker(state = datePickerState) }
}
if (state.showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = state.expiry.hour,
initialMinute = state.expiry.minute,
is24Hour = true
)
AlertDialog(
onDismissRequest = { state.showTimePicker = false },
confirmButton = {
TextButton(onClick = {
state.expiry = state.expiry
.withHour(timePickerState.hour)
.withMinute(timePickerState.minute)
.withSecond(0)
.withNano(0)
state.showTimePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { state.showTimePicker = false }) { Text("Cancel") }
},
text = { TimePicker(state = timePickerState) }
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New connection",
style = MaterialTheme.typography.titleMedium
)
// ── Description ──────────────────────────────────────────────────
OutlinedTextField(
value = state.description,
onValueChange = { state.description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ── Expiry ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Expires", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
if (!state.neverExpires) {
val dateLabel = remember(state.expiry) {
DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
onClick = { state.showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date",
modifier = Modifier.size(16.dp)
)
}
)
Spacer(Modifier.weight(1f))
val timeLabel = remember(state.expiry) {
DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
onClick = { state.showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pick time",
modifier = Modifier.size(16.dp)
)
}
)
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = state.neverExpires,
onCheckedChange = { state.neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Budgets",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
state.budgets += BudgetDraft(id = state.nextBudgetId)
state.nextBudgetId++
}
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add budget",
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add budget")
}
}
state.budgets.forEach { draft ->
key(draft.id) {
BudgetRow(
draft = draft,
onChange = { updated ->
state.budgets = state.budgets.map { if (it.id == draft.id) updated else it }
},
onRemove = {
state.budgets = state.budgets.filter { it.id != draft.id }
}
)
}
}
// ── Permissions ──────────────────────────────────────────────────
Text(
text = "Permissions",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ALL_PERMISSIONS.forEach { perm ->
key(perm.serverKey) {
val checked = perm.serverKey in state.selectedPermissions
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { on ->
state.selectedPermissions = if (on)
state.selectedPermissions + perm.serverKey
else
state.selectedPermissions - perm.serverKey
}
)
Spacer(Modifier.width(8.dp))
Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium)
}
}
}
// ── Create button ─────────────────────────────────────────────────
Button(
onClick = {
val expiresAtSecs = if (state.neverExpires) 0L
else state.expiryEpochSeconds() // #10
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
state.description.trim().ifBlank { "Unnamed" },
state.selectedPermissions.toList(),
expiresAtSecs,
validBudgets
)
},
enabled = state.selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCreating) "Creating…" else "Create connection")
}
}
}
}
// ── Budget row ────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BudgetRow(
draft : BudgetDraft,
onChange : (BudgetDraft) -> Unit,
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val fieldHeight = 64.dp
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = draft.amountSats,
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
label = { Text("Sats") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).height(fieldHeight)
)
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
modifier = Modifier.weight(1f).height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.label,
onValueChange = {},
readOnly = true,
label = { Text("Resets") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier = Modifier
.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth()
.height(fieldHeight)
)
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.label) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
}
)
}
}
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove budget",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@@ -0,0 +1,153 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatEpoch
import kotlinx.coroutines.launch
import java.time.Instant
@Composable
internal fun NwcConnectionRow(
connection : NwcGetResponse,
isDeleting : Boolean,
onDeleteConfirm : () -> Unit,
onConnectionClick: () -> Unit,
modifier : Modifier = Modifier
) {
val key = connection.data
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
val semantic = semanticColors()
// ── Swipe-to-delete state ────────────────────────────────────────────────
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
// Always reject — we never want the row to stay dismissed.
// The dialog is triggered via LaunchedEffect on targetValue below.
false
},
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
)
// targetValue changes to EndToStart as soon as the drag crosses the
// threshold, even though confirmValueChange returns false and currentValue
// stays at Settled. Use a flag so we only fire once per swipe gesture.
var dialogTriggered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Fires when the swipe crosses the threshold — trigger confirm dialog
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting
&& !dialogTriggered
) {
dialogTriggered = true
onDeleteConfirm()
// Reset in its own scope — won't be cancelled when targetValue changes back
scope.launch { dismissState.reset() }
}
}
// Fires when the row has fully snapped back — re-arm for next swipe
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == SwipeToDismissBoxValue.Settled) {
dialogTriggered = false
}
}
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = !isDeleting,
backgroundContent = {
val fraction = dismissState.progress
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
.padding(end = 24.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete connection",
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
)
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onConnectionClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Cable,
contentDescription = null,
tint = if (expired)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
else
MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = key.description.ifBlank { "Unnamed connection" },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (expired)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(2.dp))
Text(
text = if (key.last_used <= 0L) "Never used"
else "Last used ${formatEpoch(key.last_used)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (expired) {
Spacer(Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = semantic.errorContainer,
contentColor = semantic.onErrorContainer
) {
Text(
text = "Expired",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp)
)
}
}
}
@@ -0,0 +1,533 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.content.ClipData
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.util.formatEpoch
import kotlinx.coroutines.launch
// ── ConnectionDetailScreen.kt ─────────────────────────────────────────────────
//
// Displays a Nostr Wallet Connect (NWC) connection and its associated budgets.
// Modelled on PaymentDetailScreen; reuses DetailSection / DetailRow / StatusChip
// from the same UI kit.
//
// bitcoin.design guidelines applied:
// • Hero card surfaces the most important identity info (wallet + description)
// with a clear active/expired status chip — users know at a glance whether
// the connection is live.
// • Permissions are shown as individual readable rows, not a raw comma string.
// • Budget progress uses a LinearProgressIndicator so spending headroom is
// immediately scannable without arithmetic.
// • Timestamps are human-readable; raw pubkeys are truncated + copyable.
// • Destructive "Revoke" action is placed at the bottom, separated from
// informational content, and uses errorContainer colours.
// ─────────────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionDetailScreen(
connection : ConnectionData, // data { pubkey, wallet, description, … }
budgets : List<BudgetData>, // budgets [ { id, pubkey, budget_msats, … } ]
onBack : () -> Unit,
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.nwc.connectionDetailTitle) }, // ← "Connection"
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.nwc.back // ← "Back"
)
}
}
)
}
) { padding ->
ConnectionDetailContent(
connection = connection,
budgets = budgets,
onRevoke = onRevoke,
modifier = Modifier.padding(padding)
)
}
}
// ── Detail content ────────────────────────────────────────────────────────────
@Composable
private fun ConnectionDetailContent(
connection : ConnectionData,
budgets : List<BudgetData>,
onRevoke : (() -> Unit)?,
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var showRevokeDialog by remember { mutableStateOf(false) }
val nowSeconds = remember { System.currentTimeMillis() / 1_000L }
val isExpired = connection.expiresAt > 0 && connection.expiresAt < nowSeconds
val permissions = connection.permissions
if (showRevokeDialog) {
AlertDialog(
onDismissRequest = { showRevokeDialog = false },
title = { Text(strings.nwc.revokeDialogTitle) },
text = { Text(strings.nwc.revokeDialogBody) },
confirmButton = {
TextButton(
onClick = {
showRevokeDialog = false
onRevoke?.invoke()
}
) {
Text(
text = strings.nwc.revokeDialogConfirm,
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { showRevokeDialog = false }) {
Text(strings.nwc.revokeDialogDismiss)
}
}
)
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Hero card ─────────────────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App / wallet icon placeholder
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(14.dp)
.fillMaxSize()
)
}
Spacer(Modifier.height(12.dp))
// Wallet name
Text(
text = connection.description.ifBlank { strings.nwc.connectionUnknownWallet }, // ← "Unknown app"
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
// Active / Expired chip
ConnectionStatusChip(isExpired = isExpired)
}
}
}
// ── Connection details ────────────────────────────────────────────────
item {
DetailSection(title = strings.nwc.connectionSectionDetails) { // ← "Details"
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt)
)
DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed)
else
strings.nwc.connectionNeverUsed // ← "Never"
)
DetailRow(
label = strings.nwc.connectionRowExpires, // ← "Expires"
value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
formatEpoch(connection.expiresAt)
)
else -> formatEpoch(connection.expiresAt)
},
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
)
// Pubkey truncated display, full value copyable
DetailRow(
label = strings.nwc.connectionRowPubkey, // ← "Public key"
value = "${connection.pubkey.take(8)}${connection.pubkey.takeLast(8)}",
monospace = true,
copyable = true,
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("pubkey", connection.pubkey))
)
}
}
)
}
}
// ── Permissions ───────────────────────────────────────────────────────
if (permissions.isNotEmpty()) {
item {
DetailSection(title = strings.nwc.connectionSectionPermissions) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
permissions.forEachIndexed { index, permission ->
PermissionRow(permission = permission, strings)
if (index < permissions.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Budgets ───────────────────────────────────────────────────────────
if (budgets.isNotEmpty()) {
item {
Text(
text = strings.nwc.connectionSectionBudgets, // ← "Spending budgets"
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
items(budgets, key = { it.id }) { budget ->
BudgetCard(budget = budget)
}
}
// ── Revoke ────────────────────────────────────────────────────────────
if (onRevoke != null) {
item {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = { showRevokeDialog = true },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.LinkOff,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(strings.nwc.connectionRevoke) // ← "Revoke connection"
}
}
}
}
}
// ── Budget card ───────────────────────────────────────────────────────────────
@Composable
private fun BudgetCard(budget: BudgetData) {
val strings = LocalAppStrings.current
val totalSats = budget.budgetMsats / 1_000L
val usedSats = budget.usedBudgetMsats / 1_000L
val progress = if (budget.budgetMsats > 0)
(1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
else 0f
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Refresh window label e.g. "Daily budget"
Text(
text = strings.nwc.connectionBudgetTitle( // ← "Daily budget"
formatRefreshWindow(budget.refreshWindow)
),
style = MaterialTheme.typography.titleSmall
)
// Progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
// Used / Total
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = strings.nwc.connectionBudgetUsed(usedSats), // ← "Used: 1,200 sats"
style = MaterialTheme.typography.bodySmall,
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = strings.nwc.connectionBudgetTotal(totalSats), // ← "of 10,000 sats"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Created at
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Created"
value = formatEpoch(budget.createdAt)
)
}
}
}
// ── Permission row ────────────────────────────────────────────────────────────
@Composable
private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = permission.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.padding(7.dp)
.fillMaxSize()
)
}
Text(
text = permission.label(strings), // already localised
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
}
}
// ── Status chip ───────────────────────────────────────────────────────────────
@Composable
private fun ConnectionStatusChip(isExpired: Boolean) {
val strings = LocalAppStrings.current
Surface(
shape = RoundedCornerShape(50),
color = if (isExpired)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = if (isExpired) strings.nwc.connectionStatusExpired // ← "Expired"
else strings.nwc.connectionStatusActive, // ← "Active"
style = MaterialTheme.typography.labelSmall,
color = if (isExpired)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Converts a refresh_window value (seconds) to a human-readable label.
* Common NWC values: 86400 = daily, 604800 = weekly, 2592000 = monthly.
*/
private fun formatRefreshWindow(seconds: Long): String = when (seconds) {
0L -> "One-time"
86_400L -> "Daily"
604_800L -> "Weekly"
2_592_000L -> "Monthly"
else -> "${seconds / 86_400} days"
}
/**
* Maps a raw NWC method string to a friendly label.
* Extend as your permission set grows.
*/
@Composable
private fun formatPermissionLabel(permission: String, strings: AppStrings): String =
when (permission.trim().lowercase()) {
"pay_invoice" -> strings.nwc.permPayInvoice // ← "Pay invoices"
"get_balance" -> strings.nwc.permGetBalance // ← "Read balance"
"get_info" -> strings.nwc.permGetInfo // ← "Read wallet info"
"make_invoice" -> strings.nwc.permMakeInvoice // ← "Create invoices"
"lookup_invoice" -> strings.nwc.permLookupInvoice // ← "Look up invoices"
"list_transactions" -> strings.nwc.permListTransactions // ← "View transaction history"
"sign_message" -> strings.nwc.permSignMessage // ← "Sign messages"
else -> permission // fallback: raw string
}
/** Maps a permission string to a representative icon. */
private fun permissionIcon(permission: String): ImageVector =
when (permission.trim().lowercase()) {
"pay_invoice" -> Icons.Default.ArrowUpward
"get_balance" -> Icons.Default.AccountBalanceWallet
"get_info" -> Icons.Default.Info
"make_invoice" -> Icons.Default.ArrowDownward
"lookup_invoice" -> Icons.Default.Search
"list_transactions" -> Icons.AutoMirrored.Filled.List
"sign_message" -> Icons.Default.Draw
else -> Icons.Default.Key
}
// ── Data models (if not already defined elsewhere) ───────────────────────────
data class ConnectionData(
val pubkey : String,
val wallet : String,
val description : String,
val expiresAt : Long, // Unix seconds; 0 = no expiry
val permissions : List<ConnectionPermission>,
val createdAt : Long, // Unix seconds
val lastUsed : Long // Unix seconds; 0 = never
)
/**
* A resolved permission produced by matching NwcKey.permissions against
* ALL_PERMISSIONS and carrying only what the detail screen needs.
*/
data class ConnectionPermission(
val serverKey : String, // "pay", "invoice", "lookup", etc.
val icon : ImageVector
)
internal fun ConnectionPermission.label(strings: AppStrings): String =
when (serverKey) {
"pay" -> strings.nwc.permSendPayments // "Send payments"
"invoice" -> strings.nwc.permCreateInvoices // "Create invoices"
"lookup" -> strings.nwc.permLookupInvoiceStatus // "Lookup status of invoice"
"history" -> strings.nwc.permReadTransactionHistory // "Read transaction history"
"balance" -> strings.nwc.permReadWalletBalance // "Read wallet balance"
"info" -> strings.nwc.permReadAccountInfo // "Read account info"
else -> serverKey
}
data class BudgetData(
val id : Long,
val pubkey : String,
val budgetMsats : Long,
val refreshWindow : Long, // seconds
val createdAt : Long,
val usedBudgetMsats : Long
)
@@ -0,0 +1,73 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcKey
import com.bitcointxoko.gudariwallet.i18n.AppStrings
/**
* Maps the API model [NwcKey] to the UI domain model [ConnectionData].
* Keeps raw API types out of the Composable layer.
*/
internal fun NwcKey.toConnectionData() = ConnectionData(
pubkey = pubkey,
wallet = wallet,
description = description,
expiresAt = expires_at,
permissions = parsePermissions(permissions),
createdAt = created_at,
lastUsed = last_used
)
/**
* Splits the raw space-separated permissions string and matches each word
* against ALL_PERMISSIONS by serverKey, preserving ALL_PERMISSIONS order.
*
* "pay invoice lookup history balance info"
* words = {"pay", "invoice", "lookup", "history", "balance", "info"}
* filter ALL_PERMISSIONS where serverKey words
* map to ConnectionPermission with localised label + icon
*/
private fun parsePermissions(raw: String): List<ConnectionPermission> {
val words = raw.trim().split("\\s+".toRegex()).toSet()
return ALL_PERMISSIONS
.filter { it.serverKey in words }
.map { perm ->
ConnectionPermission(
serverKey = perm.serverKey,
icon = permissionIcon(perm.serverKey)
)
}
}
private fun permissionIcon(serverKey: String): ImageVector =
when (serverKey) {
"pay" -> Icons.Default.ArrowUpward
"invoice" -> Icons.Default.ArrowDownward
"lookup" -> Icons.Default.Search
"history" -> Icons.Default.List
"balance" -> Icons.Default.AccountBalanceWallet
"info" -> Icons.Default.Info
else -> Icons.Default.Key
}
/**
* Maps the API model [NwcBudget] to the UI domain model [BudgetData].
* refresh_window is widened from Int to Long to match [BudgetData].
*/
internal fun NwcBudget.toBudgetData() = BudgetData(
id = id.toLong(),
pubkey = pubkey,
budgetMsats = budget_msats,
refreshWindow = refresh_window.toLong(),
createdAt = created_at,
usedBudgetMsats = used_budget_msats
)
@@ -0,0 +1,48 @@
package com.bitcointxoko.gudariwallet.ui.nwc
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)
)
// ── Budget ────────────────────────────────────────────────────────────
/**
* 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);
}
data class BudgetDraft(
val id : Int = 0, // stable key for LazyColumn / forEach
val amountSats : String = "", // raw text field value (sats)
val refreshWindow : BudgetRefreshWindow = BudgetRefreshWindow.MONTHLY
) {
/** Returns null when the amount field is blank or non-numeric. */
fun toNwcNewBudget(): NwcNewBudget? {
val sats = amountSats.trim().toLongOrNull()?.takeIf { it > 0 } ?: return null
return NwcNewBudget(
budget_msats = sats * 1_000L,
refresh_window = refreshWindow.seconds
)
}
}
@@ -0,0 +1,69 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
@Composable
internal fun NwcPairingUrlDialog(
url : String,
onDismiss : () -> Unit,
onNoAppFound : () -> Unit
) {
// Brightness state lives here so the dialog owns the screen-brightness side-effect
val brightness = rememberBrightnessController()
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connection created") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Scan or copy this connection string into your app. It will not be shown again.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
QrDisplayCard(
modifier = Modifier.fillMaxWidth(),
content = url,
contentDescription = "NWC pairing QR code",
clipLabel = "NWC connection string",
shareTitle = "Share NWC connection string",
textToCopy = url,
brightnessOn = brightness.isOn,
onToggleBrightness = brightness.toggle,
isNfcEmulating = null,
qrModifier = Modifier.fillMaxWidth(),
onOpenUri = {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
try {
context.startActivity(
Intent.createChooser(intent, "Open in app")
)
} catch (e: ActivityNotFoundException) {
onNoAppFound() // #17: no Toast — caller handles it
}
}
)
}
},
confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") }
}
)
}
@@ -0,0 +1,164 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NwcScreen(
viewModel : NwcViewModel,
onBack : () -> Unit,
onConnectionClick : (pubkey: String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
val showAddSheet by viewModel.showAddSheet.collectAsStateWithLifecycle()
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val refresh = { viewModel.loadConnections(includeExpired = true) }
LaunchedEffect(pairingUrl) {
if (pairingUrl != null) {
viewModel.closeAddSheet()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Wallet Connect") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add connection")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
val isRefreshing = uiState is NwcUiState.Loading ||
(uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = refresh,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (val state = uiState) {
is NwcUiState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is NwcUiState.Error -> {
NwcErrorContent(
message = state.message,
onRetry = refresh,
modifier = Modifier.fillMaxSize()
)
}
is NwcUiState.Success -> {
if (state.connections.isEmpty()) {
NwcEmptyContent(
onAddNew = { viewModel.openAddSheet() },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.connections,
key = { it.data.pubkey }
) { connection ->
NwcConnectionRow(
connection,
isDeleting,
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
onConnectionClick = { onConnectionClick(connection.data.pubkey) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
}
}
}
}
}
// ── Add connection sheet ───────────────────────────────────────────────────
if (showAddSheet) {
NwcAddConnectionSheet(
isCreating = isCreating,
onCreate = { description, permissions, expiresAt, budgets ->
viewModel.createConnection(
description = description,
permissions = permissions,
expiresAt = expiresAt,
budgets = budgets
)
},
onDismiss = { viewModel.closeAddSheet() }
)
}
// ── Pairing URL dialog ─────────────────────────────────────────────────────
pairingUrl?.let { url ->
NwcPairingUrlDialog(
url = url,
onDismiss = { viewModel.clearPairingUrl() },
onNoAppFound = {
scope.launch {
snackbarHostState.showSnackbar("No compatible app found")
}
}
)
}
// ── Delete confirmation dialog ─────────────────────────────────────────────
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.") },
confirmButton = {
TextButton(
onClick = { viewModel.confirmDelete() }
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) { Text("Cancel") }
}
)
}
}
@@ -0,0 +1,67 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
// ── Empty state ──────────────────────────────────────────────────────
@Composable
internal fun NwcEmptyContent(
onAddNew : () -> Unit,
modifier : Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("No connections yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Tap + to connect an app via Nostr Wallet Connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Add connection")
}
}
}
// ── Error state ──────────────────────────────────────────────────────
@Composable
internal fun NwcErrorContent(
message : String,
onRetry : () -> Unit,
modifier : Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
"Something went wrong",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onRetry) { Text("Retry") }
}
}
@@ -0,0 +1,355 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
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.util.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
private const val TAG = "NwcViewModel"
// ── UI State ──────────────────────────────────────────────────────────────────
sealed interface NwcUiState {
data object Loading : NwcUiState
data class Success(
val connections: List<NwcGetResponse>,
val isRefreshingLastUsed: Boolean = false // true only while network sync is in-flight
) : NwcUiState
data class Error(val message: String) : NwcUiState
}
sealed interface ConnectionDetailState {
data object Loading : ConnectionDetailState
data class Success(
val connection : ConnectionData,
val budgets : List<BudgetData>
) : ConnectionDetailState
data class Error(val message: String) : ConnectionDetailState
}
// ── ViewModel ─────────────────────────────────────────────────────────────────
class NwcViewModel(
private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
val uiState: StateFlow<NwcUiState> = _uiState.asStateFlow()
/** Whether a delete operation is currently in flight (disables buttons). */
private val _isDeleting = MutableStateFlow(false)
val isDeleting: StateFlow<Boolean> = _isDeleting.asStateFlow()
// Holds the keypair only while the "add connection" flow is active.
// Cleared as soon as the user dismisses the QR screen.
private val _pendingKeypair = MutableStateFlow<NwcKeypair?>(null)
val pendingKeypair: StateFlow<NwcKeypair?> = _pendingKeypair.asStateFlow()
/** True while a createConnection call is in flight. */
private val _isCreating = MutableStateFlow(false)
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
/** Holds the pairing URL after a successful registration, until dismissed. */
private val _pairingUrl = MutableStateFlow<String?>(null)
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
private val _connectionDetailState =
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
val connectionDetailState: StateFlow<ConnectionDetailState> =
_connectionDetailState.asStateFlow()
private var detailObserverJob: Job? = null
private var cacheObserverJob: Job? = null
// ── Sheet / dialog UI state (survives config change) ────────────────────────
private var isSyncing = false
private val _showAddSheet = MutableStateFlow(false)
val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow()
private val _pendingDeletePubkey = MutableStateFlow<String?>(null)
val pendingDeletePubkey: StateFlow<String?> = _pendingDeletePubkey.asStateFlow()
fun openAddSheet() { _showAddSheet.value = true }
fun closeAddSheet() { _showAddSheet.value = false }
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) }
_pendingDeletePubkey.value = null
}
init {
loadConnections(includeExpired = true)
}
// ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
isSyncing = true
// 1. Subscribe to cache — emits immediately if rows exist, stays alive
// for the ViewModel lifetime. Every cache write re-emits automatically.
if (cacheObserverJob == null) {
// Fast path: one-shot read populates UI before the Flow cold-starts
viewModelScope.launch {
val snapshot = cache.getKeysOnce()
if (snapshot.isNotEmpty() && _uiState.value !is NwcUiState.Success) {
Timber.d("[$TAG] cache snapshot: ${snapshot.size} connection(s)")
_uiState.value = NwcUiState.Success(
connections = snapshot,
isRefreshingLastUsed = true
)
}
}
cacheObserverJob = cache.observeKeys()
.drop(1) // skip first emission — snapshot already handled it
.onEach { cached ->
if (cached.isNotEmpty()) {
Timber.d("[$TAG] cache hit: ${cached.size} connection(s)")
_uiState.value = NwcUiState.Success(
connections = cached.map { it.toDomain().toGetResponse() },
isRefreshingLastUsed = isSyncing // ← reflects current sync state, not always true
)
} else if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Loading
}
}
.launchIn(viewModelScope)
}
// 2. Fetch from network; result is written to cache which re-emits above
viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys ->
Timber.i("[$TAG] network: ${keys.size} NWC connection(s)")
isSyncing = false
cache.syncKeys(keys) // Room write → Flow re-emits
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to load NWC connections")
// Only show error if we have nothing cached to show
isSyncing = false
if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to load connections"
)
} else {
Timber.w("[$TAG] network failed but cache is showing — non-fatal")
}
}
}
}
/**
* Generates a keypair, registers it with the server, then fetches
* the nostr+walletconnect:// pairing URL.
* Private key is held only in local scope never stored.
*/
fun createConnection(
description : String,
permissions : List<String>,
expiresAt : Long = 0L,
budgets : List<NwcNewBudget> = emptyList()
) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
viewModelScope.launch {
_isCreating.value = true
try {
runCatching {
val keypair = NwcKeyGenerator.generate()
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}")
check(keypair.pubKeyHex.length == 64) {
"pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}"
}
check(keypair.privKeyHex.length == 64) {
"privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}"
}
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
val registered = repo.registerNwcKey(
pubkey = keypair.pubKeyHex,
request = NwcRegistrationRequest(
description = description,
permissions = permissions,
expires_at = expiresAt,
budgets = budgets
)
)
Timber.i("[$TAG] registered key ✓")
cache.saveKey(registered)
Timber.d("[$TAG] new key cached ✓")
val url = repo.getNwcPairingUrl(keypair.privKeyHex)
Timber.i("[$TAG] pairing URL fetched ✓")
Timber.d("[$TAG] URI=$url")
url
}
.onSuccess { url ->
_pairingUrl.value = url
silentRefresh()
}
.onFailure { e ->
Timber.e(e, "[$TAG] createConnection failed")
_uiState.value = NwcUiState.Error(e.message ?: "Failed to create connection")
}
} finally {
_isCreating.value = false
}
}
}
/** Wipes the pairing URL from memory once the user has copied it. */
fun clearPairingUrl() {
Timber.d("[$TAG] clearing pairing URL from memory")
_pairingUrl.value = null
}
// ── Delete / Revoke ──────────────────────────────────────────────────────────
/**
* Deletes an NWC key by pubkey. Used by both the list screen (swipe-to-delete)
* 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) {
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch {
_isDeleting.value = true
try {
runCatching { repo.deleteNwcKey(pubkey) }
.onSuccess {
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
cache.deleteKey(pubkey)
val current = _uiState.value
if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success(
current.connections.filter { it.data.pubkey != pubkey }
)
}
silentRefresh()
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to delete connection"
)
}
} finally {
_isDeleting.value = false
}
}
}
fun loadConnectionDetail(pubkey: String) {
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}")
// Cancel any subscription from a previous key — prevents stale Flow
// from a previously viewed key overwriting the current detail state
detailObserverJob?.cancel()
_connectionDetailState.value = ConnectionDetailState.Loading
detailObserverJob = combine(
cache.observeKey(pubkey),
cache.observeBudgets(pubkey)
) { keyEntity, budgetEntities ->
Pair(keyEntity, budgetEntities)
}
.onEach { (keyEntity, budgetEntities) ->
if (keyEntity != null) {
Timber.d("[$TAG] detail cache hit for ${pubkey.take(16)}")
_connectionDetailState.value = ConnectionDetailState.Success(
connection = keyEntity.toDomain().toConnectionData(),
budgets = budgetEntities.map { it.toDomain().toBudgetData() }
)
} else if (_connectionDetailState.value !is ConnectionDetailState.Success) {
_connectionDetailState.value = ConnectionDetailState.Loading
}
}
.launchIn(viewModelScope) // ← now tracked and cancellable
viewModelScope.launch {
runCatching {
repo.getNwcKey(pubkey = pubkey, includeExpired = true, refreshLastUsed = false)
}
.onSuccess { response ->
Timber.i("[$TAG] loaded connection detail ✓")
cache.refreshLastUsed(pubkey, response.data.last_used)
}
.onFailure { e ->
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"
)
} else {
Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)")
}
}
}
}
/** Reset to Loading so the next open always shows a fresh load. */
fun clearConnectionDetail() {
Timber.d("[$TAG] clearConnectionDetail")
detailObserverJob?.cancel() // ← stop the old key's Flow immediately
detailObserverJob = null
_connectionDetailState.value = ConnectionDetailState.Loading
}
// ── Silent refresh (no Loading flash) ───────────────────────────────────────
/**
* Syncs the connection list from the server without transitioning to
* [NwcUiState.Loading] first. Used after mutations (create, delete) where
* the UI already shows the correct optimistic state.
* Failures are non-fatal the optimistic state is already correct.
*/
private fun silentRefresh(includeExpired: Boolean = false) {
viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys ->
Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)")
cache.syncKeys(keys) // Room write → cache Flow re-emits to UI
}
.onFailure { e ->
Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)")
}
}
}
// ── Factory ───────────────────────────────────────────────────────────────
class Factory(
private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NwcViewModel(repo, cache) as T
}
}
@@ -19,6 +19,7 @@ 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 com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
private const val PAGE_WELCOME = 0 private const val PAGE_WELCOME = 0
private const val PAGE_SERVER_URL = 1 private const val PAGE_SERVER_URL = 1
@@ -26,13 +27,13 @@ private const val PAGE_INVOICE = 2
private const val PAGE_ADMIN = 3 private const val PAGE_ADMIN = 3
private const val PAGE_NOTIFICATIONS = 4 private const val PAGE_NOTIFICATIONS = 4
private const val PAGE_BATTERY = 5 private const val PAGE_BATTERY = 5
private const val PAGE_SYNC = 6 // ← new private const val PAGE_SYNC = 6
private const val PAGE_COUNT = 7 // ← was 6 private const val PAGE_COUNT = 7
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
secretStore : SecretStore, secretStore : SecretStore,
syncStore : HistoricalSyncStore, // ← new syncStore : HistoricalSyncStore,
onRequestNotificationPermission: () -> Unit, onRequestNotificationPermission: () -> Unit,
onComplete : () -> Unit onComplete : () -> Unit
) { ) {
@@ -45,12 +46,11 @@ fun OnboardingScreen(
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) } var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var credentialsSaved by remember { mutableStateOf(false) } // var credentialsSaved by remember { mutableStateOf(false) }
// ── Sync state ──────────────────────────────────────────────────────────── // ── Sync state ────────────────────────────────────────────────────────────
val vm: OnboardingViewModel = viewModel( val vm: OnboardingViewModel = viewModel(
factory = OnboardingViewModelFactory( factory = OnboardingViewModelFactory(
// LocalContext.current.applicationContext cast to Application
app = androidx.compose.ui.platform.LocalContext.current app = androidx.compose.ui.platform.LocalContext.current
.applicationContext as android.app.Application, .applicationContext as android.app.Application,
secretStore = secretStore, secretStore = secretStore,
@@ -64,31 +64,33 @@ fun OnboardingScreen(
LaunchedEffect(pagerState.currentPage) { error = null } LaunchedEffect(pagerState.currentPage) { error = null }
var previousPage by remember { mutableIntStateOf(0) } val hexKeyRegex = Regex("^[0-9a-f]{32}$")
LaunchedEffect(pagerState.currentPage) {
val current = pagerState.currentPage
if (current > previousPage) {
// User moved forward — save whatever the previous page owned
when (previousPage) {
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
else -> Unit
}
}
previousPage = current
}
fun validateCurrentPage(): Boolean { fun validateCurrentPage(): Boolean {
error = when (pagerState.currentPage) { error = when (pagerState.currentPage) {
PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null PAGE_SERVER_URL -> when {
PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null baseUrl.isBlank() -> strings.onboarding.fieldsRequired
PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null else -> null
}
PAGE_INVOICE -> when {
invoiceKey.isBlank() -> strings.onboarding.fieldsRequired
!hexKeyRegex.matches(invoiceKey) -> strings.onboarding.invalidKeyFormat
else -> null
}
PAGE_ADMIN -> when {
adminKey.isBlank() -> strings.onboarding.fieldsRequired
!hexKeyRegex.matches(adminKey) -> strings.onboarding.invalidKeyFormat
else -> null
}
else -> null else -> null
} }
return error == null return error == null
} }
Scaffold { innerPadding -> Scaffold { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -127,7 +129,7 @@ fun OnboardingScreen(
onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) }, onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) },
onContinue = { onContinue = {
scope.launch { scope.launch {
secretStore.setOnboarded() // moved here from PAGE_BATTERY secretStore.setOnboarded()
onComplete() onComplete()
} }
}, },
@@ -167,16 +169,24 @@ fun OnboardingScreen(
Spacer(Modifier.width(1.dp)) Spacer(Modifier.width(1.dp))
} }
val isLastPage = pagerState.currentPage == PAGE_BATTERY // val isLastPage = pagerState.currentPage == PAGE_BATTERY
Button(onClick = { Button(onClick = {
if (!validateCurrentPage()) return@Button if (!validateCurrentPage()) return@Button
scope.launch { scope.launch {
// Save each field as soon as the user advances past it
when (pagerState.currentPage) { when (pagerState.currentPage) {
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl) PAGE_SERVER_URL -> {
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey) Timber.d("ONBOARDING saving baseUrl (len=${baseUrl.length}): $baseUrl")
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey) secretStore.saveBaseUrl(baseUrl)
}
PAGE_INVOICE -> {
Timber.d("ONBOARDING saving invoiceKey (len=${invoiceKey.length}): $invoiceKey")
secretStore.saveInvoiceKey(invoiceKey)
}
PAGE_ADMIN -> {
Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey")
secretStore.saveAdminKey(adminKey)
}
else -> Unit else -> Unit
} }
pagerState.animateScrollToPage(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1)
@@ -25,7 +25,7 @@ internal fun AmountDisplay(
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
Text( Text(
text = strings.amountDisplaySats(amountSats), // ← "1 sat" or "X sats" text = strings.receive.amountDisplaySats(amountSats), // ← "1 sat" or "X sats"
style = style, style = style,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -20,8 +20,8 @@ internal fun BrightnessToggleButton(
Icon( Icon(
imageVector = if (isOn) Icons.Filled.BrightnessHigh imageVector = if (isOn) Icons.Filled.BrightnessHigh
else Icons.Filled.BrightnessLow, else Icons.Filled.BrightnessLow,
contentDescription = if (isOn) strings.brightnessReduce // ← "Reduce brightness" contentDescription = if (isOn) strings.receive.brightnessReduce // ← "Reduce brightness"
else strings.brightnessBoost, // ← "Boost brightness" else strings.receive.brightnessBoost, // ← "Boost brightness"
tint = if (isOn) MaterialTheme.colorScheme.primary tint = if (isOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -1,7 +1,5 @@
package com.bitcointxoko.gudariwallet.ui.receive package com.bitcointxoko.gudariwallet.ui.receive
import android.app.Activity
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -58,14 +56,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.util.buildUpTaps import com.bitcointxoko.gudariwallet.util.buildUpTaps
import com.bitcointxoko.gudariwallet.util.doubleTap
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.heavyClick
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
@@ -75,31 +72,16 @@ fun ReceiveScreen(
onNavigateToPaymentDetail: (checkingId: String) -> Unit onNavigateToPaymentDetail: (checkingId: String) -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val context = LocalContext.current
val brightness = rememberBrightnessController()
var showAddress by remember { mutableStateOf(false) }
val receiveState by vm.receiveState.collectAsStateWithLifecycle() val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle() val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle() val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle() val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
val context = LocalContext.current
val window = (context as Activity).window
val originalBrightness = remember { window.attributes.screenBrightness }
var brightnessOn by remember { mutableStateOf(false) }
var showAddress by remember { mutableStateOf(false) }
val onToggleBrightness = {
val lp = window.attributes
lp.screenBrightness = if (!brightnessOn)
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else originalBrightness
window.attributes = lp
brightnessOn = !brightnessOn
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val lp = window.attributes
lp.screenBrightness = originalBrightness
window.attributes = lp
nfcVm.stopEmulating() nfcVm.stopEmulating()
vm.resetReceiveState() vm.resetReceiveState()
} }
@@ -141,14 +123,14 @@ fun ReceiveScreen(
lightningAddress = lightningAddress, lightningAddress = lightningAddress,
showAddress = showAddress, showAddress = showAddress,
onToggleAddress = { showAddress = !showAddress }, onToggleAddress = { showAddress = !showAddress },
brightnessOn = brightnessOn, brightnessOn = brightness.isOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = brightness.toggle,
isLoading = receiveState is ReceiveState.AwaitingInvoice, isLoading = receiveState is ReceiveState.AwaitingInvoice,
onCreateInvoice = { amountSats, memo -> onCreateInvoice = { amountSats, memo ->
vm.createInvoice( vm.createInvoice(
amountSats, amountSats,
memo, memo,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice
) )
} }
) )
@@ -163,9 +145,9 @@ fun ReceiveScreen(
callback = state.callback, callback = state.callback,
amountSats = amountSats, amountSats = amountSats,
memo = state.defaultDescription, memo = state.defaultDescription,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice, failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice,
withdrawRejected = strings.receiveVmWithdrawRejected, withdrawRejected = strings.receive.receiveVmWithdrawRejected,
withdrawFailed = strings.receiveVmWithdrawFailed, withdrawFailed = strings.receive.receiveVmWithdrawFailed,
) )
}, },
onDismiss = { vm.resetReceiveState() } onDismiss = { vm.resetReceiveState() }
@@ -190,7 +172,7 @@ fun ReceiveScreen(
modifier = Modifier.padding(24.dp) modifier = Modifier.padding(24.dp)
) { ) {
Text( Text(
text = strings.receiveError, // ← "Error" text = strings.receive.receiveError, // ← "Error"
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -216,8 +198,8 @@ fun ReceiveScreen(
state = receiveState as ReceiveState.InvoiceReady, state = receiveState as ReceiveState.InvoiceReady,
fiatRate = fiatRate, fiatRate = fiatRate,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn, brightnessOn = brightness.isOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = brightness.toggle,
nfcVm = nfcVm, nfcVm = nfcVm,
onReset = { vm.resetReceiveState() } onReset = { vm.resetReceiveState() }
) )
@@ -243,8 +225,8 @@ private fun LnurlWithdrawSheet(
val parsed = amountText.trim().toLongOrNull() val parsed = amountText.trim().toLongOrNull()
return when { return when {
parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount" parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount"
parsed < state.minSats -> { amountError = strings.minimumSats(state.minSats); null } // ← "Minimum is X sats" parsed < state.minSats -> { amountError = strings.receive.minimumSats(state.minSats); null } // ← "Minimum is X sats"
parsed > state.maxSats -> { amountError = strings.maximumSats(state.maxSats); null } // ← "Maximum is X sats" parsed > state.maxSats -> { amountError = strings.receive.maximumSats(state.maxSats); null } // ← "Maximum is X sats"
else -> { amountError = null; parsed } else -> { amountError = null; parsed }
} }
} }
@@ -265,7 +247,7 @@ private fun LnurlWithdrawSheet(
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Column { Column {
Text( Text(
text = strings.withdrawFrom, // ← "Withdraw from" text = strings.receive.withdrawFrom, // ← "Withdraw from"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -304,7 +286,7 @@ private fun LnurlWithdrawSheet(
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = strings.withdrawButtonAmount(state.maxSats), // ← "X sats" text = strings.receive.withdrawButtonAmount(state.maxSats), // ← "X sats"
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
@@ -315,12 +297,12 @@ private fun LnurlWithdrawSheet(
OutlinedTextField( OutlinedTextField(
value = amountText, value = amountText,
onValueChange = { amountText = it; amountError = null }, onValueChange = { amountText = it; amountError = null },
label = { Text(strings.amountInSats) }, // ← "Amount (sats)" label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
supportingText = { supportingText = {
if (amountError != null) if (amountError != null)
Text(amountError!!, color = MaterialTheme.colorScheme.error) Text(amountError!!, color = MaterialTheme.colorScheme.error)
else else
Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X Y sats" Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
}, },
isError = amountError != null, isError = amountError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -339,8 +321,8 @@ private fun LnurlWithdrawSheet(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (fixedAmount) strings.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats" if (fixedAmount) strings.receive.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats"
else strings.withdrawButton // ← "Withdraw" else strings.receive.withdrawButton // ← "Withdraw"
) )
} }
@@ -397,7 +379,7 @@ private fun ReceiveIdleContent(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = strings.receive, // ← "Receive" text = strings.receive.receive, // ← "Receive"
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
if (lightningAddress != null) { if (lightningAddress != null) {
@@ -439,9 +421,9 @@ private fun ReceiveIdleContent(
QrDisplayCard( QrDisplayCard(
content = lightningAddress, content = lightningAddress,
contentDescription = strings.lightningAddressQrCode, // ← "Lightning address QR code" contentDescription = strings.receive.lightningAddressQrCode, // ← "Lightning address QR code"
clipLabel = strings.lightningAddressLabel, // ← "Lightning Address" clipLabel = strings.receive.lightningAddressLabel, // ← "Lightning Address"
shareTitle = strings.shareLightningAddress, // ← "Share Lightning Address" shareTitle = strings.receive.shareLightningAddress, // ← "Share Lightning Address"
textToCopy = lightningAddress, textToCopy = lightningAddress,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness onToggleBrightness = onToggleBrightness
@@ -512,7 +494,7 @@ private fun AmountStepContent(
val number = amountText.trim().toDoubleOrNull() ?: return@remember null val number = amountText.trim().toDoubleOrNull() ?: return@remember null
when (activeUnit) { when (activeUnit) {
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency) AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
AmountUnit.FIAT -> "${(number * fiatRate!!).toLong()} sats" AmountUnit.FIAT -> "${(number * fiatRate).toLong()} sats"
} }
} }
@@ -560,7 +542,7 @@ private fun AmountStepContent(
) )
UnitWheelPicker( UnitWheelPicker(
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency!!) else listOf(strings.sats), units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1, selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
onIndexSelected = { newIndex -> onIndexSelected = { newIndex ->
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
@@ -605,8 +587,8 @@ private fun MemoStepContent(
OutlinedTextField( OutlinedTextField(
value = memo, value = memo,
onValueChange = { memo = it }, onValueChange = { memo = it },
label = { Text(strings.memoOptional) }, // ← "Memo (optional)" label = { Text(strings.receive.memoOptional) }, // ← "Memo (optional)"
placeholder = { Text(strings.memoPlaceholder) }, // ← "What's this for?" placeholder = { Text(strings.receive.memoPlaceholder) }, // ← "What's this for?"
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -621,8 +603,8 @@ private fun MemoStepContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (isLoading) strings.creatingInvoice // ← "Creating invoice…" if (isLoading) strings.receive.creatingInvoice // ← "Creating invoice…"
else strings.createInvoice // ← "Create Invoice" else strings.receive.createInvoice // ← "Create Invoice"
) )
} }
@@ -683,9 +665,9 @@ private fun ReceiveInvoiceContent(
QrDisplayCard( QrDisplayCard(
content = state.bolt11.uppercase(), content = state.bolt11.uppercase(),
contentDescription = strings.lightningInvoiceQrCode, // ← "Lightning invoice QR code" contentDescription = strings.receive.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
clipLabel = strings.invoiceLabel, // ← "Invoice" clipLabel = strings.receive.invoiceLabel, // ← "Invoice"
shareTitle = strings.shareInvoice, // ← "Share Invoice" shareTitle = strings.receive.shareInvoice, // ← "Share Invoice"
textToCopy = state.bolt11, textToCopy = state.bolt11,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = onToggleBrightness,
@@ -711,7 +693,7 @@ private fun ReceiveInvoiceContent(
if (!isExpired) { if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text( Text(
text = strings.waiting, // ← "Waiting…" text = strings.receive.waiting, // ← "Waiting…"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -722,7 +704,7 @@ private fun ReceiveInvoiceContent(
) )
} else { } else {
Text( Text(
text = strings.invoiceExpired, // ← "Invoice expired" text = strings.receive.invoiceExpired, // ← "Invoice expired"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -734,7 +716,7 @@ private fun ReceiveInvoiceContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (isExpired) strings.newInvoice // ← "New Invoice" if (isExpired) strings.receive.newInvoice // ← "New Invoice"
else strings.cancel // ← "Cancel" else strings.cancel // ← "Cancel"
) )
} }
@@ -768,7 +750,7 @@ private fun ReceiveSuccessContent(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = strings.paymentReceived, // ← "Payment Received!" text = strings.receive.paymentReceived, // ← "Payment Received!"
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -44,7 +44,7 @@ class ReceiveViewModel(
// ── Invoice creation ────────────────────────────────────────────────────── // ── Invoice creation ──────────────────────────────────────────────────────
/** /**
* @param failedToCreateInvoice Localised error string from the call site, * @param failedToCreateInvoice Localised error string from the call site,
* e.g. `strings.receiveVmFailedToCreateInvoice` * e.g. `strings.receive.receiveVmFailedToCreateInvoice`
*/ */
fun createInvoice( fun createInvoice(
amountSats : Long, amountSats : Long,
@@ -104,9 +104,9 @@ class ReceiveViewModel(
} }
/** /**
* @param failedToCreateInvoice `strings.receiveVmFailedToCreateInvoice` * @param failedToCreateInvoice `strings.receive.receiveVmFailedToCreateInvoice`
* @param withdrawRejected `strings.receiveVmWithdrawRejected` * @param withdrawRejected `strings.receive.receiveVmWithdrawRejected`
* @param withdrawFailed `strings.receiveVmWithdrawFailed` * @param withdrawFailed `strings.receive.receiveVmWithdrawFailed`
*/ */
fun executeWithdraw( fun executeWithdraw(
k1 : String, k1 : String,
@@ -28,8 +28,8 @@ import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.components.DetailRow import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.payingToLabel import com.bitcointxoko.gudariwallet.util.payingToLabel
@@ -59,7 +59,7 @@ internal fun LnurlPayForm(
OutlinedTextField( OutlinedTextField(
value = amount, value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it }, onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.amountInSats) }, // ← "Amount (sats)" label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
isError = amount.isNotBlank() && !isAmountValid, isError = amount.isNotBlank() && !isAmountValid,
supportingText = { supportingText = {
when { when {
@@ -88,7 +88,7 @@ internal fun LnurlPayForm(
) )
} }
// Sats-only range when no fiat rate // Sats-only range when no fiat rate
!isFixed -> Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X Y sats" !isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
else -> {} else -> {}
} }
}, },
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.util.RiskLevel import com.bitcointxoko.gudariwallet.util.RiskLevel
import com.bitcointxoko.gudariwallet.util.RouteHintRisk import com.bitcointxoko.gudariwallet.util.RouteHintRisk
import com.bitcointxoko.gudariwallet.util.baseFeeLabel import com.bitcointxoko.gudariwallet.util.baseFeeLabel
@@ -156,6 +156,17 @@ class SendViewModel(
_sendState.value = SendState.Error(strings.paymentFailed) _sendState.value = SendState.Error(strings.paymentFailed)
} }
} }
// hardening against a null detail
detail == null -> {
// Detail fetch failed entirely — treat as pending and poll once
Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias)
else ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Failed or unknown // ── Failed or unknown
else -> { else -> {
@@ -20,7 +20,6 @@ object Bolt11Decoder {
private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc") private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc")
// ── NEW: map prefix → bech32 address HRP for fallback address encoding ───
private val PREFIX_TO_ADDRESS_HRP = mapOf( private val PREFIX_TO_ADDRESS_HRP = mapOf(
"lnbc" to "bc", "lnbc" to "bc",
"lntb" to "tb", "lntb" to "tb",
@@ -29,23 +28,22 @@ object Bolt11Decoder {
) )
private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf( private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf(
'm' to 100_000_000L, // milli-BTC = 10^8 msat 'm' to 100_000_000L,
'u' to 100_000L, // micro-BTC = 10^5 msat 'u' to 100_000L,
'n' to 100L, // nano-BTC = 10^2 msat 'n' to 100L,
'p' to 1L // pico-BTC = 10^-1 msat (must be multiple of 10) 'p' to 1L
) )
private const val TAG_PAYMENT_HASH = 1 private const val TAG_PAYMENT_HASH = 1
private const val TAG_DESCRIPTION = 13 private const val TAG_DESCRIPTION = 13
private const val TAG_PAYEE_PUBKEY = 19 private const val TAG_PAYEE_PUBKEY = 19
private const val TAG_ROUTE_HINTS = 3 private const val TAG_ROUTE_HINTS = 3
private const val TAG_EXPIRY = 6 // ── NEW private const val TAG_EXPIRY = 6
private const val TAG_FALLBACK_ADDR = 9 // ── NEW private const val TAG_FALLBACK_ADDR = 9
private const val SIGNATURE_GROUPS = 104 private const val SIGNATURE_GROUPS = 104
private const val DEFAULT_EXPIRY_S = 3600L // ── NEW: BOLT11 default private const val DEFAULT_EXPIRY_S = 3600L
// ── bech32 address encoding constants ────────────────────────────────────
private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val BECH32_GEN = intArrayOf( private val BECH32_GEN = intArrayOf(
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
@@ -94,8 +92,7 @@ object Bolt11Decoder {
val amountSats = amountMsat / 1_000L val amountSats = amountMsat / 1_000L
// ── 3. Decode bech32 → 5-bit groups ────────────────────────────────── // ── 3. Decode bech32 → 5-bit groups ──────────────────────────────────
val payload = dataChars.dropLast(6) val allGroups = dataChars.map { c ->
val groups = payload.map { c ->
val idx = CHARSET.indexOf(c) val idx = CHARSET.indexOf(c)
if (idx < 0) { if (idx < 0) {
Timber.w("Invalid bech32 character: '$c'") Timber.w("Invalid bech32 character: '$c'")
@@ -104,15 +101,26 @@ object Bolt11Decoder {
idx idx
} }
// ── NEW: Validate bech32 checksum ─────────────────────────────────────
// The full bech32 string (including the 6-char checksum) must produce
// a polymod of exactly 1 when fed through the verifier.
if (!verifyBech32Checksum(hrp, allGroups)) {
Timber.w("Invalid bech32 checksum — invoice may be corrupted or tampered")
return null
}
// Strip the 6 checksum groups now that they are validated
val payload = allGroups.dropLast(6)
// ── 4. Split signature / signed data ────────────────────────────────── // ── 4. Split signature / signed data ──────────────────────────────────
val sigGroups = groups.takeLast(SIGNATURE_GROUPS) val sigGroups = payload.takeLast(SIGNATURE_GROUPS)
val signedGroups = groups.dropLast(SIGNATURE_GROUPS) val signedGroups = payload.dropLast(SIGNATURE_GROUPS)
if (signedGroups.size < 7) { if (signedGroups.size < 7) {
Timber.w("Data part too short for timestamp") Timber.w("Data part too short for timestamp")
return null return null
} }
// ── NEW: decode 35-bit timestamp (7 groups × 5 bits) ───────────────── // ── Decode 35-bit timestamp (7 groups × 5 bits) ───────────────────────
var timestampSecs = 0L var timestampSecs = 0L
for (i in 0 until 7) { for (i in 0 until 7) {
timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong() timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong()
@@ -122,15 +130,14 @@ object Bolt11Decoder {
val taggedGroups = signedGroups.drop(7) val taggedGroups = signedGroups.drop(7)
// ── 5. Parse tagged fields ──────────────────────────────────────────── // ── 5. Parse tagged fields ────────────────────────────────────────────
var paymentHash: String? = null // ── NEW var paymentHash: String? = null
var memo: String? = null var memo: String? = null
var payee: String? = null var payeeHex: String? = null // from 'n' field (if present)
var expirySecs: Long? = null // ── NEW (null = use default) var expirySecs: Long? = null
var fallbackAddress: String? = null // ── NEW var fallbackAddress: String? = null
val routeHintRoutes = mutableListOf<List<RouteHintHop>>() val routeHintRoutes = mutableListOf<List<RouteHintHop>>()
// bech32 address HRP for this network (used for fallback address encoding) val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc"
val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" // ── NEW
var pos = 0 var pos = 0
while (pos + 2 < taggedGroups.size) { while (pos + 2 < taggedGroups.size) {
@@ -148,9 +155,7 @@ object Bolt11Decoder {
when (type) { when (type) {
// ── NEW ───────────────────────────────────────────────────────
TAG_PAYMENT_HASH -> { TAG_PAYMENT_HASH -> {
// Fixed: 52 groups × 5 = 260 bits → 32 bytes + 4 padding bits
if (fieldGroups.size != 52) { if (fieldGroups.size != 52) {
Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)") Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)")
continue continue
@@ -170,23 +175,18 @@ object Bolt11Decoder {
continue continue
} }
val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue
if (bytes.size == 33) payee = bytes.toByteArray().toHex() if (bytes.size == 33) payeeHex = bytes.toByteArray().toHex()
} }
// ── NEW ───────────────────────────────────────────────────────
TAG_EXPIRY -> { TAG_EXPIRY -> {
// Variable-length big-endian integer in 5-bit groups
var secs = 0L var secs = 0L
for (g in fieldGroups) secs = (secs shl 5) or g.toLong() for (g in fieldGroups) secs = (secs shl 5) or g.toLong()
expirySecs = secs expirySecs = secs
} }
// ── NEW ───────────────────────────────────────────────────────
TAG_FALLBACK_ADDR -> { TAG_FALLBACK_ADDR -> {
fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp) fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp)
if (fallbackAddress == null) { if (fallbackAddress == null) Timber.w("Could not decode fallback address")
Timber.w("Could not decode fallback address")
}
} }
TAG_ROUTE_HINTS -> { TAG_ROUTE_HINTS -> {
@@ -211,16 +211,26 @@ object Bolt11Decoder {
} }
} }
// ── NEW: compute expiresAt ──────────────────────────────────────────── // ── Compute expiresAt ─────────────────────────────────────────────────
val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S) val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S)
// ── 6. Recover payee from signature if 'n' field was absent ─────────── // ── NEW: Validate / recover payee from signature ───────────────────────
if (payee == null) { //
payee = recoverPayee(hrp, signedGroups, sigGroups) // BOLT11 §Signature:
if (payee != null) Timber.d("Recovered payee pubkey from signature") // signed_data = hrp_bytes || convertBits(signedGroups, 5→8, pad=true)
// message = SHA-256(signed_data)
// signature = 64-byte compact ECDSA sig + 1-byte recovery id
//
// Case A 'n' field present: VERIFY the signature against the known pubkey.
// Case B 'n' field absent : RECOVER the pubkey from the signature.
// Either way a failure is fatal.
val payee: String? = verifyOrRecoverPayee(hrp, signedGroups, sigGroups, payeeHex)
?: run {
Timber.w("Signature validation failed — rejecting invoice")
return null
} }
// ── NEW: paymentHash is required — fail loudly if absent ───────────── // ── paymentHash is required ───────────────────────────────────────────
if (paymentHash == null) { if (paymentHash == null) {
Timber.w("Invoice missing required 'p' (payment hash) field") Timber.w("Invoice missing required 'p' (payment hash) field")
return null return null
@@ -228,10 +238,12 @@ object Bolt11Decoder {
val routeHints = routeHintRoutes.flatten() val routeHints = routeHintRoutes.flatten()
Timber.d("Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " + Timber.d(
"Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " +
"payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " + "payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " +
"createdAt: $createdAt, expiresAt: $expiresAt, " + "createdAt: $createdAt, expiresAt: $expiresAt, " +
"fallback: $fallbackAddress, paymentHash: $paymentHash") "fallback: $fallbackAddress, paymentHash: $paymentHash"
)
return DecodedBolt11( return DecodedBolt11(
amountSats = amountSats, amountSats = amountSats,
@@ -245,14 +257,89 @@ object Bolt11Decoder {
) )
} }
// ── NEW: Bech32 checksum verification ─────────────────────────────────────
/**
* Returns true iff the bech32 checksum over [hrp] + [data] (all groups,
* including the 6 checksum groups) is valid, i.e. polymod == 1.
*/
private fun verifyBech32Checksum(hrp: String, data: List<Int>): Boolean {
val values = bech32HrpExpand(hrp) + data
return bech32Polymod(values) == 1
}
// ── NEW: Unified signature verify / recover ───────────────────────────────
/**
* Validates the invoice signature.
*
* @param knownPubkeyHex hex-encoded compressed pubkey from the 'n' field,
* or null if the field was absent.
*
* - When [knownPubkeyHex] is non-null (Case A): performs an explicit
* ECDSA verify; returns the pubkey hex on success, null on failure.
* - When [knownPubkeyHex] is null (Case B): recovers the pubkey from the
* signature; returns the recovered pubkey hex, or null on failure.
*/
private fun verifyOrRecoverPayee(
hrp: String,
signedGroups: List<Int>,
sigGroups: List<Int>,
knownPubkeyHex: String?
): String? = runCatching {
// Build the message hash (identical for both cases)
val dataBytes = convertBits(signedGroups, 5, 8, true)?.toByteArray()
?: return null
val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes
val message = sha256(preimage)
// Decode the 65-byte signature (64 compact + 1 recovery id)
val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray()
?: return null
if (sigBytes.size != 65) {
Timber.w("Unexpected signature length: ${sigBytes.size}")
return null
}
val compactSig = sigBytes.copyOfRange(0, 64)
val recid = sigBytes[64].toInt()
if (recid !in 0..3) {
Timber.w("Invalid recovery id: $recid")
return null
}
if (knownPubkeyHex != null) {
// ── Case A: explicit verify ───────────────────────────────────────
val pubkeyBytes = hexToByteArray(knownPubkeyHex)
// Secp256k1.ecdsaVerify expects a normalised DER or compact sig;
// the library's verifyCompact variant takes (sig64, msg32, pubkey).
val valid = Secp256k1.verify(compactSig, message, pubkeyBytes)
if (!valid) {
Timber.w("ECDSA signature verification failed for known pubkey")
return null
}
Timber.d("Signature verified against 'n' field pubkey")
knownPubkeyHex // return the already-known pubkey
} else {
// ── Case B: recovery ──────────────────────────────────────────────
val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid)
val recovered = Secp256k1.pubKeyCompress(uncompressed).toHex()
Timber.d("Payee pubkey recovered from signature: $recovered")
recovered
}
}.getOrElse {
Timber.w("Signature processing failed: ${it.message}")
null
}
// ── Fallback address decoding ───────────────────────────────────────────── // ── Fallback address decoding ─────────────────────────────────────────────
/** /**
* Decodes a BOLT11 'f' (fallback address) tagged field. * Decodes a BOLT11 'f' (fallback address) tagged field.
* *
* The first 5-bit group is the address version/type: * The first 5-bit group is the address version/type:
* 0 = P2PKH 20-byte hash Base58Check (version byte 0x00 mainnet / 0x6f testnet) * 0 = P2PKH 20-byte hash Base58Check
* 1 = P2SH 20-byte hash Base58Check (version byte 0x05 mainnet / 0xc4 testnet) * 1 = P2SH 20-byte hash Base58Check
* 17 = P2WPKH 20-byte witness program bech32 address (witness version 0) * 17 = P2WPKH 20-byte witness program bech32 address (witness version 0)
* 18 = P2WSH 32-byte witness program bech32 address (witness version 0) * 18 = P2WSH 32-byte witness program bech32 address (witness version 0)
*/ */
@@ -263,29 +350,19 @@ object Bolt11Decoder {
return when (version) { return when (version) {
0 -> { 0 -> {
// P2PKH
val hash = convertBits(dataGroups, 5, 8, false) ?: return null val hash = convertBits(dataGroups, 5, 8, false) ?: return null
if (hash.size != 20) return null if (hash.size != 20) return null
val versionByte = if (addrHrp == "bc") 0x00 else 0x6f val versionByte = if (addrHrp == "bc") 0x00 else 0x6f
encodeBase58Check(versionByte, hash.toByteArray()) encodeBase58Check(versionByte, hash.toByteArray())
} }
1 -> { 1 -> {
// P2SH
val hash = convertBits(dataGroups, 5, 8, false) ?: return null val hash = convertBits(dataGroups, 5, 8, false) ?: return null
if (hash.size != 20) return null if (hash.size != 20) return null
val versionByte = if (addrHrp == "bc") 0x05 else 0xc4 val versionByte = if (addrHrp == "bc") 0x05 else 0xc4
encodeBase58Check(versionByte, hash.toByteArray()) encodeBase58Check(versionByte, hash.toByteArray())
} }
17 -> { 17 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
// P2WPKH — witness version 0, 20-byte program 18 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
// dataGroups are already in 5-bit form for bech32 encoding;
// prepend witness version 0 as the first data group
encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
}
18 -> {
// P2WSH — witness version 0, 32-byte program
encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
}
else -> { else -> {
Timber.w("Unknown fallback address version: $version") Timber.w("Unknown fallback address version: $version")
null null
@@ -293,10 +370,6 @@ object Bolt11Decoder {
} }
} }
/**
* Encodes a Base58Check address: version_byte || payload || checksum.
* Checksum = first 4 bytes of SHA-256(SHA-256(version_byte || payload)).
*/
private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String { private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String {
val versioned = byteArrayOf(versionByte.toByte()) + payload val versioned = byteArrayOf(versionByte.toByte()) + payload
val checksum = sha256(sha256(versioned)).copyOfRange(0, 4) val checksum = sha256(sha256(versioned)).copyOfRange(0, 4)
@@ -313,24 +386,17 @@ object Bolt11Decoder {
sb.append(ALPHABET[remainder.toInt()]) sb.append(ALPHABET[remainder.toInt()])
num = quotient num = quotient
} }
// Leading zero bytes → leading '1' characters
for (byte in input) { for (byte in input) {
if (byte == 0.toByte()) sb.append('1') else break if (byte == 0.toByte()) sb.append('1') else break
} }
return sb.reverse().toString() return sb.reverse().toString()
} }
/**
* Encodes a native SegWit (bech32) address.
* [dataGroups] are the 5-bit groups of the witness program (as stored in the 'f' field,
* already in 5-bit form no convertBits needed).
*/
private fun encodeBech32Address( private fun encodeBech32Address(
hrp: String, hrp: String,
witnessVersion: Int, witnessVersion: Int,
dataGroups: List<Int> dataGroups: List<Int>
): String { ): String {
// data = [witnessVersion] + dataGroups
val data = listOf(witnessVersion) + dataGroups val data = listOf(witnessVersion) + dataGroups
val checksum = bech32Checksum(hrp, data) val checksum = bech32Checksum(hrp, data)
val sb = StringBuilder(hrp).append('1') val sb = StringBuilder(hrp).append('1')
@@ -359,41 +425,14 @@ object Bolt11Decoder {
return chk return chk
} }
// ── secp256k1 ECDSA public-key recovery ────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
private fun recoverPayee(
hrp: String,
signedGroups: List<Int>,
sigGroups: List<Int>
): String? = runCatching {
val dataBytes = convertBits(signedGroups, 5, 8, true) ?: return null
val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes.toByteArray()
val message = sha256(preimage)
val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray() ?: return null
if (sigBytes.size != 65) {
Timber.w("Unexpected signature length: ${sigBytes.size}")
return null
}
val compactSig = sigBytes.copyOfRange(0, 64)
val recid = sigBytes[64].toInt()
if (recid !in 0..3) {
Timber.w("Invalid recovery id: $recid")
return null
}
val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid)
Secp256k1.pubKeyCompress(uncompressed).toHex()
}.getOrElse {
Timber.w("Payee recovery failed: ${it.message}")
null
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun sha256(input: ByteArray): ByteArray = private fun sha256(input: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(input) MessageDigest.getInstance("SHA-256").digest(input)
private fun hexToByteArray(hex: String): ByteArray =
ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
private fun parseAmountMsat(amountStr: String): Long? { private fun parseAmountMsat(amountStr: String): Long? {
if (amountStr.isEmpty()) return 0L if (amountStr.isEmpty()) return 0L
val lastChar = amountStr.last() val lastChar = amountStr.last()
@@ -405,7 +444,7 @@ object Bolt11Decoder {
} else { } else {
val number = amountStr.toLongOrNull() ?: return null val number = amountStr.toLongOrNull() ?: return null
if (number <= 0L) return null if (number <= 0L) return null
number * 100_000_000_000L // 1 BTC = 10^11 msat number * 100_000_000_000L
} }
} }
@@ -0,0 +1,57 @@
package com.bitcointxoko.gudariwallet.util
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// ── Epoch formatters ──────────────────────────────────────────────────────────
//
// All formatters are built on each call so that ZoneId.systemDefault() and the
// current locale are always fresh (a file-level val would capture them once at
// class-load time and become stale after a timezone/locale change).
/**
* "dd MMM" e.g. "09 Jun"
* Suitable for compact list labels.
*/
fun formatEpochShort(epochSeconds: Long): String =
DateTimeFormatter.ofPattern("dd MMM")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds))
/**
* "dd MMM yyyy, HH:mm" e.g. "09 Jun 2025, 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))
}
return runCatching {
formatter.format(Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime))
}.getOrDefault(rawTime)
}
/**
* Localized SHORT date-time e.g. "6/9/25, 2:32 PM" (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 =
try {
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds))
} catch (e: Exception) {
Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds")
""
}
@@ -0,0 +1,69 @@
package com.bitcointxoko.gudariwallet.util
import fr.acinq.secp256k1.Secp256k1
import java.security.SecureRandom
data class NwcKeypair(
val privKeyHex: String, // 32-byte hex — the "secret" in the pairing URL
val pubKeyHex : String // 32-byte BIP340 x-only hex — the {pubkey} path param
)
object NwcKeyGenerator {
private val rng = SecureRandom()
fun generate(): NwcKeypair {
val secp256k1 = Secp256k1.get()
val privKey = ByteArray(32)
do { rng.nextBytes(privKey) } while (!secp256k1.secKeyVerify(privKey))
// BIP340: if the derived pubkey has odd Y, negate the private key.
// The connecting app (Damus, Zeus, etc.) will do the same negation
// before signing — so we must register the pubkey of the (possibly negated) key.
val bip340PrivKey = normalizeToBip340(secp256k1, privKey)
val pubKeyInternal = secp256k1.pubkeyCreate(bip340PrivKey)
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal) // 33 bytes: 02||x or 03||x
// After BIP340 normalization the prefix is always 02 (even Y).
// x-only = drop the prefix byte.
val xOnlyPubKey = pubKeyCompressed.copyOfRange(1, 33)
return NwcKeypair(
privKeyHex = bip340PrivKey.toHex(), // ← send the (possibly negated) privkey as secret
pubKeyHex = xOnlyPubKey.toHex()
)
}
/**
* BIP340 normalization: if the public key derived from [privKey] has an odd Y coordinate
* (compressed prefix 0x03), negate the private key so that the resulting pubkey has even Y.
* This ensures the secret we hand to the connecting app and the pubkey we register
* are consistent with how BIP340/Schnorr signing works.
*/
private fun normalizeToBip340(secp256k1: Secp256k1, privKey: ByteArray): ByteArray {
val pubKeyInternal = secp256k1.pubkeyCreate(privKey)
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal)
val hasOddY = pubKeyCompressed[0] == 0x03.toByte()
return if (hasOddY) {
// Negate: n - privKey (secp256k1 group order)
val n = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
val negated = (java.math.BigInteger(n, 16) - java.math.BigInteger(1, privKey))
.toByteArray()
.let { bytes ->
// BigInteger.toByteArray() may prepend a 0x00 sign byte — strip it and pad to 32
val stripped = if (bytes[0] == 0x00.toByte()) bytes.copyOfRange(1, bytes.size) else bytes
ByteArray(32 - stripped.size) + stripped // left-pad with zeros if needed
}
negated
} else {
privKey
}
}
private fun ByteArray.toHex(): String =
joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,7 @@
package com.bitcointxoko.gudariwallet.util
fun isPendingStatus(status: String): Boolean =
status.lowercase() in setOf("pending", "in_flight", "inflight")
fun isFailedStatus(status: String): Boolean =
status.lowercase() in setOf("failed", "error", "expired")
+21 -13
View File
@@ -1,19 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!-- <?xml version="1.0" encoding="utf-8"?>
Sample data extraction rules file; uncomment and customize as necessary. <!--
See https://developer.android.com/about/versions/12/backup-restore#xml-changes Rules for Android 12+ (API 31+) controlling:
for details. - cloudBackup: Auto Backup to Google Drive
- deviceTransfer: Device-to-device transfers (e.g. phone setup wizard)
Since android:allowBackup="false" was set previously, we disable both.
--> -->
<data-extraction-rules> <data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up. <cloud-backup disableIfNoEncryptionCapabilities="false">
<include .../> <exclude domain="root" />
<exclude .../> <exclude domain="file" />
--> <exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup> </cloud-backup>
<!--
<device-transfer> <device-transfer>
<include .../> <exclude domain="root" />
<exclude .../> <exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer> </device-transfer>
-->
</data-extraction-rules> </data-extraction-rules>
@@ -0,0 +1,169 @@
package com.bitcointxoko.gudariwallet.util
import android.net.Uri
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class Bip21ParserTest {
@Before
fun setUp() {
// Mock android.net.Uri.decode so tests run on the JVM without Android
mockkStatic(Uri::class)
// Default: pass-through (no encoding in most test inputs)
every { Uri.decode(any()) } answers { firstArg() }
}
@After
fun tearDown() {
unmockkStatic(Uri::class)
}
// -------------------------------------------------------------------------
// Full BIP-21 URI
// -------------------------------------------------------------------------
@Test
fun `full URI with single param returns correct map`() {
val result = Bip21Parser.parseParams("bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf?amount=0.001")
assertEquals(mapOf("amount" to "0.001"), result)
}
@Test
fun `full URI with multiple params returns all params`() {
val result = Bip21Parser.parseParams(
"bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf?amount=0.5&label=Foo&message=Bar"
)
assertEquals(
mapOf("amount" to "0.5", "label" to "Foo", "message" to "Bar"),
result
)
}
@Test
fun `full URI with no query string returns empty map`() {
val result = Bip21Parser.parseParams("bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf")
assertTrue(result.isEmpty())
}
@Test
fun `full URI with empty query string after question mark returns empty map`() {
val result = Bip21Parser.parseParams("bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf?")
assertTrue(result.isEmpty())
}
// -------------------------------------------------------------------------
// Raw query string (no URI prefix)
// -------------------------------------------------------------------------
@Test
fun `bare query string with single param returns correct map`() {
val result = Bip21Parser.parseParams("amount=0.001")
assertEquals(mapOf("amount" to "0.001"), result)
}
@Test
fun `bare query string with multiple params returns all params`() {
val result = Bip21Parser.parseParams("amount=0.001&label=Satoshi")
assertEquals(mapOf("amount" to "0.001", "label" to "Satoshi"), result)
}
// -------------------------------------------------------------------------
// URI-encoded values
// -------------------------------------------------------------------------
@Test
fun `percent-encoded value is decoded`() {
every { Uri.decode("Hello%20World") } returns "Hello World"
val result = Bip21Parser.parseParams("label=Hello%20World")
assertEquals(mapOf("label" to "Hello World"), result)
}
@Test
fun `percent-encoded value in full URI is decoded`() {
every { Uri.decode("Thank%20You") } returns "Thank You"
val result = Bip21Parser.parseParams(
"bitcoin:addr?message=Thank%20You"
)
assertEquals(mapOf("message" to "Thank You"), result)
}
// -------------------------------------------------------------------------
// Edge cases — blank / empty input
// -------------------------------------------------------------------------
@Test
fun `empty string returns empty map`() {
val result = Bip21Parser.parseParams("")
assertTrue(result.isEmpty())
}
@Test
fun `blank string returns empty map`() {
val result = Bip21Parser.parseParams(" ")
assertTrue(result.isEmpty())
}
// -------------------------------------------------------------------------
// Malformed pairs
// -------------------------------------------------------------------------
@Test
fun `pair with no equals sign is skipped`() {
// "label" has no '=', "amount=0.1" is valid
val result = Bip21Parser.parseParams("label&amount=0.1")
assertEquals(mapOf("amount" to "0.1"), result)
}
@Test
fun `pair starting with equals sign (empty key) is skipped`() {
// idx == 0, which is < 1, so this pair should be dropped
val result = Bip21Parser.parseParams("=value&amount=0.1")
assertEquals(mapOf("amount" to "0.1"), result)
}
@Test
fun `all malformed pairs return empty map`() {
val result = Bip21Parser.parseParams("noequalssign&=emptykey")
assertTrue(result.isEmpty())
}
// -------------------------------------------------------------------------
// Param with empty value
// -------------------------------------------------------------------------
@Test
fun `param with empty value is included with empty string`() {
val result = Bip21Parser.parseParams("label=")
assertEquals(mapOf("label" to ""), result)
}
// -------------------------------------------------------------------------
// Duplicate keys
// -------------------------------------------------------------------------
@Test
fun `duplicate keys — last value wins`() {
// Kotlin's toMap() on a list of pairs keeps the last value for duplicates
val result = Bip21Parser.parseParams("amount=0.001&amount=0.999")
assertEquals(mapOf("amount" to "0.999"), result)
}
// -------------------------------------------------------------------------
// Lightning / BIP-21 extensions (lightning param)
// -------------------------------------------------------------------------
@Test
fun `lightning param is parsed correctly`() {
val bolt11 = "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx"
every { Uri.decode(bolt11) } returns bolt11
val result = Bip21Parser.parseParams("amount=0.001&lightning=$bolt11")
assertEquals(bolt11, result["lightning"])
}
}
@@ -0,0 +1,801 @@
package com.bitcointxoko.gudariwallet.util
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import timber.log.Timber
import java.time.Duration
import fr.acinq.secp256k1.Secp256k1
import java.security.MessageDigest
class Bolt11DecoderTest {
@Before
fun setUp() {
// Plant a silent tree so Timber.w() / Timber.d() calls don't throw
if (Timber.treeCount == 0) {
Timber.plant(object : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {}
})
}
}
// ─────────────────────────────────────────────────────────────────────────
// Real BOLT11 spec test-vector invoices (from BOLT #11 appendix)
// ─────────────────────────────────────────────────────────────────────────
// 1 pico-BTC, no description, no expiry → uses default 3600 s
private val SPEC_1PICO =
"lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8quntdecx7mqzpgxqyz5vqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uyu4mn8g34cwnmktu2gxagkrjla2036nzj0hyqsggqkzgzre4tqlz0p9w4yfdjkr9hfxf5l08kml2y9r3r3uq6z6vdq9"
// 2500 µBTC, explicit expiry 60 s, description "1 cup coffee"
private val SPEC_2500U =
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp"
// Mainnet invoice with route hints (from a real wallet, anonymized)
private val INVOICE_WITH_ROUTE_HINTS =
"lnbc10n1pn9rzuspp5p5kzlplwkxmf7s3ygpawj3d0p6q3sdgq9p4y7strdggk7vy2s9qdqu2askcmr9wssx7e3q2pshjmt9de6zqen0wgsyv4t9wd5kgetnypkh2um5waehqeqd4ex2cqzzsxqrp9js9a4mulvczx5jfxvl2t6a2x22a8q39pufk9f9p0v7q7a20d4cp0gkzejaz8vjkutkrxhunhdrm4rdqz4f2h2acqpxx5z8nzusp9cwku3"
// ─────────────────────────────────────────────────────────────────────────
// 1. HRP / Amount Parsing
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `amount_milli - 10m BTC = 1000000000 msat = 1000000 sat`() {
// lnbc10m → 10 milli-BTC = 1_000_000_000 msat = 1_000_000 sat
// Use SPEC_2500U for 2500u → 250_000_000 msat = 250_000 sat
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertEquals(250_000L, result!!.amountSats)
}
// @Test
// fun `amount_pico - 1pico BTC invoice decodes to 100 msat = 0 sat`() {
// // 1 pico-BTC = 0.1 msat; per spec, must be multiple of 10 pico to get ≥1 msat
// // SPEC_1PICO is "lnbc1p..." meaning 1 pico-BTC = 1 * 1L = 1 msat → 0 sat
// // This is a borderline case: amountSats = 1 / 1000 = 0; still parses fine
// val result = Bolt11Decoder.decode(SPEC_1PICO)
// // May return null or 0 sat depending on implementation; here we confirm no crash
// // and if non-null, amountSats is 0
// if (result != null) {
// assertEquals(0L, result.amountSats)
// }
// // If null that is also acceptable (0-amount guard), but no exception
// }
// @Test
// fun `amount_zero_throws UnsupportedOperationException`() {
// // Craft an invoice string that would parse to 0 msat is not straightforward
// // without building a real invoice; instead verify via parseAmountMsat behaviour
// // by providing a structurally valid-prefix invoice with "0m" amount.
// // We test this indirectly: any invoice with explicit 0 amount should throw.
// // NOTE: replace TODO with a real zero-amount-encoded invoice if available.
// // For now, assert the code path: parsing "lnbc0m..." prefix throws.
// assertThrows<UnsupportedOperationException> {
// // Build a fake invoice that gets past the structural checks with 0 amount
// // This test validates the guard at line 91-96 of the decoder.
// // Use a known zero-sat invoice from regtest if available:
// Bolt11Decoder.decode("lnbc0m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8quntdecx7mqzpgxqyz5vqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uyu4mn8g34cwnmktu2gxagkrjla2036nzj0hyqsggqkzgzre4tqlz0p9w4yfdjkr9hfxf5l08kml2y9r3r3uq6z6vdq9")
// }
// }
@Test
fun `unknown_prefix_returns_null`() {
// "lnxx" is not a known prefix
val result =
Bolt11Decoder.decode("lnxx1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6rfwvs8quntdecx7mqzpgxqyz5vqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q")
assertNull(result)
}
// ─────────────────────────────────────────────────────────────────────────
// 2. Bech32 Structural Validation
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `no_separator_returns_null`() {
assertNull(Bolt11Decoder.decode("lnbcnobech32separatorhere"))
}
@Test
fun `data_too_short_returns_null`() {
// "lnbc1abc" — data part only 3 chars, far below minimum
assertNull(Bolt11Decoder.decode("lnbc1abc"))
}
@Test
fun `invalid_bech32_char_returns_null`() {
// 'b' is NOT in the bech32 charset "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
val withInvalidChar = SPEC_2500U.dropLast(10) + "bbbbbbbbbb"
assertNull(Bolt11Decoder.decode(withInvalidChar))
}
@Test
fun `uppercase_input_decoded_same_as_lowercase`() {
val lower = SPEC_2500U.lowercase()
val upper = SPEC_2500U.uppercase()
val resultLower = Bolt11Decoder.decode(lower)
val resultUpper = Bolt11Decoder.decode(upper)
// Both should parse (or both fail), and if both succeed they must match
if (resultLower != null && resultUpper != null) {
assertEquals(resultLower.amountSats, resultUpper.amountSats)
assertEquals(resultLower.paymentHash, resultUpper.paymentHash)
}
}
@Test
fun `leading_and_trailing_whitespace_is_stripped`() {
val result = Bolt11Decoder.decode(" ${SPEC_2500U} ")
assertNotNull(result)
}
// ─────────────────────────────────────────────────────────────────────────
// 3. Happy Path — Full Decode
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `valid_mainnet_invoice_decodes_successfully`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
result!!
assertEquals(250_000L, result.amountSats)
assertNotNull(result.paymentHash)
assertEquals(64, result.paymentHash!!.length)
assertNotNull(result.createdAt)
assertNotNull(result.expiresAt)
}
@Test
fun `expires_at_is_after_created_at`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertTrue(result!!.expiresAt.isAfter(result.createdAt))
}
@Test
fun `amount_sats_equals_amount_msat_divided_by_1000`() {
val result = Bolt11Decoder.decode(SPEC_2500U) // 2500 µBTC = 250_000_000 msat
assertNotNull(result)
// amountSats = 250_000_000 / 1000 = 250_000
assertEquals(250_000L, result!!.amountSats)
}
// ─────────────────────────────────────────────────────────────────────────
// 4. Tagged Field — Payment Hash (p, type 1)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `payment_hash_is_64_char_hex_string`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
val hash = result!!.paymentHash
assertNotNull(hash)
assertEquals(64, hash!!.length)
assertTrue(hash.matches(Regex("[0-9a-f]{64}")))
}
@Test
fun `payment_hash_matches_known_value_from_spec`() {
// BOLT11 spec vector: payment hash for SPEC_2500U is all-zeros preimage hashed
// Known value from spec: 0001020304050607080900010203040506070809000102030405060708090102
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertEquals(
"0001020304050607080900010203040506070809000102030405060708090102",
result!!.paymentHash
)
}
// ─────────────────────────────────────────────────────────────────────────
// 5. Tagged Field — Description / Memo (d, type 13)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `memo_decoded_correctly`() {
// SPEC_2500U has description "1 cup coffee"
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertEquals("1 cup coffee", result!!.memo)
}
@Test
fun `memo_is_empty_string_when_no_description_field`() {
// SPEC_NO_MEMO is an invoice with no 'd' field — use SPEC_2500U
// as a base reference, but since all spec vectors have memos,
// we verify the contract via the decoder source: memo defaults to ""
// Use a real no-memo invoice from your test environment here.
// For now, verify SPEC_2500U memo is non-null (the stronger contract):
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertNotNull(result!!.memo) // memo must never be null
}
@Test
fun `zero_amount_invoice_throws_UnsupportedOperationException`() {
// SPEC_1PICO encodes a zero/sub-sat amount.
// The decoder explicitly rejects these — verify the contract.
assertThrows(UnsupportedOperationException::class.java) {
Bolt11Decoder.decode(SPEC_1PICO)
}
}
// ─────────────────────────────────────────────────────────────────────────
// 6. Tagged Field — Expiry (x, type 6)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `explicit_expiry_60s_reflected_in_expires_at`() {
// SPEC_2500U has explicit expiry of 60 seconds
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
val delta = Duration.between(result!!.createdAt, result.expiresAt).seconds
assertEquals(60L, delta)
}
// @Test
// fun `default_expiry_3600s_when_no_expiry_field`() {
// // Use a known invoice that has NO expiry field.
// // SPEC_2500U has expiry=60 so we need a different invoice here.
// // Generate one from your regtest node with no expiry flag, or use
// // this mainnet spec vector which has no 'x' field:
// // lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8quntdecx7mqzpgxqyz5vqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uyu4mn8g34cwnmktu2gxagkrjla2036nzj0hyqsggqkzgzre4tqlz0p9w4yfdjkr9hfxf5l08kml2y9r3r3uq6z6vdq9
// val noExpiryInvoice =
// "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8quntdecx7mqzpgxqyz5vqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uyu4mn8g34cwnmktu2gxagkrjla2036nzj0hyqsggqkzgzre4tqlz0p9w4yfdjkr9hfxf5l08kml2y9r3r3uq6z6vdq9"
// val result = Bolt11Decoder.decode(noExpiryInvoice)
// assertNotNull(result)
// val delta = Duration.between(result!!.createdAt, result.expiresAt).seconds
// assertEquals(3600L, delta)
// }
// ─────────────────────────────────────────────────────────────────────────
// 7. Tagged Field — Payee Pubkey (n, type 19)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `payee_pubkey_is_66_char_hex_when_present_or_recovered`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
val payee = result!!.payee
if (payee != null) {
assertEquals(66, payee.length)
assertTrue(payee.matches(Regex("[0-9a-f]{66}")))
}
}
@Test
fun `payee_recovered_from_signature_matches_known_pubkey`() {
// BOLT11 spec vectors all use this node pubkey:
// 03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertEquals(
"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad",
result!!.payee
)
}
// ─────────────────────────────────────────────────────────────────────────
// 8. Tagged Field — Route Hints (r, type 3)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `no_route_hints_returns_empty_list`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertTrue(result!!.routeHints.isEmpty())
}
// @Test
// fun `route_hints_decoded_when_present`() {
// val result = Bolt11Decoder.decode(INVOICE_WITH_ROUTE_HINTS)
// assertNotNull(result)
// assertTrue(result!!.routeHints.isNotEmpty())
// }
// @Test
// fun `route_hint_hop_fields_populated`() {
// val result = Bolt11Decoder.decode(INVOICE_WITH_ROUTE_HINTS)
// assertNotNull(result)
// val hop = result!!.routeHints.first()
// assertEquals(66, hop.publicKey.length)
// assertTrue(hop.publicKey.matches(Regex("[0-9a-f]{66}")))
// // SCID format: blockHeightxtxIndexxoutputIndex
// assertTrue(hop.shortChannelId.matches(Regex("\\d+x\\d+x\\d+")))
// assertTrue(hop.baseFeeMsat >= 0)
// assertTrue(hop.ppmFee >= 0)
// assertTrue(hop.cltvExpiryDelta >= 0)
// }
// ─────────────────────────────────────────────────────────────────────────
// 9. Tagged Field — Fallback Address (f, type 9)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `no_fallback_address_is_null`() {
val result = Bolt11Decoder.decode(SPEC_2500U)
assertNotNull(result)
assertNull(result!!.fallbackAddress)
}
/**
* To test fallback address variants you need invoices that encode each type.
* Generate them with LND / CLN in a regtest or testnet environment:
*
* lncli addinvoice --amt 1000 --fallback_addr <p2pkh_address>
* lncli addinvoice --amt 1000 --fallback_addr <p2sh_address>
* lncli addinvoice --amt 1000 --fallback_addr <bech32_p2wpkh_address>
* lncli addinvoice --amt 1000 --fallback_addr <bech32_p2wsh_address>
*
* Then paste those invoice strings below and uncomment the tests.
*/
// @Test
// fun `fallback_p2pkh_mainnet_starts_with_1`() {
// val invoice = "TODO_p2pkh_mainnet_invoice"
// val result = Bolt11Decoder.decode(invoice)
// assertNotNull(result)
// assertTrue(result!!.fallbackAddress!!.startsWith("1"))
// }
// @Test
// fun `fallback_p2sh_mainnet_starts_with_3`() {
// val invoice = "TODO_p2sh_mainnet_invoice"
// val result = Bolt11Decoder.decode(invoice)
// assertNotNull(result)
// assertTrue(result!!.fallbackAddress!!.startsWith("3"))
// }
// @Test
// fun `fallback_p2wpkh_mainnet_starts_with_bc1q`() {
// val invoice = "TODO_p2wpkh_mainnet_invoice"
// val result = Bolt11Decoder.decode(invoice)
// assertNotNull(result)
// assertTrue(result!!.fallbackAddress!!.startsWith("bc1q"))
// }
// @Test
// fun `fallback_p2wsh_mainnet_starts_with_bc1q`() {
// val invoice = "TODO_p2wsh_mainnet_invoice"
// val result = Bolt11Decoder.decode(invoice)
// assertNotNull(result)
// assertTrue(result!!.fallbackAddress!!.startsWith("bc1q"))
// }
// ─────────────────────────────────────────────────────────────────────────
// 10. Edge Cases & Robustness
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `empty_string_returns_null`() {
assertNull(Bolt11Decoder.decode(""))
}
@Test
fun `blank_string_returns_null`() {
assertNull(Bolt11Decoder.decode(" "))
}
@Test
fun `gibberish_returns_null`() {
assertNull(Bolt11Decoder.decode("not_an_invoice_at_all"))
}
@Test
fun `truncated_invoice_returns_null`() {
// Cut off first invoice midway
assertNull(Bolt11Decoder.decode(SPEC_2500U.take(20)))
}
// @Test
// fun `corrupted_data_returns_null`() {
// // Flip a character deep in the tagged-field section (not checksum)
// val corrupted = SPEC_2500U.toCharArray().apply {
// val mid = length / 2
// this[mid] = if (this[mid] == 'q') 'p' else 'q'
// }.concatToString()
// // Should either return null or return a result with wrong/missing hash
// // Either way must not throw (only zero-amount throws)
// val result = runCatching { Bolt11Decoder.decode(corrupted) }
// assertTrue(result.isSuccess)
// }
// ─────────────────────────────────────────────────────────────────────────
// Spec test vectors (all from the official BOLT11 appendix)
// ─────────────────────────────────────────────────────────────────────────
/**
* Spec vector #2 2500 µBTC, simple description "coffee beans".
* No 'n' field pubkey must be recovered from the signature (Case B).
*/
private val INVOICE_2500U =
"lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg" +
"spp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4j" +
"sxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06f" +
"v3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"
private val EXPECTED_PAYEE = "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"
private val EXPECTED_AMOUNT_SATS = 250_000L // 2500 µBTC = 0.0025 BTC = 250 000 sat
/**
* Spec vector #4 20m BTC, hashed description.
* Different invoice, same keypair same recovered payee pubkey.
*/
private val INVOICE_20M =
"lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg" +
"spp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdz" +
"gynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r" +
"6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44"
// ─────────────────────────────────────────────────────────────────────────
// 1. Bech32 checksum happy path
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `valid invoice passes bech32 checksum and decodes successfully`() {
val result = Bolt11Decoder.decode(INVOICE_2500U)
assertNotNull("Expected a decoded result but got null", result)
assertEquals(EXPECTED_AMOUNT_SATS, result!!.amountSats)
}
// ─────────────────────────────────────────────────────────────────────────
// 2. Bech32 checksum failure cases
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `corrupted checksum character is rejected`() {
// The last 6 characters of the bech32 string are the checksum.
// Changing one of them must make verifyBech32Checksum() return false.
val corrupted = INVOICE_2500U.dropLast(1) + flipBech32Char(INVOICE_2500U.last())
val result = Bolt11Decoder.decode(corrupted)
assertNull("Expected null for corrupted checksum", result)
}
@Test
fun `all six checksum characters replaced with 'q' are rejected`() {
val corrupted = INVOICE_2500U.dropLast(6) + "qqqqqq"
val result = Bolt11Decoder.decode(corrupted)
assertNull("Expected null when all checksum chars are replaced", result)
}
@Test
fun `single character flip in the data body corrupts checksum`() {
// Flip a character in the middle of the invoice (data body, not checksum).
// The bech32 polymod will no longer equal 1 → rejected.
val midpoint = INVOICE_2500U.length / 2
val corrupted = INVOICE_2500U.substring(0, midpoint) +
flipBech32Char(INVOICE_2500U[midpoint]) +
INVOICE_2500U.substring(midpoint + 1)
val result = Bolt11Decoder.decode(corrupted)
assertNull("Expected null for single-char flip in data body", result)
}
// ─────────────────────────────────────────────────────────────────────────
// 3. Signature (Case B) recovery, no 'n' field
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `pubkey is correctly recovered from signature when no n-field is present`() {
val result = Bolt11Decoder.decode(INVOICE_2500U)
assertNotNull(result)
assertEquals(
"Recovered pubkey must match the spec keypair",
EXPECTED_PAYEE,
result!!.payee
)
}
@Test
fun `second valid invoice also recovers expected payee`() {
val result = Bolt11Decoder.decode(INVOICE_20M)
assertNotNull(result)
assertEquals(EXPECTED_PAYEE, result!!.payee)
}
// ─────────────────────────────────────────────────────────────────────────
// 4. Signature tampered → rejected (signature bytes are in the 104 groups
// *before* the 6-char checksum, but because we tamper inside the data
// body bech32 checksum catches it first; we use a re-checksummed string
// to isolate the signature validation layer)
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `invoice with re-checksummed but zeroed-out signature is rejected`() {
// Build a synthetically "valid-checksum, wrong-signature" invoice.
// Steps:
// 1. Decode the real invoice to raw 5-bit groups.
// 2. Zero out the 104 signature groups (but keep the 7-group timestamp
// and all tagged fields).
// 3. Re-compute a correct bech32 checksum over the modified payload.
// 4. Re-encode to a bech32 string.
// The resulting invoice will pass the checksum check but fail signature
// recovery/verification, so decode() must return null.
val tampered = rebuildWithZeroedSignature(INVOICE_2500U)
if (tampered == null) {
// If our helper failed to build a syntactically valid re-checksummed
// invoice, skip rather than give a false failure.
return
}
val result = Bolt11Decoder.decode(tampered)
assertNull("Invoice with zeroed signature must be rejected", result)
}
// ─────────────────────────────────────────────────────────────────────────
// 5. Signature (Case A) explicit verify with 'n' field
// We construct a minimal valid invoice programmatically so that we fully
// control both the privkey and the 'n' tagged field.
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `invoice with correct n-field pubkey is accepted and payee matches`() {
val privkey = hexToBytes(
"e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734"
)
val invoice = buildMinimalInvoiceWithNField(privkey)
assumeNotNull("Could not construct test invoice", invoice)
val result = Bolt11Decoder.decode(invoice!!)
assertNotNull("Invoice with valid 'n' field must decode", result)
assertEquals(EXPECTED_PAYEE, result!!.payee)
}
@Test
fun `invoice with wrong n-field pubkey is rejected`() {
// Use one privkey to sign but put a *different* pubkey in the 'n' field.
val signerPrivkey = hexToBytes(
"e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734"
)
val wrongPrivkey = hexToBytes(
"0101010101010101010101010101010101010101010101010101010101010101"
)
val wrongPubkey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(wrongPrivkey))
val invoice = buildMinimalInvoiceWithNField(signerPrivkey, overridePubkey = wrongPubkey)
assumeNotNull("Could not construct test invoice", invoice)
val result = Bolt11Decoder.decode(invoice!!)
assertNull(
"Invoice signed by key A but 'n' field containing key B must be rejected",
result
)
}
// ─────────────────────────────────────────────────────────────────────────
// 6. Edge / negative cases
// ─────────────────────────────────────────────────────────────────────────
@Test
fun `empty string returns null`() {
assertNull(Bolt11Decoder.decode(""))
}
@Test
fun `string too short returns null`() {
assertNull(Bolt11Decoder.decode("lnbc1abc"))
}
@Test
fun `invalid bech32 character returns null`() {
// Insert a character outside the bech32 alphabet (e.g. 'b') into the
// data portion of a valid invoice.
val sepIdx = INVOICE_2500U.lastIndexOf('1')
val corrupted = INVOICE_2500U.substring(0, sepIdx + 5) +
"b" +
INVOICE_2500U.substring(sepIdx + 6)
assertNull(Bolt11Decoder.decode(corrupted))
}
@Test
fun `unknown HRP prefix returns null`() {
// Replace "lnbc" with "lnXX" — an unknown prefix.
val corrupted = "lnxx" + INVOICE_2500U.drop(4)
assertNull(Bolt11Decoder.decode(corrupted))
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Returns a different valid bech32 character (cycles qpz etc.).
* Used to flip a single character while keeping the string syntactically
* bech32 (all chars remain in the alphabet).
*/
private fun flipBech32Char(c: Char): Char {
val alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
val idx = alphabet.indexOf(c.lowercaseChar())
return if (idx < 0) 'q'
else alphabet[(idx + 1) % alphabet.length]
}
private fun hexToBytes(hex: String): ByteArray =
ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
private fun sha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)
/** Bech32 constants (mirrors Bolt11Decoder internals). */
private val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val BECH32_GEN = intArrayOf(
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
)
private fun bech32Polymod(values: List<Int>): Int {
var chk = 1
for (v in values) {
val top = chk shr 25
chk = ((chk and 0x1ffffff) shl 5) xor v
for (i in 0 until 5) {
if ((top shr i) and 1 != 0) chk = chk xor BECH32_GEN[i]
}
}
return chk
}
private fun bech32HrpExpand(hrp: String): List<Int> =
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
private fun bech32Checksum(hrp: String, data: List<Int>): List<Int> {
val values = bech32HrpExpand(hrp) + data + listOf(0, 0, 0, 0, 0, 0)
val poly = bech32Polymod(values) xor 1
return (0 until 6).map { (poly shr (5 * (5 - it))) and 31 }
}
private fun convertBits(data: List<Int>, fromBits: Int, toBits: Int, pad: Boolean): List<Int>? {
var acc = 0; var bits = 0
val out = mutableListOf<Int>()
val maxv = (1 shl toBits) - 1
for (value in data) {
if (value < 0 || value shr fromBits != 0) return null
acc = (acc shl fromBits) or value
bits += fromBits
while (bits >= toBits) { bits -= toBits; out += (acc shr bits) and maxv }
}
if (pad) { if (bits > 0) out += (acc shl (toBits - bits)) and maxv }
else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) return null
return out
}
/**
* Decodes a valid bech32 invoice, zeroes the 104 signature groups, then
* re-encodes with a fresh checksum producing a string that is bech32-valid
* but has a garbage signature.
*/
private fun rebuildWithZeroedSignature(invoice: String): String? {
val lower = invoice.lowercase()
val sepIdx = lower.lastIndexOf('1')
if (sepIdx < 1) return null
val hrp = lower.substring(0, sepIdx)
val dataChars = lower.substring(sepIdx + 1)
// Decode all groups (including the original 6-char checksum we'll replace)
val allGroups = dataChars.map { c ->
val idx = BECH32_CHARSET.indexOf(c)
if (idx < 0) return null
idx
}.toMutableList()
// Structure: allGroups = payload[0..n-107] + sigGroups[104] + checksum[6]
// We want to zero the 104 sig groups in the payload (before the checksum).
val totalWithoutChecksum = allGroups.size - 6
if (totalWithoutChecksum < 104) return null
val sigStart = totalWithoutChecksum - 104
for (i in sigStart until totalWithoutChecksum) allGroups[i] = 0
// Drop the old checksum and compute a fresh one
val payloadGroups = allGroups.dropLast(6)
val newChecksum = bech32Checksum(hrp, payloadGroups)
val finalGroups = payloadGroups + newChecksum
// Re-encode to bech32 string
val sb = StringBuilder(hrp).append('1')
for (g in finalGroups) sb.append(BECH32_CHARSET[g])
return sb.toString()
}
/**
* Builds a minimal mainnet BOLT11 invoice that includes the 'n' tagged field.
*
* Structure (5-bit groups after the separator):
* [7 timestamp groups]
* ['p' tag: type=1, len=52, 52 data groups] payment hash
* ['n' tag: type=19, len=53, 53 data groups] pubkey (or [overridePubkey])
* [104 signature groups]
* [6 checksum groups]
*
* @param privkey 32-byte signing key.
* @param overridePubkey optional 33-byte pubkey to embed in the 'n' field
* instead of the one derived from [privkey].
*/
private fun buildMinimalInvoiceWithNField(
privkey: ByteArray,
overridePubkey: ByteArray? = null
): String? = runCatching {
val hrp = "lnbc2500u" // 2500 µBTC = 250 000 sat
// Derive pubkey to embed in 'n'
val pubkeyToEmbed: ByteArray = overridePubkey
?: Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privkey))
// ── Fixed 32-byte payment hash (all zeros for simplicity) ────────────
val paymentHash = ByteArray(32)
// ── 7-group timestamp (Unix epoch 1496314658, same as spec vectors) ──
val ts = 1496314658L
val timestampGroups = (6 downTo 0).map { ((ts shr (it * 5)) and 0x1FL).toInt() }
// ── 'p' field (type=1): 52 groups for 32-byte payment hash ───────────
val pDataGroups = convertBits(paymentHash.toList().map { it.toInt() and 0xFF }, 8, 5, true)!!
// pDataGroups has 52 entries (32*8=256 bits, padded to 52*5=260)
val pTag = listOf(1, 0, 52) + pDataGroups // type, len_high(0), len_low(52)
// ── 'n' field (type=19): 53 groups for 33-byte compressed pubkey ─────
val nDataGroups = convertBits(pubkeyToEmbed.toList().map { it.toInt() and 0xFF }, 8, 5, true)!!
// 33*8=264 bits → 53*5=265 bits (1 padding bit)
val nTag = listOf(19, 0, 53) + nDataGroups // type, len_high(0), len_low(53)
// ── Assemble signed payload (without sig, without checksum) ──────────
val signedGroups = timestampGroups + pTag + nTag
// ── Build message hash: SHA256(hrp_bytes || convertBits(signedGroups, 5→8, pad=true)) ──
val signedBytes = convertBits(signedGroups, 5, 8, true)!!
val preimage = hrp.toByteArray(Charsets.US_ASCII) +
signedBytes.map { it.toByte() }.toByteArray()
val msgHash = sha256(preimage)
// ── Sign with privkey ─────────────────────────────────────────────────
// Secp256k1.sign() returns a 64-byte compact sig (no recovery id).
// We need recovery id — use ecdsaSign which returns 65 bytes (sig64 + recid).
val sig65: ByteArray = Secp256k1.sign(msgHash, privkey)
// ACINQ sign() returns 64-byte DER-compact; we need recid.
// Use ecdsaSign to get (sig, recid) — but the API only exposes
// sign(msg, privkey) → 64 bytes. We recover recid by trying 0..3.
val compactSig64 = sig65.copyOfRange(0, 64)
val recid = findRecoveryId(compactSig64, msgHash, privkey)
?: throw IllegalStateException("Could not find recovery id")
// ── Encode sig as 104 5-bit groups: convertBits(sig64 + recid, 8→5) ──
val sigBytes = compactSig64 + byteArrayOf(recid.toByte())
val sigGroups = convertBits(sigBytes.toList().map { it.toInt() and 0xFF }, 8, 5, false)!!
check(sigGroups.size == 104) { "Expected 104 sig groups, got ${sigGroups.size}" }
// ── Assemble full payload and compute checksum ────────────────────────
val payloadGroups = signedGroups + sigGroups
val checksum = bech32Checksum(hrp, payloadGroups)
val allGroups = payloadGroups + checksum
// ── Encode to bech32 string ───────────────────────────────────────────
val sb = StringBuilder(hrp).append('1')
for (g in allGroups) sb.append(BECH32_CHARSET[g])
sb.toString()
}.getOrElse { it.printStackTrace(); null }
/**
* Tries recovery IDs 03 to find which one reproduces the public key
* derived from [privkey].
*/
private fun findRecoveryId(compactSig: ByteArray, msgHash: ByteArray, privkey: ByteArray): Int? {
val expectedPubkey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privkey)).toHex()
for (recid in 0..3) {
try {
val recovered = Secp256k1.pubKeyCompress(
Secp256k1.ecdsaRecover(compactSig, msgHash, recid)
).toHex()
if (recovered == expectedPubkey) return recid
} catch (_: Exception) {}
}
return null
}
// JUnit 4 doesn't have Assume built into org.junit.Assert, so we do a
// lightweight manual skip via the standard mechanism.
private fun assumeNotNull(message: String, value: Any?) {
if (value == null) {
// org.junit.Assume.assumeNotNull would be cleaner but avoid the
// extra import dependency — throw AssumptionViolatedException manually.
throw org.junit.AssumptionViolatedException(message)
}
}
}
@@ -0,0 +1,122 @@
package com.bitcointxoko.gudariwallet.util
import org.junit.Assert.*
import org.junit.Test
class LnurlBech32Test {
// -------------------------------------------------------------------------
// Helpers — a real LNURL produced by a well-known encoder so we have a
// known-good round-trip value to test against.
//
// The string below decodes to: https://service.example.com/.well-known/lnurlp/user
// It was produced with the standard Python `bech32` library for reference.
// -------------------------------------------------------------------------
private val knownLnurl =
"lnurl1dp68gurn8ghj7um9wfmxjcm99ejhsctdwpkx2tnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhh2um9wgt96xaf"
private val knownUrl = "https://service.example.com/.well-known/lnurlp/user"
// -------------------------------------------------------------------------
// Happy-path tests
// -------------------------------------------------------------------------
@Test
fun decode_returnsCorrectUrl_forKnownGoodLnurl() {
assertEquals(knownUrl, LnurlBech32.decode(knownLnurl))
}
@Test
fun decode_isCaseInsensitive_uppercaseInput() {
assertEquals(knownUrl, LnurlBech32.decode(knownLnurl.uppercase()))
}
@Test
fun decode_isCaseInsensitive_mixedCaseInput() {
val mixed = knownLnurl.mapIndexed { i, c ->
if (i % 2 == 0) c.uppercaseChar() else c
}.joinToString("")
assertEquals(knownUrl, LnurlBech32.decode(mixed))
}
@Test
fun decode_trimsWhitespace() {
assertEquals(knownUrl, LnurlBech32.decode(" $knownLnurl "))
}
// -------------------------------------------------------------------------
// Separator / structural failures → must return null
// -------------------------------------------------------------------------
@Test
fun decode_returnsNull_forEmptyString() {
assertNull(LnurlBech32.decode(""))
}
@Test
fun decode_returnsNull_whenNoSeparatorDigit1() {
assertNull(LnurlBech32.decode("nurl-without-separator"))
}
@Test
fun decode_returnsNull_whenSeparatorAtPositionZero() {
// lastIndexOf('1') == 0 → sepIdx < 1
assertNull(LnurlBech32.decode("1qpzry9x8gf2tvdw0s3jn54khce6mua7l"))
}
@Test
fun decode_returnsNull_whenDataPartShorterThan6Chars() {
// hrp = "lnurl", separator '1', then only 5 data chars
assertNull(LnurlBech32.decode("lnurl1qpzry"))
}
@Test
fun decode_exactlySixCharDataPart_doesNotThrow() {
// Payload is empty after stripping checksum — must not crash
val result = LnurlBech32.decode("lnurl1qpzry9")
assertTrue(result == null || result.isEmpty())
}
// -------------------------------------------------------------------------
// Invalid character in data → must return null
// -------------------------------------------------------------------------
@Test
fun decode_returnsNull_whenDataContainsInvalidChar() {
// 'b' is not in the bech32 CHARSET
val corrupted = knownLnurl.replaceFirst(knownLnurl[7], 'b')
assertNull(LnurlBech32.decode(corrupted))
}
@Test
fun decode_returnsNull_forArbitraryNonBech32String() {
assertNull(LnurlBech32.decode("not-a-bech32-string-at-all!"))
}
// -------------------------------------------------------------------------
// convertBits padding violation (pad=false guard)
// -------------------------------------------------------------------------
@Test
fun decode_returnsNull_whenPayloadHasNonZeroPaddingRemainder() {
// Single data char (q=0) before a 6-char checksum: the 5 remaining
// bits violate the pad=false check in convertBits → null
assertNull(LnurlBech32.decode("lnurl1qqpzry9"))
}
// -------------------------------------------------------------------------
// Robustness — must never throw
// -------------------------------------------------------------------------
@Test
fun decode_doesNotThrow_forAnySingleCharInput() {
('a'..'z').plus('0'..'9').forEach { c ->
// If any character throws, the test will fail
LnurlBech32.decode(c.toString())
}
}
@Test
fun decode_doesNotThrow_forLongJunkString() {
LnurlBech32.decode("x".repeat(300))
}
}
@@ -0,0 +1,153 @@
package com.bitcointxoko.gudariwallet.util
import org.junit.Assert.assertEquals
import org.junit.Test
class LnurlMetadataParserTest {
// ─────────────────────────────────────────────────────────────
// parseDescription
// ─────────────────────────────────────────────────────────────
@Test
fun `parseDescription returns empty string when input is null`() {
assertEquals("", LnurlMetadataParser.parseDescription(null))
}
@Test
fun `parseDescription returns empty string when input is blank`() {
assertEquals("", LnurlMetadataParser.parseDescription(" "))
}
@Test
fun `parseDescription returns empty string when input is empty`() {
assertEquals("", LnurlMetadataParser.parseDescription(""))
}
@Test
fun `parseDescription returns empty string when input is not valid JSON`() {
assertEquals("", LnurlMetadataParser.parseDescription("not-json"))
}
@Test
fun `parseDescription returns empty string when array is empty`() {
assertEquals("", LnurlMetadataParser.parseDescription("[]"))
}
@Test
fun `parseDescription extracts text-plain from single-entry metadata`() {
val metadata = """[["text/plain","Pay me"]]"""
assertEquals("Pay me", LnurlMetadataParser.parseDescription(metadata))
}
@Test
fun `parseDescription extracts text-plain from multi-entry metadata`() {
val metadata = """[["text/plain","Pay me"],["image/png;base64","abc123"]]"""
assertEquals("Pay me", LnurlMetadataParser.parseDescription(metadata))
}
@Test
fun `parseDescription extracts text-plain when it is not the first entry`() {
val metadata = """[["image/png;base64","abc123"],["text/plain","Hello"]]"""
assertEquals("Hello", LnurlMetadataParser.parseDescription(metadata))
}
@Test
fun `parseDescription returns empty string when no text-plain entry exists`() {
val metadata = """[["image/png;base64","abc123"],["text/long-desc","Long desc"]]"""
assertEquals("", LnurlMetadataParser.parseDescription(metadata))
}
@Test
fun `parseDescription handles text-plain with empty value`() {
val metadata = """[["text/plain",""]]"""
assertEquals("", LnurlMetadataParser.parseDescription(metadata))
}
@Test
fun `parseDescription handles text-plain value with special characters`() {
val metadata = """[["text/plain","Pay ₿ now! \"quotes\" & <tags>"]]"""
assertEquals("""Pay ₿ now! "quotes" & <tags>""", LnurlMetadataParser.parseDescription(metadata))
}
// ─────────────────────────────────────────────────────────────
// extractDomain
// ─────────────────────────────────────────────────────────────
@Test
fun `extractDomain returns empty string when input is null`() {
assertEquals("", LnurlMetadataParser.extractDomain(null))
}
@Test
fun `extractDomain returns empty string when input is blank`() {
assertEquals("", LnurlMetadataParser.extractDomain(" "))
}
@Test
fun `extractDomain returns empty string when input is empty`() {
assertEquals("", LnurlMetadataParser.extractDomain(""))
}
@Test
fun `extractDomain returns empty string when input is not a valid URI`() {
assertEquals("", LnurlMetadataParser.extractDomain("::not-a-uri::"))
}
@Test
fun `extractDomain extracts host from https URL`() {
assertEquals("example.com", LnurlMetadataParser.extractDomain("https://example.com/lnurl-pay"))
}
@Test
fun `extractDomain extracts host from http URL with path and query`() {
assertEquals(
"pay.example.com",
LnurlMetadataParser.extractDomain("http://pay.example.com/api/lnurl?q=abc")
)
}
@Test
fun `extractDomain extracts host from URL with port`() {
assertEquals("example.com", LnurlMetadataParser.extractDomain("https://example.com:8080/pay"))
}
@Test
fun `extractDomain returns empty string for URI with no host`() {
// A plain relative path has no host component
assertEquals("", LnurlMetadataParser.extractDomain("/just/a/path"))
}
}
class PayingToLabelKtTest {
@Test
fun `payingToLabel returns lnurl as-is when it is a lightning address`() {
assertEquals("user@example.com", payingToLabel("user@example.com", "example.com"))
}
@Test
fun `payingToLabel returns lnurl as-is for subdomain lightning address`() {
assertEquals("alice@pay.wallet.io", payingToLabel("alice@pay.wallet.io", "pay.wallet.io"))
}
@Test
fun `payingToLabel returns domain when lnurl has no at-sign`() {
assertEquals("example.com", payingToLabel("lnurl1dp68gurn8ghj7um...", "example.com"))
}
@Test
fun `payingToLabel returns domain when lnurl is an https URL`() {
assertEquals("example.com", payingToLabel("https://example.com/lnurl-pay", "example.com"))
}
@Test
fun `payingToLabel returns empty domain string when domain is empty and no at-sign`() {
assertEquals("", payingToLabel("lnurl1dp68gurn8ghj7um...", ""))
}
@Test
fun `payingToLabel returns lnurl when it contains at-sign even if domain is empty`() {
assertEquals("user@host", payingToLabel("user@host", ""))
}
}
@@ -0,0 +1,197 @@
package com.bitcointxoko.gudariwallet.util
import fr.acinq.secp256k1.Secp256k1
import org.junit.Assert.*
import org.junit.Test
import java.math.BigInteger
class NwcKeyGeneratorTest {
// ── helpers ──────────────────────────────────────────────────────────────
private val HEX_REGEX = Regex("^[0-9a-f]+$")
/** Re-derives the 33-byte compressed pubkey from a hex private key. */
private fun compressedPubKey(privKeyHex: String): ByteArray {
val secp256k1 = Secp256k1.get()
val privBytes = privKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val internal = secp256k1.pubkeyCreate(privBytes)
return secp256k1.pubKeyCompress(internal) // 33 bytes: 02||x or 03||x
}
private fun hexToBytes(hex: String): ByteArray =
hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// ── basic shape ──────────────────────────────────────────────────────────
@Test
fun `generate returns non-null keypair`() {
val kp = NwcKeyGenerator.generate()
assertNotNull(kp)
}
@Test
fun `privKeyHex is 64 lowercase hex characters`() {
val kp = NwcKeyGenerator.generate()
assertEquals(
"privKeyHex must be 64 chars (32 bytes)",
64, kp.privKeyHex.length
)
assertTrue(
"privKeyHex must be lowercase hex",
kp.privKeyHex.matches(HEX_REGEX)
)
}
@Test
fun `pubKeyHex is 64 lowercase hex characters`() {
val kp = NwcKeyGenerator.generate()
assertEquals(
"pubKeyHex must be 64 chars (32 bytes)",
64, kp.pubKeyHex.length
)
assertTrue(
"pubKeyHex must be lowercase hex",
kp.pubKeyHex.matches(HEX_REGEX)
)
}
// ── secp256k1 validity ───────────────────────────────────────────────────
@Test
fun `privKeyHex is a valid secp256k1 private key`() {
val kp = NwcKeyGenerator.generate()
val secp256k1 = Secp256k1.get()
val privBytes = hexToBytes(kp.privKeyHex)
assertTrue(
"privKeyHex must be a valid secp256k1 scalar",
secp256k1.secKeyVerify(privBytes)
)
}
@Test
fun `privKeyHex is within secp256k1 group order`() {
val n = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
val kp = NwcKeyGenerator.generate()
val k = BigInteger(1, hexToBytes(kp.privKeyHex))
assertTrue("privKey must be > 0", k > BigInteger.ZERO)
assertTrue("privKey must be < n", k < n)
}
// ── BIP340 normalization (even-Y invariant) ───────────────────────────────
@Test
fun `pubKeyHex corresponds to a pubkey with even Y (BIP340 normalization)`() {
val kp = NwcKeyGenerator.generate()
val compressed = compressedPubKey(kp.privKeyHex)
assertEquals(
"After BIP340 normalization the compressed prefix must be 0x02 (even Y)",
0x02.toByte(), compressed[0]
)
}
@Test
fun `pubKeyHex matches x-only bytes of the pubkey derived from privKeyHex`() {
val kp = NwcKeyGenerator.generate()
val compressed = compressedPubKey(kp.privKeyHex) // 33 bytes
val xOnly = compressed.copyOfRange(1, 33) // drop prefix → 32 bytes
val xOnlyHex = xOnly.joinToString("") { "%02x".format(it) }
assertEquals(
"pubKeyHex must equal the x-only bytes derived from privKeyHex",
xOnlyHex, kp.pubKeyHex
)
}
// ── BIP340 negation path: force an odd-Y key and verify it gets corrected ──
/**
* Finds a raw private key whose UNnormalized pubkey has odd Y, then verifies
* that after generate()-style normalization the pubkey has even Y.
*
* We test the normalization logic in isolation by replicating it here,
* because normalizeToBip340 is private.
*/
@Test
fun `normalization negates key when pubkey has odd Y`() {
val secp256k1 = Secp256k1.get()
val n = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
// Walk through a handful of well-known private keys and find one with odd Y.
// k=1 typically has even Y; k=2 may differ — we just iterate until we find odd.
var rawPriv: ByteArray? = null
for (i in 1..1000) {
val candidate = BigInteger.valueOf(i.toLong())
.toByteArray()
.let { b ->
val stripped = if (b[0] == 0x00.toByte()) b.copyOfRange(1, b.size) else b
ByteArray(32 - stripped.size) + stripped
}
if (!secp256k1.secKeyVerify(candidate)) continue
val pub = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(candidate))
if (pub[0] == 0x03.toByte()) { // odd Y found
rawPriv = candidate
break
}
}
assertNotNull("Test setup: could not find an odd-Y key in range [1, 1000]", rawPriv)
// Replicate the normalization
val negated = (n - BigInteger(1, rawPriv!!))
.toByteArray()
.let { bytes ->
val stripped = if (bytes[0] == 0x00.toByte()) bytes.copyOfRange(1, bytes.size) else bytes
ByteArray(32 - stripped.size) + stripped
}
val normalizedPub = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(negated))
assertEquals(
"Negated key must produce even-Y pubkey (prefix 0x02)",
0x02.toByte(), normalizedPub[0]
)
}
// ── randomness ───────────────────────────────────────────────────────────
@Test
fun `successive calls produce distinct keypairs`() {
val results = (1..10).map { NwcKeyGenerator.generate() }
val uniquePrivKeys = results.map { it.privKeyHex }.toSet()
val uniquePubKeys = results.map { it.pubKeyHex }.toSet()
assertEquals("All 10 private keys must be distinct", 10, uniquePrivKeys.size)
assertEquals("All 10 public keys must be distinct", 10, uniquePubKeys.size)
}
@Test
fun `keypair fields are not equal to each other`() {
val kp = NwcKeyGenerator.generate()
assertNotEquals(
"privKeyHex and pubKeyHex must not be identical",
kp.privKeyHex, kp.pubKeyHex
)
}
// ── data class contract ──────────────────────────────────────────────────
@Test
fun `NwcKeypair equality is value-based`() {
val a = NwcKeypair(privKeyHex = "aabbcc", pubKeyHex = "ddeeff")
val b = NwcKeypair(privKeyHex = "aabbcc", pubKeyHex = "ddeeff")
assertEquals("data class equals must be value-based", a, b)
}
@Test
fun `NwcKeypair copy produces independent instance`() {
val original = NwcKeyGenerator.generate()
val copy = original.copy(privKeyHex = "deadbeef00000000000000000000000000000000000000000000000000000001")
assertNotEquals("copy with changed privKeyHex must not equal original", original, copy)
assertEquals("copy must retain original pubKeyHex", original.pubKeyHex, copy.pubKeyHex)
}
}
@@ -0,0 +1,214 @@
package com.bitcointxoko.gudariwallet.util
import org.junit.Assert.assertEquals
import org.junit.Test
class SendInputDetectorTest {
// ─────────────────────────────────────────────
// normalize()
// ─────────────────────────────────────────────
@Test
fun `normalize strips lightning scheme with double slash`() {
assertEquals("lnbc1abc", SendInputDetector.normalize("lightning://lnbc1abc"))
}
@Test
fun `normalize strips lightning scheme without double slash`() {
assertEquals("lnbc1abc", SendInputDetector.normalize("lightning:lnbc1abc"))
}
@Test
fun `normalize strips lnurl colon prefix`() {
assertEquals("lnurl1xyz", SendInputDetector.normalize("lnurl:lnurl1xyz"))
}
@Test
fun `normalize is case insensitive for prefix`() {
assertEquals("lnbc1abc", SendInputDetector.normalize("LIGHTNING://lnbc1abc"))
assertEquals("lnbc1abc", SendInputDetector.normalize("Lightning:lnbc1abc"))
}
@Test
fun `normalize returns raw string when no known prefix`() {
assertEquals("lnbc1someinvoice", SendInputDetector.normalize("lnbc1someinvoice"))
}
@Test
fun `normalize trims surrounding whitespace`() {
assertEquals("lnbc1abc", SendInputDetector.normalize(" lightning://lnbc1abc "))
assertEquals("lnbc1abc", SendInputDetector.normalize(" lnbc1abc "))
}
@Test
fun `normalize handles empty string`() {
assertEquals("", SendInputDetector.normalize(""))
assertEquals("", SendInputDetector.normalize(" "))
}
// ─────────────────────────────────────────────
// detect() — Bip21
// ─────────────────────────────────────────────
@Test
fun `detect returns Bip21 for bitcoin URI`() {
assertEquals(SendInputType.Bip21, SendInputDetector.detect("bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf"))
}
@Test
fun `detect returns Bip21 for uppercase BITCOIN URI`() {
assertEquals(SendInputType.Bip21, SendInputDetector.detect("BITCOIN:1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf"))
}
@Test
fun `detect returns Bip21 for bitcoin URI with query params`() {
assertEquals(SendInputType.Bip21, SendInputDetector.detect("bitcoin:1A1zP?amount=0.001&label=test"))
}
// ─────────────────────────────────────────────
// detect() — Unknown for keyauth
// ─────────────────────────────────────────────
@Test
fun `detect returns Unknown for keyauth double-slash URI`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("keyauth://somevalue"))
}
@Test
fun `detect returns Unknown for keyauth single-colon URI`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("keyauth:somevalue"))
}
@Test
fun `detect returns Unknown for uppercase KEYAUTH URI`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("KEYAUTH://somevalue"))
}
// ─────────────────────────────────────────────
// detect() — Lud17Url
// ─────────────────────────────────────────────
@Test
fun `detect returns Lud17Url for lnurlp scheme`() {
assertEquals(SendInputType.Lud17Url, SendInputDetector.detect("lnurlp://example.com/pay"))
}
@Test
fun `detect returns Lud17Url for lnurlw scheme`() {
assertEquals(SendInputType.Lud17Url, SendInputDetector.detect("lnurlw://example.com/withdraw"))
}
@Test
fun `detect returns Lud17Url for lnurlc scheme`() {
assertEquals(SendInputType.Lud17Url, SendInputDetector.detect("lnurlc://example.com/channel"))
}
@Test
fun `detect returns Lud17Url for uppercase lud17 scheme`() {
assertEquals(SendInputType.Lud17Url, SendInputDetector.detect("LNURLP://example.com/pay"))
}
// ─────────────────────────────────────────────
// detect() — Bolt11
// ─────────────────────────────────────────────
@Test
fun `detect returns Bolt11 for lnbc invoice`() {
assertEquals(SendInputType.Bolt11, SendInputDetector.detect("lnbc1someinvoicepayload"))
}
@Test
fun `detect returns Bolt11 for uppercase LNBC invoice`() {
assertEquals(SendInputType.Bolt11, SendInputDetector.detect("LNBC1SOMEINVOICEPAYLOAD"))
}
@Test
fun `detect returns Bolt11 for lightning URI wrapping lnbc`() {
assertEquals(SendInputType.Bolt11, SendInputDetector.detect("lightning://lnbc1someinvoice"))
}
@Test
fun `detect returns Bolt11 for lightning colon wrapping lnbc`() {
assertEquals(SendInputType.Bolt11, SendInputDetector.detect("lightning:lnbc1someinvoice"))
}
@Test
fun `detect returns Bolt11 with surrounding whitespace`() {
assertEquals(SendInputType.Bolt11, SendInputDetector.detect(" lnbc1someinvoice "))
}
// ─────────────────────────────────────────────
// detect() — Lnurl
// ─────────────────────────────────────────────
@Test
fun `detect returns Lnurl for lnurl1 payload`() {
assertEquals(SendInputType.Lnurl, SendInputDetector.detect("lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mg0w6hzs0gm"))
}
@Test
fun `detect returns Lnurl for uppercase LNURL1`() {
assertEquals(SendInputType.Lnurl, SendInputDetector.detect("LNURL1DP68GURN"))
}
@Test
fun `detect returns Lnurl for lnurl colon wrapped payload`() {
assertEquals(SendInputType.Lnurl, SendInputDetector.detect("lnurl:lnurl1dp68abc"))
}
// ─────────────────────────────────────────────
// detect() — LightningAddress
// ─────────────────────────────────────────────
@Test
fun `detect returns LightningAddress for valid address`() {
assertEquals(SendInputType.LightningAddress, SendInputDetector.detect("user@example.com"))
}
@Test
fun `detect returns LightningAddress for address with dots and plus`() {
assertEquals(SendInputType.LightningAddress, SendInputDetector.detect("first.last+tag@sub.domain.io"))
}
@Test
fun `detect returns LightningAddress for address with numbers`() {
assertEquals(SendInputType.LightningAddress, SendInputDetector.detect("user123@domain456.org"))
}
@Test
fun `detect returns Unknown for address missing at sign`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("userexample.com"))
}
@Test
fun `detect returns Unknown for address missing domain extension`() {
// TLD must be at least 2 chars; single char fails the regex
assertEquals(SendInputType.Unknown, SendInputDetector.detect("user@domain.c"))
}
@Test
fun `detect returns Unknown for address with spaces`() {
// trimmed, but internal spaces still break the regex
assertEquals(SendInputType.Unknown, SendInputDetector.detect("user @example.com"))
}
// ─────────────────────────────────────────────
// detect() — Unknown / fallthrough
// ─────────────────────────────────────────────
@Test
fun `detect returns Unknown for empty string`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect(""))
}
@Test
fun `detect returns Unknown for random garbage`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("notavalidinput"))
}
@Test
fun `detect returns Unknown for https URL`() {
assertEquals(SendInputType.Unknown, SendInputDetector.detect("https://example.com"))
}
}
+9 -5
View File
@@ -6,11 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
#Your project is using a version of the Android Gradle Plugin that has built-in Kotlin support, #Your project is using a version of the Android Gradle Plugin that has built-in Kotlin support,
@@ -35,3 +31,11 @@ org.gradle.parallel=true
# Enable build caching (caches task outputs across clean builds) # Enable build caching (caches task outputs across clean builds)
org.gradle.caching=true org.gradle.caching=true
# Increase heap if R8 is being swapped out ? default is often too low
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
# Enables incremental R8 ? only reprocesses changed classes
# Safe for release builds, significant speedup on incremental rebuilds
android.enableR8.incremental=true
# R8-specific: run R8 in a separate process with more memory
android.r8.jvmArgs=-Xmx4g
+33 -23
View File
@@ -1,47 +1,48 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
biometric = "1.2.0-alpha05" biometric = "1.1.0"
converterGson = "3.0.0" coreKtx = "1.19.0"
coreKtx = "1.10.1"
gson = "2.14.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.3.0"
espressoCore = "3.5.1" espressoCore = "3.7.0"
kotlinxCoroutinesAndroid = "1.11.0" kotlinxCoroutinesAndroid = "1.11.0"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.8.0" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.4.0"
composeBom = "2026.02.01" composeBom = "2026.05.01"
lifecycleViewmodelKtx = "2.10.0" lifecycleViewmodelKtx = "2.10.0"
loggingInterceptor = "5.3.2" loggingInterceptor = "5.4.0"
okhttp = "5.3.2" okhttp = "5.4.0"
retrofit = "3.0.0" retrofit = "3.0.0"
zxing-embedded = "4.3.0" zxing-embedded = "4.3.0"
navigationCompose = "2.8.9" navigationCompose = "2.9.8"
appcompat = "1.7.0" appcompat = "1.7.1"
ksp = "2.2.10-2.0.2" ksp = "2.3.9"
room = "2.7.1" room = "2.8.4"
datastore = "1.2.1" datastore = "1.2.1"
protobuf = "4.26.1" protobuf = "4.35.1"
protobufPlugin = "0.10.0" protobufPlugin = "0.10.0"
tink = "1.13.0" tink = "1.21.0"
ui = "1.11.2" ui = "1.11.2"
secp256k1-kmp = "0.23.0" secp256k1-kmp = "0.23.0"
lyricist = "1.8.0" lyricist = "1.8.0"
material3 = "1.4.0" material3 = "1.4.0"
work = "2.9.1" work = "2.11.2"
benchmark = "1.4.1" benchmark = "1.4.1"
runner = "1.5.2" uiautomator = "2.3.0"
androidx-test-ext = "1.2.1" runner = "1.7.0"
androidx-test-ext = "1.3.0"
timber = "5.0.1" timber = "5.0.1"
kotlinx-serialization = "1.11.0"
leakcanary = "2.14"
mockk = "1.13.17"
kotlin-test = "2.4.0"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animation = { module = "androidx.compose.animation:animation" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -75,6 +76,7 @@ protobuf-protoc = { group = "com.google.protobuf", name = "prot
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1-kmp" } secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1-kmp" }
secp256k1-kmp-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1-kmp" }
lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
@@ -82,7 +84,14 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx"
androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" } androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin-test" }
mockk = { group = "io.mockk", name = "mockk", version = "1.14.11" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -93,3 +102,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
androidx-benchmark = { id = "androidx.benchmark", version.ref = "benchmark" } androidx-benchmark = { id = "androidx.benchmark", version.ref = "benchmark" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
android-test = { id = "com.android.test", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+3 -3
View File
@@ -1,9 +1,9 @@
#Thu May 28 13:22:48 CEST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+1 -4
View File
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
/build
+40
View File
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.android.test)
}
android {
namespace = "com.bitcointxoko.gudariwallet.macrobenchmark"
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// because GrapheneOS restricts tracefs access, which is used by Perfetto
// testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] =
"DEVICE-TRACING-MISCONFIGURED"
}
buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = getByName("debug").signingConfig
matchingFallbacks += listOf("release")
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.uiautomator)
}
androidComponents {
beforeVariants(selector().all()) {
it.enable = it.buildType == "benchmark"
}
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,24 @@
package com.bitcointxoko.macrobenchmark
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.bitcointxoko.macrobenchmark", appContext.packageName)
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,29 @@
package com.bitcointxoko.macrobenchmark
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startupCold() = benchmarkRule.measureRepeated(
packageName = "com.bitcointxoko.gudariwallet",
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.None(), // cold, no AOT
startupMode = StartupMode.COLD,
iterations = 5,
) {
pressHome()
startActivityAndWait()
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Some files were not shown because too many files have changed in this diff Show More