commit 7f3d14a764753ec1f8e2a4810186d58734dcf015 Author: rasputin Date: Mon Jun 1 02:38:52 2026 +0200 initial commit 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 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ 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 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ 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 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ 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 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ 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 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ 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 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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