initial commit

This commit is contained in:
2026-06-01 02:38:52 +02:00
commit 7f3d14a764
106 changed files with 9334 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
# Built application files
*.apk
*.aab
*.ap_
*.dex
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# If you prefer to keep some .idea config tracked, use these instead:
# .idea/caches
# .idea/libraries
# .idea/tasks.xml
# .idea/gradle.xml
# .idea/assetWizardSettings.xml
# .idea/dictionaries
# .idea/navEditor.xml
# Keystore files
# Note: uncomment the following if you commit keystore files.
# *.jks
# *.keystore
# External native build folder generated in Android Studio 2.2+
.externalNativeBuild/
.cxx/
# Google Services (if you use Firebase/Google APIs, keep this ignored
# and use a template like google-services.json.example instead)
# google-services.json
# Kotlin
*.kotlin_module
# Fabric/Google Crashlytics
fabric.properties
# Secrets / API keys
*.keystore
*.jks
secrets.properties
api_keys.properties
# OS files
.DS_Store
Thumbs.db
# Lint reports
lint-results.xml
lint-results_files/
# APK signing info
signing.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
Generated
+1
View File
@@ -0,0 +1 @@
Gudari Wallet
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+157
View File
@@ -0,0 +1,157 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<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>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>amboss</w>
<w>lnbc</w>
<w>msat</w>
<w>satoshis</w>
<w>sats</w>
</words>
</dictionary>
</component>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+1
View File
@@ -0,0 +1 @@
/build
+93
View File
@@ -0,0 +1,93 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.gudariwallet"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.example.gudariwallet"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
// Networking
implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Lifecycle / ViewModel
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Biometric
implementation(libs.androidx.biometric)
// JSON
implementation(libs.gson)
// icons
implementation(libs.androidx.material.icons.extended)
// QR
implementation(libs.zxing.android.embedded)
//Navigation
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.appcompat)
// animations
implementation(libs.androidx.compose.animation)
}
+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.example.gudariwallet
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.example.gudariwallet", appContext.packageName)
}
}
+7
View File
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<profileable
android:shell="true"
android:enabled="true" />
</application>
</manifest>
+60
View File
@@ -0,0 +1,60 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lightning" />
<data android:scheme="bitcoin" />
<data android:scheme="lnurlp" />
<data android:scheme="lnurlw" />
<data android:scheme="lnurlc" />
<data android:scheme="lnurl" />
</intent-filter>
</activity>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
android:stateNotNeeded="true"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden"
tools:replace="android:screenOrientation, android:theme" />
<service
android:name=".service.WalletNotificationService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,155 @@
package com.example.gudariwallet
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import com.example.gudariwallet.security.EncryptedSecretStore
import com.example.gudariwallet.service.WalletNotificationService
import com.example.gudariwallet.ui.OnboardingScreen
import com.example.gudariwallet.ui.WalletScreen
import com.example.gudariwallet.ui.WalletViewModel
import com.example.gudariwallet.ui.WalletViewModelFactory
import com.example.gudariwallet.ui.theme.GudariWalletTheme
import com.example.gudariwallet.util.NotificationHelper
import com.example.gudariwallet.util.IntentUtils
import kotlinx.coroutines.flow.MutableSharedFlow
class MainActivity : AppCompatActivity() {
private lateinit var secretStore: EncryptedSecretStore
private val vm: WalletViewModel by viewModels {
WalletViewModelFactory(application)
}
// Holds a paymentHash that arrived via notification tap.
// WalletScreen observes this and navigates to the detail screen.
// MutableState so Compose recomposes when it changes.
private val pendingPaymentHash = mutableStateOf<String?>(null)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
private val pendingPaymentUri = mutableStateOf<String?>(null)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ }
// Broadcasts window focus gains to any observer in the composition
val windowFocusEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) windowFocusEvents.tryEmit(Unit)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext)
// Must be first — before any notification is posted
NotificationHelper.createChannels(applicationContext)
// Read hash from the intent that launched the activity (cold start from notification)
pendingPaymentHash.value = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
// Read payment URI from the intent that launched the activity (cold start from deep link)
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
enableEdgeToEdge()
setContent {
GudariWalletTheme {
var isOnboarded by remember { mutableStateOf(secretStore.isOnboarded) }
if (isOnboarded) {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded()
}
// Pass pendingPaymentHash so WalletScreen can consume it
WalletScreen(
vm = vm,
pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri
)
} else {
OnboardingScreen(
secretStore = secretStore,
onComplete = { isOnboarded = true }
)
}
}
}
}
// Called when the activity is already running and a notification is tapped
// (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
if (hash != null) {
pendingPaymentHash.value = hash
}
val uri = IntentUtils.extractPaymentUri(intent)
if (uri != null) {
pendingPaymentUri.value = uri
}
}
// ── Permission / battery helpers (unchanged) ──────────────────────────────
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun promptBatteryOptimizationIfNeeded() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
val prefs = getSharedPreferences("gudari_ui_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_prompted", false)) return
prefs.edit { putBoolean("battery_opt_prompted", true) }
AlertDialog.Builder(this)
.setTitle("Enable background notifications")
.setMessage(
"To receive payment alerts when the app is closed, allow Gudari Wallet " +
"to run without battery restrictions.\n\n" +
"Tap 'Open Settings', then select Battery → Unrestricted."
)
.setPositiveButton("Open Settings") { _, _ -> openBatterySettings() }
.setNegativeButton("Not now", null)
.show()
}
private fun openBatterySettings() {
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
if (tryStartActivity(appDetailsIntent)) return
tryStartActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
private fun tryStartActivity(intent: Intent): Boolean {
return try { startActivity(intent); true } catch (e: ActivityNotFoundException) { false }
}
}
@@ -0,0 +1,21 @@
package com.example.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,137 @@
package com.example.gudariwallet.api
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
interface LNbitsApi {
/** GET /api/v1/wallet — returns balance and wallet info */
@GET("api/v1/wallet")
suspend fun getWallet(
@Header("X-Api-Key") apiKey: String
): WalletResponse
/** POST /api/v1/payments — creates a BOLT-11 invoice */
@POST("api/v1/payments")
suspend fun createInvoice(
@Header("X-Api-Key") apiKey: String,
@Body request: CreateInvoiceRequest
): CreateInvoiceResponse
/**
* GET /api/v1/payments/{checking_id}
* Returns paid status and details for a specific payment hash.
* Auth: invoice key is sufficient.
*/
@GET("api/v1/payments/{checking_id}")
suspend fun checkPayment(
@Header("X-Api-Key") apiKey: String,
@Path("checking_id") checkingId: String
): PaymentStatusResponse
/** POST /api/v1/payments/pay — pays a BOLT-11 invoice */
@POST("api/v1/payments")
suspend fun payInvoice(
@Header("X-Api-Key") adminKey: String,
@Body request: PayInvoiceRequest
): PayInvoiceResponse
/** GET /api/v1/payments — returns payment history */
@GET("api/v1/payments")
suspend fun getPayments(
@Header("X-Api-Key") apiKey: String
): List<WsPayment>
/** POST /api/v1/payments/decode — returns decoded payment request */
@POST("api/v1/payments/decode")
suspend fun decodeInvoice(
@Header("X-Api-Key") invoiceKey: String,
@Body request: DecodeInvoiceRequest
): DecodeInvoiceResponse
// Unified scanner — accepts LNURL, Lightning Address, OR bolt11
@GET("api/v1/lnurlscan/{code}")
suspend fun lnurlScan(
@Header("X-Api-Key") invoiceKey: String,
@Path("code") code: String
): LnurlScanResponse
// Pay an LNURL-pay (after scanning)
@POST("api/v1/payments/lnurl")
suspend fun payLnurl(
@Header("X-Api-Key") adminKey: String,
@Body body: PayLnurlRequest
): PayLnurlResponse
// LNURL-withdraw callback (dynamic URL, per LUD-03)
@GET
suspend fun withdrawCallback(
@Url url: String
): WithdrawCallbackResponse
// Direct fetch of a raw LNURL metadata URL (LUD-17: lnurlp://, lnurlw://)
@GET
suspend fun fetchLnurlMetadata(
@Url url: String
): LnurlScanResponse
/**
* GET {callback}?amount={msat}&comment={comment}
* Fetches a BOLT-11 invoice from an LNURL-pay callback URL directly.
* This is a raw @Url call — bypasses LNbits server-side proxy entirely.
*/
@GET
suspend fun fetchLnurlCallback(
@Url url: String
): LnurlCallbackResponse
/**
* GET /lnurlp/api/v1/links?all_wallets=false
* Returns all LNURLp pay links for the current wallet.
* Auth: invoice key is sufficient.
*/
@GET("lnurlp/api/v1/links")
suspend fun getLnurlpLinks(
@Header("X-Api-Key") invoiceKey: String,
@Query("all_wallets") allWallets: Boolean = false
): List<LnurlpLink>
// Get historical payments
@GET("api/v1/payments")
suspend fun getPayments(
@Header("X-Api-Key") invoiceKey: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int
): List<PaymentRecord>
// Get payment detail
@GET("api/v1/payments/{checking_id}")
suspend fun getPaymentDetail(
@Header("X-Api-Key") invoiceKey: String,
@Path("checking_id") checkingId: String
): PaymentDetailResponse
/**
* Returns the current BTC price in [currency].
* Example: GET /api/v1/rate/USD → {"rate": 43250.50}
* No X-Api-Key header needed.
*/
@GET("api/v1/rate/{currency}")
suspend fun getFiatRate(
@Path("currency") currency: String
): FiatRateResponse
/**
* Returns the list of supported ISO 4217 currency codes.
* Example: GET /api/v1/currencies → ["USD","EUR","GBP",...]
* No X-Api-Key header needed.
*/
@GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String>
}
@@ -0,0 +1,37 @@
package com.example.gudariwallet.api
import com.example.gudariwallet.BuildConfig
import com.example.gudariwallet.util.WalletConstants
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object LNbitsClient {
// Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) // time to establish TCP connection
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS) // time to wait for data (longer for WebSocket pings)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // time to send a request body
.apply {
// Only log in debug builds — prevents API keys leaking in production logs
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build()
fun create(baseUrl: String): LNbitsApi {
val url = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
return Retrofit.Builder()
.baseUrl(url)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
.build()
.create(LNbitsApi::class.java)
}
}
@@ -0,0 +1,272 @@
package com.example.gudariwallet.api
import com.google.gson.TypeAdapter
import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
// ── Wallet ────────────────────────────────────────────────────────────────────
data class WalletResponse(
val id: String,
val name: String,
val balance: Long // millisatoshis
)
// ── Create invoice ────────────────────────────────────────────────────────────
data class CreateInvoiceRequest(
val out: Boolean,
val amount: Long, // satoshis
val memo: String
)
data class CreateInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("payment_request") val paymentRequest: String,
@SerializedName("expiry") val expiry : String?
)
// ── Pay invoice ───────────────────────────────────────────────────────────────
data class PayInvoiceRequest(
val out: Boolean,
val bolt11: String
)
data class PayInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String
)
// ── Check payment ─────────────────────────────────────────────────────────────
data class PaymentStatusResponse(
val paid: Boolean,
val details: PaymentDetails? = null
)
data class PaymentDetails(
val amount: Long?,
val fee: Long?,
val memo: String?
)
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
data class DecodeInvoiceRequest(
val data: String
)
data class DecodeInvoiceResponse(
@SerializedName("payment_hash") val paymentHash : String? = null,
@SerializedName("amount_msat") val amountMsat : Long? = null,
@SerializedName("description") val description : String? = null,
@SerializedName("payee") val payee : String? = null,
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
)
data class RouteHintHop(
@SerializedName("public_key") val publicKey : String,
@SerializedName("short_channel_id") val shortChannelId : String? = null,
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
@SerializedName("ppm_fee") val ppmFee : Long = 0,
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
)
// ── LNURL scan ────────────────────────────────────────────────────────────────
data class LnurlScanResponse(
val tag: String?,
// ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null,
@SerializedName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ───────────────────────────────────────────────
val k1 : String? = null,
@SerializedName("defaultDescription")
val defaultDescription: String? = null,
@SerializedName("minWithdrawable")
val minWithdrawable : Long? = null,
@SerializedName("maxWithdrawable")
val maxWithdrawable : Long? = null,
@SerializedName("balanceCheck")
val balanceCheck : String? = null,
@SerializedName("currentBalance")
val currentBalance : Long? = null,
// ── Error fields ─────────────────────────────────────────────────────────
val status : String? = null, // "OK" or "ERROR"
val reason : String? = null // error message when status == "ERROR"
)
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
data class LnurlCallbackResponse(
val pr : String, // BOLT-11 invoice to pay
val routes : List<Any>? = null,
@SerializedName("successAction")
val successAction : SuccessAction? = null,
// Error fields (server may return status=ERROR instead of pr)
val status : String? = null,
val reason : String? = null
)
// ── LNURL pay ─────────────────────────────────────────────────────────────────
data class LnurlPayRes(
val tag: String?,
val callback: String?,
@SerializedName("minSendable") val minSendable: Long?,
@SerializedName("maxSendable") val maxSendable: Long?,
val metadata: String?,
@SerializedName("commentAllowed") val commentAllowed: Int?,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
)
data class PayLnurlRequest(
val res: LnurlPayRes,
val lnurl: String,
val amount: Long,
val comment: String? = null
)
data class PayLnurlResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String
)
data class WithdrawCallbackResponse(
val status : String,
val reason : String? = null
)
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
data class LnurlpLink(
val id : String,
val wallet : String,
val username : String?, // the part before the @ in the lightning address
val description : String?,
val min : Long,
val max : Long
)
// ── Payment history ───────────────────────────────────────────────────────────
data class PaymentRecord(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String,
@SerializedName("amount") val amountMsat: Long,
@SerializedName("fee") val feeMsat: Long,
@SerializedName("memo") val memo: String?,
@SerializedName("time") val time: String,
@SerializedName("status") val status: String,
@SerializedName("bolt11") val bolt11: String?,
@SerializedName("pending") val pending: Boolean,
@SerializedName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra?
) {
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
val isOutgoing: Boolean get() = amountMsat < 0
}
data class PaymentExtra(
@SerializedName("tag") val tag: String?,
@SerializedName("comment") val comment: String?,
@SerializedName("success_action") val successAction: SuccessAction?,
@SerializedName("wallet_id") val walletId: String?,
@SerializedName("destination") val destination: String?,
@SerializedName("expires_at") val expiresAt: String?
)
data class SuccessAction(
@SerializedName("tag") val tag: String?,
@SerializedName("message") val message: String?,
@SerializedName("url") val url: String?,
@SerializedName("description") val description: String?
)
/**
* LNbits returns `success_action` as either:
* - a JSON object {"tag":"message","message":"Thanks!"}
* - a plain string "Thanks!"
* - null
*
* Gson's default deserialiser throws IllegalStateException when it sees a
* string where it expects {. This adapter handles all three cases.
*
* Registered centrally in [GsonProvider] — applies to all deserialization
* paths automatically.
*/
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
override fun write(out: JsonWriter, value: SuccessAction?) {
if (value == null) { out.nullValue(); return }
out.beginObject()
out.name("tag"); out.value(value.tag)
out.name("message"); out.value(value.message)
out.name("url"); out.value(value.url)
out.name("description"); out.value(value.description)
out.endObject()
}
override fun read(reader: JsonReader): SuccessAction? {
return when (reader.peek()) {
JsonToken.NULL -> { reader.nextNull(); null }
// Plain string — treat as a message-type success action
JsonToken.STRING -> {
val text = reader.nextString()
if (text.isBlank()) null
else SuccessAction(tag = "message", message = text, url = null, description = null)
}
// Normal object
JsonToken.BEGIN_OBJECT -> {
reader.beginObject()
var tag: String? = null
var message: String? = null
var url: String? = null
var description: String? = null
while (reader.hasNext()) {
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 }
}
}
}
data class PaymentDetailResponse(
val paid: Boolean,
val details: PaymentRecord?
)
// ── WebSocket payment message ─────────────────────────────────────────────────
data class WsPaymentMessage(
val payment: WsPayment?
)
data class WsPayment(
@SerializedName("payment_hash") val paymentHash: String,
val amount: Long,
val status: String,
val memo: String?
)
// ── Fiat rate ────────────────────────────────────────────────────────────────
data class FiatRateResponse(
val rate: Double, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency
)
@@ -0,0 +1,28 @@
package com.example.gudariwallet.api
import retrofit2.http.GET
import retrofit2.http.Path
// ── mempool.space ─────────────────────────────────────────────────────────────
data class MempoolNodeResponse(
val alias: String?,
val public_key: String?
)
interface MempoolApi {
@GET("api/v1/lightning/nodes/{pubkey}")
suspend fun getNode(@Path("pubkey") pubkey: String): MempoolNodeResponse
}
// ── 1ml.com ───────────────────────────────────────────────────────────────────
data class OneMlNodeResponse(
val alias: String?,
val pub_key: String?
)
interface OneMlApi {
@GET("node/{pubkey}/json")
suspend fun getNode(@Path("pubkey") pubkey: String): OneMlNodeResponse
}
@@ -0,0 +1,53 @@
package com.example.gudariwallet.api
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Resolves a Lightning node pubkey to a human-readable alias.
* Tries 1ml.com first, falls back to mempool.space.
* Returns null if neither source has the node or both fail.
*/
class NodeAliasService {
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private val mempoolApi: MempoolApi = Retrofit.Builder()
.baseUrl("https://mempool.space/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MempoolApi::class.java)
private val oneMlApi: OneMlApi = Retrofit.Builder()
.baseUrl("https://1ml.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OneMlApi::class.java)
suspend fun resolveAlias(pubkey: String): String? {
if (pubkey.isBlank()) return null
// 1. Try 1ml.com
runCatching { oneMlApi.getNode(pubkey) }
.onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias
}
// 2. Fall back to mempool.space
runCatching { mempoolApi.getNode(pubkey) }
.onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias
}
return null
}
}
@@ -0,0 +1,43 @@
package com.example.gudariwallet.data
import com.example.gudariwallet.api.LnurlScanResponse
import com.example.gudariwallet.api.RouteHintHop
/**
* Domain model classes for the wallet layer.
*
* These are the types returned by [WalletRepository] methods — deliberately
* decoupled from the raw API response models in [com.example.gudariwallet.api].
*/
data class WalletBalance(
val sats: Long
)
data class CreatedInvoice(
val bolt11 : String,
val paymentHash : String,
val expiry : String?
)
data class DecodedBolt11(
val amountSats : Long,
val memo : String,
val payee : String?,
val routeHints : List<RouteHintHop> = emptyList()
)
data class ScannedLnurl(
val tag : String?,
val callback : String?,
val minSats : Long,
val maxSats : Long,
val description : String,
val domain : String,
val commentAllowed : Int,
val rawRes : LnurlScanResponse
)
data class PaymentConfirmation(
val paymentHash: String
)
@@ -0,0 +1,19 @@
package com.example.gudariwallet.data
import com.example.gudariwallet.service.NodeAliasService
import kotlinx.coroutines.flow.StateFlow
/**
* [NodeAliasRepository] backed by [NodeAliasService], which queries
* mempool.space then 1ml.com and caches results in SharedPreferences.
*/
class LNbitsNodeAliasRepository(
private val service: NodeAliasService
) : NodeAliasRepository {
override val aliases: StateFlow<Map<String, String>>
get() = service.aliases
override suspend fun resolve(pubkey: String): String? =
service.resolve(pubkey)
}
@@ -0,0 +1,260 @@
package com.example.gudariwallet.data
import android.content.Context
import android.net.Uri
import android.util.Log
import com.example.gudariwallet.api.*
import com.example.gudariwallet.api.LNbitsClient.httpClient
import com.example.gudariwallet.security.SecretStore
import com.example.gudariwallet.util.LnurlBech32
import com.example.gudariwallet.util.LnurlMetadataParser
import com.example.gudariwallet.util.WalletConstants
private const val TAG = "LNbitsWalletRepo"
class LNbitsWalletRepository(
private val api: LNbitsApi,
private val secrets: SecretStore,
private val context : Context,
private val nodeAliasService: com.example.gudariwallet.service.NodeAliasService =
com.example.gudariwallet.service.NodeAliasService(httpClient, context),
) : WalletRepository {
// ── Balance ───────────────────────────────────────────────────────────────
override suspend fun getBalance(): WalletBalance {
val response = api.getWallet(secrets.invoiceKey)
return WalletBalance(sats = response.balance / WalletConstants.MSAT_PER_SAT)
}
// ── Receive ───────────────────────────────────────────────────────────────
override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice {
val response = api.createInvoice(
secrets.invoiceKey,
CreateInvoiceRequest(
out = false,
amount = amountSats,
memo = memo.ifBlank { "Gudari Wallet" }
)
)
return CreatedInvoice(
bolt11 = response.paymentRequest,
paymentHash = response.paymentHash,
expiry = response.expiry
)
}
override suspend fun isPaymentReceived(paymentHash: String): Boolean {
return api.checkPayment(secrets.invoiceKey, paymentHash).paid
}
// ── Send ──────────────────────────────────────────────────────────────────
override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 {
val response = api.decodeInvoice(secrets.invoiceKey, DecodeInvoiceRequest(data = bolt11))
val amountSats = response.amountMsat
?.takeIf { it > 0L }
?.div(WalletConstants.MSAT_PER_SAT)
?: throw UnsupportedOperationException(
"Zero-amount invoices are not supported. Please ask the sender to specify an amount."
)
return DecodedBolt11(
amountSats = amountSats,
memo = response.description ?: "",
payee = response.payee?.takeIf { it.isNotBlank() },
routeHints = response.routeHints?.flatten() ?: emptyList()
)
}
override suspend fun scanLnurl(code: String): ScannedLnurl {
val r = api.lnurlScan(secrets.invoiceKey, code)
return ScannedLnurl(
tag = r.tag,
callback = r.callback,
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
description = LnurlMetadataParser.parseDescription(r.metadata),
domain = LnurlMetadataParser.extractDomain(r.callback),
commentAllowed = r.commentAllowed ?: 0,
rawRes = r
)
}
override suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse {
return api.fetchLnurlMetadata(httpsUrl)
}
/**
* Client-side LNURL/Lightning Address resolution.
* - Lightning Address (user@domain) → GET https://domain/.well-known/lnurlp/user
* - Bech32 LNURL → decode → GET decoded https URL
* Throws on any failure so the caller can fall back to the server proxy.
*/
override suspend fun scanLnurlDirect(code: String): ScannedLnurl {
val httpsUrl = when {
// Lightning Address: user@domain.com
'@' in code -> {
val parts = code.trim().split("@")
require(parts.size == 2) { "Invalid Lightning Address: $code" }
val (user, domain) = parts
"https://$domain/.well-known/lnurlp/$user"
}
// Bech32 LNURL: decode to https URL
else -> {
LnurlBech32.decode(code)
?: throw IllegalArgumentException("Could not decode LNURL: $code")
}
}
Log.d(TAG, "LNURL [DIRECT SCAN] $code$httpsUrl")
val r = api.fetchLnurlMetadata(httpsUrl)
return ScannedLnurl(
tag = r.tag,
callback = r.callback,
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
description = LnurlMetadataParser.parseDescription(r.metadata),
domain = LnurlMetadataParser.extractDomain(r.callback),
commentAllowed = r.commentAllowed ?: 0,
rawRes = r
)
}
/**
* Client-side LNURL-pay: fetches the invoice from the callback URL directly,
* then pays it via the standard bolt11 path.
* Throws on any failure so the caller can fall back to the server proxy.
*/
override suspend fun payLnurlDirect(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): PaymentConfirmation {
val bolt11 = fetchLnurlCallbackInvoice(rawRes, amountMsat, comment)
Log.d(TAG, "LNURL [DIRECT PAY ] got invoice, paying bolt11")
return payBolt11(bolt11)
}
/**
* Fetches the bolt11 invoice from the LNURL callback URL without paying it.
* Reuses the same URL-building and error-handling logic as [payLnurlDirect],
* but returns the raw `pr` string instead of proceeding to payment.
*/
override suspend fun fetchLnurlCallbackInvoice(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): String {
val callback = requireNotNull(rawRes.callback) { "LNURL callback is null" }
val callbackUrl = buildString {
append(callback)
append(if ('?' in callback) '&' else '?')
append("amount=")
append(amountMsat)
if (!comment.isNullOrBlank()) {
append("&comment=")
append(android.net.Uri.encode(comment))
}
}
Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl")
val callbackResponse = api.fetchLnurlCallback(callbackUrl)
if (callbackResponse.status == "ERROR") {
throw RuntimeException(
callbackResponse.reason ?: "LNURL callback returned an error"
)
}
return callbackResponse.pr.ifBlank {
throw RuntimeException("LNURL callback returned an empty invoice")
}
}
override suspend fun payBolt11(bolt11: String): PaymentConfirmation {
val response = api.payInvoice(
secrets.adminKey,
PayInvoiceRequest(out = true, bolt11 = bolt11)
)
return PaymentConfirmation(paymentHash = response.paymentHash)
}
override suspend fun payLnurl(
rawRes: LnurlScanResponse,
lnurl: String,
amountMsat: Long,
comment: String?
): PaymentConfirmation {
val response = api.payLnurl(
secrets.adminKey,
PayLnurlRequest(
res = LnurlPayRes(
tag = rawRes.tag,
callback = rawRes.callback,
minSendable = rawRes.minSendable,
maxSendable = rawRes.maxSendable,
metadata = rawRes.metadata,
commentAllowed = rawRes.commentAllowed,
allowsNostr = rawRes.allowsNostr,
nostrPubkey = rawRes.nostrPubkey
),
lnurl = lnurl,
amount = amountMsat,
comment = comment
)
)
return PaymentConfirmation(paymentHash = response.paymentHash)
}
override suspend fun executeWithdraw(
callback: String,
k1 : String,
bolt11 : String
): WithdrawCallbackResponse {
val url = buildString {
append(callback)
append(if ('?' in callback) '&' else '?')
append("k1=")
append(Uri.encode(k1))
append("&pr=")
append(Uri.encode(bolt11))
}
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK] $url")
return api.withdrawCallback(url)
}
override suspend fun getLightningAddress(): String? {
val links = api.getLnurlpLinks(secrets.invoiceKey, allWallets = false)
val username = links.firstOrNull { !it.username.isNullOrBlank() }?.username
?: return null
val domain = secrets.baseUrl
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
.substringBefore("/")
return "$username@$domain"
}
// ── Fiat ──────────────────────────────────────────────────────────────────
override suspend fun getFiatRate(currency: String): Double {
Log.d(TAG, "FIAT [RATE FETCH ] currency=$currency")
return api.getFiatRate(currency).rate
}
override suspend fun getSupportedCurrencies(): List<String> {
Log.d(TAG, "FIAT [CURRENCIES] fetching supported currency list")
return api.getSupportedCurrencies()
}
// ── History ───────────────────────────────────────────────────────────────
override suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> {
return api.getPayments(secrets.invoiceKey, limit, offset)
}
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey, checkingId)
}
}
@@ -0,0 +1,123 @@
package com.example.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.example.gudariwallet.api.LnurlScanResponse
import com.example.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private const val TAG = "LnurlCacheRepository"
data class LnurlCacheEntry(
val key : String,
val response : LnurlScanResponse,
val savedAt : Long
)
class LnurlCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.LNURL_CACHE_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// In-memory map for fast access within a session
private val memCache = mutableMapOf<String, LnurlCacheEntry>()
init { loadFromDisk() }
/**
* Returns a cached [LnurlScanResponse] for [key], or null if:
* - no entry exists
* - the entry is older than [WalletConstants.LNURL_CACHE_TTL_MS]
* - the server marked the endpoint as disposable (null is treated as true per LUD-06)
*/
fun get(key: String): LnurlScanResponse? {
val entry = memCache[key] ?: return null
val ageMs = System.currentTimeMillis() - entry.savedAt
if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) {
Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)")
memCache.remove(key)
persistToDisk()
return null
}
// LUD-06: disposable == null means treat as true — do not serve from cache
val disposable = entry.response.disposable
if (disposable == null || disposable == true) {
Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)")
return null
}
Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)")
return entry.response
}
/**
* Stores [response] under [key].
* Silently skips if the response is marked disposable (or disposable is null).
*/
fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable
if (disposable == null || disposable == true) {
Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return
}
val entry = LnurlCacheEntry(key, response, System.currentTimeMillis())
memCache[key] = entry
persistToDisk()
Log.d(TAG, "LNURL [CACHE SET ] $key")
}
/**
* Removes the entry for [key] from both memory and disk.
* Called when a payment attempt with cached data fails, so the next
* scan will always fetch fresh metadata from the server.
*/
fun invalidate(key: String) {
if (memCache.remove(key) != null) {
persistToDisk()
Log.d(TAG, "LNURL [INVALIDATED] $key")
}
}
fun clearAll() {
memCache.clear()
prefs.edit().clear().apply()
Log.d(TAG, "LNURL [CLEARED ] all entries")
}
// ── Persistence ───────────────────────────────────────────────────────────
private fun loadFromDisk() {
val json = prefs.getString(WalletConstants.LNURL_CACHE_PREFS_KEY, null) ?: return
runCatching {
val type = object : TypeToken<List<LnurlCacheEntry>>() {}.type
val list: List<LnurlCacheEntry> = gson.fromJson(json, type)
val now = System.currentTimeMillis()
var loaded = 0
list.forEach { entry ->
if (now - entry.savedAt < WalletConstants.LNURL_CACHE_TTL_MS) {
memCache[entry.key] = entry
loaded++
}
}
Log.d(TAG, "LNURL [LOADED ] $loaded valid entries from disk (${list.size - loaded} expired)")
}.onFailure {
Log.w(TAG, "LNURL [LOAD ERR ] ${it.message}")
}
}
private fun persistToDisk() {
runCatching {
prefs.edit()
.putString(WalletConstants.LNURL_CACHE_PREFS_KEY, gson.toJson(memCache.values.toList()))
.apply()
}.onFailure {
Log.w(TAG, "LNURL [PERSIST ERR] ${it.message}")
}
}
}
@@ -0,0 +1,21 @@
package com.example.gudariwallet.data
import kotlinx.coroutines.flow.StateFlow
/**
* Alias resolution concern, separated from the payment/wallet domain.
*
* Implementations are expected to cache results (in-memory and/or on disk)
* and expose the full resolved alias map as a [StateFlow] for observers.
*/
interface NodeAliasRepository {
/** Live map of pubkey → resolved alias for all pubkeys resolved so far. */
val aliases: StateFlow<Map<String, String>>
/**
* Resolves the human-readable alias for [pubkey].
* Returns the alias string, or null if resolution fails.
* Results are cached — repeated calls for the same pubkey are cheap.
*/
suspend fun resolve(pubkey: String): String?
}
@@ -0,0 +1,87 @@
package com.example.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.example.gudariwallet.api.GsonProvider
import com.example.gudariwallet.api.PaymentDetailResponse
import com.example.gudariwallet.api.PaymentRecord
import com.example.gudariwallet.util.WalletConstants
import com.example.gudariwallet.api.SuccessAction
import com.example.gudariwallet.api.SuccessActionAdapter
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
private const val TAG = "PaymentCacheRepository"
class PaymentCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.PAYMENTS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = GsonProvider.gson
// ── In-memory detail cache — immutable once confirmed, no TTL needed ──────
private val detailCache = mutableMapOf<String, PaymentDetailResponse>()
// ── Payment list ──────────────────────────────────────────────────────────
/** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */
fun loadCachedPayments(): List<PaymentRecord> {
val json = prefs.getString(WalletConstants.PAYMENTS_PREFS_KEY, null) ?: return emptyList()
val savedAt = prefs.getLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, 0L)
val ageMs = System.currentTimeMillis() - savedAt
if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) {
Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list")
return emptyList()
}
return runCatching {
val type: java.lang.reflect.Type = object : TypeToken<List<PaymentRecord>>() {}.type
val list: List<PaymentRecord> = gson.fromJson(json, type)
Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${list.size} payments (age ${ageMs / 1000}s)")
list
}.getOrElse {
Log.w(TAG, "PAYMENTS [CACHE ERR ] failed to deserialise: ${it.message}")
emptyList()
}
}
/** Persist the first page of payments to SharedPreferences. */
fun savePayments(payments: List<PaymentRecord>) {
runCatching {
prefs.edit()
.putString(WalletConstants.PAYMENTS_PREFS_KEY, gson.toJson(payments))
.putLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, System.currentTimeMillis())
.apply()
Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments")
}.onFailure {
Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}")
}
}
// ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */
fun getCachedDetail(checkingId: String): PaymentDetailResponse? {
return detailCache[checkingId]?.also {
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId")
}
}
/** Store a detail response in the in-memory cache. */
fun saveDetail(checkingId: String, detail: PaymentDetailResponse) {
detailCache[checkingId] = detail
Log.d(TAG, "PAYMENTS [DETAIL SET] $checkingId")
}
/** Clear everything — useful for testing and logout. */
fun clearAll() {
detailCache.clear()
prefs.edit()
.remove(WalletConstants.PAYMENTS_PREFS_KEY)
.remove(WalletConstants.PAYMENTS_PREFS_SAVED_AT)
.apply()
Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped")
}
}
@@ -0,0 +1,42 @@
package com.example.gudariwallet.data
import com.example.gudariwallet.api.LnurlScanResponse
import com.example.gudariwallet.api.PaymentDetailResponse
import com.example.gudariwallet.api.PaymentRecord
import com.example.gudariwallet.api.WithdrawCallbackResponse
/**
* Contract for all wallet operations.
* Domain model types are defined in [DomainModels.kt].
*/
interface WalletRepository {
suspend fun getBalance(): WalletBalance
suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice
suspend fun isPaymentReceived(paymentHash: String): Boolean
suspend fun decodeBolt11(bolt11: String): DecodedBolt11
suspend fun scanLnurl(code: String): ScannedLnurl
suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse
suspend fun scanLnurlDirect(code: String): ScannedLnurl
suspend fun payLnurlDirect(
rawRes : LnurlScanResponse,
amountMsat: Long,
comment : String?
): PaymentConfirmation
suspend fun fetchLnurlCallbackInvoice(
rawRes: LnurlScanResponse,
amountMsat: Long,
comment: String?
): String
suspend fun payBolt11(bolt11: String): PaymentConfirmation
suspend fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?): PaymentConfirmation
suspend fun executeWithdraw(
callback: String,
k1 : String,
bolt11 : String
): WithdrawCallbackResponse
suspend fun getLightningAddress(): String?
suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord>
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String>
}
@@ -0,0 +1,58 @@
package com.example.gudariwallet.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64
object SecretManager {
private const val KEY_ALIAS = "lnbits_api_key"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
/** Generate (or retrieve) the AES key in the Keystore */
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
keyGen.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// Require device lock screen to be set up
.setUserAuthenticationRequired(false)
.build()
)
return keyGen.generateKey()
}
/** Encrypt a plaintext string; returns Base64(IV + ciphertext) */
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = iv + ciphertext
return Base64.encodeToString(combined, Base64.DEFAULT)
}
/** Decrypt a Base64(IV + ciphertext) string */
fun decrypt(encoded: String): String {
val combined = Base64.decode(encoded, Base64.DEFAULT)
val iv = combined.copyOfRange(0, 12)
val ciphertext = combined.copyOfRange(12, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_LENGTH, iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
}
@@ -0,0 +1,46 @@
package com.example.gudariwallet.security
import android.content.Context
import com.example.gudariwallet.util.WalletConstants
// Interface — lets you swap in a FakeSecretStore in tests
interface SecretStore {
val invoiceKey: String
val adminKey: String
val baseUrl: String
val isOnboarded: Boolean
fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String)
}
class EncryptedSecretStore(context: Context) : SecretStore {
private val prefs = context.getSharedPreferences(WalletConstants.PREFS_NAME, Context.MODE_PRIVATE)
override val invoiceKey: String
get() = decryptOrEmpty(WalletConstants.PREFS_INVOICE_KEY_ENC)
override val adminKey: String
get() = decryptOrEmpty(WalletConstants.PREFS_ADMIN_KEY_ENC)
override val baseUrl: String
get() = prefs.getString(WalletConstants.PREFS_BASE_URL, "").orEmpty()
override val isOnboarded: Boolean
get() = prefs.getBoolean(WalletConstants.PREFS_ONBOARDED, false)
override fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) {
prefs.edit()
.putString(WalletConstants.PREFS_BASE_URL, baseUrl)
.putString(WalletConstants.PREFS_INVOICE_KEY_ENC, SecretManager.encrypt(invoiceKey))
.putString(WalletConstants.PREFS_ADMIN_KEY_ENC, SecretManager.encrypt(adminKey))
.putBoolean(WalletConstants.PREFS_ONBOARDED, true)
.apply()
}
private fun decryptOrEmpty(key: String): String {
val enc = prefs.getString(key, "").orEmpty()
if (enc.isBlank()) return ""
return runCatching { SecretManager.decrypt(enc) }.getOrDefault("")
}
}
@@ -0,0 +1,184 @@
package com.example.gudariwallet.service
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.example.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "NodeAliasService"
class NodeAliasService(
private val httpClient : OkHttpClient,
private val context : Context
) {
// ── Persistence ───────────────────────────────────────────────────────────
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.ALIAS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// ── In-memory alias cache ─────────────────────────────────────────────────
private data class CachedAlias(val alias: String, val fetchedAt: Long)
// Type token for Gson deserialisation
private val cacheType = object : TypeToken<Map<String, CachedAlias>>() {}.type
private val cache = ConcurrentHashMap<String, CachedAlias>()
// ── Shared alias StateFlow — all screens collect this ─────────────────────
private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap())
val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow()
// ── Init — load persisted cache from SharedPreferences ────────────────────
init {
loadPersistedCache()
}
private fun loadPersistedCache() {
val json = prefs.getString(WalletConstants.ALIAS_PREFS_KEY, null) ?: run {
Log.d(TAG, "ALIAS [PERSIST ] No persisted cache found — starting fresh")
return
}
runCatching {
val persisted: Map<String, CachedAlias> = gson.fromJson(json, cacheType)
val now = System.currentTimeMillis()
var loaded = 0
var dropped = 0
persisted.forEach { (pubkey, entry) ->
val ageMs = now - entry.fetchedAt
if (ageMs < WalletConstants.ALIAS_PERSIST_TTL_MS) {
cache[pubkey] = entry
loaded++
} else {
dropped++
Log.d(TAG, "ALIAS [PERSIST ] Dropped stale entry for $pubkey (age ${ageMs / 3_600_000}h)")
}
}
// Seed the StateFlow with everything we loaded
_aliases.value = cache.mapValues { it.value.alias }
Log.d(TAG, "ALIAS [PERSIST ] Loaded $loaded entries, dropped $dropped stale entries")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to deserialise cache — starting fresh: ${e.message}")
}
}
private fun persistCache() {
runCatching {
val json = gson.toJson(cache)
prefs.edit().putString(WalletConstants.ALIAS_PREFS_KEY, json).apply()
Log.d(TAG, "ALIAS [PERSIST ] Wrote ${cache.size} entries to SharedPreferences")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to write cache: ${e.message}")
}
}
// ── Public API ────────────────────────────────────────────────────────────
suspend fun resolve(pubkey: String): String? {
val now = System.currentTimeMillis()
cache[pubkey]?.let { entry ->
if (now - entry.fetchedAt < WalletConstants.ALIAS_CACHE_TTL_MS) {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${(now - entry.fetchedAt) / 1000}s)")
return entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${(now - entry.fetchedAt) / 1000}s) — refetching")
}
} ?: Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey — fetching from network")
val alias = fetchFromNetwork(pubkey)
if (alias == null) {
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey — no alias from mempool or 1ml")
return null
}
// Update in-memory cache
cache[pubkey] = CachedAlias(alias = alias, fetchedAt = System.currentTimeMillis())
// Publish to shared StateFlow
_aliases.value = _aliases.value + (pubkey to alias)
// Persist to SharedPreferences
persistCache()
Log.d(TAG, "ALIAS [API FETCHED] $pubkey\"$alias\"")
return alias
}
/** Clears in-memory cache, StateFlow, and persisted SharedPreferences entry. */
fun clearPersistedCache() {
cache.clear()
_aliases.value = emptyMap()
prefs.edit().remove(WalletConstants.ALIAS_PREFS_KEY).apply()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
// ── Private helpers ───────────────────────────────────────────────────────
private suspend fun fetchFromNetwork(pubkey: String): String? = withContext(Dispatchers.IO) {
Log.d(TAG, "ALIAS [TRYING ] mempool.space for $pubkey")
val mempoolResult = fetchFromMempool(pubkey)
if (mempoolResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] mempool.space resolved $pubkey\"$mempoolResult\"")
return@withContext mempoolResult
}
Log.d(TAG, "ALIAS [TRYING ] 1ml.com fallback for $pubkey")
val mlResult = fetchFrom1ml(pubkey)
if (mlResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] 1ml.com resolved $pubkey\"$mlResult\"")
}
mlResult
}
private fun fetchFromMempool(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://mempool.space/api/v1/lightning/nodes/$pubkey")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "mempool.space lookup failed for $pubkey: ${e.message}")
null
}
}
private fun fetchFrom1ml(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://1ml.com/node/$pubkey/json")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "1ml.com lookup failed for $pubkey: ${e.message}")
null
}
}
}
@@ -0,0 +1,256 @@
package com.example.gudariwallet.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import com.example.gudariwallet.api.GsonProvider
import com.example.gudariwallet.api.LNbitsClient
import com.example.gudariwallet.api.WsPaymentMessage
import com.example.gudariwallet.security.EncryptedSecretStore
import com.example.gudariwallet.util.NotificationConstants
import com.example.gudariwallet.util.NotificationHelper
import com.example.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
private const val TAG = "WalletNotifService"
/**
* Foreground service that owns the single wallet-level WebSocket connection.
*
* Responsibilities:
* - Keeps one WebSocket open to wss://{baseUrl}/api/v1/ws/{invoiceKey}
* - Posts user-facing notifications for all payment events (in-app or external)
* - Emits payment events to [paymentEvents] SharedFlow so the ViewModel can
* update the UI when the app is foregrounded — zero extra network cost
* - Reconnects with exponential backoff on failure
* - Survives process kill via START_REDELIVER_INTENT
*
* Lifetime: starts once after onboarding completes, stops only on logout.
* Never started or stopped by invoice creation.
*/
class WalletNotificationService : Service() {
// ── Companion: shared state accessible by ViewModel ───────────────────────
companion object {
/**
* Payment events emitted by the service.
* The ViewModel collects this flow to update UI state when the app is
* in the foreground. replay=0 means no stale events are delivered to
* late collectors — a payment that arrived while the app was closed
* is communicated via the notification, not via this flow.
*/
private val _paymentEvents = MutableSharedFlow<PaymentEvent>(replay = 0)
val paymentEvents: SharedFlow<PaymentEvent> = _paymentEvents.asSharedFlow()
/** Convenience wrapper — use instead of constructing the Intent manually. */
fun start(context: Context) {
context.startForegroundService(Intent(context, WalletNotificationService::class.java))
}
/** Convenience wrapper — stops the service and closes the WebSocket. */
fun stop(context: Context) {
context.stopService(Intent(context, WalletNotificationService::class.java))
}
}
/**
* Represents a payment event received from the wallet-level WebSocket.
* Emitted to [paymentEvents] for in-process UI updates.
*/
data class PaymentEvent(
val amountSats: Long,
val memo: String?,
val isOutgoing: Boolean,
val paymentHash: String
)
// ── Service internals ─────────────────────────────────────────────────────
// SupervisorJob ensures one failing coroutine doesn't cancel the others.
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var webSocket: WebSocket? = null
private var reconnectJob: Job? = null
private var reconnectAttempts = 0
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
private val gson = GsonProvider.gson
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Step 1: Call startForeground immediately — Android requires this within
// 5 seconds of startForegroundService() or the app is ANR'd.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NotificationConstants.NOTIF_ID_SERVICE,
NotificationHelper.buildServiceNotification(this),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(
NotificationConstants.NOTIF_ID_SERVICE,
NotificationHelper.buildServiceNotification(this)
)
}
// Step 2: Open the WebSocket. If already open (e.g. service restarted
// by Android after a kill), close the old one first.
openWebSocket()
// START_REDELIVER_INTENT: if Android kills the service under memory
// pressure, it will restart it and re-deliver the last intent.
// This ensures the service always has credentials to reconnect.
return START_REDELIVER_INTENT
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed — closing WebSocket")
reconnectJob?.cancel()
webSocket?.close(WalletConstants.WS_CLOSE_NORMAL, "Service stopped")
webSocket = null
serviceScope.cancel()
}
// onBind returns null — this is a started service, not a bound service.
// Communication with the ViewModel happens via the companion SharedFlow.
override fun onBind(intent: Intent?): IBinder? = null
// ── WebSocket ─────────────────────────────────────────────────────────────
private fun openWebSocket() {
val secrets = EncryptedSecretStore(applicationContext)
if (secrets.invoiceKey.isBlank() || secrets.baseUrl.isBlank()) {
Log.e(TAG, "Credentials not available — cannot open WebSocket")
stopSelf()
return
}
val wsUrl = secrets.baseUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + "/api/v1/ws/${secrets.invoiceKey}"
Log.d(TAG, "Opening WebSocket: $wsUrl")
val request = Request.Builder().url(wsUrl).build()
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected")
// Reset backoff on successful connection
reconnectAttempts = 0
currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "WebSocket message: $text")
handleMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure (attempt $reconnectAttempts): ${t.message}")
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closing: $code $reason")
webSocket.close(WalletConstants.WS_CLOSE_NORMAL, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: $code $reason")
// Only reconnect on unexpected closes (not our own WS_CLOSE_NORMAL)
if (code != WalletConstants.WS_CLOSE_NORMAL) {
scheduleReconnect()
}
}
})
}
private fun handleMessage(text: String) {
val msg = runCatching {
gson.fromJson(text, WsPaymentMessage::class.java)
}.getOrNull() ?: return
val payment = msg.payment ?: return
// Only act on successful payments — ignore pending/failed events
if (payment.status != "success") return
val amountSats = kotlin.math.abs(payment.amount) / WalletConstants.MSAT_PER_SAT
val isOutgoing = payment.amount < 0
// Post notification — works whether app is foreground or background
if (isOutgoing) {
NotificationHelper.notifyPaymentSent(
context = applicationContext,
amountSats = amountSats,
paymentHash = payment.paymentHash
)
} else {
NotificationHelper.notifyPaymentReceived(
context = applicationContext,
amountSats = amountSats,
memo = payment.memo,
paymentHash = payment.paymentHash
)
}
// Emit to SharedFlow — ViewModel collects this to update UI in real time
// when the app is foregrounded. Fire-and-forget; no suspension needed.
serviceScope.launch {
_paymentEvents.emit(
PaymentEvent(
amountSats = amountSats,
memo = payment.memo,
isOutgoing = isOutgoing,
paymentHash = payment.paymentHash
)
)
}
}
private fun scheduleReconnect() {
if (reconnectAttempts >= WalletConstants.WS_MAX_ATTEMPTS) {
Log.e(TAG, "WebSocket gave up after ${WalletConstants.WS_MAX_ATTEMPTS} attempts — service will wait for next start")
// Do not stop the service — it will reconnect on next onStartCommand
// (e.g. when the app is brought to foreground and MainActivity calls start())
return
}
val backoff = currentBackoffMs
reconnectAttempts++
currentBackoffMs = (currentBackoffMs * 2).coerceAtMost(WalletConstants.WS_MAX_BACKOFF_MS)
Log.d(TAG, "Reconnecting in ${backoff}ms (attempt $reconnectAttempts of ${WalletConstants.WS_MAX_ATTEMPTS})")
reconnectJob?.cancel()
reconnectJob = serviceScope.launch {
delay(backoff)
openWebSocket()
}
}
}
@@ -0,0 +1,677 @@
package com.example.gudariwallet.ui
import android.content.ClipData
import android.content.Intent
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.platform.LocalContext
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 androidx.core.net.toUri
import com.example.gudariwallet.api.PaymentRecord
import com.example.gudariwallet.ui.theme.semanticColors
import com.example.gudariwallet.util.feePpm
import com.example.gudariwallet.util.formatFiatForSats // ← new import
import kotlinx.coroutines.launch
// ── List screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
vm: HistoryViewModel,
onClose: () -> Unit,
onPaymentClick: (PaymentRecord) -> Unit
) {
val state by vm.state.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val listState = rememberLazyListState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("History") },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close"
)
}
},
actions = {
IconButton(onClick = { vm.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is HistoryState.Error -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Text("Retry") }
}
}
is HistoryState.Success -> {
if (s.payments.isEmpty()) {
Text(
text = "No payments yet.",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
if (s.isRefreshing) {
item {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
)
}
}
items(
items = s.payments,
key = { it.paymentHash }
) { payment ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
OutlinedButton(onClick = { vm.loadMore() }) {
Text("More")
}
}
}
}
}
}
}
}
}
}
}
// ── Single row ───────────────────────────────────────────────────────────────
@Composable
private fun PaymentRow(
payment : PaymentRecord,
fiatCurrency : String?,
fiatSatsPerUnit : Double?,
onClick : () -> Unit
) {
val semantic = semanticColors()
// ── Pending detection ────────────────────────────────────────────────────
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
// bitcoin.design: green for received, neutral for sent, muted for pending.
val amountColor = when {
isPending -> MaterialTheme.colorScheme.onSurfaceVariant
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface
else -> semantic.success
}
val amountPrefix = if (payment.isOutgoing) "" else "+"
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward
// Fiat secondary line — only shown when rate is available
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
else null
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Direction icon — tinted to match amount color
Icon(
imageVector = icon,
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
tint = amountColor,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
// Left: memo + timestamp + optional pending badge
Column(modifier = Modifier.weight(1f)) {
Text(
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
Text(
text = formatTimestamp(payment.time),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// ── Pending badge ────────────────────────────────────────────────
if (isPending) {
Spacer(Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = semantic.warningContainer,
contentColor = semantic.onWarningContainer
) {
Text(
text = "Pending",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Spacer(Modifier.width(12.dp))
// Right: sats (primary) + fiat (secondary) — right-aligned
Column(horizontalAlignment = Alignment.End) {
Text(
text = "$amountPrefix${payment.amountSat} sats",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
}
// ── Detail screen ────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentDetailScreen(
payment: PaymentRecord,
vm: HistoryViewModel,
onBack: () -> Unit
) {
val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
val enriched: PaymentRecord = when (val s = detailState) {
is DetailState.Success -> {
val detail = s.detail.details
when {
detail == null -> payment
else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
)
}
}
is DetailState.Partial -> s.record
else -> payment
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
when (detailState) {
is DetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is DetailState.Error -> {
Column(Modifier.fillMaxSize().padding(padding)) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Could not load full details",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
}
PaymentDetailContent(
payment = enriched,
vm = vm,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit
)
}
}
else -> {
PaymentDetailContent(
payment = enriched,
vm = vm,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
modifier = Modifier.padding(padding)
)
}
}
}
}
// ── Detail content ───────────────────────────────────────────────────────────
@Composable
private fun PaymentDetailContent(
payment : PaymentRecord,
vm : HistoryViewModel,
fiatCurrency : String?, // ← new
fiatSatsPerUnit : Double?, // ← new
modifier : Modifier = Modifier
) {
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
val semantic = semanticColors()
val amountColor = when {
isPending -> MaterialTheme.colorScheme.onSurfaceVariant // muted
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral
else -> semantic.success // green
}
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val destinationMap by vm.destinationState.collectAsState()
val bolt11 = payment.bolt11?.takeIf { it.isNotBlank() && payment.isOutgoing }
LaunchedEffect(payment.paymentHash) {
if (bolt11 != null) vm.resolveDestination(bolt11, payment.paymentHash)
}
val pubkey = destinationMap[payment.paymentHash]
val aliases by vm.nodeAliases.collectAsState()
// Pre-compute fiat strings for hero + fee
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
else null
}
val feeFiat: String? = remember(payment.feeSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.feeSat > 0)
formatFiatForSats(payment.feeSat, fiatSatsPerUnit, fiatCurrency)
else null
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Hero amount ──────────────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward,
contentDescription = null,
tint = amountColor,
modifier = Modifier.size(36.dp)
)
Spacer(Modifier.height(8.dp))
// Primary: sats
Text(
text = "${if (payment.isOutgoing) "" else "+"}${payment.amountSat} sats",
style = MaterialTheme.typography.headlineLarge,
color = amountColor
)
// Secondary: fiat equivalent
if (heroFiat != null) {
Spacer(Modifier.height(4.dp))
Text(
text = "$heroFiat",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
// Fee line (with fiat if available)
if (payment.feeSat > 0) {
val ppm = feePpm(
amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat)
)
val feeLabel = buildString {
append("Fee: ${payment.feeSat} sats")
if (feeFiat != null) append(" (≈ $feeFiat)")
if (ppm != null) append(" · $ppm ppm")
}
Spacer(Modifier.height(4.dp))
Text(
text = feeLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
StatusChip(status = payment.status)
}
}
}
// ── Basic info ───────────────────────────────────────────────────────
item {
DetailSection(title = "Details") {
DetailRow("Date", formatTimestamp(payment.time))
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "")
if (payment.extra?.tag != null) {
DetailRow("Type", payment.extra.tag.uppercase())
}
if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow("Comment", payment.extra.comment)
}
}
}
// ── Success action (LNURL) ───────────────────────────────────────────
payment.extra?.successAction?.let { action ->
item {
DetailSection(title = "Success Action") {
action.message?.let { DetailRow("Message", it) }
action.url?.let { DetailRow("URL", it) }
action.description?.let { DetailRow("Description", it) }
}
}
}
// ── Technical ────────────────────────────────────────────────────────
item {
DetailSection(title = "Technical") {
DetailRow(
label = "Payment Hash",
value = payment.paymentHash,
monospace = true,
copyable = true,
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))) } }
)
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow(
label = "Preimage",
value = payment.preimage,
monospace = true,
copyable = true,
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))) } }
)
}
if (pubkey != null) {
val ambossUrl = "https://amboss.space/node/$pubkey"
val label = aliases[pubkey] ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow(
label = "Destination",
value = label,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
},
trailingContent = {
IconButton(
onClick = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("node_id", pubkey))) } },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy node ID",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
}
}
// ── BOLT11 invoice ───────────────────────────────────────────────────
if (!payment.bolt11.isNullOrBlank()) {
item {
DetailSection(title = "Invoice") {
DetailRow(
label = "BOLT11",
value = payment.bolt11,
monospace = true,
copyable = true,
maxLines = 3,
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))) } }
)
}
}
}
}
}
// ── Reusable section wrapper ─────────────────────────────────────────────────
// (unchanged — kept for completeness)
@Composable
private 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()
}
}
}
// ── Single label/value row ───────────────────────────────────────────────────
// (unchanged)
@Composable
private 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()
}
}
}
// ── Status chip ──────────────────────────────────────────────────────────────
// (unchanged)
@Composable
private fun StatusChip(status: String) {
val semantic = semanticColors()
val (containerColor, contentColor) = when (status.lowercase()) {
"success", "complete", "paid" ->
semantic.successContainer to semantic.onSuccessContainer
"pending", "in_flight", "inflight" ->
semantic.warningContainer to semantic.onWarningContainer
"failed", "error", "expired" ->
semantic.errorContainer to semantic.onErrorContainer
else ->
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
shape = MaterialTheme.shapes.small,
color = containerColor,
contentColor = contentColor
) {
Text(
text = status.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
private fun formatTimestamp(time: String): String {
return runCatching {
val epochSeconds = time.toLong()
java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
.format(java.time.Instant.ofEpochSecond(epochSeconds))
}.recoverCatching {
val odt = java.time.OffsetDateTime.parse(time)
java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
.format(odt)
}.getOrDefault(time)
}
@@ -0,0 +1,202 @@
package com.example.gudariwallet.ui
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.gudariwallet.data.NodeAliasRepository
import com.example.gudariwallet.api.PaymentRecord
import com.example.gudariwallet.data.PaymentCacheRepository
import com.example.gudariwallet.data.WalletRepository
import com.example.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HistoryViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>, // passed from WalletViewModel.selectedCurrency
val fiatSatsPerUnit : StateFlow<Double?> // passed from WalletViewModel.fiatSatsPerUnit
) : ViewModel() {
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state
private var currentOffset = 0
private var loadJob: Job? = null
init {
// Cache read moved to IO dispatcher — never blocks the main thread,
// so navigation to HistoryScreen is instant on tap.
viewModelScope.launch {
val cached = withContext(Dispatchers.IO) {
paymentCache.loadCachedPayments()
}
if (cached.isNotEmpty()) {
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
_state.value = HistoryState.Success(
payments = cached,
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = true
)
} else {
Log.d("HistoryViewModel", "PAYMENTS [INIT ] No cache — cold load")
}
loadPage()
}
// Note: no loadFiatRate() here — fiatSatsPerUnit is owned by WalletViewModel
// and already kept up to date. Zero extra network calls.
}
// ── Payments list ─────────────────────────────────────────────────────────
fun refresh() {
loadJob?.cancel()
currentOffset = 0
val current = _state.value
if (current is HistoryState.Success) {
_state.value = current.copy(isRefreshing = true)
} else {
_state.value = HistoryState.Loading
}
loadPage()
}
fun loadMore() {
if (loadJob?.isActive == true) return
val current = _state.value
if (current is HistoryState.Success && !current.canLoadMore) return
loadPage()
}
private fun loadPage() {
loadJob = viewModelScope.launch {
runCatching {
repo.getPayments(
offset = currentOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
}.onSuccess { newPage ->
val canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE
val existing = (_state.value as? HistoryState.Success)?.payments ?: emptyList()
val combined = if (currentOffset == 0) newPage else existing + newPage
val deduplicated = combined.distinctBy { it.paymentHash }
currentOffset += newPage.size
val previousCount = existing.size
val newCount = deduplicated.size
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
when {
previousCount == 0 -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cold load — got ${newPage.size} payments")
newCount > previousCount -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] ${newCount - previousCount} new payment(s) since cache")
else -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cache was up to date — no new payments")
}
}
_state.value = HistoryState.Success(
payments = deduplicated,
canLoadMore = canLoadMore,
isRefreshing = false
)
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
paymentCache.savePayments(deduplicated)
}
}.onFailure { e ->
val current = _state.value
if (current is HistoryState.Success) {
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] Keeping cached list — ${e.message}")
_state.value = current.copy(isRefreshing = false)
} else {
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] No cache to fall back on — ${e.message}")
_state.value = HistoryState.Error(e.message ?: "Failed to load payments")
}
}
}
}
// ── Payment detail ────────────────────────────────────────────────────────
private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
val detailState: StateFlow<DetailState> = _detailState
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
paymentCache.getCachedDetail(checkingId)?.let {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
_detailState.value = DetailState.Success(it)
return
}
if (record != null) {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
_detailState.value = DetailState.Partial(record)
} else {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
_detailState.value = DetailState.Loading
}
viewModelScope.launch {
runCatching {
repo.getPaymentDetail(checkingId)
}.onSuccess { detail ->
Log.d("HistoryViewModel", "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
paymentCache.saveDetail(checkingId, detail)
_detailState.value = DetailState.Success(detail)
}.onFailure { e ->
Log.w("HistoryViewModel", "PAYMENTS [DETAIL ERR] $checkingId${e.message}")
if (_detailState.value !is DetailState.Partial) {
_detailState.value = DetailState.Error(e.message ?: "Failed to load payment")
}
}
}
}
fun clearDetail() {
_detailState.value = DetailState.Idle
}
// ── Destination pubkey + alias resolution ─────────────────────────────────
val nodeAliases: StateFlow<Map<String, String>> = aliasRepo.aliases
private val _destinationState = MutableStateFlow<Map<String, String?>>(emptyMap())
val destinationState: StateFlow<Map<String, String?>> = _destinationState
fun resolveDestination(bolt11: String, paymentHash: String) {
if (_destinationState.value.containsKey(paymentHash)) return
viewModelScope.launch {
val pubkey = runCatching { repo.decodeBolt11(bolt11) }
.getOrNull()
?.payee
_destinationState.update { it + (paymentHash to pubkey) }
if (pubkey != null) aliasRepo.resolve(pubkey)
}
}
}
// ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit: StateFlow<Double?>
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return HistoryViewModel(
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit
) as T
}
}
@@ -0,0 +1,433 @@
package com.example.gudariwallet.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.compose.material3.ExperimentalMaterial3Api
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.draw.alpha
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.example.gudariwallet.util.formatFiat
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit
) {
val balanceState by vm.balanceState.collectAsState()
val balanceHidden by vm.balanceHidden.collectAsState()
var eyeVisible by remember { mutableStateOf(false) }
LaunchedEffect(balanceHidden) {
eyeVisible = true
delay(1_500)
eyeVisible = false
}
val eyeAlpha by animateFloatAsState(
targetValue = if (eyeVisible) 1f else 0f,
animationSpec = tween(durationMillis = 400),
label = "eyeAlpha"
)
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = "Loading balance…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.alpha(eyeAlpha)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (balanceHidden) "••••••" else "${s.sats}",
style = MaterialTheme.typography.displayLarge,
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.weight(1f, fill = false)
.clickable(
onClick = { vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) "Reveal balance" else "Hide balance",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.size(20.dp))
}
Text(
text = "sats",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(2.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "≈ ••••"
else "${formatFiat(s.fiatAmount, s.fiatCurrency)}",
style = MaterialTheme.typography.bodyMedium,
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(2.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(start = 0.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
) {
Text(
text = selectedCurrency ?: "",
style = MaterialTheme.typography.bodyMedium,
color = if (selectedCurrency != null)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
// Triangle only shown when null — signals "tap to set a currency"
if (selectedCurrency == null) {
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Change currency",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.size(14.dp)
)
}
}
}
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated)
}
Text(
text = "Updated $updatedLabel",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onHistoryClick) {
Text(
text = "View History",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else "${s.staleSats}",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = "sats",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
"Could not refresh — pull down to try again"
else
"Could not load balance — pull down to try again",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
}
private fun formatLastUpdated(epochMs: Long): String {
val ageMs = System.currentTimeMillis() - epochMs
return when {
ageMs < 60_000L -> "just now"
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
else -> {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CurrencyPickerSheet(
currencies : List<String>,
isLoading : Boolean, // NEW — true while list is being fetched
selectedCurrency: String?,
onSelect : (String?) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var query by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
// Filter list client-side — no network, instant response
// Only active once the list is populated
val filtered = remember(query, currencies) {
if (query.isBlank()) currencies
else currencies.filter { it.contains(query.trim(), ignoreCase = true) }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(modifier = Modifier.fillMaxWidth()) {
// ── Header ────────────────────────────────────────────────────────
Text(
text = "Select currency",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
// ── "None" option — always pinned at top, never filtered ──────────
ListItem(
headlineContent = {
Text(
text = "None",
style = MaterialTheme.typography.bodyLarge,
color = if (selectedCurrency == null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
Text(
text = "Hide fiat equivalent",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = if (selectedCurrency == null) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}) else null,
modifier = Modifier.clickable { onSelect(null) }
)
HorizontalDivider(thickness = 1.dp)
// ── Search field — hidden while loading ───────────────────────────
if (!isLoading) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search…") },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { focusManager.clearFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
// ── Body — spinner OR list OR empty state ─────────────────────────
when {
isLoading -> {
// Spinner while list is being fetched on first open
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(28.dp))
}
}
filtered.isEmpty() -> {
// No results after search
Text(
text = if (query.isBlank()) "No currencies available"
else "No currencies match \"$query\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 32.dp)
) {
items(filtered, key = { it }) { code ->
val isSelected = code == selectedCurrency
ListItem(
headlineContent = {
Text(
text = code,
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
},
trailingContent = if (isSelected) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}) else null,
modifier = Modifier.clickable { onSelect(code) }
)
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
}
}
@@ -0,0 +1,245 @@
package com.example.gudariwallet.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
// ─── Permission state model ───────────────────────────────────────────────────
sealed class NotificationPermissionState {
data object NeverAsked : NotificationPermissionState()
data object Rationale : NotificationPermissionState() // asked once, denied
data object PermanentlyDenied: NotificationPermissionState() // "don't ask again"
data object Granted : NotificationPermissionState()
}
// ─── Prefs helper ─────────────────────────────────────────────────────────────
/**
* Persists a flag so we can distinguish between:
* - "never asked" → show the first-time request screen
* - "asked and denied" → show rationale
* - "permanently denied"→ send user to system settings
*/
object PermissionPrefs {
private const val PREFS_NAME = "lnbits_prefs"
private const val KEY = "notification_permission_requested"
fun hasRequestedBefore(context: Context): Boolean =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean(KEY, false)
fun markRequested(context: Context) =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY, true).apply()
}
// ─── Main composable ──────────────────────────────────────────────────────────
/**
* Handles the full POST_NOTIFICATIONS runtime permission flow.
*
* On Android 12 and below this permission doesn't exist — we skip straight
* to onPermissionGranted since notifications are always allowed.
*
* @param hasRequestedBefore Read from PermissionPrefs — distinguishes
* "never asked" from "permanently denied"
* @param onPermissionGranted Called when permission is granted
* @param onPermissionDenied Called when user skips or permanently denies
* @param onRequestedFirstTime Called after the first system prompt fires —
* persists the flag via PermissionPrefs
*/
@Composable
fun NotificationPermissionScreen(
hasRequestedBefore: Boolean,
onPermissionGranted: () -> Unit,
onPermissionDenied: () -> Unit,
onRequestedFirstTime: () -> Unit
) {
// Android 12 and below — POST_NOTIFICATIONS doesn't exist, skip entirely
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
LaunchedEffect(Unit) { onPermissionGranted() }
return
}
val context = LocalContext.current
// Tracks whether the system already granted the permission
// (e.g. user granted it in a previous session)
var permissionState by remember {
mutableStateOf<NotificationPermissionState>(
when {
!hasRequestedBefore -> NotificationPermissionState.NeverAsked
else -> NotificationPermissionState.Rationale
}
)
}
// The system permission launcher
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionState = if (granted) {
NotificationPermissionState.Granted
} else {
// If we've already asked before and they denied again → permanent denial
if (hasRequestedBefore) NotificationPermissionState.PermanentlyDenied
else NotificationPermissionState.Rationale
}
onRequestedFirstTime()
}
// React to state changes
LaunchedEffect(permissionState) {
when (permissionState) {
is NotificationPermissionState.Granted -> onPermissionGranted()
else -> { /* handled by UI below */ }
}
}
// ── UI ────────────────────────────────────────────────────────────────────
when (permissionState) {
is NotificationPermissionState.NeverAsked,
is NotificationPermissionState.Rationale -> {
PermissionRequestCard(
onAllow = {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
},
onSkip = onPermissionDenied
)
}
is NotificationPermissionState.PermanentlyDenied -> {
PermanentlyDeniedCard(
onOpenSettings = {
// Send user to the app's system notification settings
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
},
onSkip = onPermissionDenied
)
}
is NotificationPermissionState.Granted -> {
// LaunchedEffect above handles this — show nothing
}
}
}
// ─── Sub-composables ──────────────────────────────────────────────────────────
@Composable
private fun PermissionRequestCard(
onAllow: () -> Unit,
onSkip: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = "Stay notified of payments",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Allow notifications so you know the moment a Lightning payment arrives, even when the app is in the background.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(40.dp))
Button(
onClick = onAllow,
modifier = Modifier.fillMaxWidth()
) {
Text("Allow Notifications")
}
Spacer(Modifier.height(12.dp))
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text("Not Now")
}
}
}
@Composable
private fun PermanentlyDeniedCard(
onOpenSettings: () -> Unit,
onSkip: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.NotificationsOff,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(24.dp))
Text(
text = "Notifications are blocked",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "You've permanently denied notifications. To receive payment alerts, enable them in system settings.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(40.dp))
Button(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth()
) {
Text("Open Settings")
}
Spacer(Modifier.height(12.dp))
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text("Skip")
}
}
}
@@ -0,0 +1,108 @@
package com.example.gudariwallet.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import com.example.gudariwallet.security.SecretStore
@Composable
fun OnboardingScreen(
secretStore: SecretStore,
onComplete: () -> Unit
) {
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
var invoiceKey by remember { mutableStateOf("") }
var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Connect to LNbits", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
"Enter your LNbits server URL and API keys.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://bitcointxoko.org") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = invoiceKey,
onValueChange = { invoiceKey = it },
label = { Text("Invoice Key (read-only)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = PasswordVisualTransformation()
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = adminKey,
onValueChange = { adminKey = it },
label = { Text("Admin Key (for sending)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (adminVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { adminVisible = !adminVisible }) {
Icon(
imageVector = if (adminVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = if (adminVisible) "Hide" else "Show"
)
}
}
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) {
error = "All fields are required."
return@Button
}
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
onComplete()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Connect")
}
}
}
@@ -0,0 +1,110 @@
package com.example.gudariwallet.ui
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Shown when an invoice has been paid.
* Displays a pulsing checkmark, the amount received, and the memo.
*
* @param amountSats Amount received in satoshis
* @param memo Invoice memo
* @param onDone Called when the user taps "Done" — navigates back to Home
* @param onReceiveMore Called when the user taps "Receive More" — resets the screen
*/
@Composable
fun PaymentSuccessScreen(
amountSats: Long,
memo: String,
onDone: () -> Unit,
onReceiveMore: () -> Unit
) {
// Pulsing scale animation on the checkmark icon
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.08f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, easing = EaseInOut),
repeatMode = RepeatMode.Reverse
),
label = "checkScale"
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// ── Animated checkmark ────────────────────────────────────────────
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Payment received",
tint = MaterialTheme.colorScheme.tertiary, // green role
modifier = Modifier
.size(96.dp)
.scale(scale)
)
Spacer(Modifier.height(32.dp))
// ── Title ─────────────────────────────────────────────────────────
Text(
text = "Payment Received!",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(16.dp))
// ── Amount ────────────────────────────────────────────────────────
Text(
text = "+$amountSats sats",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center
)
// ── Memo ──────────────────────────────────────────────────────────
if (memo.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(
text = memo,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(48.dp))
// ── Actions ───────────────────────────────────────────────────────
Button(
onClick = onDone,
modifier = Modifier.fillMaxWidth()
) {
Text("Done")
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onReceiveMore,
modifier = Modifier.fillMaxWidth()
) {
Text("Receive More")
}
}
}
@@ -0,0 +1,42 @@
package com.example.gudariwallet.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
/**
* Launches the zxing-android-embedded scanner activity and calls [onScanned]
* with the raw result string, or [onDismiss] if the user cancels.
*
* Camera permission is handled internally by the scanner activity — no
* Accompanist boilerplate needed here.
*/
@Composable
fun QrScannerScreen(
onScanned: (String) -> Unit,
onDismiss: () -> Unit
) {
val launcher = rememberLauncherForActivityResult(ScanContract()) { result ->
val content = result.contents
if (content != null) {
onScanned(content)
} else {
onDismiss()
}
}
// Launch the scanner as soon as this composable enters the composition.
// The scanner activity takes over the screen; when it finishes the result
// callback above fires and we navigate away.
LaunchedEffect(Unit) {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("") // no bottom prompt text
setBeepEnabled(false) // wallet apps are usually silent
setOrientationLocked(true)
}
launcher.launch(options)
}
}
@@ -0,0 +1,687 @@
package com.example.gudariwallet.ui
import android.app.Activity
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.gudariwallet.ui.components.UnitWheelPicker
import com.example.gudariwallet.ui.receive.AmountDisplay
import com.example.gudariwallet.ui.receive.QrDisplayCard
import com.example.gudariwallet.util.fiatLabel
import kotlinx.coroutines.delay
@Composable
fun ReceiveScreen(vm: WalletViewModel) {
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
val fiatRate by vm.fiatSatsPerUnit.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) {
onDispose {
val lp = window.attributes
lp.screenBrightness = originalBrightness
window.attributes = lp
vm.resetReceiveState()
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (receiveState !is ReceiveState.InvoiceReady) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(16.dp))
when (val state = receiveState) {
is ReceiveState.Idle,
is ReceiveState.AwaitingInvoice -> {
ReceiveIdleContent(
fiatRate = fiatRate,
fiatCurrency = fiatCurrency,
lightningAddress = lightningAddress,
showAddress = showAddress,
onToggleAddress = { showAddress = !showAddress },
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
isLoading = receiveState is ReceiveState.AwaitingInvoice,
onCreateInvoice = { amountSats, memo ->
vm.createInvoice(amountSats, memo)
}
)
}
is ReceiveState.LnurlWithdrawReady -> {
LnurlWithdrawSheet(
state = state,
onConfirm = { amountSats ->
vm.executeWithdraw(
k1 = state.k1,
callback = state.callback,
amountSats = amountSats,
memo = state.defaultDescription
)
},
onDismiss = { vm.resetReceiveState() }
)
}
is ReceiveState.PaymentReceived -> {
ReceiveSuccessContent(
amountSats = state.amountSats,
memo = state.memo,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency,
onDone = { vm.resetReceiveState() }
)
}
is ReceiveState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp)
) {
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
Text(
text = state.message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { vm.resetReceiveState() }) {
Text("Try Again")
}
}
}
}
else -> Unit
}
}
}
if (receiveState is ReceiveState.InvoiceReady) {
ReceiveInvoiceContent(
state = receiveState as ReceiveState.InvoiceReady,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
onReset = { vm.resetReceiveState() }
)
}
}
}
@Composable
private fun LnurlWithdrawSheet(
state : ReceiveState.LnurlWithdrawReady,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit
) {
val fixedAmount = state.minSats == state.maxSats
var amountText by remember { mutableStateOf(state.maxSats.toString()) }
var amountError by remember { mutableStateOf<String?>(null) }
fun validate(): Long? {
val parsed = amountText.trim().toLongOrNull()
return when {
parsed == null || parsed <= 0 -> { amountError = "Enter a valid amount"; null }
parsed < state.minSats -> { amountError = "Minimum is ${state.minSats} sats"; null }
parsed > state.maxSats -> { amountError = "Maximum is ${state.maxSats} sats"; null }
else -> { amountError = null; parsed }
}
}
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(Modifier.size(12.dp))
Column {
Text(
text = "Withdraw from",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = state.domain,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
}
}
if (state.defaultDescription.isNotBlank()) {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)) {
Text(
text = state.defaultDescription,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp)
)
}
}
if (fixedAmount) {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Amount",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "${state.maxSats} sats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
} else {
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it; amountError = null },
label = { Text("Amount (sats)") },
supportingText = {
if (amountError != null) Text(amountError!!, color = MaterialTheme.colorScheme.error)
else Text("${state.minSats} ${state.maxSats} sats")
},
isError = amountError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.weight(1f))
Button(
onClick = {
val amount = if (fixedAmount) state.maxSats else validate()
if (amount != null) onConfirm(amount)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Withdraw ${if (fixedAmount) "${state.maxSats} sats" else ""}")
}
TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text("Cancel")
}
}
}
private enum class AmountUnit { SATS, FIAT }
@Composable
private fun ReceiveIdleContent(
fiatRate : Double?,
fiatCurrency : String?,
lightningAddress : String?,
showAddress : Boolean,
onToggleAddress : () -> Unit,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
isLoading : Boolean,
onCreateInvoice : (Long, String) -> Unit
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
// Wrap the toggle so that opening the address card always clears focus + keyboard
val onToggleAddressWithDismiss = {
if (!showAddress) {
// About to show address — dismiss keyboard and clear focus first
keyboardController?.hide()
focusManager.clearFocus()
}
onToggleAddress()
}
var step by remember { mutableIntStateOf(1) }
var confirmedAmountText by remember { mutableStateOf("") }
var confirmedUnit by remember { mutableStateOf(AmountUnit.SATS) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.height(8.dp))
// ── Header row ────────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Receive",
style = MaterialTheme.typography.headlineSmall
)
if (lightningAddress != null) {
IconButton(onClick = onToggleAddressWithDismiss) { // ← use wrapped version
Text(
text = "@",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (showAddress) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ── Lightning address card ────────────────────────────────────────────
AnimatedVisibility(visible = showAddress && lightningAddress != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = lightningAddress!!,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
QrDisplayCard(
content = lightningAddress!!,
contentDescription = "Lightning address QR code",
clipLabel = "Lightning Address",
shareTitle = "Share Lightning Address",
textToCopy = lightningAddress!!,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness
)
}
}
}
if (step == 1) {
AmountStepContent(
fiatRate = fiatRate,
fiatCurrency = fiatCurrency,
addressShowing = showAddress,
onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper
onNext = { text, unit ->
confirmedAmountText = text
confirmedUnit = unit
step = 2
}
)
} else {
val confirmedSats = when (confirmedUnit) {
AmountUnit.SATS -> confirmedAmountText.toDouble().toLong()
AmountUnit.FIAT -> (confirmedAmountText.toDouble() * (fiatRate ?: 1.0)).toLong()
}
MemoStepContent(
confirmedSats = confirmedSats,
fiatLabel = fiatLabel(confirmedSats, fiatRate, fiatCurrency),
isLoading = isLoading,
onCreateInvoice = { memo -> onCreateInvoice(confirmedSats, memo) },
onBack = { step = 1 }
)
}
}
}
@Composable
private fun AmountStepContent(
fiatRate : Double?,
fiatCurrency : String?,
addressShowing : Boolean,
onCollapseAddress : () -> Unit,
onNext : (amountText: String, activeUnit: AmountUnit) -> Unit
) {
val showFiatToggle = fiatRate != null && fiatCurrency != null
var amountText by remember { mutableStateOf("") }
var amountError by remember { mutableStateOf<String?>(null) }
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
val parsedAmount = amountText.trim().toDoubleOrNull()
val amountValid = parsedAmount != null && parsedAmount > 0
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
if (!showFiatToggle) return@remember null
val number = amountText.trim().toDoubleOrNull() ?: return@remember null
when (activeUnit) {
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
AmountUnit.FIAT -> "${(number * fiatRate!!).toLong()} sats"
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it; amountError = null },
placeholder = {
Text(if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00")
},
isError = amountError != null,
supportingText = when {
amountError != null -> { { Text(amountError!!, color = MaterialTheme.colorScheme.error) } }
conversionLabel != null -> { { Text(conversionLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
else -> null
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier
.weight(1f)
.onFocusChanged { if (it.isFocused && addressShowing) onCollapseAddress() }
)
if (showFiatToggle) {
UnitWheelPicker(
units = listOf("sats", fiatCurrency),
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
onIndexSelected = { newIndex ->
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
if (newUnit != activeUnit) {
activeUnit = newUnit
amountText = ""
}
},
modifier = Modifier.width(64.dp)
)
}
}
Button(
onClick = {
if (!amountValid) amountError = "Enter a valid amount"
else { amountError = null; onNext(amountText, activeUnit) }
},
enabled = amountText.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("Next")
}
}
}
@Composable
private fun MemoStepContent(
confirmedSats : Long,
fiatLabel : String?,
isLoading : Boolean,
onCreateInvoice : (memo: String) -> Unit,
onBack : () -> Unit
) {
var memo by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AmountDisplay(amountSats = confirmedSats, fiatLabel = fiatLabel)
OutlinedTextField(
value = memo,
onValueChange = { memo = it },
label = { Text("Memo (optional)") },
placeholder = { Text("What's this for?") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Button(
onClick = { onCreateInvoice(memo.trim()) },
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Creating invoice…" else "Create Invoice")
}
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("← Back")
}
}
}
@Composable
private fun ReceiveInvoiceContent(
state : ReceiveState.InvoiceReady,
fiatRate : Double?,
fiatCurrency : String?,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
onReset : () -> Unit
) {
var secondsLeft by remember {
mutableLongStateOf(
(state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L)
)
}
LaunchedEffect(state.expiresAt) {
while (secondsLeft > 0L) {
delay(1_000L)
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
.coerceAtLeast(0L)
}
}
val isExpired = secondsLeft == 0L
val expiryLabel = "%d:%02d".format(secondsLeft / 60, secondsLeft % 60)
val fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ── Amount ────────────────────────────────────────────────────────────
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel)
if (state.memo.isNotBlank()) {
Text(
text = state.memo,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// ── QR — weight(1f) claims all remaining vertical space ───────────────
// modifier targets the QrDisplayCard's outer Column (gives it the height)
// qrModifier targets the Image inside (fills that height, stays square)
QrDisplayCard(
content = state.bolt11.uppercase(),
contentDescription = "Lightning invoice QR code",
clipLabel = "Invoice",
shareTitle = "Share Invoice",
textToCopy = state.bolt11,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
modifier = Modifier
.weight(1f) // outer Column gets all remaining height
.fillMaxWidth(),
qrModifier = Modifier
.fillMaxWidth() // Image fills the column width
.weight(1f) // Image also takes remaining height inside QrDisplayCard's Column
)
// ── Status ────────────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = "Waiting for payment…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = expiryLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "Invoice expired",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
TextButton(
onClick = onReset,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isExpired) "New Invoice" else "Cancel")
}
}
}
@Composable
private fun ReceiveSuccessContent(
amountSats : Long,
memo : String?,
fiatRate : Double?,
fiatCurrency : String?,
onDone : () -> Unit
) {
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(16.dp))
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
// AmountDisplay renders both the sats line and the fiat subtitle
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(memo, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(32.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text("Done")
}
}
}
@@ -0,0 +1,218 @@
package com.example.gudariwallet.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.gudariwallet.ui.send.Bolt11ConfirmCard
import com.example.gudariwallet.ui.send.LnurlInvoiceConfirmCard
import com.example.gudariwallet.ui.send.LnurlPayForm
import com.example.gudariwallet.util.SendInputDetector
import com.example.gudariwallet.util.feePpm
import kotlinx.coroutines.launch
@Composable
fun SendScreen(vm: WalletViewModel) {
val sendState: SendState by vm.sendState.collectAsStateWithLifecycle()
var input by remember { mutableStateOf("") }
val context = LocalContext.current
val activity = context as FragmentActivity
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
// Read rate directly from VM cache — same source HomeTab uses.
// Both are null when no currency is selected; fiat display is suppressed.
val fiatRate = vm.fiatRate // Double?
val fiatCurrency = vm.fiatCurrency // String?
DisposableEffect(Unit) { onDispose { vm.resetSendState() } }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(16.dp))
Text("Send", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(24.dp))
if (sendState !is SendState.PaymentSent) {
OutlinedTextField(
value = input,
onValueChange = { input = SendInputDetector.normalize(it) },
label = { Text("Invoice, LNURL, or Lightning Address") },
placeholder = { Text("lnbc… / lnurl1… / user@domain.com") },
modifier = Modifier.fillMaxWidth(),
enabled = sendState is SendState.Idle || sendState is SendState.Error,
singleLine = false,
maxLines = 4,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = { if (input.isNotBlank()) vm.scan(input) }
),
)
Spacer(Modifier.height(8.dp))
}
when (val state = sendState) {
is SendState.Idle -> {
Button(
onClick = {
scope.launch {
val text = clipboard.getClipEntry()
?.clipData
?.getItemAt(0)
?.text
?.toString()
if (!text.isNullOrBlank()) {
input = SendInputDetector.normalize(text)
vm.scan(text)
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.ContentPaste,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Paste")
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { if (input.isNotBlank()) vm.scan(input) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text("Continue") }
}
is SendState.Scanning -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text("Resolving…")
}
is SendState.Bolt11Decoded -> {
Bolt11ConfirmCard(
state = state,
activity = activity,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
)
}
is SendState.LnurlReady -> {
LnurlPayForm(
state = state,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
)
}
is SendState.LnurlInvoiceReady -> {
LnurlInvoiceConfirmCard(
state = state,
activity = activity,
vm = vm,
fiatRate = fiatRate,
fiatCurrency = fiatCurrency
)
}
is SendState.Paying -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text("Sending payment…")
}
is SendState.PaymentSent -> {
Spacer(Modifier.height(32.dp))
Text(
text = "",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
Text("Payment sent!", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(4.dp))
Text(
text = "${state.amountSats} sats",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
val feeText = when {
state.feeSats == null || state.feeSats == 0L -> "No fees"
else -> {
val ppm = feePpm(
amountMsat = state.amountSats * 1_000L,
feeMsat = state.feeSats * 1_000L
)
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
"Routing fee: ${state.feeSats} sat${if (state.feeSats == 1L) "" else "s"}$ppmSuffix"
}
}
Text(
text = feeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Button(
onClick = { vm.resetSendState(); input = "" },
modifier = Modifier.fillMaxWidth()
) { Text("Send Another") }
}
is SendState.Error -> {
Spacer(Modifier.height(8.dp))
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Button(
onClick = { if (input.isNotBlank()) vm.scan(input) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text("Try Again") }
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { vm.resetSendState(); input = "" },
modifier = Modifier.fillMaxWidth()
) { Text("Clear") }
}
}
}
}
@@ -0,0 +1,150 @@
package com.example.gudariwallet.ui
import com.example.gudariwallet.api.LnurlScanResponse
import com.example.gudariwallet.api.PaymentDetailResponse
import com.example.gudariwallet.api.PaymentRecord
import com.example.gudariwallet.api.RouteHintHop
import com.example.gudariwallet.util.RouteHintRisk
import com.example.gudariwallet.util.SendInputType
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
sealed class ReceiveState {
data object Idle : ReceiveState()
data object AwaitingInvoice : ReceiveState()
data class InvoiceReady(
val bolt11 : String,
val paymentHash : String,
val amountSats : Long,
val memo : String,
val expiresAt : Long
) : ReceiveState()
data class PaymentReceived(
val amountSats: Long,
val memo : String?
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady(
val k1 : String,
val callback : String,
val defaultDescription : String,
val minSats : Long,
val maxSats : Long,
val domain : String,
val lnurl : String // original encoded string, for display
) : ReceiveState()
}
sealed class SendState {
data object Idle : SendState()
data object Scanning : SendState()
data class Bolt11Decoded(
override val bolt11 : String,
override val amountSats : Long,
override val memo : String,
override val payee : String? = null,
override val nodeAlias : String? = null,
override val routeRisk : RouteHintRisk? = null,
override val allRouteHints : List<RouteHintHop> = emptyList(),
override val routeHintAliases : Map<String, String> = emptyMap()
) : SendState(), DecodedInvoice
data class LnurlReady(
val callback : String,
val description : String,
val domain : String,
val minSats : Long,
val maxSats : Long,
val commentAllowed : Int,
val lnurl : String,
val rawRes : LnurlScanResponse
) : SendState()
data class LnurlInvoiceReady(
override val bolt11 : String,
override val amountSats : Long,
override val memo : String,
val domain : String,
val lnurl : String,
val rawRes : LnurlScanResponse,
val amountMsat : Long,
val comment : String?,
override val payee : String? = null,
override val nodeAlias : String? = null,
override val routeRisk : RouteHintRisk? = null,
override val allRouteHints : List<RouteHintHop> = emptyList(),
override val routeHintAliases : Map<String, String> = emptyMap()
) : SendState(), DecodedInvoice
data object Paying : SendState()
data class PaymentSent(
val amountSats : Long,
val feeSats : Long? = null
) : SendState()
data class Error(val message: String) : SendState()
}
// ── History list states ────────────────────────────────────────────────────────
sealed interface HistoryState {
data object Loading : HistoryState
data class Success(
val payments : List<PaymentRecord>,
val canLoadMore : Boolean,
val isRefreshing: Boolean = false
) : HistoryState
data class Error(val message: String) : HistoryState
}
// ── Payment detail states ──────────────────────────────────────────────────────
sealed interface DetailState {
data object Idle : DetailState
data object Loading : DetailState
data class Partial(val record: PaymentRecord) : DetailState
data class Success(val detail: PaymentDetailResponse) : DetailState
data class Error(val message: String) : DetailState
}
// ── Balance states ─────────────────────────────────────────────────────────────
sealed interface BalanceState {
data object Loading : BalanceState
data class Success(
val sats : Long,
val isRefreshing: Boolean = false,
val lastUpdated : Long = System.currentTimeMillis(),
val fiatAmount : Double? = null,
val fiatCurrency : String? = null
) : BalanceState
data class Error(
val message : String,
val staleSats: Long? = null
) : BalanceState
}
// ── Clipboard offer states ─────────────────────────────────────────────────────
sealed interface ClipboardOfferState {
data object None : ClipboardOfferState
data class Detected(
val raw : String,
val inputType: SendInputType
) : ClipboardOfferState
}
/**
* Shared interface for send states that represent a fully decoded invoice
* ready for user confirmation. Allows ConfirmCardContent to accept either
* Bolt11Decoded or LnurlInvoiceReady without knowing which it is.
*/
interface DecodedInvoice {
val amountSats : Long
val memo : String
val payee : String?
val nodeAlias : String?
val bolt11 : String
val routeRisk : RouteHintRisk?
val allRouteHints : List<RouteHintHop>
val routeHintAliases : Map<String, String>
}
@@ -0,0 +1,393 @@
package com.example.gudariwallet.ui
import android.content.ClipboardManager
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.example.gudariwallet.MainActivity
import com.example.gudariwallet.util.SendInputType
import kotlinx.coroutines.delay
// ── Tab destinations ──────────────────────────────────────────────────────────
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
}
private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
@Composable
fun WalletScreen(
vm : WalletViewModel,
pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?> // SESSION 09: deep link / intent URI
) {
val navController = rememberNavController()
val context = LocalContext.current
val historyFactory = remember {
HistoryViewModelFactory(
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
LaunchedEffect(hash) {
if (hash != null) {
navController.navigate("payment_detail/$hash") {
launchSingleTop = true
}
pendingPaymentHash.value = null
}
}
val activity = context as MainActivity
LaunchedEffect(Unit) {
activity.windowFocusEvents.collect {
val cm = context.getSystemService(ClipboardManager::class.java)
val text = cm?.primaryClip?.getItemAt(0)
?.coerceToText(context)?.toString()
vm.checkClipboard(text)
}
}
LaunchedEffect(Unit) {
vm.navigateToReceive.collect {
Log.d("WalletScreen", "LNURL-WITHDRAW [NAV RECEIVED] switching to Receive tab")
navController.navigate(TabItem.Receive.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
val intentUri = pendingPaymentUri.value
LaunchedEffect(intentUri) {
if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null // consume immediately
vm.scan(intentUri)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
}
}
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
// Auto-dismiss banner after 8 seconds
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
vm.dismissClipboardOffer()
}
}
Scaffold(
bottomBar = {
if (showBottomBar) {
// ── Floating island nav bar: Receive | [Scan] | Send ──────────
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 6.dp,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Receive
NavigationBarItem(
selected = currentRoute == TabItem.Receive.route,
icon = { Icon(TabItem.Receive.icon, contentDescription = TabItem.Receive.label) },
label = { Text(TabItem.Receive.label) },
modifier = Modifier.weight(1f),
onClick = {
if (currentRoute != TabItem.Receive.route) {
navController.navigate(TabItem.Receive.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
)
// Scan — centre, elevated, not a nav destination
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
FilledIconButton(
onClick = { navController.navigate(ROUTE_SCANNER) },
modifier = Modifier.size(52.dp),
shape = CircleShape
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = "Scan",
modifier = Modifier.size(26.dp)
)
}
}
// Send
NavigationBarItem(
selected = currentRoute == TabItem.Send.route,
icon = { Icon(TabItem.Send.icon, contentDescription = TabItem.Send.label) },
label = { Text(TabItem.Send.label) },
modifier = Modifier.weight(1f),
onClick = {
if (currentRoute != TabItem.Send.route) {
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
)
}
}
}
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
NavHost(
navController = navController,
startDestination = ROUTE_HOME,
modifier = if (isFullScreenDestination)
Modifier.fillMaxSize()
else
Modifier
.fillMaxSize()
.statusBarsPadding()
) {
composable(ROUTE_HOME) {
HomeTab(
vm = vm,
onHistoryClick = {
navController.navigate(ROUTE_HISTORY) {
launchSingleTop = true
}
}
)
}
composable(TabItem.Receive.route) { ReceiveScreen(vm) }
composable(TabItem.Send.route) { SendScreen(vm) }
// Scanner — full screen, no bottom chrome
composable(ROUTE_SCANNER) {
QrScannerScreen(
onScanned = { scanned ->
navController.popBackStack()
vm.scan(scanned)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
},
onDismiss = { navController.popBackStack() }
)
}
// History — full screen, no bottom chrome, close → home
composable(
ROUTE_HISTORY,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
HistoryScreen(
vm = historyVm,
onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onPaymentClick = { payment ->
navController.navigate("payment_detail/${payment.paymentHash}")
}
)
}
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry ->
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val historyState by historyVm.state.collectAsState()
val payment = (historyState as? HistoryState.Success)
?.payments
?.firstOrNull { it.paymentHash == paymentHash }
if (payment != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(paymentHash) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
// ── Clipboard banner — floats over content, slides in from top ────
AnimatedVisibility(
visible = clipboardOffer is ClipboardOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f)
.fillMaxWidth()
.statusBarsPadding()
) {
if (clipboardOffer is ClipboardOfferState.Detected) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
else -> "Payment"
}
val subLabel = when (offer.inputType) {
is SendInputType.Lnurl -> "Tap to open"
else -> "Tap to pay"
}
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable {
vm.dismissClipboardOffer()
vm.scan(offer.raw)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.ContentPaste, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"$label detected in clipboard",
style = MaterialTheme.typography.bodyMedium
)
Text(
subLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
IconButton(onClick = { vm.dismissClipboardOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
package com.example.gudariwallet.ui
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.gudariwallet.api.LNbitsClient
import com.example.gudariwallet.data.LNbitsNodeAliasRepository
import com.example.gudariwallet.data.LNbitsWalletRepository
import com.example.gudariwallet.data.LnurlCacheRepository
import com.example.gudariwallet.data.PaymentCacheRepository
import com.example.gudariwallet.security.EncryptedSecretStore
import com.example.gudariwallet.service.NodeAliasService
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app)
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
val api = LNbitsClient.create(baseUrl)
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST")
return WalletViewModel(
context = app,
repo = repo,
aliasRepo = aliasRepo,
lnurlCache = lnurlCache,
paymentCache = paymentCache
) as T
}
}
@@ -0,0 +1,106 @@
package com.example.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
)
}
}
@@ -0,0 +1,78 @@
package com.example.gudariwallet.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UnitWheelPicker(
units : List<String>,
selectedIndex : Int,
onIndexSelected : (Int) -> Unit,
modifier : Modifier = Modifier // caller controls width entirely
) {
val itemHeightDp = 32.dp
val totalHeightDp = itemHeightDp * 3
val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)
val snapBehavior = rememberSnapFlingBehavior(lazyListState = listState)
LaunchedEffect(listState.isScrollInProgress) {
if (!listState.isScrollInProgress) {
val realIndex = listState.firstVisibleItemIndex.coerceIn(0, units.lastIndex)
if (realIndex != selectedIndex) onIndexSelected(realIndex)
}
}
LazyColumn(
state = listState,
flingBehavior = snapBehavior,
modifier = modifier.height(totalHeightDp), // width comes from caller
horizontalAlignment = Alignment.CenterHorizontally
) {
item(key = "ghost_top") {
Spacer(modifier = Modifier.height(itemHeightDp))
}
items(count = units.size, key = { it }) { index ->
val isCentre = index == selectedIndex
val alpha = if (isCentre) 1f else 0.55f
val fontWeight = if (isCentre) FontWeight.SemiBold else FontWeight.Normal
Box(
modifier = Modifier
.height(itemHeightDp)
.fillMaxWidth()
.graphicsLayer { this.alpha = alpha },
contentAlignment = Alignment.Center
) {
Text(
text = units[index],
style = MaterialTheme.typography.bodyLarge,
fontWeight = fontWeight,
color = if (isCentre) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
item(key = "ghost_bottom") {
Spacer(modifier = Modifier.height(itemHeightDp))
}
}
}
@@ -0,0 +1,36 @@
package com.example.gudariwallet.ui.receive
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.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Displays a sats amount in bold with an optional fiat subtitle.
* Used in MemoStepContent, ReceiveInvoiceContent, and ReceiveSuccessContent.
*/
@Composable
internal fun AmountDisplay(
amountSats : Long,
fiatLabel : String?,
style : TextStyle = MaterialTheme.typography.titleLarge
) {
Text(
text = "$amountSats sats",
style = style,
fontWeight = FontWeight.Bold
)
if (fiatLabel != null) {
Spacer(Modifier.height(2.dp))
Text(
text = fiatLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,26 @@
package com.example.gudariwallet.ui.receive
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessHigh
import androidx.compose.material.icons.filled.BrightnessLow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
internal fun BrightnessToggleButton(
isOn : Boolean,
onToggle: () -> Unit
) {
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isOn) Icons.Filled.BrightnessHigh
else Icons.Filled.BrightnessLow,
contentDescription = if (isOn) "Reduce brightness"
else "Boost brightness",
tint = if (isOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,137 @@
package com.example.gudariwallet.ui.receive
import android.content.ClipData
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.gudariwallet.util.IntentUtils
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.launch
import android.graphics.Color
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
/**
* QR image followed by Copy / Share / Brightness action row.
*
* @param content What to encode into QR
* @param contentDescription Accessibility label for the image.
* @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address").
* @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.
*/
@Composable
internal fun QrDisplayCard(
content : String,
contentDescription : String,
clipLabel : String,
shareTitle : String,
textToCopy : String,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
modifier : Modifier = Modifier,
qrModifier : Modifier = Modifier.size(260.dp)
) {
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Image(
bitmap = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = qrModifier
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(clipLabel, textToCopy))
)
}
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy",
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Copy")
}
Button(
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.Share, contentDescription = "Share",
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Share")
}
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness)
}
}
}
private const val QR_BITMAP_SIZE = 512
private fun generateQrBitmap(content: String): Bitmap {
val bits = QRCodeWriter().encode(
content,
BarcodeFormat.QR_CODE,
QR_BITMAP_SIZE,
QR_BITMAP_SIZE
)
return createBitmap(QR_BITMAP_SIZE, QR_BITMAP_SIZE, Bitmap.Config.RGB_565).apply {
for (x in 0 until QR_BITMAP_SIZE)
for (y in 0 until QR_BITMAP_SIZE)
this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE
}
}
@@ -0,0 +1,175 @@
package com.example.gudariwallet.ui.send
import android.content.Intent
import androidx.compose.foundation.layout.Column
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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import com.example.gudariwallet.ui.DecodedInvoice
import com.example.gudariwallet.ui.SendState
import com.example.gudariwallet.ui.WalletViewModel
import com.example.gudariwallet.ui.components.CopyIconButton
import com.example.gudariwallet.ui.components.DetailRow
import com.example.gudariwallet.util.fiatLabel
import com.example.gudariwallet.util.payingToLabel
@Composable
internal fun Bolt11ConfirmCard(
state : SendState.Bolt11Decoded,
activity : FragmentActivity,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
) {
ConfirmCardContent(
title = "Invoice Details",
invoice = state,
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
onPay = {
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
vm.payBolt11(state.bolt11)
}
},
onCancel = { vm.resetSendState() }
)
}
@Composable
internal fun LnurlInvoiceConfirmCard(
state : SendState.LnurlInvoiceReady,
activity : FragmentActivity,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
) {
val label = payingToLabel(state.lnurl, state.domain)
ConfirmCardContent(
title = "Paying to $label",
invoice = state,
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
onPay = {
val label = payingToLabel(state.lnurl, state.domain)
vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") {
vm.payLnurlInvoice(state)
}
},
onCancel = { vm.resetSendState() }
)
}
@Composable
private fun ConfirmCardContent(
title : String,
invoice : DecodedInvoice,
fiatLabel: String?,
onPay : () -> Unit,
onCancel : () -> Unit
) {
val amountSats = invoice.amountSats
val memo = invoice.memo
val nodeAlias = invoice.nodeAlias
val bolt11 = invoice.bolt11
val payee = invoice.payee
val routeRisk = invoice.routeRisk
val allRouteHints = invoice.allRouteHints
val routeHintAliases = invoice.routeHintAliases
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
DetailRow(
label = "Amount",
value = "$amountSats sats",
subtitle = fiatLabel
)
if (memo.isNotBlank()) {
Spacer(Modifier.height(4.dp))
DetailRow("Memo", memo)
}
if (!payee.isNullOrBlank()) {
val ambossUrl = "https://amboss.space/node/${payee}"
val destinationLabel = nodeAlias
?: "${payee.take(8)}${payee.takeLast(8)}"
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Destination",
value = destinationLabel,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
},
trailingContent = {
CopyIconButton("node_id", payee)
}
)
}
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Invoice",
value = bolt11.take(20) + "" + bolt11.takeLast(6),
trailingContent = {
CopyIconButton("lightning_invoice", bolt11)
}
)
if (routeRisk != null) {
Spacer(Modifier.height(12.dp))
RouteHintWarningBanner(
risk = routeRisk,
allRouteHints = allRouteHints,
routeHintAliases = routeHintAliases
)
}
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onPay,
modifier = Modifier.fillMaxWidth()
) {
PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel)
}
Spacer(Modifier.height(8.dp))
PayCancelButton(onCancel = onCancel)
}
@Composable
private fun PayButtonText(amountSats: Long, fiatLabel: String?) {
Text(
if (fiatLabel != null) "Pay $amountSats sats · $fiatLabel"
else "Pay $amountSats sats"
)
}
@Composable
private fun PayCancelButton(
onCancel : () -> Unit,
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
}
@@ -0,0 +1,117 @@
package com.example.gudariwallet.ui.send
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.gudariwallet.ui.SendState
import com.example.gudariwallet.ui.WalletViewModel
import com.example.gudariwallet.util.WalletConstants
import com.example.gudariwallet.util.fiatLabel
import com.example.gudariwallet.util.formatFiat
import com.example.gudariwallet.util.payingToLabel
import com.example.gudariwallet.util.satsToFiat
@Composable
internal fun LnurlPayForm(
state : SendState.LnurlReady,
vm : WalletViewModel,
fiatRate : Double?,
fiatCurrency: String?
) {
var amount by remember { mutableStateOf(state.minSats.toString()) }
var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats
// Long? — null means the field is empty or non-numeric, not zero.
// This is the key hardening: 0L can no longer silently pass through.
val sats: Long? = amount.toLongOrNull()
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
Column {
Text("Paying to ${payingToLabel(state.lnurl, state.domain)}", style = MaterialTheme.typography.titleMedium)
if (state.description.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(state.description, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text("Amount (sats)") },
isError = amount.isNotBlank() && !isAmountValid,
supportingText = {
when {
// Show error when user has typed something invalid
amount.isNotBlank() && !isAmountValid -> {
Text(
text = "Enter an amount between ${state.minSats} and ${state.maxSats} sats",
color = MaterialTheme.colorScheme.error
)
}
// Show fiat range when rate is available
fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
if (isFixed) Text(minFiat)
else Text("${state.minSats} ${state.maxSats} sats · $minFiat $maxFiat")
}
// Sats-only range when no rate
!isFixed -> Text("${state.minSats} ${state.maxSats} sats")
else -> {}
}
},
enabled = !isFixed,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
label = { Text("Comment (optional)") },
supportingText = { Text("${comment.length}/${state.commentAllowed}") },
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
// Double-guard: sats must be non-null and in range before firing
if (sats != null && isAmountValid) {
vm.fetchLnurlInvoice(
rawRes = state.rawRes,
lnurl = state.lnurl,
domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() }
)
}
},
enabled = isAmountValid,
modifier = Modifier.fillMaxWidth()
) {
Text(
if (fiatLabel != null) "Pay $amount sats · $fiatLabel"
else "Pay $amount sats"
)
}
}
}
@@ -0,0 +1,192 @@
package com.example.gudariwallet.ui.send
import android.content.Intent
import androidx.compose.foundation.clickable
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.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.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.example.gudariwallet.api.RouteHintHop
import com.example.gudariwallet.ui.components.CopyIconButton
import com.example.gudariwallet.util.RiskLevel
import com.example.gudariwallet.util.RouteHintRisk
import com.example.gudariwallet.util.baseFeeLabel
@Composable
internal fun RouteHintWarningBanner(
risk : RouteHintRisk,
allRouteHints : List<RouteHintHop>,
routeHintAliases: Map<String, String>,
) {
val isHigh = risk.level == RiskLevel.HIGH
val containerCol = if (isHigh) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.tertiaryContainer
val contentCol = if (isHigh) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onTertiaryContainer
val offendingNode = routeHintAliases[risk.worstHop.publicKey]
?: risk.worstHop.publicKey.let { "${it.take(8)}${it.takeLast(8)}" }
val estimatedFeeSats = (risk.estimatedFeeMsat + 999L) / 1_000L
var detailsExpanded by remember { mutableStateOf(false) }
Surface(
color = containerCol,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.Top) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = contentCol,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isHigh) "High routing fees in invoice"
else "Elevated routing fees in invoice",
style = MaterialTheme.typography.labelMedium,
color = contentCol
)
Spacer(Modifier.height(4.dp))
Text(
text = "This invoice forces payment through a private channel " +
"operated by $offendingNode.",
style = MaterialTheme.typography.bodySmall,
color = contentCol
)
Spacer(Modifier.height(4.dp))
val feeRateLine = buildString {
append("Effective fee rate: ${risk.effectivePpm} ppm")
append(" (${risk.worstHop.ppmFee} ppm")
if (risk.worstHop.baseFeeMsat > 0) append(" + ${baseFeeLabel(risk.worstHop.baseFeeMsat)} base")
append(")")
}
Text(text = feeRateLine, style = MaterialTheme.typography.bodySmall, color = contentCol)
Text(
text = "Estimated extra fee on this payment: " +
"~$estimatedFeeSats sat${if (estimatedFeeSats == 1L) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = contentCol
)
}
}
if (allRouteHints.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { detailsExpanded = !detailsExpanded },
modifier = Modifier.align(Alignment.End),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
) {
Text(
text = if (detailsExpanded) "Hide details ▴" else "See details ▾",
style = MaterialTheme.typography.labelSmall,
color = contentCol
)
}
if (detailsExpanded) {
RouteHintDetailsPanel(
hops = allRouteHints,
aliases = routeHintAliases,
worstKey = risk.worstHop.publicKey,
tintColor = contentCol
)
}
}
}
}
}
@Composable
private fun RouteHintDetailsPanel(
hops : List<RouteHintHop>,
aliases : Map<String, String>,
worstKey : String,
tintColor: Color
) {
val context = LocalContext.current
Column(modifier = Modifier.padding(top = 6.dp)) {
HorizontalDivider(color = tintColor.copy(alpha = 0.3f))
Spacer(Modifier.height(6.dp))
Text(
text = "Private route hint hops",
style = MaterialTheme.typography.labelSmall,
color = tintColor.copy(alpha = 0.7f)
)
Spacer(Modifier.height(4.dp))
hops.forEachIndexed { index, hop ->
val alias = aliases[hop.publicKey]
val truncatedKey = "${hop.publicKey.take(8)}${hop.publicKey.takeLast(8)}"
val displayName = alias ?: truncatedKey
val ambossUrl = "https://amboss.space/node/${hop.publicKey}"
val isWorst = hop.publicKey == worstKey
if (index > 0) Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = displayName,
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline,
fontWeight = if (isWorst) androidx.compose.ui.text.font.FontWeight.Bold
else androidx.compose.ui.text.font.FontWeight.Normal
),
color = tintColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}
)
CopyIconButton("node_id", hop.publicKey, size = 28.dp, iconSize = 14.dp)
Text(
text = "${hop.ppmFee} ppm · ${baseFeeLabel(hop.baseFeeMsat)} base",
style = MaterialTheme.typography.labelSmall,
color = tintColor.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 160.dp)
)
}
}
Spacer(Modifier.height(4.dp))
}
}
@@ -0,0 +1,11 @@
package com.example.gudariwallet.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,60 @@
// ui/theme/SemanticColors.kt
package com.example.gudariwallet.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class SemanticColors(
val success : Color, // text / icon color
val successContainer : Color, // chip background
val onSuccessContainer: Color, // chip text
val warning : Color,
val warningContainer : Color,
val onWarningContainer: Color,
// Error is already in MaterialTheme.colorScheme — mirrored here for symmetry
val error : Color,
val errorContainer : Color,
val onErrorContainer : Color,
)
val LightSemanticColors = SemanticColors(
success = Color(0xFF2E7D32),
successContainer = Color(0xFFC8E6C9),
onSuccessContainer = Color(0xFF1B5E20),
warning = Color(0xFF92400E),
warningContainer = Color(0xFFFFF3E0),
onWarningContainer = Color(0xFF4E2600),
error = Color(0xFFBA1A1A),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
)
val DarkSemanticColors = SemanticColors(
success = Color(0xFFA5D6A7),
successContainer = Color(0xFF1B5E20),
onSuccessContainer = Color(0xFFC8E6C9),
warning = Color(0xFFFFB74D),
warningContainer = Color(0xFF4E2600),
onWarningContainer = Color(0xFFFFE0B2),
error = Color(0xFFFFB4AB),
errorContainer = Color(0xFF410002),
onErrorContainer = Color(0xFFFFDAD6),
)
val LocalSemanticColors = staticCompositionLocalOf { LightSemanticColors }
// Convenience accessor — use like: MaterialTheme.semanticColors.success
@Composable
fun semanticColors(): SemanticColors = LocalSemanticColors.current
@@ -0,0 +1,59 @@
package com.example.gudariwallet.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun GudariWalletTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.example.gudariwallet.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,82 @@
package com.example.gudariwallet.util
import com.example.gudariwallet.api.LnurlScanResponse
import org.json.JSONObject
import retrofit2.HttpException
/**
* Extracts a human-readable message from a Retrofit [HttpException].
*
* LNbits error bodies look like: {"detail": "Insufficient balance"}
* Falls back to a generic message if the body can't be parsed.
*/
fun parseApiError(e: Throwable, fallback: String = "An error occurred"): String {
if (e !is HttpException) return e.message ?: fallback
val body = try {
e.response()?.errorBody()?.string()
} catch (_: Exception) { null }
if (!body.isNullOrBlank()) {
// Try {"detail": "..."} — standard LNbits format
try {
val detail = JSONObject(body).optString("detail", "")
if (detail.isNotBlank()) return friendlyMessage(detail, e.code())
} catch (_: Exception) {}
// Try {"message": "..."} — some endpoints use this
try {
val msg = JSONObject(body).optString("message", "")
if (msg.isNotBlank()) return friendlyMessage(msg, e.code())
} catch (_: Exception) {}
}
return friendlyMessage(null, e.code())
}
fun lnurlProtocolError(raw: LnurlScanResponse?): String? {
raw ?: return null
if (raw.status?.uppercase() == "ERROR") {
return raw.reason?.ifBlank { null }
?: "LNURL server returned an error"
}
return null
}
fun lnurlProtocolErrorFromJson(body: String?): String? {
if (body.isNullOrBlank()) return null
return try {
val json = JSONObject(body)
if (json.optString("status").uppercase() == "ERROR") {
json.optString("reason").ifBlank { "LNURL server returned an error" }
} else null
} catch (_: Exception) { null }
}
private fun friendlyMessage(detail: String?, httpCode: Int): String {
// Map known LNbits detail strings to user-friendly messages
return when {
detail == null -> genericForCode(httpCode)
detail.contains("Insufficient balance", ignoreCase = true) -> "Insufficient balance"
detail.contains("Invoice expired", ignoreCase = true) -> "Invoice has expired"
detail.contains("already paid", ignoreCase = true) -> "This invoice has already been paid"
detail.contains("Self-payment", ignoreCase = true) -> "Cannot pay your own invoice"
detail.contains("Failed to decode", ignoreCase = true) -> "Invalid invoice — could not decode"
detail.contains("No route", ignoreCase = true) -> "No route found to destination"
detail.contains("Failed to pay", ignoreCase = true) -> "Payment failed — no route found"
detail.contains("Unauthorized", ignoreCase = true) -> "Wallet key is invalid — check settings"
detail.contains("Rate limited", ignoreCase = true) -> "Too many requests — please wait"
httpCode == 401 -> "Wallet key is invalid — check settings"
httpCode == 429 -> "Too many requests — please wait"
httpCode >= 500 -> "Server error — please try again"
else -> detail
}
}
private fun genericForCode(httpCode: Int): String = when (httpCode) {
400 -> "Invalid request"
401 -> "Wallet key is invalid — check settings"
404 -> "Not found"
429 -> "Too many requests — please wait"
in 500..599 -> "Server error — please try again"
else -> "An error occurred"
}
@@ -0,0 +1,80 @@
package com.example.gudariwallet.util
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
object BiometricHelper {
private const val TAG = "BiometricHelper"
/**
* Unwraps a Context chain to find the hosting FragmentActivity, if any.
*/
fun resolveActivity(context: Context): FragmentActivity? {
var ctx: Context = context
while (ctx is ContextWrapper) {
if (ctx is FragmentActivity) return ctx
ctx = ctx.baseContext
}
return null
}
/**
* Returns true if the device has at least one enrolled biometric or a
* device credential (PIN/pattern/password) that can be used for auth.
*/
fun canAuthenticate(context: Context): Boolean =
BiometricManager.from(context).canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
) == BiometricManager.BIOMETRIC_SUCCESS
/**
* Shows a biometric prompt attached to [activity].
*
* [onSuccess] is called on the main thread when authentication succeeds.
* [onError] is called for hard errors (not for user-initiated cancels).
*/
fun prompt(
activity : FragmentActivity,
title : String = "Confirm Payment",
subtitle : String,
onSuccess: () -> Unit,
onError : (code: Int, msg: CharSequence) -> Unit = { _, _ -> }
) {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) = onSuccess()
override fun onAuthenticationError(code: Int, msg: CharSequence) {
// Silently ignore user-initiated cancel / back press
if (code != BiometricPrompt.ERROR_USER_CANCELED &&
code != BiometricPrompt.ERROR_NEGATIVE_BUTTON
) {
Log.e(TAG, "Biometric error $code: $msg")
onError(code, msg)
}
}
}
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
prompt.authenticate(info)
}
}
@@ -0,0 +1,107 @@
package com.example.gudariwallet.util
/**
* Converts a satoshi amount to its fiat equivalent.
*
* @param sats Amount in satoshis.
* @param satsPerFiat How many sats equal 1 fiat unit (e.g. if BTC = $100,000 then
* satsPerFiat = 100,000,000 / 100,000 = 1,000 sats per dollar).
* @return Fiat amount as a Double.
*/
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
sats / satsPerFiat
/**
* Formats a fiat amount for display with a currency symbol prefix.
*
* Tiers (bitcoin.design rules):
* - amount >= 1.00 2 dp, e.g. "$43.25"
* - 0.01 <= amount < 1.00 4 dp, e.g. "$0.0043"
* - amount < 0.01 (sub-cent) dynamic precision with "~" prefix,
* e.g. "~$0.0039" (2 significant digits after leading zeros)
* - amount == 0.0 "$0.00"
*
* @param amount Fiat amount to format (always pass a positive value; sign is handled by caller).
* @param currency ISO 4217 currency code (e.g. "USD", "EUR").
* @return Formatted string with symbol, e.g. "$43.25" or "~$0.0039".
*/
fun formatFiat(amount: Double, currency: String): String {
val symbol = currencySymbol(currency)
return when {
amount == 0.0 -> "${symbol}0.00"
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
else -> formatSubCent(amount, symbol)
}
}
/**
* Converts sats fiat and formats in one call, applying bitcoin.design display rules.
*
* Use this everywhere you display a sat amount alongside a fiat equivalent.
*
* @param sats Absolute satoshi count (always positive; caller adds +/ sign).
* @param satsPerFiat Exchange rate: sats per 1 fiat unit.
* @param currency ISO 4217 currency code.
* @return Formatted fiat string, e.g. "$12.50", "~$0.0039", or "$0.00".
*/
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
formatFiat(satsToFiat(sats, satsPerFiat), currency)
// ── Internal helpers ──────────────────────────────────────────────────────────
/**
* Handles the sub-cent case (0 < amount < 0.01).
*
* Finds the first two significant (non-zero) digits after the decimal point
* and rounds to that precision, prefixing with "~" to signal approximation.
*
* Examples:
* 0.003887 "~$0.0039"
* 0.000038 "~$0.000038"
* 0.0000431 "~$0.000043"
*/
private fun formatSubCent(amount: Double, symbol: String): String {
// Render with enough decimal places to capture at least 2 significant digits
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
val dotIdx = raw.indexOf('.')
if (dotIdx == -1) return "~$symbol$raw"
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000"
val firstSigIdx = decimals.indexOfFirst { it != '0' }
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
// Keep all leading zeros + 2 significant digits
val precision = firstSigIdx + 2
val rounded = "%.${precision}f".format(amount)
// Only add "~" if rounding actually changed the value
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
}
/**
* Returns the display symbol for a given ISO 4217 currency code.
* Falls back to the currency code + space for unlisted currencies.
*/
private fun currencySymbol(currency: String): String = when (currency) {
"USD" -> "$"
"EUR" -> ""
"GBP" -> "£"
"JPY" -> "¥"
"BRL" -> "R$"
"AUD" -> "A$"
"CAD" -> "C$"
"CHF" -> "Fr"
else -> "$currency "
}
/**
* Returns a formatted fiat string for [sats] at the given rate, or null if
* rate or currency is unavailable. Suppresses display for zero/null amounts.
*/
fun fiatLabel(sats: Long?, fiatRate: Double?, fiatCurrency: String?, minSats: Long = 1L): String? =
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
else null
@@ -0,0 +1,49 @@
package com.example.gudariwallet.util
import android.content.Context
import android.content.Intent
import android.net.Uri
object IntentUtils {
private val PAYMENT_SCHEMES = setOf(
"lightning", "bitcoin", "lnurlp", "lnurlw", "lnurlc", "lnurl"
)
fun shareText(context:
Context, text: String, chooserTitle: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, chooserTitle))
}
/**
* Returns a raw payment URI string if the intent carries one, else null.
*
* Handles:
* - ACTION_VIEW with a recognised scheme (BTCPay "Pay in wallet", browser links)
* - ACTION_SEND with text/plain (share sheet from another app)
*/
fun extractPaymentUri(intent: Intent?): String? {
if (intent == null) return null
// Case 1: direct URI intent (most common — BTCPay, wallet links)
if (intent.action == Intent.ACTION_VIEW) {
val uri: Uri? = intent.data
if (uri != null && uri.scheme?.lowercase() in PAYMENT_SCHEMES) {
return uri.toString()
}
}
// Case 2: share-sheet text (e.g. another app shares a BOLT-11 string)
if (intent.action == Intent.ACTION_SEND &&
intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()
if (!text.isNullOrBlank()) return text
}
return null
}
}
@@ -0,0 +1,73 @@
package com.example.gudariwallet.util
import kotlin.collections.toByteArray
/**
* Minimal bech32 decoder for LNURL strings (LUD-01).
*
* LNURL bech32 uses the standard bech32 charset and encodes a UTF-8 URL
* in the data part. We only need decode no checksum verification needed
* because the decoded URL will fail to fetch if corrupted.
*/
object LnurlBech32 {
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
/**
* Decodes a bech32-encoded LNURL string to its plaintext https:// URL.
* Returns null if the input is not valid bech32 or not an LNURL.
*/
fun decode(lnurl: String): String? = runCatching {
val lower = lnurl.trim().lowercase()
// Must have a separator
val sepIdx = lower.lastIndexOf('1')
if (sepIdx < 1) return null
val dataChars = lower.substring(sepIdx + 1)
// Strip the 6-char checksum
if (dataChars.length < 6) return null
val payload = dataChars.dropLast(6)
// Convert bech32 5-bit groups → 8-bit bytes
val decoded = convertBits(
data = payload.map { c ->
val idx = CHARSET.indexOf(c)
if (idx < 0) return null
idx
},
fromBits = 5,
toBits = 8,
pad = false
) ?: return null
String(ByteArray(decoded.size) { decoded[it].toByte() }, Charsets.UTF_8)
}.getOrNull()
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
}
}
@@ -0,0 +1,46 @@
package com.example.gudariwallet.util
import org.json.JSONArray
/**
* Stateless helpers for parsing LNURL-pay step-1 metadata fields.
* Shared between [com.example.gudariwallet.data.LNbitsWalletRepository]
* and [com.example.gudariwallet.ui.WalletViewModel] so neither duplicates
* the logic.
*/
object LnurlMetadataParser {
/**
* Extracts the `text/plain` description from a LNURL-pay metadata JSON string.
* The metadata field is a JSON array of [type, value] pairs, e.g.:
* [["text/plain","Pay me"],["image/png;base64","..."]]
* Returns an empty string if the field is null, blank, or unparseable.
*/
fun parseDescription(metadata: String?): String {
if (metadata.isNullOrBlank()) return ""
return runCatching {
val array = JSONArray(metadata)
(0 until array.length())
.map { array.getJSONArray(it) }
.firstOrNull { it.getString(0) == "text/plain" }
?.getString(1) ?: ""
}.getOrDefault("")
}
/**
* Extracts the host portion of [callback] for display purposes.
* Returns an empty string if [callback] is null, blank, or not a valid URI.
*/
fun extractDomain(callback: String?): String {
if (callback.isNullOrBlank()) return ""
return runCatching { java.net.URI(callback).host ?: "" }.getOrDefault("")
}
}
/**
* Returns the display label for a LNURL payment target.
* If [lnurl] is a Lightning Address (contains '@'), returns it as-is.
* Otherwise returns the [domain] of the LNURL endpoint.
*/
fun payingToLabel(lnurl: String, domain: String): String =
if ('@' in lnurl) lnurl else domain
@@ -0,0 +1,15 @@
package com.example.gudariwallet.util
object NotificationConstants {
// ── Channel IDs ───────────────────────────────────────────────────────────
const val CHANNEL_PAYMENTS = "gudari_payments"
const val CHANNEL_SERVICE_STATUS = "gudari_service"
// ── Notification IDs ──────────────────────────────────────────────────────
// ID 1 is reserved for the foreground service — Android requires a stable,
// non-zero ID for startForeground(). Keep it separate from user-facing IDs.
const val NOTIF_ID_SERVICE = 1
const val NOTIF_ID_PAYMENT_RECEIVED = 1001
const val NOTIF_ID_PAYMENT_SENT = 1002
}
@@ -0,0 +1,170 @@
package com.example.gudariwallet.util
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.example.gudariwallet.MainActivity
import com.example.gudariwallet.R
object NotificationHelper {
// ── Intent extra key — read by MainActivity ───────────────────────────────
// Public so MainActivity can reference it without a magic string.
const val EXTRA_PAYMENT_HASH = "extra_payment_hash"
// ── Channel setup ─────────────────────────────────────────────────────────
fun createChannels(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(
NotificationConstants.CHANNEL_PAYMENTS,
"Payments",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Incoming and outgoing Lightning payment alerts"
enableVibration(true)
enableLights(true)
}
)
nm.createNotificationChannel(
NotificationChannel(
NotificationConstants.CHANNEL_SERVICE_STATUS,
"Wallet Monitor",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Background service monitoring for incoming payments"
setShowBadge(false)
enableVibration(false)
setSound(null, null)
}
)
}
// ── User-facing notifications ─────────────────────────────────────────────
/**
* Posts a high-priority notification for an incoming payment.
* Tapping opens MainActivity and navigates directly to the payment detail
* screen for [paymentHash].
*/
fun notifyPaymentReceived(context: Context, amountSats: Long, memo: String?, paymentHash: String) {
val title = "Payment received · +$amountSats sats"
val text = if (!memo.isNullOrBlank()) memo else "No memo"
val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_PAYMENTS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(mainActivityPendingIntent(context, paymentHash))
// BigTextStyle shows the full memo when the notification is expanded,
// in case it is long enough to be truncated in the compact view.
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(text)
.setSummaryText("+$amountSats sats")
)
.build()
notify(context, NotificationConstants.NOTIF_ID_PAYMENT_RECEIVED, notification)
}
/**
* Posts a default-priority notification for a successfully sent payment.
* Tapping opens MainActivity and navigates directly to the payment detail
* screen for [paymentHash].
*/
fun notifyPaymentSent(context: Context, amountSats: Long, paymentHash: String) {
val title = "Payment sent"
val text = "$amountSats sats"
notify(
context,
NotificationConstants.NOTIF_ID_PAYMENT_SENT,
buildPaymentNotification(context, title, text, paymentHash)
)
}
// ── Foreground service notification ───────────────────────────────────────
fun buildServiceNotification(context: Context): Notification {
return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_SERVICE_STATUS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Gudari Wallet")
.setContentText("Monitoring for incoming payments")
.setOngoing(true)
.setSilent(true)
.setShowWhen(false)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
// Service notification taps open the app with no specific destination
.setContentIntent(mainActivityPendingIntent(context, paymentHash = null))
.build()
}
// ── Dismiss ───────────────────────────────────────────────────────────────
fun cancel(context: Context, notificationId: Int) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
// ── Private helpers ───────────────────────────────────────────────────────
private fun buildPaymentNotification(
context: Context,
title: String,
text: String,
paymentHash: String
): Notification {
return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_PAYMENTS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(mainActivityPendingIntent(context, paymentHash))
.build()
}
/**
* Builds a PendingIntent that opens MainActivity.
*
* If [paymentHash] is non-null, it is attached as [EXTRA_PAYMENT_HASH].
* MainActivity reads this in onCreate / onNewIntent and navigates to the
* payment detail screen.
*
* FLAG_UPDATE_CURRENT ensures that if a second notification arrives before
* the first is tapped, the hash is updated to the latest payment.
* Each notification ID gets its own requestCode so they don't overwrite
* each other's extras.
*/
private fun mainActivityPendingIntent(context: Context, paymentHash: String?): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (paymentHash != null) {
putExtra(EXTRA_PAYMENT_HASH, paymentHash)
}
}
// Use the hash's hashCode as requestCode so each unique payment gets
// its own PendingIntent — prevents one notification's tap from
// overwriting another's extras.
val requestCode = paymentHash?.hashCode() ?: 0
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun notify(context: Context, id: Int, notification: Notification) {
runCatching {
NotificationManagerCompat.from(context).notify(id, notification)
}
}
}
@@ -0,0 +1,47 @@
package com.example.gudariwallet.util
import com.example.gudariwallet.api.RouteHintHop
data class RouteHintRisk(
val worstHop : RouteHintHop,
val effectivePpm : Long,
val estimatedFeeMsat : Long,
val level : RiskLevel
)
enum class RiskLevel { NONE, ELEVATED, HIGH }
object RouteHintAnalyzer {
private const val PPM_ELEVATED = 1_000L // 0.1%
private const val PPM_HIGH = 2_000L // 0.2%
private const val BASE_HIGH_MSAT = 10_000L // 10 sat flat fee on a single hop
fun analyze(amountMsat: Long, hints: List<RouteHintHop>): RouteHintRisk? {
if (hints.isEmpty()) return null
val refMsat = if (amountMsat > 0) amountMsat else 1_000L
val worstHop = hints.maxByOrNull { hop ->
hop.baseFeeMsat + (refMsat * hop.ppmFee) / 1_000_000L
} ?: return null
val estimatedFeeMsat = worstHop.baseFeeMsat + (refMsat * worstHop.ppmFee) / 1_000_000L
val effectivePpm = (estimatedFeeMsat * 1_000_000L) / refMsat
val level = when {
effectivePpm >= PPM_HIGH || worstHop.baseFeeMsat >= BASE_HIGH_MSAT -> RiskLevel.HIGH
effectivePpm >= PPM_ELEVATED -> RiskLevel.ELEVATED
else -> RiskLevel.NONE
}
if (level == RiskLevel.NONE) return null
return RouteHintRisk(
worstHop = worstHop,
effectivePpm = effectivePpm,
estimatedFeeMsat = estimatedFeeMsat,
level = level
)
}
}
@@ -0,0 +1,29 @@
package com.example.gudariwallet.util
import com.example.gudariwallet.util.WalletConstants.MSAT_PER_SAT
/** Convert millisatoshis to satoshis. e.g. 10_000L.msatToSat == 10L */
val Long.msatToSat: Long get() = this / MSAT_PER_SAT
/** Convert satoshis to millisatoshis. e.g. 10L.satToMsat == 10_000L */
val Long.satToMsat: Long get() = this * MSAT_PER_SAT
fun feePpm(amountMsat: Long, feeMsat: Long): Long? {
if (amountMsat == 0L) return null
return feeMsat * 1_000_000L / amountMsat
}
/**
* Formats a base fee in millisatoshis as a human-readable string.
* e.g. 1500 msat "1 sat + 500 msat", 1000 msat "1 sat", 500 msat "500 msat"
*/
fun baseFeeLabel(baseFeeMsat: Long): String {
val sats = baseFeeMsat / 1_000L
val remainder = baseFeeMsat % 1_000L
return when {
sats > 0 && remainder > 0 -> "$sats sat + $remainder msat"
sats > 0 -> "$sats sat"
remainder > 0 -> "$remainder msat"
else -> "0"
}
}
@@ -0,0 +1,46 @@
package com.example.gudariwallet.util
sealed class SendInputType {
data object Bolt11 : SendInputType()
data object Lnurl : SendInputType()
data object Lud17Url : SendInputType()
data object LightningAddress : SendInputType()
data object Bip21 : SendInputType()
data object Unknown : SendInputType()
}
object SendInputDetector {
private val lightningAddressRegex =
Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
private val lud17Schemes = setOf("lnurlp", "lnurlw", "lnurlc")
fun detect(raw: String): SendInputType {
val trimmed = raw.trim()
val lower = trimmed.lowercase()
if (lower.startsWith("bitcoin:")) return SendInputType.Bip21
if (lower.startsWith("keyauth://") || lower.startsWith("keyauth:")) {
return SendInputType.Unknown
}
val scheme = lower.substringBefore("://")
if (lower.contains("://") && scheme in lud17Schemes) {
return SendInputType.Lud17Url
}
val s = normalize(trimmed).lowercase()
return when {
s.startsWith("lnbc") -> SendInputType.Bolt11
s.startsWith("lnurl1") -> SendInputType.Lnurl
lightningAddressRegex.matches(trimmed) -> SendInputType.LightningAddress
else -> SendInputType.Unknown
}
}
fun normalize(raw: String): String {
val s = raw.trim()
val lower = s.lowercase()
return when {
lower.startsWith("lightning://") -> s.substring("lightning://".length)
lower.startsWith("lightning:") -> s.substring("lightning:".length)
lower.startsWith("lnurl:") -> s.substring("lnurl:".length)
else -> s
}.trim()
}
}
@@ -0,0 +1,53 @@
package com.example.gudariwallet.util
object WalletConstants {
// ── Unit conversion ───────────────────────────────────────────────────────
const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi
// ── Polling ───────────────────────────────────────────────────────────────
const val POLL_INTERVAL_MS = 3_000L // delay between payment status checks
const val POLL_MAX_ATTEMPTS = 200 // 200 × 3s = 10 minutes max
// ── WebSocket reconnect ───────────────────────────────────────────────────
const val WS_INITIAL_BACKOFF_MS = 1_000L // first retry delay
const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this
const val WS_MAX_ATTEMPTS = 5 // give up after 5 failed reconnects
// ── SharedPreferences ─────────────────────────────────────────────────────
const val PREFS_NAME = "lnbits_prefs"
const val PREFS_BASE_URL = "base_url"
const val PREFS_INVOICE_KEY_ENC = "invoice_key_enc"
const val PREFS_ADMIN_KEY_ENC = "admin_key_enc"
const val PREFS_ONBOARDED = "onboarded"
// ── Network ───────────────────────────────────────────────────────────────
const val CONNECT_TIMEOUT_S = 15L
const val READ_TIMEOUT_S = 30L
const val WRITE_TIMEOUT_S = 15L
// ── WebSocket close ───────────────────────────────────────────────────────
const val WS_CLOSE_NORMAL = 1000
// ── History pagination ────────────────────────────────────────────────────
const val PAYMENTS_PAGE_SIZE = 20
// ── Alias cache ───────────────────────────────────────────────────────────
const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold
const val ALIAS_PREFS_NAME = "alias_cache_prefs"
const val ALIAS_PREFS_KEY = "alias_cache"
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
// ── Payments cache ────────────────────────────────────────────────────────
const val PAYMENTS_PREFS_NAME = "payment_cache_prefs"
const val PAYMENTS_PREFS_KEY = "payment_list"
const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at"
const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
// ── Balance cache ─────────────────────────────────────────────────────────
const val BALANCE_PREFS_NAME = "balance_cache_prefs"
const val BALANCE_PREFS_KEY = "last_balance_sats"
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
// ── LNURL metadata cache ──────────────────────────────────────────────────
const val LNURL_CACHE_PREFS_NAME = "lnurl_cache_prefs"
const val LNURL_CACHE_PREFS_KEY = "lnurl_entries"
const val LNURL_CACHE_TTL_MS = 3_600_000L // 1 hour
}
@@ -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,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M13,2L4.5,13.5H11L10,22L19.5,10.5H13L13,2Z" />
</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Gudari Wallet</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<style name="Theme.GudariWallet" parent="Theme.AppCompat.DayNight.NoActionBar">
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?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
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
@@ -0,0 +1,17 @@
package com.example.gudariwallet
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+15
View File
@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
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=official
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

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