From 7f3d14a764753ec1f8e2a4810186d58734dcf015 Mon Sep 17 00:00:00 2001 From: rasputin Date: Mon, 1 Jun 2026 02:38:52 +0200 Subject: [PATCH] initial commit --- .gitignore | 83 ++ .idea/.gitignore | 3 + .idea/.name | 1 + .idea/AndroidProjectSystem.xml | 6 + .idea/codeStyles/Project.xml | 157 +++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 18 + .idea/deviceManager.xml | 13 + .idea/dictionaries/project.xml | 11 + .idea/gradle.xml | 18 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + .idea/vcs.xml | 6 + app/.gitignore | 1 + app/build.gradle.kts | 93 ++ app/proguard-rules.pro | 21 + .../gudariwallet/ExampleInstrumentedTest.kt | 24 + app/src/debug/AndroidManifest.xml | 7 + app/src/main/AndroidManifest.xml | 60 + .../com/example/gudariwallet/MainActivity.kt | 155 +++ .../example/gudariwallet/api/GsonProvider.kt | 21 + .../com/example/gudariwallet/api/LNbitsApi.kt | 137 +++ .../example/gudariwallet/api/LNbitsClient.kt | 37 + .../com/example/gudariwallet/api/Models.kt | 272 +++++ .../example/gudariwallet/api/NodeAliasApi.kt | 28 + .../gudariwallet/api/NodeAliasService.kt | 53 + .../example/gudariwallet/data/DomainModels.kt | 43 + .../data/LNbitsNodeAliasRepository.kt | 19 + .../data/LNbitsWalletRepository.kt | 260 +++++ .../gudariwallet/data/LnurlCacheRepository.kt | 123 ++ .../gudariwallet/data/NodeAliasRepository.kt | 21 + .../data/PaymentCacheRepository.kt | 87 ++ .../gudariwallet/data/WalletRepository.kt | 42 + .../gudariwallet/security/SecretManager.kt | 58 + .../gudariwallet/security/SecretStore.kt | 46 + .../gudariwallet/service/NodeAliasService.kt | 184 +++ .../service/WalletNotificationService.kt | 256 ++++ .../example/gudariwallet/ui/HistoryScreen.kt | 677 +++++++++++ .../gudariwallet/ui/HistoryViewModel.kt | 202 ++++ .../com/example/gudariwallet/ui/HomeTab.kt | 433 +++++++ .../ui/NotificationPermissionScreen.kt | 245 ++++ .../gudariwallet/ui/OnboardingScreen.kt | 108 ++ .../gudariwallet/ui/PaymentSuccessScreen.kt | 110 ++ .../gudariwallet/ui/QrScannerScreen.kt | 42 + .../example/gudariwallet/ui/ReceiveScreen.kt | 687 +++++++++++ .../com/example/gudariwallet/ui/SendScreen.kt | 218 ++++ .../com/example/gudariwallet/ui/UiStates.kt | 150 +++ .../example/gudariwallet/ui/WalletScreen.kt | 393 +++++++ .../gudariwallet/ui/WalletViewModel.kt | 1038 +++++++++++++++++ .../gudariwallet/ui/WalletViewModelFactory.kt | 33 + .../ui/components/CommonComponents.kt | 106 ++ .../ui/components/UnitWheelPicker.kt | 78 ++ .../gudariwallet/ui/receive/AmountDisplay.kt | 36 + .../ui/receive/BrightnessToggleButton.kt | 26 + .../gudariwallet/ui/receive/QrDisplayCard.kt | 137 +++ .../gudariwallet/ui/send/ConfirmCard.kt | 175 +++ .../gudariwallet/ui/send/LnurlPayForm.kt | 117 ++ .../gudariwallet/ui/send/RouteHintWarning.kt | 192 +++ .../example/gudariwallet/ui/theme/Color.kt | 11 + .../gudariwallet/ui/theme/SemanticColors.kt | 60 + .../example/gudariwallet/ui/theme/Theme.kt | 59 + .../com/example/gudariwallet/ui/theme/Type.kt | 34 + .../gudariwallet/util/ApiErrorParser.kt | 82 ++ .../gudariwallet/util/BiometricHelper.kt | 80 ++ .../gudariwallet/util/CurrencyFormatter.kt | 107 ++ .../example/gudariwallet/util/IntentUtils.kt | 49 + .../example/gudariwallet/util/LnurlBech32.kt | 73 ++ .../gudariwallet/util/LnurlMetadataParser.kt | 46 + .../util/NotificationConstants.kt | 15 + .../gudariwallet/util/NotificationHelper.kt | 170 +++ .../gudariwallet/util/RouteHintAnalyzer.kt | 47 + .../gudariwallet/util/SatExtensions.kt | 29 + .../gudariwallet/util/SendInputDetector.kt | 46 + .../gudariwallet/util/WalletConstants.kt | 53 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 30 + app/src/main/res/drawable/ic_notification.xml | 13 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../main/res/xml/network_security_config.xml | 4 + .../example/gudariwallet/ExampleUnitTest.kt | 17 + build.gradle.kts | 5 + gradle.properties | 15 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 61 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++ gradlew.bat | 94 ++ settings.gradle.kts | 27 + 106 files changed, 9334 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/dictionaries/project.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/example/gudariwallet/ExampleInstrumentedTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/gudariwallet/MainActivity.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/GsonProvider.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/LNbitsApi.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/LNbitsClient.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/Models.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/NodeAliasApi.kt create mode 100644 app/src/main/java/com/example/gudariwallet/api/NodeAliasService.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/DomainModels.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/LNbitsNodeAliasRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/LNbitsWalletRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/LnurlCacheRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/NodeAliasRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/PaymentCacheRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/data/WalletRepository.kt create mode 100644 app/src/main/java/com/example/gudariwallet/security/SecretManager.kt create mode 100644 app/src/main/java/com/example/gudariwallet/security/SecretStore.kt create mode 100644 app/src/main/java/com/example/gudariwallet/service/NodeAliasService.kt create mode 100644 app/src/main/java/com/example/gudariwallet/service/WalletNotificationService.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/HistoryScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/HistoryViewModel.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/HomeTab.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/NotificationPermissionScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/OnboardingScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/PaymentSuccessScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/QrScannerScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/ReceiveScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/SendScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/UiStates.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/WalletScreen.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/WalletViewModel.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/WalletViewModelFactory.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/components/CommonComponents.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/components/UnitWheelPicker.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/receive/AmountDisplay.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/receive/BrightnessToggleButton.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/receive/QrDisplayCard.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/send/ConfirmCard.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/send/LnurlPayForm.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/send/RouteHintWarning.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/theme/Color.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/theme/SemanticColors.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/gudariwallet/ui/theme/Type.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/ApiErrorParser.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/BiometricHelper.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/CurrencyFormatter.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/IntentUtils.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/LnurlBech32.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/LnurlMetadataParser.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/NotificationConstants.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/NotificationHelper.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/RouteHintAnalyzer.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/SatExtensions.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/SendInputDetector.kt create mode 100644 app/src/main/java/com/example/gudariwallet/util/WalletConstants.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/com/example/gudariwallet/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48dac21 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..f39faf4 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Gudari Wallet \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..9c3d95a --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..4fbe208 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..ac00a9c --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,11 @@ + + + + amboss + lnbc + msat + satoshis + sats + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7c36d8d --- /dev/null +++ b/app/build.gradle.kts @@ -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) + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/gudariwallet/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/gudariwallet/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..412b911 --- /dev/null +++ b/app/src/androidTest/java/com/example/gudariwallet/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..b073118 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8489cde --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/gudariwallet/MainActivity.kt b/app/src/main/java/com/example/gudariwallet/MainActivity.kt new file mode 100644 index 0000000..c28f202 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/MainActivity.kt @@ -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(null) + + // Holds a payment URI that arrived via deep link / intent (BTCPay, external apps). + private val pendingPaymentUri = mutableStateOf(null) + + private val requestNotificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ } + + // Broadcasts window focus gains to any observer in the composition + val windowFocusEvents = MutableSharedFlow(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 } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/api/GsonProvider.kt b/app/src/main/java/com/example/gudariwallet/api/GsonProvider.kt new file mode 100644 index 0000000..03f4ce7 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/GsonProvider.kt @@ -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() +} diff --git a/app/src/main/java/com/example/gudariwallet/api/LNbitsApi.kt b/app/src/main/java/com/example/gudariwallet/api/LNbitsApi.kt new file mode 100644 index 0000000..5b7ef31 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/LNbitsApi.kt @@ -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 + + /** 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 + + // 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 + + // 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 +} diff --git a/app/src/main/java/com/example/gudariwallet/api/LNbitsClient.kt b/app/src/main/java/com/example/gudariwallet/api/LNbitsClient.kt new file mode 100644 index 0000000..2abd319 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/LNbitsClient.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/api/Models.kt b/app/src/main/java/com/example/gudariwallet/api/Models.kt new file mode 100644 index 0000000..29d6435 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/Models.kt @@ -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>? = 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? = 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() { + + 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 +) \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/api/NodeAliasApi.kt b/app/src/main/java/com/example/gudariwallet/api/NodeAliasApi.kt new file mode 100644 index 0000000..2110008 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/NodeAliasApi.kt @@ -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 +} diff --git a/app/src/main/java/com/example/gudariwallet/api/NodeAliasService.kt b/app/src/main/java/com/example/gudariwallet/api/NodeAliasService.kt new file mode 100644 index 0000000..2b02fc1 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/api/NodeAliasService.kt @@ -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 + } +} diff --git a/app/src/main/java/com/example/gudariwallet/data/DomainModels.kt b/app/src/main/java/com/example/gudariwallet/data/DomainModels.kt new file mode 100644 index 0000000..6c2e2ef --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/DomainModels.kt @@ -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 = 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 +) diff --git a/app/src/main/java/com/example/gudariwallet/data/LNbitsNodeAliasRepository.kt b/app/src/main/java/com/example/gudariwallet/data/LNbitsNodeAliasRepository.kt new file mode 100644 index 0000000..803e530 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/LNbitsNodeAliasRepository.kt @@ -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> + get() = service.aliases + + override suspend fun resolve(pubkey: String): String? = + service.resolve(pubkey) +} diff --git a/app/src/main/java/com/example/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/example/gudariwallet/data/LNbitsWalletRepository.kt new file mode 100644 index 0000000..7d65037 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/LNbitsWalletRepository.kt @@ -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 { + Log.d(TAG, "FIAT [CURRENCIES] fetching supported currency list") + return api.getSupportedCurrencies() + } + + // ── History ─────────────────────────────────────────────────────────────── + + override suspend fun getPayments(offset: Int, limit: Int): List { + return api.getPayments(secrets.invoiceKey, limit, offset) + } + + override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { + return api.getPaymentDetail(secrets.invoiceKey, checkingId) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/data/LnurlCacheRepository.kt b/app/src/main/java/com/example/gudariwallet/data/LnurlCacheRepository.kt new file mode 100644 index 0000000..851dee5 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/LnurlCacheRepository.kt @@ -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() + + 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>() {}.type + val list: List = 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}") + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/data/NodeAliasRepository.kt b/app/src/main/java/com/example/gudariwallet/data/NodeAliasRepository.kt new file mode 100644 index 0000000..adf0d60 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/NodeAliasRepository.kt @@ -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> + + /** + * 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? +} diff --git a/app/src/main/java/com/example/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/example/gudariwallet/data/PaymentCacheRepository.kt new file mode 100644 index 0000000..30d9cbe --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/PaymentCacheRepository.kt @@ -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() + + // ── Payment list ────────────────────────────────────────────────────────── + + /** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */ + fun loadCachedPayments(): List { + 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>() {}.type + val list: List = 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) { + 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") + } +} diff --git a/app/src/main/java/com/example/gudariwallet/data/WalletRepository.kt b/app/src/main/java/com/example/gudariwallet/data/WalletRepository.kt new file mode 100644 index 0000000..9a367b0 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/data/WalletRepository.kt @@ -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 + suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse + suspend fun getFiatRate(currency: String): Double + suspend fun getSupportedCurrencies(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/security/SecretManager.kt b/app/src/main/java/com/example/gudariwallet/security/SecretManager.kt new file mode 100644 index 0000000..ef5500d --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/security/SecretManager.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/security/SecretStore.kt b/app/src/main/java/com/example/gudariwallet/security/SecretStore.kt new file mode 100644 index 0000000..a9e71ed --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/security/SecretStore.kt @@ -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("") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/service/NodeAliasService.kt b/app/src/main/java/com/example/gudariwallet/service/NodeAliasService.kt new file mode 100644 index 0000000..d0603fc --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/service/NodeAliasService.kt @@ -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>() {}.type + + private val cache = ConcurrentHashMap() + + // ── Shared alias StateFlow — all screens collect this ───────────────────── + + private val _aliases = MutableStateFlow>(emptyMap()) + val aliases: StateFlow> = _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 = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/example/gudariwallet/service/WalletNotificationService.kt new file mode 100644 index 0000000..cc69b65 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/service/WalletNotificationService.kt @@ -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(replay = 0) + val paymentEvents: SharedFlow = _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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/HistoryScreen.kt new file mode 100644 index 0000000..8d08b19 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/HistoryScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/example/gudariwallet/ui/HistoryViewModel.kt new file mode 100644 index 0000000..73caf0d --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/HistoryViewModel.kt @@ -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, // passed from WalletViewModel.selectedCurrency + val fiatSatsPerUnit : StateFlow // passed from WalletViewModel.fiatSatsPerUnit +) : ViewModel() { + + private val _state = MutableStateFlow(HistoryState.Loading) + val state: StateFlow = _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.Idle) + val detailState: StateFlow = _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> = aliasRepo.aliases + + private val _destinationState = MutableStateFlow>(emptyMap()) + val destinationState: StateFlow> = _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, + private val fiatSatsPerUnit: StateFlow +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return HistoryViewModel( + repo = repo, + aliasRepo = aliasRepo, + paymentCache = paymentCache, + fiatCurrency = fiatCurrency, + fiatSatsPerUnit = fiatSatsPerUnit + ) as T + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/HomeTab.kt b/app/src/main/java/com/example/gudariwallet/ui/HomeTab.kt new file mode 100644 index 0000000..6cb3561 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/HomeTab.kt @@ -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>(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, + 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) + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/example/gudariwallet/ui/NotificationPermissionScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/NotificationPermissionScreen.kt new file mode 100644 index 0000000..b0b54e2 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/NotificationPermissionScreen.kt @@ -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( + 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") + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/OnboardingScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/OnboardingScreen.kt new file mode 100644 index 0000000..c242eb0 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/OnboardingScreen.kt @@ -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(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") + } + } +} + diff --git a/app/src/main/java/com/example/gudariwallet/ui/PaymentSuccessScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/PaymentSuccessScreen.kt new file mode 100644 index 0000000..6580402 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/PaymentSuccessScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/QrScannerScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/QrScannerScreen.kt new file mode 100644 index 0000000..da56fab --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/QrScannerScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/ReceiveScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/ReceiveScreen.kt new file mode 100644 index 0000000..d0f5b1e --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/ReceiveScreen.kt @@ -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(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(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") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/SendScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/SendScreen.kt new file mode 100644 index 0000000..932f455 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/SendScreen.kt @@ -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") } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/UiStates.kt b/app/src/main/java/com/example/gudariwallet/ui/UiStates.kt new file mode 100644 index 0000000..6cfd602 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/UiStates.kt @@ -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 { + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} + +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 = emptyList(), + override val routeHintAliases : Map = 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 = emptyList(), + override val routeHintAliases : Map = 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, + 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 + val routeHintAliases : Map +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/example/gudariwallet/ui/WalletScreen.kt new file mode 100644 index 0000000..f3d6cd2 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/WalletScreen.kt @@ -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, + pendingPaymentUri : MutableState // 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") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/example/gudariwallet/ui/WalletViewModel.kt new file mode 100644 index 0000000..c59cd59 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/WalletViewModel.kt @@ -0,0 +1,1038 @@ +package com.example.gudariwallet.ui + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.gudariwallet.api.LnurlScanResponse +import com.example.gudariwallet.data.LnurlCacheRepository +import com.example.gudariwallet.data.NodeAliasRepository +import com.example.gudariwallet.data.PaymentCacheRepository +import com.example.gudariwallet.data.WalletRepository +import com.example.gudariwallet.service.WalletNotificationService +import com.example.gudariwallet.util.LnurlMetadataParser +import com.example.gudariwallet.util.RouteHintAnalyzer +import com.example.gudariwallet.util.SendInputDetector +import com.example.gudariwallet.util.SendInputType +import com.example.gudariwallet.util.WalletConstants +import com.example.gudariwallet.util.satsToFiat +import com.example.gudariwallet.util.parseApiError +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import androidx.core.content.edit +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.SharingStarted +import com.example.gudariwallet.util.lnurlProtocolError +import androidx.fragment.app.FragmentActivity +import com.example.gudariwallet.util.BiometricHelper + + +private const val TAG = "WalletViewModel" + +class WalletViewModel( + val repo : WalletRepository, + context : Context, + val aliasRepo : NodeAliasRepository, + private val lnurlCache: LnurlCacheRepository, + val paymentCache: PaymentCacheRepository +) : ViewModel() { + + // ── Balance prefs ───────────────────────────────────────────────────────── + + private val balancePrefs: SharedPreferences = context.getSharedPreferences( + WalletConstants.BALANCE_PREFS_NAME, Context.MODE_PRIVATE + ) + + // ── Balance state ───────────────────────────────────────────────────────── + + private val _balanceState = MutableStateFlow(BalanceState.Loading) + val balanceState: StateFlow = _balanceState + + // ── Balance visible state ───────────────────────────────────────────────── + + private val _balanceHidden = MutableStateFlow( + balancePrefs.getBoolean("balance_hidden", false) + ) + val balanceHidden: StateFlow = _balanceHidden + + fun toggleBalanceVisibility() { + val next = !_balanceHidden.value + _balanceHidden.value = next + balancePrefs.edit { putBoolean("balance_hidden", next) } + } + + // ── Fiat cache ──────────────────────────────────────────────────────────── + private var cachedRate : Double? = null // in-memory rate value + private var rateLastFetchedAt : Long = 0L // epoch ms of last fetch + private var cachedRateCurrency : String = "" // currency the cached rate is for + private var cachedCurrencies : List? = null + val fiatRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() } + val fiatCurrency: String? get() = _selectedCurrency.value + val fiatSatsPerUnit: StateFlow = balanceState + .mapNotNull { state -> + (state as? BalanceState.Success)?.let { s -> + if (s.fiatAmount != null && s.sats > 0) + s.sats.toDouble() / s.fiatAmount + else null + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = null + ) + private val _selectedCurrency = MutableStateFlow( + balancePrefs.getString("fiat_currency", null) + ) + val selectedCurrency: StateFlow = _selectedCurrency + + fun setSelectedCurrency(currency: String?) { + if (currency == _selectedCurrency.value) return + _selectedCurrency.value = currency + if (currency != null) { + balancePrefs.edit { putString("fiat_currency", currency) } + } else { + // Remove the key so next cold start also starts with null + balancePrefs.edit { remove("fiat_currency") } + } + // Invalidate rate cache regardless + cachedRate = null + rateLastFetchedAt = 0L + cachedRateCurrency = "" + Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") + if (currency != null) { + viewModelScope.launch { fetchAndApplyFiatRate() } + } else { + // Clear fiat from balance state immediately + val current = _balanceState.value as? BalanceState.Success ?: return + _balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null) + } + } + + companion object { + private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes + } + + + // ── Other states ────────────────────────────────────────────────────────── + + private val _receiveState = MutableStateFlow(ReceiveState.Idle) + val receiveState: StateFlow = _receiveState + + private val _sendState = MutableStateFlow(SendState.Idle) + val sendState: StateFlow = _sendState + + private val _navigateToReceive = MutableSharedFlow(extraBufferCapacity = 1) + val navigateToReceive: SharedFlow = _navigateToReceive + + private val _lightningAddress = MutableStateFlow( + balancePrefs.getString("lightning_address", null) + ) + val lightningAddress: StateFlow = _lightningAddress + + // ── Init ────────────────────────────────────────────────────────────────── + + init { + loadBalanceWithCache() + observePaymentEvents() + fetchLightningAddress() + } + + // ── Balance loading ─────────────────────────────────────────────────────── + + private fun loadBalanceWithCache() { + val savedSats = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L) + val savedAt = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L) + val ageMs = System.currentTimeMillis() - savedAt + + if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) { + Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately") + _balanceState.value = BalanceState.Success( + sats = savedSats, + isRefreshing = true, + lastUpdated = savedAt + ) + } else { + if (savedSats >= 0) { + Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL ${WalletConstants.BALANCE_CACHE_TTL_MS / 1000}s — cold load") + } else { + Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load") + } + _balanceState.value = BalanceState.Loading + } + + refreshBalance() + } + + fun refreshBalance() { + val current = _balanceState.value + if (current is BalanceState.Success && !current.isRefreshing) { + Log.d(TAG, "BALANCE [REFRESH ] Manual refresh triggered (current: ${current.sats} sats)") + _balanceState.value = current.copy(isRefreshing = true) + } + + viewModelScope.launch { + runCatching { repo.getBalance() } + .onSuccess { balance -> + val sats = balance.sats + val prev = (_balanceState.value as? BalanceState.Success)?.sats + when { + prev == null -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — cold load complete") + prev == sats -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — matches cache, no change") + else -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})") + } + _balanceState.value = BalanceState.Success( + sats = sats, + isRefreshing = false, + lastUpdated = System.currentTimeMillis() + ) + persistBalance(sats) + fetchAndApplyFiatRate() + } + .onFailure { e -> + Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}") + val staleSats = (_balanceState.value as? BalanceState.Success)?.sats + ?: balancePrefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L).takeIf { it >= 0 } + if (staleSats != null) { + Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats") + } else { + Log.w(TAG, "BALANCE [NET ERROR ] No stale value available — showing error state") + } + _balanceState.value = BalanceState.Error( + message = e.message ?: "Failed to load balance", + staleSats = staleSats + ) + } + } + } + + private fun persistBalance(sats: Long) { + balancePrefs.edit { + putLong(WalletConstants.BALANCE_PREFS_KEY, sats) + .putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis()) + } + Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences") + } + + // ── Fiat rate fetch + apply ─────────────────────────────────────────────── + private suspend fun fetchAndApplyFiatRate() { + val currency = _selectedCurrency.value ?: return + val now = System.currentTimeMillis() + + // ── 1. Serve from in-memory cache if still fresh ────────────────────── + val inMemoryFresh = cachedRate != null + && cachedRateCurrency == currency + && (now - rateLastFetchedAt) < RATE_TTL_MS + + val rate: Double = if (inMemoryFresh) { + Log.d(TAG, "FIAT [RATE CACHE ] $currency — in-memory hit (age ${(now - rateLastFetchedAt) / 1000}s)") + cachedRate!! + } else { + // ── 2. Fetch from network ───────────────────────────────────────── + runCatching { repo.getFiatRate(currency) } + .getOrElse { e -> + Log.w(TAG, "FIAT [RATE ERROR ] $currency — ${e.message}") + // ── 3. Fall back to last persisted rate ─────────────────── + val persisted = balancePrefs.getFloat("fiat_rate_$currency", 0f).toDouble() + if (persisted > 0.0) { + Log.w(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted") + persisted + } else { + Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display") + return // nothing to show; leave fiatAmount = null + } + } + .also { fresh -> + // Update in-memory cache + cachedRate = fresh + rateLastFetchedAt = now + cachedRateCurrency = currency + // Persist for next cold start + balancePrefs.edit { + putFloat("fiat_rate_$currency", fresh.toFloat()) + .putLong("fiat_rate_fetched_at_$currency", now) + } + Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $fresh sats — cached + persisted") + } + } + // ── 4. Apply to current balance state ───────────────────────────────── + val current = _balanceState.value as? BalanceState.Success ?: return + val fiat = satsToFiat(current.sats, rate) + Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency") + _balanceState.value = current.copy( + fiatAmount = fiat, + fiatCurrency = currency + ) + } + + // ── Currency list ──────────────────────────────────────────────────────── + suspend fun getSupportedCurrencies(): List { + // 1. In-memory + cachedCurrencies?.let { + Log.d(TAG, "FIAT [CURRENCIES] in-memory hit (${it.size} currencies)") + return it + } + // 2. SharedPreferences + val json = balancePrefs.getString("fiat_currencies_json", null) + if (json != null) { + return try { + val fromPrefs = json + .removeSurrounding("[", "]") + .split(",") + .map { it.trim().removeSurrounding("\"") } + .filter { it.isNotBlank() } + Log.d(TAG, "FIAT [CURRENCIES] prefs hit (${fromPrefs.size} currencies)") + cachedCurrencies = fromPrefs + fromPrefs + } catch (e: Exception) { + Log.w(TAG, "FIAT [CURRENCIES] prefs parse failed — fetching from network") + fetchAndCacheCurrencies() + } + } + // 3. Network + return fetchAndCacheCurrencies() + } + + private suspend fun fetchAndCacheCurrencies(): List { + return runCatching { repo.getSupportedCurrencies() } + .getOrElse { e -> + Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}") + emptyList() + } + .also { list -> + if (list.isNotEmpty()) { + cachedCurrencies = list + // Persist as a simple JSON array string + val json = "[${list.joinToString(",") { "\"$it\"" }}]" + balancePrefs.edit { putString("fiat_currencies_json", json) } + Log.d(TAG, "FIAT [CURRENCIES] fetched + cached ${list.size} currencies") + } + } + } + + // ── WebSocket event collection ──────────────────────────────────────────── + + private fun observePaymentEvents() { + viewModelScope.launch { + WalletNotificationService.paymentEvents.collect { event -> + Log.d(TAG, "BALANCE [WS EVENT ] ${if (event.isOutgoing) "SENT" else "RECEIVED"} ${event.amountSats} sats") + + val current = _balanceState.value + if (current is BalanceState.Success) { + val delta = if (event.isOutgoing) -event.amountSats else event.amountSats + val newSats = (current.sats + delta).coerceAtLeast(0L) + Log.d(TAG, "BALANCE [WS DELTA ] ${current.sats} ${if (delta >= 0) "+" else ""}$delta = $newSats sats (optimistic)") + _balanceState.value = current.copy(sats = newSats, isRefreshing = true) + persistBalance(newSats) + } else { + Log.w(TAG, "BALANCE [WS EVENT ] Received event but balance state is ${current::class.simpleName} — skipping delta") + } + + refreshBalance() + fetchAndApplyFiatRate() + + if (!event.isOutgoing) { + val rs = _receiveState.value + if (rs is ReceiveState.InvoiceReady) { + Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received") + _receiveState.value = ReceiveState.PaymentReceived( + amountSats = event.amountSats, + memo = event.memo + ) + } + } + } + } + } + + + private fun fetchLightningAddress() { + // If we already have a persisted address, show it immediately and + // still refresh in the background (address could have changed). + viewModelScope.launch { + runCatching { repo.getLightningAddress() } + .onSuccess { address -> + if (address != null) { + Log.d(TAG, "LNADDR [FETCH OK ] $address") + _lightningAddress.value = address + balancePrefs.edit { putString("lightning_address", address) } + } else { + Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found") + } + } + .onFailure { e -> + Log.w(TAG, "LNADDR [FETCH ERR] ${e.message} — keeping cached value") + // Keep whatever was loaded from prefs; don't clear it on network error + } + } + } + + // ── Receive ─────────────────────────────────────────────────────────────── + + fun createInvoice(amountSats: Long, memo: String) { + viewModelScope.launch { + _receiveState.value = ReceiveState.AwaitingInvoice + runCatching { repo.createInvoice(amountSats, memo) } + .onSuccess { invoice -> + val fallbackExpiry = System.currentTimeMillis() / 1000L + 600L + val expiresAt = invoice.expiry?.let { isoString -> + runCatching { + java.time.LocalDateTime + .parse(isoString, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .toEpochSecond(java.time.ZoneOffset.UTC) + }.getOrNull() + } ?: fallbackExpiry + _receiveState.value = ReceiveState.InvoiceReady( + bolt11 = invoice.bolt11, + paymentHash = invoice.paymentHash, + amountSats = amountSats, + memo = memo, + expiresAt = expiresAt + ) + } + .onFailure { e -> + Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}") + _receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) + } + } + } + + fun resetReceiveState() { _receiveState.value = ReceiveState.Idle } + + // ── Send ────────────────────────────────────────────────────────────────── + + fun scan(rawInput: String) { + val normalized = SendInputDetector.normalize(rawInput) + val type = SendInputDetector.detect(rawInput) + Log.d("SCAN_DEBUG", "input='$rawInput' → type=$type") + + if (type == SendInputType.Unknown) { + val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { + "Lightning Login (LNURL-auth) is not yet supported" + } else { + "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" + } + _sendState.value = SendState.Error(msg) + return + } + if (type == SendInputType.Bip21) { + val raw = rawInput.trim() + val query = raw.substringAfter("?", missingDelimiterValue = "") + val params = query.split("&").associate { pair -> + val key = pair.substringBefore("=") + val value = pair.substringAfter("=", missingDelimiterValue = "") + .replace("+", " ") + .let { java.net.URLDecoder.decode(it, "UTF-8") } + key to value + } + val bolt11 = params["lightning"] + val lnurl = params["lnurl"] ?: params["lnurlp"] + + return when { + bolt11 != null -> scan(bolt11) + lnurl != null -> scan(lnurl) + else -> { + _sendState.value = SendState.Error( + "On-chain Bitcoin payments are not supported — " + + "share a BIP-21 URI with a lightning= parameter" + ) + } + } + } + if (type == SendInputType.Lud17Url) { + val scheme = rawInput.trim().lowercase().substringBefore("://") + if (scheme == "lnurlc") { + _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") + return + } + val httpsUrl = "https://" + rawInput.trim().substringAfter("://") + Log.d(TAG, "LUD17 [FETCH] $httpsUrl") + _sendState.value = SendState.Scanning + viewModelScope.launch { + runCatching { repo.fetchLnurlDirect(httpsUrl) } + .onSuccess { raw -> + Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") + when (raw.tag) { + "withdrawRequest" -> handleWithdrawResponse(raw, httpsUrl) + "payRequest" -> { + lnurlCache.put(httpsUrl, raw) + _sendState.value = SendState.LnurlReady( + callback = raw.callback ?: "", + description = LnurlMetadataParser.parseDescription(raw.metadata), + domain = LnurlMetadataParser.extractDomain(raw.callback), + minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + commentAllowed = raw.commentAllowed ?: 0, + lnurl = httpsUrl, + rawRes = raw + ) + } + else -> _sendState.value = SendState.Error( + "Unsupported LNURL type: ${raw.tag ?: "unknown"}" + ) + } + } + .onFailure { e -> + Log.e(TAG, "LUD17 [ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + return + } + viewModelScope.launch { + _sendState.value = SendState.Scanning + + if (type == SendInputType.Bolt11) { + runCatching { repo.decodeBolt11(normalized) } + .onSuccess { decoded -> + val routeRisk = RouteHintAnalyzer.analyze( + amountMsat = decoded.amountSats * 1_000L, + hints = decoded.routeHints + ) + _sendState.value = SendState.Bolt11Decoded( + bolt11 = normalized, + amountSats = decoded.amountSats, + memo = decoded.memo, + payee = decoded.payee, + nodeAlias = null, + routeRisk = routeRisk, + allRouteHints = decoded.routeHints, + routeHintAliases = emptyMap() + ) + decoded.payee?.let { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) + val current = _sendState.value + if (alias != null && + current is SendState.Bolt11Decoded && + current.payee == pubkey) { + _sendState.value = current.copy(nodeAlias = alias) + } + } + } + decoded.routeHints + .map { it.publicKey } + .distinct() + .forEach { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) ?: return@launch + val current = _sendState.value + if (current is SendState.Bolt11Decoded) { + _sendState.value = current.copy( + routeHintAliases = current.routeHintAliases + (pubkey to alias) + ) + } + } + } + } + .onFailure { e -> + Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + } + return@launch + } + + // ── LNURL / Lightning Address ───────────────────────────────────── + + // 1. Try cache first — skips the lnurlscan round trip entirely + val cacheKey = normalized + val cached = lnurlCache.get(cacheKey) + if (cached != null) { + Log.d(TAG, "LNURL [SCAN CACHE ] $cacheKey — skipping lnurlscan network call") + when (cached.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(cached, normalized) + "withdrawRequest" -> handleWithdrawResponse(cached, normalized) + else -> _sendState.value = buildLnurlReadyState(cached, normalized) + } + return@launch + } + Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized") + + // 2. Cache miss — try client-side resolution first (avoids server proxy) + val directResult = runCatching { repo.scanLnurlDirect(normalized) } + + if (directResult.isSuccess) { + val scanned = directResult.getOrThrow() + Log.d(TAG, "LNURL [SCAN DIRECT] tag=${scanned.tag} for $normalized") + val protocolError = lnurlProtocolError(scanned.rawRes) + if (protocolError != null) { + Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") + _sendState.value = SendState.Error(protocolError) + return@launch + } + lnurlCache.put(cacheKey, scanned.rawRes) // cache on client-side success + when (scanned.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) + "withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + return@launch + } + + Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to server proxy") + + // 3. Client-side failed — fall back to server proxy + runCatching { repo.scanLnurl(normalized) } + .onSuccess { scanned -> + Log.d(TAG, "LNURL [SCAN PROXY ] tag=${scanned.tag} for $normalized") + val protocolError = lnurlProtocolError(scanned.rawRes) + if (protocolError != null) { + Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") + _sendState.value = SendState.Error(protocolError) + return@onSuccess + } + lnurlCache.put(cacheKey, scanned.rawRes) // cache on proxy success too + when (scanned.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) + "withdrawRequest" -> handleWithdrawResponse(scanned.rawRes, normalized) + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + } + .onFailure { e -> + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + } + + private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) { + val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT + val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT + _receiveState.value = ReceiveState.LnurlWithdrawReady( + k1 = raw.k1 ?: "", + callback = raw.callback ?: "", + defaultDescription = raw.defaultDescription ?: "", + minSats = minSats, + maxSats = maxSats, + domain = LnurlMetadataParser.extractDomain(raw.callback), + lnurl = lnurl + ) + _sendState.value = SendState.Idle // clear scanning spinner + _navigateToReceive.tryEmit(Unit) + Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive") + } + + fun executeWithdraw( + k1 : String, + callback : String, + amountSats: Long, + memo : String + ) { + viewModelScope.launch { + _receiveState.value = ReceiveState.AwaitingInvoice + + val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) } + if (invoiceResult.isFailure) { + val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice") + Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}") + _receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) + return@launch + } + val invoice = invoiceResult.getOrThrow() + Log.d(TAG, "LNURL-WITHDRAW [INVOICE] ${invoice.paymentHash} — submitting to callback") + + runCatching { repo.executeWithdraw(callback, k1, invoice.bolt11) } + .onSuccess { response -> + if (response.status == "OK") { + Log.d(TAG, "LNURL-WITHDRAW [CALLBACK OK] waiting for push payment") + _receiveState.value = ReceiveState.InvoiceReady( + bolt11 = invoice.bolt11, + paymentHash = invoice.paymentHash, + amountSats = amountSats, + memo = memo, + expiresAt = System.currentTimeMillis() / 1000L + 600L + ) + } else { + val reason = response.reason + ?.ifBlank { null } + ?: "Withdraw service rejected the invoice" + Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason") + _receiveState.value = ReceiveState.Error(reason) + } + } + .onFailure { e -> + Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}") + _receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed")) + } + } + } + + fun payBolt11(bolt11: String) { + viewModelScope.launch { + val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L + _sendState.value = SendState.Paying + runCatching { repo.payBolt11(bolt11) } + .onSuccess { response -> + val feeSats = runCatching { + repo.getPaymentDetail(response.paymentHash) + .details + ?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountSats, + feeSats = feeSats + ) + refreshBalance() + } + .onFailure { e -> + Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Payment failed")) + } + } + } + + + fun payLnurl( + rawRes : LnurlScanResponse, + lnurl : String, + amountMsat: Long, + comment : String? + ) { + viewModelScope.launch { + _sendState.value = SendState.Paying + + // ── 1. Try client-side pay first ────────────────────────────────────── + val directResult = runCatching { + repo.payLnurlDirect(rawRes, amountMsat, comment) + } + + if (directResult.isSuccess) { + val feeSats = runCatching { + repo.getPaymentDetail(directResult.getOrThrow().paymentHash) + .details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + feeSats = feeSats + ) + refreshBalance() + return@launch + } + + Log.w(TAG, "LNURL [PAY DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to server proxy") + + // ── 2. Client-side failed — try server proxy ─────────────────────────── + val firstResult = runCatching { repo.payLnurl(rawRes, lnurl, amountMsat, comment) } + + if (firstResult.isSuccess) { + val feeSats = runCatching { + repo.getPaymentDetail(firstResult.getOrThrow().paymentHash) + .details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + feeSats = feeSats + ) + refreshBalance() + return@launch + } + + // ── 3. Both failed — check if worth retrying ─────────────────────────── + val firstError = firstResult.exceptionOrNull() + if (isTerminalPayError(firstError)) { + Log.w(TAG, "LNURL [PAY TERMINAL] ${firstError?.message} — not retrying") + _sendState.value = SendState.Error( + parseApiError(firstError ?: Exception("Payment failed"), "Payment failed") + ) + return@launch + } + + Log.w(TAG, "LNURL [PAY RETRY ] ${firstError?.message} — invalidating cache and retrying with fresh scan") + lnurlCache.invalidate(lnurl) + + // ── 4. Re-fetch metadata — try client-side first, then proxy ────────── + val freshRaw: LnurlScanResponse = run rescan@{ + // Try client-side rescan + val directRescan = runCatching { repo.scanLnurlDirect(lnurl) } + if (directRescan.isSuccess) { + Log.d(TAG, "LNURL [RESCAN DIRECT] success") + return@rescan directRescan.getOrThrow().rawRes + } + + Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${directRescan.exceptionOrNull()?.message} — trying proxy rescan") + val proxyRescan = runCatching { repo.scanLnurl(lnurl) } + if (proxyRescan.isSuccess) { + return@rescan proxyRescan.getOrThrow().rawRes + } + + Log.e(TAG, "LNURL [RESCAN ERR] ${proxyRescan.exceptionOrNull()?.message}") + _sendState.value = SendState.Error( + parseApiError( + proxyRescan.exceptionOrNull() ?: Exception(), + "Payment failed — could not re-fetch address" + ) + ) + return@launch + } + val scanProtocolError = lnurlProtocolError(freshRaw) + if (scanProtocolError != null) { + Log.w(TAG, "LNURL [RESCAN PROTO] $scanProtocolError") + _sendState.value = SendState.Error(scanProtocolError) + return@launch + } + + lnurlCache.put(lnurl, freshRaw) + + // ── 5. Final attempt — client-side first, then proxy ────────────────── + val finalDirectResult = runCatching { + repo.payLnurlDirect(freshRaw, amountMsat, comment) + } + if (finalDirectResult.isSuccess) { + val feeSats = runCatching { + repo.getPaymentDetail(finalDirectResult.getOrThrow().paymentHash) + .details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + feeSats = feeSats + ) + refreshBalance() + return@launch + } + + runCatching { repo.payLnurl(freshRaw, lnurl, amountMsat, comment) } + .onSuccess { result -> + val feeSats = runCatching { + repo.getPaymentDetail(result.paymentHash).details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + feeSats = feeSats + ) + refreshBalance() + } + .onFailure { e -> + Log.e(TAG, "LNURL [PAY FINAL ERR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Payment failed")) + } + } + } + + + private fun isTerminalPayError(e: Throwable?): Boolean { + if (e == null) return false + val msg = e.message?.lowercase() ?: return false + return msg.contains("insufficient balance") || + msg.contains("unauthorized") || + msg.contains("amount") && msg.contains("bound") + } + + // ── Step 1: fetch the LNURL callback invoice, decode it, analyse route hints + // Called after the user taps "Pay" in LnurlPayForm and passes biometric auth. + // Emits LnurlInvoiceReady (with routeRisk populated if fees are elevated). + fun fetchLnurlInvoice( + rawRes : LnurlScanResponse, + lnurl : String, + domain : String, + amountMsat: Long, + comment : String? + ) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + + // ── 1. Hit the callback URL — returns the raw bolt11 string + val bolt11 = runCatching { + repo.fetchLnurlCallbackInvoice(rawRes, amountMsat, comment) + }.getOrElse { e -> + Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}") + _sendState.value = SendState.Error( + parseApiError(e, "Could not fetch invoice from recipient") + ) + return@launch + } + + // ── 2. Decode the bolt11 to get memo and route hints + val decoded = runCatching { + repo.decodeBolt11(bolt11) + }.getOrElse { e -> + Log.e(TAG, "LNURL [DECODE ERR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + return@launch + } + + // ── 3. Run route-hint fee analysis (same logic as the Bolt11 path) + val routeRisk = RouteHintAnalyzer.analyze( + amountMsat = amountMsat, + hints = decoded.routeHints + ) + + Log.d(TAG, "LNURL [INVOICE READY] amountMsat=$amountMsat " + + "routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}") + + // ── 4. Emit LnurlInvoiceReady — UI shows warning card if risk != null + _sendState.value = SendState.LnurlInvoiceReady( + bolt11 = bolt11, + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + memo = decoded.memo, + domain = domain, + lnurl = lnurl, + rawRes = rawRes, + amountMsat = amountMsat, + comment = comment, + payee = decoded.payee, + nodeAlias = null, + routeRisk = routeRisk, + allRouteHints = decoded.routeHints, + routeHintAliases = emptyMap() + ) + + decoded.payee?.let { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) + val current = _sendState.value + if (alias != null && + current is SendState.LnurlInvoiceReady && + current.payee == pubkey) { + _sendState.value = current.copy(nodeAlias = alias) + } + } + } + + // ── 5. Resolve node aliases for route-hint hops in the background + decoded.routeHints + .map { it.publicKey } + .distinct() + .forEach { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) ?: return@launch + val current = _sendState.value + if (current is SendState.LnurlInvoiceReady) { + _sendState.value = current.copy( + routeHintAliases = current.routeHintAliases + (pubkey to alias) + ) + } + } + } + } + } + + // ── Step 2: pay the bolt11 that was fetched in fetchLnurlInvoice + // Called after the user confirms in LnurlInvoiceConfirmCard. + // Tries payBolt11 directly first; falls back to the full payLnurl path + // (which includes rescan/retry logic) if the direct attempt fails. + fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) { + viewModelScope.launch { + _sendState.value = SendState.Paying + + // ── 1. Direct bolt11 payment — fastest path, no server round-trip + val directResult = runCatching { repo.payBolt11(state.bolt11) } + + if (directResult.isSuccess) { + val feeSats = runCatching { + repo.getPaymentDetail(directResult.getOrThrow().paymentHash) + .details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = state.amountSats, + feeSats = feeSats + ) + refreshBalance() + return@launch + } + + Log.w(TAG, "LNURL [PAY INVOICE DIRECT FAIL] " + + "${directResult.exceptionOrNull()?.message} — falling back to payLnurl") + + // ── 2. Direct failed — delegate to payLnurl which has full + // rescan/retry logic and handles both direct + proxy paths + payLnurl( + rawRes = state.rawRes, + lnurl = state.lnurl, + amountMsat = state.amountMsat, + comment = state.comment + ) + } + } + + /** + * Gate for payment confirmation. Shows a biometric/device-credential prompt + * if the device supports it; calls [pay] directly if it does not. + * + * On a hard auth failure the send state is set to [SendState.Error] so the + * UI surfaces the message without any extra wiring. + */ + fun requestPayment( + activity: FragmentActivity, + subtitle: String, + pay : () -> Unit + ) { + if (!BiometricHelper.canAuthenticate(activity)) { + // Device has no enrolled biometric or credential — pay directly. + pay() + return + } + BiometricHelper.prompt( + activity = activity, + subtitle = subtitle, + onSuccess = pay, + onError = { _, msg -> + _sendState.value = SendState.Error("Authentication failed: $msg") + } + ) + } + + fun resetSendState() { _sendState.value = SendState.Idle } + + // ── Clipboard offer ─────────────────────────────────────────────────────── + + private var lastOfferedClipboard: String? = null + + private val _clipboardOfferState = MutableStateFlow(ClipboardOfferState.None) + val clipboardOfferState: StateFlow = _clipboardOfferState + + fun checkClipboard(text: String?) { + Log.d("Clipboard", "checkClipboard called with: $text") + val trimmed = text?.trim().takeIf { !it.isNullOrBlank() } ?: return + + // ── Deduplicate: don't re-offer the same string twice ───────────────── + if (trimmed == lastOfferedClipboard) return + + // ── Guard: ignore our own invoice ───────────────────────────────────── + val ownInvoice = (_receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 + if (ownInvoice != null && trimmed.equals(ownInvoice, ignoreCase = true)) { + Log.d("Clipboard", "Skipping own invoice") + lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger + return + } + + // ── Guard: ignore our own lightning address ──────────────────────────── + val ownAddress = _lightningAddress.value + if (ownAddress != null && trimmed.equals(ownAddress, ignoreCase = true)) { + Log.d("Clipboard", "Skipping own lightning address") + lastOfferedClipboard = trimmed + return + } + + val type = SendInputDetector.detect(trimmed) + if (type == SendInputType.Unknown) return + + lastOfferedClipboard = trimmed + _clipboardOfferState.value = ClipboardOfferState.Detected(raw = trimmed, inputType = type) + } + + + fun dismissClipboardOffer() { + _clipboardOfferState.value = ClipboardOfferState.None + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady { + return SendState.LnurlReady( + callback = raw.callback ?: "", + description = LnurlMetadataParser.parseDescription(raw.metadata), + domain = LnurlMetadataParser.extractDomain(raw.callback), + minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + commentAllowed = raw.commentAllowed ?: 0, + lnurl = lnurl, + rawRes = raw + ) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/example/gudariwallet/ui/WalletViewModelFactory.kt new file mode 100644 index 0000000..ad6e71b --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/WalletViewModelFactory.kt @@ -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 create(modelClass: Class): 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/components/CommonComponents.kt b/app/src/main/java/com/example/gudariwallet/ui/components/CommonComponents.kt new file mode 100644 index 0000000..fc0c64d --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/components/CommonComponents.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/components/UnitWheelPicker.kt b/app/src/main/java/com/example/gudariwallet/ui/components/UnitWheelPicker.kt new file mode 100644 index 0000000..4e39107 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/components/UnitWheelPicker.kt @@ -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, + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/receive/AmountDisplay.kt b/app/src/main/java/com/example/gudariwallet/ui/receive/AmountDisplay.kt new file mode 100644 index 0000000..32de061 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/receive/AmountDisplay.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/receive/BrightnessToggleButton.kt b/app/src/main/java/com/example/gudariwallet/ui/receive/BrightnessToggleButton.kt new file mode 100644 index 0000000..d459c17 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/receive/BrightnessToggleButton.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/receive/QrDisplayCard.kt b/app/src/main/java/com/example/gudariwallet/ui/receive/QrDisplayCard.kt new file mode 100644 index 0000000..a34cf37 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/receive/QrDisplayCard.kt @@ -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 + } +} + diff --git a/app/src/main/java/com/example/gudariwallet/ui/send/ConfirmCard.kt b/app/src/main/java/com/example/gudariwallet/ui/send/ConfirmCard.kt new file mode 100644 index 0000000..725a464 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/send/ConfirmCard.kt @@ -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") } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/example/gudariwallet/ui/send/LnurlPayForm.kt new file mode 100644 index 0000000..32a39a9 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/send/LnurlPayForm.kt @@ -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" + ) + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/ui/send/RouteHintWarning.kt b/app/src/main/java/com/example/gudariwallet/ui/send/RouteHintWarning.kt new file mode 100644 index 0000000..17c88a9 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/send/RouteHintWarning.kt @@ -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, + routeHintAliases: Map, +) { + 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, + aliases : Map, + 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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/theme/Color.kt b/app/src/main/java/com/example/gudariwallet/ui/theme/Color.kt new file mode 100644 index 0000000..811a164 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/theme/SemanticColors.kt b/app/src/main/java/com/example/gudariwallet/ui/theme/SemanticColors.kt new file mode 100644 index 0000000..cbf1988 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/theme/SemanticColors.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/theme/Theme.kt b/app/src/main/java/com/example/gudariwallet/ui/theme/Theme.kt new file mode 100644 index 0000000..7a9c8ef --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/ui/theme/Type.kt b/app/src/main/java/com/example/gudariwallet/ui/theme/Type.kt new file mode 100644 index 0000000..b24ee1a --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/util/ApiErrorParser.kt b/app/src/main/java/com/example/gudariwallet/util/ApiErrorParser.kt new file mode 100644 index 0000000..1f27e93 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/ApiErrorParser.kt @@ -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" +} + + diff --git a/app/src/main/java/com/example/gudariwallet/util/BiometricHelper.kt b/app/src/main/java/com/example/gudariwallet/util/BiometricHelper.kt new file mode 100644 index 0000000..46fedf7 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/BiometricHelper.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/CurrencyFormatter.kt b/app/src/main/java/com/example/gudariwallet/util/CurrencyFormatter.kt new file mode 100644 index 0000000..e36f73d --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/CurrencyFormatter.kt @@ -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 diff --git a/app/src/main/java/com/example/gudariwallet/util/IntentUtils.kt b/app/src/main/java/com/example/gudariwallet/util/IntentUtils.kt new file mode 100644 index 0000000..fecb6e6 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/IntentUtils.kt @@ -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 + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/LnurlBech32.kt b/app/src/main/java/com/example/gudariwallet/util/LnurlBech32.kt new file mode 100644 index 0000000..69d05f7 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/LnurlBech32.kt @@ -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, + fromBits: Int, + toBits : Int, + pad : Boolean + ): List? { + var acc = 0 + var bits = 0 + val out = mutableListOf() + 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 + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/LnurlMetadataParser.kt b/app/src/main/java/com/example/gudariwallet/util/LnurlMetadataParser.kt new file mode 100644 index 0000000..796eace --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/LnurlMetadataParser.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/util/NotificationConstants.kt b/app/src/main/java/com/example/gudariwallet/util/NotificationConstants.kt new file mode 100644 index 0000000..bc26c37 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/NotificationConstants.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/util/NotificationHelper.kt b/app/src/main/java/com/example/gudariwallet/util/NotificationHelper.kt new file mode 100644 index 0000000..c97e7aa --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/NotificationHelper.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/RouteHintAnalyzer.kt b/app/src/main/java/com/example/gudariwallet/util/RouteHintAnalyzer.kt new file mode 100644 index 0000000..66c2bf9 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/RouteHintAnalyzer.kt @@ -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): 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 + ) + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/SatExtensions.kt b/app/src/main/java/com/example/gudariwallet/util/SatExtensions.kt new file mode 100644 index 0000000..d2f25f0 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/SatExtensions.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/gudariwallet/util/SendInputDetector.kt b/app/src/main/java/com/example/gudariwallet/util/SendInputDetector.kt new file mode 100644 index 0000000..2357ef2 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/SendInputDetector.kt @@ -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() + } +} diff --git a/app/src/main/java/com/example/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/example/gudariwallet/util/WalletConstants.kt new file mode 100644 index 0000000..b619da4 --- /dev/null +++ b/app/src/main/java/com/example/gudariwallet/util/WalletConstants.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..4a09614 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..02a1baf --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Gudari Wallet + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bd2fca1 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6115950 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/com/example/gudariwallet/ExampleUnitTest.kt b/app/src/test/java/com/example/gudariwallet/ExampleUnitTest.kt new file mode 100644 index 0000000..1fe968b --- /dev/null +++ b/app/src/test/java/com/example/gudariwallet/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..18318be --- /dev/null +++ b/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..34c5e9e --- /dev/null +++ b/gradle.properties @@ -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 \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..934a357 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,61 @@ +[versions] +agp = "9.2.1" +biometric = "1.2.0-alpha05" +converterGson = "3.0.0" +coreKtx = "1.10.1" +gson = "2.14.0" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +kotlinxCoroutinesAndroid = "1.11.0" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.8.0" +kotlin = "2.2.10" +composeBom = "2026.02.01" +lifecycleViewmodelKtx = "2.10.0" +loggingInterceptor = "5.3.2" +okhttp = "5.3.2" +retrofit = "3.0.0" +zxing-embedded = "4.3.0" +navigationCompose = "2.8.9" +appcompat = "1.7.0" + + + +[libraries] +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit2-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } +zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxing-embedded" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..12eeaf3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Thu May 28 13:22:48 CEST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..62b1393 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Gudari Wallet" +include(":app") + \ No newline at end of file