refactor: migrate to kotlinx serialization, eliminate gson dependency

This commit is contained in:
2026-06-10 23:06:11 +02:00
parent d1c3722781
commit e242c7b5d0
15 changed files with 326 additions and 262 deletions
+3
View File
@@ -7,12 +7,15 @@
<w>Nostr</w> <w>Nostr</w>
<w>Tink</w> <w>Tink</w>
<w>amboss</w> <w>amboss</w>
<w>cltv</w>
<w>hkdf</w> <w>hkdf</w>
<w>inflight</w> <w>inflight</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w> <w>lnbits</w>
<w>lnurlp</w> <w>lnurlp</w>
<w>msat</w> <w>msat</w>
<w>msats</w>
<w>nostr</w>
<w>satoshis</w> <w>satoshis</w>
<w>sats</w> <w>sats</w>
<w>snackbar</w> <w>snackbar</w>
+3 -2
View File
@@ -17,6 +17,7 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.protobuf) alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@@ -132,7 +133,7 @@ dependencies {
// Networking // Networking
implementation(libs.retrofit2.retrofit) implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson) implementation(libs.converter.kotlinx.serialization)
implementation(libs.okhttp) implementation(libs.okhttp)
debugImplementation(libs.logging.interceptor) debugImplementation(libs.logging.interceptor)
@@ -163,7 +164,7 @@ dependencies {
implementation(libs.androidx.biometric) implementation(libs.androidx.biometric)
// JSON // JSON
implementation(libs.gson) implementation(libs.kotlinx.serialization.json)
// icons // icons
implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material.icons.extended)
@@ -1,9 +1,16 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter import kotlinx.serialization.ExperimentalSerializationApi
import com.google.gson.stream.JsonReader import kotlinx.serialization.KSerializer
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonWriter import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
/** /**
* LNbits returns `comment` in PaymentExtra as either: * LNbits returns `comment` in PaymentExtra as either:
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
* - a JSON array ["Thanks!"] * - a JSON array ["Thanks!"]
* - null * - null
* *
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter * Mirrors the behaviour of the old Gson FlexibleStringAdapter.
* on the field for extra safety.
*/ */
class FlexibleStringAdapter : TypeAdapter<String?>() { object FlexibleStringSerializer : KSerializer<String?> {
override fun write(out: JsonWriter, value: String?) { override val descriptor: SerialDescriptor =
if (value == null) out.nullValue() else out.value(value) buildClassSerialDescriptor("FlexibleString")
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: String?) {
if (value == null) encoder.encodeNull()
else encoder.encodeString(value)
} }
override fun read(reader: JsonReader): String? { override fun deserialize(decoder: Decoder): String? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
JsonToken.BEGIN_ARRAY -> { is JsonNull -> null
val parts = mutableListOf<String>() is JsonPrimitive -> element.content // plain string → as-is
reader.beginArray() is JsonArray -> element // array → join elements
while (reader.hasNext()) { .mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
when (reader.peek()) { .joinToString(", ")
JsonToken.NULL -> reader.nextNull() .ifEmpty { null }
else -> parts.add(reader.nextString()) else -> null
}
}
reader.endArray()
parts.joinToString(", ").ifEmpty { null }
}
else -> reader.nextString()
} }
} }
} }
@@ -1,21 +0,0 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.Gson
import com.google.gson.GsonBuilder
/**
* Single source of truth for the app's Gson instance.
*
* All serialization/deserialization — Retrofit, SharedPreferences caches,
* and WebSocket message parsing — must use this instance so that registered
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
* everywhere.
*
* To add a new adapter: register it here once. It will automatically apply
* to every deserialization path in the app.
*/
object GsonProvider {
val gson: Gson = GsonBuilder()
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
.create()
}
@@ -0,0 +1,23 @@
package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.json.Json
/**
* Single source of truth for the app's Json instance.
*
* All serialization/deserialization — Retrofit, DataStore caches,
* and WebSocket message parsing — must use this instance so that
* configuration is applied consistently everywhere.
*
* Custom type serializers (e.g. [SuccessActionSerializer],
* [FlexibleStringSerializer]) are registered directly on the model
* classes via @Serializable(with = ...) so no explicit registration
* is needed here.
*/
object JsonProvider {
val json: Json = Json {
ignoreUnknownKeys = true // tolerate extra fields from the API
isLenient = true // accept unquoted/malformed JSON from some endpoints
explicitNulls = false // omit null fields when serializing requests
}
}
@@ -5,9 +5,10 @@ import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object LNbitsClient { object LNbitsClient {
@@ -59,8 +60,11 @@ object LNbitsClient {
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit; .baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .addConverterFactory(
.build() JsonProvider.json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
)
) .build()
.create(LNbitsApi::class.java) .create(LNbitsApi::class.java)
} }
} }
@@ -1,50 +1,66 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.google.gson.TypeAdapter import kotlinx.serialization.KSerializer
import com.google.gson.annotations.JsonAdapter import kotlinx.serialization.SerialName
import com.google.gson.annotations.SerializedName import kotlinx.serialization.Serializable
import com.google.gson.stream.JsonReader import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import com.google.gson.stream.JsonWriter import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
// ── Wallet ──────────────────────────────────────────────────────────────────── // ── Wallet ────────────────────────────────────────────────────────────────────
@Serializable
data class WalletResponse( data class WalletResponse(
val id: String, val id: String = "",
val name: String, val name: String = "",
val balance: Long // millisatoshis val balance: Long = 0L // millisatoshis
) )
// ── Create invoice ──────────────────────────────────────────────────────────── // ── Create invoice ────────────────────────────────────────────────────────────
@Serializable
data class CreateInvoiceRequest( data class CreateInvoiceRequest(
val out: Boolean, val out: Boolean,
val amount: Long, // satoshis val amount: Long, // satoshis
val memo: String val memo: String
) )
@Serializable
data class CreateInvoiceResponse( data class CreateInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String,
@SerializedName("payment_request") val paymentRequest: String, @SerialName("payment_request") val paymentRequest: String,
@SerializedName("expiry") val expiry : String? @SerialName("expiry") val expiry: String?
) )
// ── Pay invoice ─────────────────────────────────────────────────────────────── // ── Pay invoice ───────────────────────────────────────────────────────────────
@Serializable
data class PayInvoiceRequest( data class PayInvoiceRequest(
val out: Boolean, val out: Boolean,
val bolt11: String val bolt11: String
) )
@Serializable
data class PayInvoiceResponse( data class PayInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String
) )
// ── Check payment ───────────────────────────────────────────────────────────── // ── Check payment ─────────────────────────────────────────────────────────────
@Serializable
data class PaymentStatusResponse( data class PaymentStatusResponse(
val paid: Boolean, val paid: Boolean,
val details: PaymentDetails? = null val details: PaymentDetails? = null
) )
@Serializable
data class PaymentDetails( data class PaymentDetails(
val amount: Long?, val amount: Long?,
val fee: Long?, val fee: Long?,
@@ -52,82 +68,80 @@ data class PaymentDetails(
) )
// ── Decode BOLT-11 ──────────────────────────────────────────────────────────── // ── Decode BOLT-11 ────────────────────────────────────────────────────────────
@Serializable
data class DecodeInvoiceRequest( data class DecodeInvoiceRequest(
val data: String val data: String
) )
@Serializable
data class DecodeInvoiceResponse( data class DecodeInvoiceResponse(
@SerializedName("payment_hash") val paymentHash : String? = null, @SerialName("payment_hash") val paymentHash: String? = null,
@SerializedName("amount_msat") val amountMsat : Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
@SerializedName("description") val description : String? = null, @SerialName("description") val description: String? = null,
@SerializedName("payee") val payee : String? = null, @SerialName("payee") val payee: String? = null,
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null @SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
) )
@Serializable
data class RouteHintHop( data class RouteHintHop(
@SerializedName("public_key") val publicKey : String, @SerialName("public_key") val publicKey: String,
@SerializedName("short_channel_id") val shortChannelId : String? = null, @SerialName("short_channel_id") val shortChannelId: String? = null,
@SerializedName("base_fee") val baseFeeMsat : Long = 0, @SerialName("base_fee") val baseFeeMsat: Long = 0,
@SerializedName("ppm_fee") val ppmFee : Long = 0, @SerialName("ppm_fee") val ppmFee: Long = 0,
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0 @SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
) )
// ── LNURL scan ──────────────────────────────────────────────────────────────── // ── LNURL scan ────────────────────────────────────────────────────────────────
@Serializable
data class LnurlScanResponse( data class LnurlScanResponse(
val tag: String?, val tag: String?,
// ── payRequest fields ──────────────────────────────────────────────────── // ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null, val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long? = null, @SerialName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long? = null, @SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null, val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int? = null, @SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null, @SerialName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null, val bolt11: String? = null,
@SerializedName("amount_msat") val amountMsat: Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache) val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ─────────────────────────────────────────────── // ── withdrawRequest fields ───────────────────────────────────────────────
val k1 : String? = null, val k1: String? = null,
@SerializedName("defaultDescription") @SerialName("defaultDescription") val defaultDescription: String? = null,
val defaultDescription: String? = null, @SerialName("minWithdrawable") val minWithdrawable: Long? = null,
@SerializedName("minWithdrawable") @SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
val minWithdrawable : Long? = null, @SerialName("balanceCheck") val balanceCheck: String? = null,
@SerializedName("maxWithdrawable") @SerialName("currentBalance") val currentBalance: Long? = null,
val maxWithdrawable : Long? = null, // ── Error fields ──────────────────────────────────────────────────────────
@SerializedName("balanceCheck") val status: String? = null, // "OK" or "ERROR"
val balanceCheck : String? = null, val reason: String? = null // error message when status == "ERROR"
@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) ───────────────────────────────────── // ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
@Serializable
data class LnurlCallbackResponse( data class LnurlCallbackResponse(
val pr : String, // BOLT-11 invoice to pay val pr: String,
val routes : List<Any>? = null, val routes: List<JsonElement>? = null,
@SerializedName("successAction") @SerialName("successAction") val successAction: SuccessAction? = null,
val successAction : SuccessAction? = null, val status: String? = null,
// Error fields (server may return status=ERROR instead of pr) val reason: String? = null
val status : String? = null,
val reason : String? = null
) )
// ── LNURL pay ───────────────────────────────────────────────────────────────── // ── LNURL pay ─────────────────────────────────────────────────────────────────
@Serializable
data class LnurlPayRes( data class LnurlPayRes(
val tag: String?, val tag: String?,
val callback: String?, val callback: String?,
@SerializedName("minSendable") val minSendable: Long?, @SerialName("minSendable") val minSendable: Long?,
@SerializedName("maxSendable") val maxSendable: Long?, @SerialName("maxSendable") val maxSendable: Long?,
val metadata: String?, val metadata: String?,
@SerializedName("commentAllowed") val commentAllowed: Int?, @SerialName("commentAllowed") val commentAllowed: Int?,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null @SerialName("nostrPubkey") val nostrPubkey: String? = null
) )
@Serializable
data class PayLnurlRequest( data class PayLnurlRequest(
val res: LnurlPayRes, val res: LnurlPayRes,
val lnurl: String, val lnurl: String,
@@ -135,45 +149,48 @@ data class PayLnurlRequest(
val comment: String? = null val comment: String? = null
) )
@Serializable
data class PayLnurlResponse( data class PayLnurlResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String
) )
@Serializable
data class WithdrawCallbackResponse( data class WithdrawCallbackResponse(
val status : String, val status: String,
val reason : String? = null val reason: String? = null
) )
// ── LNURLp pay link (lightning address) ────────────────────────────────────── // ── LNURLp pay link (lightning address) ──────────────────────────────────────
@Serializable
data class LnurlpLink( data class LnurlpLink(
val id : String, val id: String,
val wallet : String, val wallet: String,
val username : String?, // the part before the @ in the lightning address val username: String?, // the part before the @ in the lightning address
val description : String?, val description: String?,
val min : Long, @Serializable(with = LongAsDoubleSerializer::class) val min: Long,
val max : Long @Serializable(with = LongAsDoubleSerializer::class) val max: Long
) )
// ── Payment history ─────────────────────────────────────────────────────────── // ── Payment history ───────────────────────────────────────────────────────────
@Immutable @Immutable
@Serializable
data class PaymentRecord( data class PaymentRecord(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String = "",
@SerializedName("amount") val amountMsat: Long, @SerialName("amount") val amountMsat: Long = 0L,
@SerializedName("fee") val feeMsat: Long, @SerialName("fee") val feeMsat: Long = 0L,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String? = null,
@SerializedName("time") val time: String, @SerialName("time") val time: String = "",
@SerializedName("status") val status: String, @SerialName("status") val status: String = "",
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String? = null,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean = false,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String? = null,
@SerializedName("extra") val extra: PaymentExtra?, @SerialName("extra") val extra: PaymentExtra? = null,
// Not from the API — populated only when loaded from Room via toDomain() // Not from the API — populated only when loaded from Room via toDomain()
val createdAt: Long? = null, val createdAt: Long? = null,
val pubkey: String? = null, val pubkey: String? = null,
val alias: String? = null val alias: String? = null
) { ) {
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
@@ -181,13 +198,13 @@ data class PaymentRecord(
companion object { companion object {
fun fromPayment( fun fromPayment(
paymentHash : String, paymentHash: String,
amountSats : Long, amountSats: Long,
feeSats : Long?, feeSats: Long?,
bolt11 : String?, bolt11: String?,
memo : String?, memo: String?,
pubkey : String?, pubkey: String?,
alias : String?, alias: String?,
): PaymentRecord = PaymentRecord( ): PaymentRecord = PaymentRecord(
paymentHash = paymentHash, paymentHash = paymentHash,
checkingId = paymentHash, checkingId = paymentHash,
@@ -207,23 +224,25 @@ data class PaymentRecord(
} }
@Immutable @Immutable
@Serializable
data class PaymentExtra( data class PaymentExtra(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String? = null,
@JsonAdapter(FlexibleStringAdapter::class) @Serializable(with = FlexibleStringSerializer::class)
val comment: String?, val comment: String? = null,
@SerializedName("success_action") val successAction: SuccessAction?, @SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
@SerializedName("wallet_id") val walletId: String?, val successAction: SuccessAction? = null,
@SerializedName("destination") val destination: String?, @SerialName("wallet_id") val walletId: String? = null,
@SerializedName("expires_at") val expiresAt: String? @SerialName("destination") val destination: String? = null,
@SerialName("expires_at") val expiresAt: String? = null
) )
@Immutable @Immutable
@Serializable(with = SuccessActionSerializer::class)
data class SuccessAction( data class SuccessAction(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String?,
@SerializedName("message") val message: String?, @SerialName("message") val message: String?,
@SerializedName("url") val url: String?, @SerialName("url") val url: String?,
@SerializedName("description") val description: String? @SerialName("description") val description: String?
) )
/** /**
@@ -232,99 +251,101 @@ data class SuccessAction(
* - a plain string "Thanks!" * - a plain string "Thanks!"
* - null * - null
* *
* Gson's default deserialiser throws IllegalStateException when it sees a * kotlinx.serialization's default deserialiser throws when it sees a
* string where it expects {. This adapter handles all three cases. * string where it expects {. This serializer handles all three cases.
* *
* Registered centrally in [GsonProvider] — applies to all deserialization * Applied via @Serializable(with = SuccessActionSerializer::class) on
* paths automatically. * the SuccessAction class and on PaymentExtra.successAction.
*/ */
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() { object SuccessActionSerializer : KSerializer<SuccessAction?> {
override fun write(out: JsonWriter, value: SuccessAction?) { override val descriptor: SerialDescriptor =
if (value == null) { out.nullValue(); return } buildClassSerialDescriptor("SuccessAction")
out.beginObject()
out.name("tag"); out.value(value.tag) override fun serialize(encoder: Encoder, value: SuccessAction?) {
out.name("message"); out.value(value.message) val jsonEncoder = encoder as JsonEncoder
out.name("url"); out.value(value.url) if (value == null) {
out.name("description"); out.value(value.description) jsonEncoder.encodeJsonElement(JsonNull)
out.endObject() } else {
jsonEncoder.encodeJsonElement(
JsonObject(
mapOf(
"tag" to JsonPrimitive(value.tag),
"message" to JsonPrimitive(value.message),
"url" to JsonPrimitive(value.url),
"description" to JsonPrimitive(value.description)
)
)
)
}
} }
override fun read(reader: JsonReader): SuccessAction? { override fun deserialize(decoder: Decoder): SuccessAction? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonNull -> null
// Plain string — treat as a message-type success action is JsonPrimitive -> {
JsonToken.STRING -> { // Plain string — treat as a message-type success action
val text = reader.nextString() val text = element.jsonPrimitive.content
if (text.isBlank()) null if (text.isBlank()) null
else SuccessAction(tag = "message", message = text, url = null, description = null) else SuccessAction(tag = "message", message = text, url = null, description = null)
} }
is JsonObject -> {
// Normal object SuccessAction(
JsonToken.BEGIN_OBJECT -> { tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
reader.beginObject() message = element.jsonObject["message"]?.jsonPrimitive?.content,
var tag: String? = null url = element.jsonObject["url"]?.jsonPrimitive?.content,
var message: String? = null description = element.jsonObject["description"]?.jsonPrimitive?.content
var url: String? = null )
var description: String? = null
while (reader.hasNext()) {
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 -> null
else -> { reader.skipValue(); null }
} }
} }
} }
data class PaymentDetailResponse( data class PaymentDetailResponse(
val paid: Boolean, val paid: Boolean,
val details: PaymentRecord? val details: PaymentRecord?
) )
// ── WebSocket payment message ───────────────────────────────────────────────── // ── WebSocket payment message ─────────────────────────────────────────────────
@Serializable
data class WsPaymentMessage( data class WsPaymentMessage(
@SerializedName("wallet_balance") val walletBalance: Long?, @SerialName("wallet_balance") val walletBalance: Long?,
val payment: WsPayment? val payment: WsPayment?
) )
@Serializable
data class WsPayment( data class WsPayment(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String,
@SerializedName("amount") val amount: Long, @SerialName("amount") val amount: Long,
@SerializedName("fee") val fee: Long, @SerialName("fee") val fee: Long,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String?,
@SerializedName("time") val time: String, @SerialName("time") val time: String,
@SerializedName("status") val status: String, @SerialName("status") val status: String,
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String?,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra? @SerialName("extra") val extra: PaymentExtra?
) )
// ── Fiat rate ─────────────────────────────────────────────────────────────────
// ── Fiat rate ──────────────────────────────────────────────────────────────── @Serializable
data class FiatRateResponse( data class FiatRateResponse(
val rate: Double, // sat to fiat rate val rate: Double, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency val price: Double // BTC price in the requested fiat currency
) )
// ── History sync ───────────────────────────────────────────────────────────── // ── History sync ─────────────────────────────────────────────────────────────
@Serializable
data class PaginatedPaymentsResponse( data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>, @SerialName("data") val data: List<PaymentRecord>,
@SerializedName("total") val total: Int @SerialName("total") val total: Int
) )
// ── NWC Provider models ─────────────────────────────────────────────────────── // ── NWC Provider models ───────────────────────────────────────────────────────
@Serializable
data class NwcKey( data class NwcKey(
val pubkey: String, val pubkey: String,
val wallet: String, val wallet: String,
@@ -335,6 +356,7 @@ data class NwcKey(
val last_used: Long val last_used: Long
) )
@Serializable
data class NwcBudget( data class NwcBudget(
val id: Int, val id: Int,
val pubkey: String, val pubkey: String,
@@ -344,22 +366,41 @@ data class NwcBudget(
val used_budget_msats: Long = 0 val used_budget_msats: Long = 0
) )
/** Response for GET /nwc and GET /nwc/{pubkey} */ @Serializable
data class NwcGetResponse( data class NwcGetResponse(
val data: NwcKey, val data: NwcKey,
val budgets: List<NwcBudget> val budgets: List<NwcBudget>
) )
@Serializable
data class NwcNewBudget( data class NwcNewBudget(
val budget_msats: Long, val budget_msats: Long,
val refresh_window: Int, // seconds val refresh_window: Int, // seconds
val created_at : Long = System.currentTimeMillis() / 1000L val created_at: Long = System.currentTimeMillis() / 1000L
) )
/** Request body for PUT /nwc/{pubkey} */ @Serializable
data class NwcRegistrationRequest( data class NwcRegistrationRequest(
val permissions: List<String>, val permissions: List<String>,
val description: String, val description: String,
val expires_at: Long, // unix timestamp; 0 = no expiry val expires_at: Long, // unix timestamp; 0 = no expiry
val budgets: List<NwcNewBudget> = emptyList() val budgets: List<NwcNewBudget> = emptyList()
) )
/**
* Deserializes a JSON number that may arrive as either an integer (1)
* or a float (1.0) into a Long, truncating any fractional part.
*/
object LongAsDoubleSerializer : KSerializer<Long> {
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
override fun deserialize(decoder: Decoder): Long {
val jsonDecoder = decoder as JsonDecoder
return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonPrimitive -> element.content.toDouble().toLong()
else -> 0L
}
}
}
@@ -1,10 +1,12 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
// ── mempool.space ───────────────────────────────────────────────────────────── // ── mempool.space ─────────────────────────────────────────────────────────────
@Serializable
data class MempoolNodeResponse( data class MempoolNodeResponse(
val alias: String?, val alias: String?,
val public_key: String? val public_key: String?
@@ -17,6 +19,7 @@ interface MempoolApi {
// ── 1ml.com ─────────────────────────────────────────────────────────────────── // ── 1ml.com ───────────────────────────────────────────────────────────────────
@Serializable
data class OneMlNodeResponse( data class OneMlNodeResponse(
val alias: String?, val alias: String?,
val pub_key: String? val pub_key: String?
@@ -1,8 +1,9 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -17,17 +18,20 @@ class NodeAliasClient {
.readTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS)
.build() .build()
private val converter = JsonProvider.json
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
private val mempoolApi: MempoolApi = Retrofit.Builder() private val mempoolApi: MempoolApi = Retrofit.Builder()
.baseUrl("https://mempool.space/") .baseUrl("https://mempool.space/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(MempoolApi::class.java) .create(MempoolApi::class.java)
private val oneMlApi: OneMlApi = Retrofit.Builder() private val oneMlApi: OneMlApi = Retrofit.Builder()
.baseUrl("https://1ml.com/") .baseUrl("https://1ml.com/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(OneMlApi::class.java) .create(OneMlApi::class.java)
@@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import java.io.IOException import java.io.IOException
private const val TAG = "FiatDataStore" private const val TAG = "FiatDataStore"
@@ -27,13 +28,13 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
class FiatDataStore(context: Context) { class FiatDataStore(context: Context) {
private val dataStore = context.applicationContext.fiatDataStore private val dataStore = context.applicationContext.fiatDataStore
private val gson = Gson() private val json = JsonProvider.json
// ── Key factories ───────────────────────────────────────────────────────── // ── Key factories ─────────────────────────────────────────────────────────
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency") private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency") private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency")
private val currenciesKey = stringPreferencesKey("fiat_currencies_json") private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
// ── Rate ────────────────────────────────────────────────────────────────── // ── Rate ──────────────────────────────────────────────────────────────────
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
} else throw e } else throw e
} }
.first() .first()
val json = prefs[currenciesKey] ?: return null val jsonString = prefs[currenciesKey] ?: return null
return runCatching { return runCatching {
val type = object : TypeToken<List<String>>() {}.type val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
val list: List<String> = gson.fromJson(json, type)
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)") Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
list list
}.getOrElse { e -> }.getOrElse { e ->
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
if (currencies.isEmpty()) return if (currencies.isEmpty()) return
runCatching { runCatching {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[currenciesKey] = gson.toJson(currencies) prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
} }
}.onFailure { e -> }.onFailure { e ->
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}") Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
import android.content.Context import android.content.Context
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
class LnurlCacheRepository(context: Context) { class LnurlCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).lnurlCacheDao() private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
private val gson = GsonProvider.gson private val json = JsonProvider.json
// Fast in-memory path — avoids a DB round-trip for keys already loaded // Fast in-memory path — avoids a DB round-trip for keys already loaded
// this session. Mirrors the pattern used in PaymentCacheRepository. // this session. Mirrors the pattern used in PaymentCacheRepository.
@@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) {
*/ */
suspend fun put(key: String, response: LnurlScanResponse) { suspend fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable val disposable = response.disposable
if ( disposable == true) { if (disposable == true) {
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching") Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return return
} }
val entity = LnurlCacheEntity( val entity = LnurlCacheEntity(
key = key, key = key,
responseJson = gson.toJson(response), responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
savedAt = System.currentTimeMillis() savedAt = System.currentTimeMillis()
) )
runCatching { runCatching {
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) } runCatching { dao.deleteByKey(key) }
} }
private fun deserialise(json: String): LnurlScanResponse? = private fun deserialise(jsonString: String): LnurlScanResponse? =
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) } runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull() .getOrNull()
} }
@@ -1,19 +1,19 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.GsonProvider
object PaymentConverters { object PaymentConverters {
private val gson = GsonProvider.gson private val json = JsonProvider.json
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun fromExtra(extra: PaymentExtra?): String? = fun fromExtra(extra: PaymentExtra?): String? =
extra?.let { gson.toJson(it) } extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun toExtra(json: String?): PaymentExtra? = fun toExtra(string: String?): PaymentExtra? =
json?.let { gson.fromJson(it, PaymentExtra::class.java) } string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
} }
@@ -1,12 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentMappers" private const val TAG = "PaymentMappers"
private val json = JsonProvider.json
// ── Parse helper — runs once at write time, never again ────────────────────── // ── Parse helper — runs once at write time, never again ──────────────────────
@@ -36,10 +36,10 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.toJson(it) }, extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
savedAt = savedAt, savedAt = savedAt,
pubkey = pubkey, pubkey = pubkey,
alias = alias alias = alias
) )
// ── PaymentRecordEntity → PaymentRecord ────────────────────────────────────── // ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) }, extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
pubkey = pubkey, pubkey = pubkey,
alias = alias alias = alias
) )
@@ -7,7 +7,7 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
@@ -98,7 +98,7 @@ class WalletNotificationService : Service() {
private var reconnectAttempts = 0 private var reconnectAttempts = 0
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
private val gson = GsonProvider.gson private val json = JsonProvider.json
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -193,7 +193,7 @@ class WalletNotificationService : Service() {
private fun handleMessage(text: String) { private fun handleMessage(text: String) {
val msg = runCatching { val msg = runCatching {
gson.fromJson(text, WsPaymentMessage::class.java) json.decodeFromString(WsPaymentMessage.serializer(), text)
}.getOrNull() ?: return }.getOrNull() ?: return
val payment = msg.payment ?: return val payment = msg.payment ?: return
+5 -4
View File
@@ -1,9 +1,7 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
biometric = "1.2.0-alpha05" biometric = "1.2.0-alpha05"
converterGson = "3.0.0"
coreKtx = "1.10.1" coreKtx = "1.10.1"
gson = "2.14.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
@@ -34,14 +32,13 @@ benchmark = "1.4.1"
runner = "1.5.2" runner = "1.5.2"
androidx-test-ext = "1.2.1" androidx-test-ext = "1.2.1"
timber = "5.0.1" timber = "5.0.1"
kotlinx-serialization = "1.8.1"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animation = { module = "androidx.compose.animation:animation" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -83,6 +80,9 @@ androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-ju
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -93,3 +93,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
androidx-benchmark = { id = "androidx.benchmark", version.ref = "benchmark" } androidx-benchmark = { id = "androidx.benchmark", version.ref = "benchmark" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
android-test = { id = "com.android.test", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }