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