From e242c7b5d093fa90e9a81783efeece279a6ea7ef Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 10 Jun 2026 23:06:11 +0200 Subject: [PATCH] refactor: migrate to kotlinx serialization, eliminate gson dependency --- .idea/dictionaries/project.xml | 3 + app/build.gradle.kts | 5 +- .../bitcointxoko/gudariwallet/api/Adapters.kt | 57 +-- .../gudariwallet/api/GsonProvider.kt | 21 - .../gudariwallet/api/JsonProvider.kt | 23 ++ .../gudariwallet/api/LNbitsClient.kt | 10 +- .../bitcointxoko/gudariwallet/api/Models.kt | 383 ++++++++++-------- .../gudariwallet/api/NodeAliasApi.kt | 3 + .../gudariwallet/api/NodeAliasClient.kt.kt | 10 +- .../gudariwallet/data/FiatDataStore.kt | 18 +- .../gudariwallet/data/LnurlCacheRepository.kt | 12 +- .../gudariwallet/data/db/PaymentConverters.kt | 10 +- .../data/db/PaymentRecordMapper.kt | 18 +- .../service/WalletNotificationService.kt | 6 +- gradle/libs.versions.toml | 9 +- 15 files changed, 326 insertions(+), 262 deletions(-) delete mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/api/GsonProvider.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/api/JsonProvider.kt diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 94d16f6..d12b73c 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -7,12 +7,15 @@ Nostr Tink amboss + cltv hkdf inflight lnbc lnbits lnurlp msat + msats + nostr satoshis sats snackbar diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e80453..aab040b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.protobuf) + alias(libs.plugins.kotlin.serialization) } android { @@ -132,7 +133,7 @@ dependencies { // Networking implementation(libs.retrofit2.retrofit) - implementation(libs.converter.gson) + implementation(libs.converter.kotlinx.serialization) implementation(libs.okhttp) debugImplementation(libs.logging.interceptor) @@ -163,7 +164,7 @@ dependencies { implementation(libs.androidx.biometric) // JSON - implementation(libs.gson) + implementation(libs.kotlinx.serialization.json) // icons implementation(libs.androidx.material.icons.extended) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt index 7f3cc26..236dc6f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Adapters.kt @@ -1,9 +1,16 @@ package com.bitcointxoko.gudariwallet.api -import com.google.gson.TypeAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken -import com.google.gson.stream.JsonWriter +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +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: @@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter * - a JSON array ["Thanks!"] * - null * - * Registered centrally in [GsonProvider] — but also applied via @JsonAdapter - * on the field for extra safety. + * Mirrors the behaviour of the old Gson FlexibleStringAdapter. */ -class FlexibleStringAdapter : TypeAdapter() { +object FlexibleStringSerializer : KSerializer { - override fun write(out: JsonWriter, value: String?) { - if (value == null) out.nullValue() else out.value(value) + override val descriptor: SerialDescriptor = + 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? { - return when (reader.peek()) { - JsonToken.NULL -> { reader.nextNull(); null } - JsonToken.BEGIN_ARRAY -> { - val parts = mutableListOf() - reader.beginArray() - while (reader.hasNext()) { - when (reader.peek()) { - JsonToken.NULL -> reader.nextNull() - else -> parts.add(reader.nextString()) - } - } - reader.endArray() - parts.joinToString(", ").ifEmpty { null } - } - else -> reader.nextString() + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as JsonDecoder + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonNull -> null + is JsonPrimitive -> element.content // plain string → as-is + is JsonArray -> element // array → join elements + .mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content } + .joinToString(", ") + .ifEmpty { null } + else -> null } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/GsonProvider.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/GsonProvider.kt deleted file mode 100644 index 23b81e7..0000000 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/GsonProvider.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/JsonProvider.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/JsonProvider.kt new file mode 100644 index 0000000..12752cd --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/JsonProvider.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt index 659524d..cc0c2c9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt @@ -5,9 +5,10 @@ import com.bitcointxoko.gudariwallet.util.WalletConstants import kotlinx.coroutines.runBlocking import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit object LNbitsClient { @@ -59,8 +60,11 @@ object LNbitsClient { return Retrofit.Builder() .baseUrl("https://placeholder.invalid/") // structurally required by Retrofit; .client(client) - .addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) - .build() + .addConverterFactory( + JsonProvider.json.asConverterFactory( + "application/json; charset=UTF-8".toMediaType() + ) + ) .build() .create(LNbitsApi::class.java) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt index b7bcee1..daafd78 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt @@ -1,50 +1,66 @@ package com.bitcointxoko.gudariwallet.api import androidx.compose.runtime.Immutable -import com.google.gson.TypeAdapter -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken -import com.google.gson.stream.JsonWriter +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +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 ──────────────────────────────────────────────────────────────────── +@Serializable data class WalletResponse( - val id: String, - val name: String, - val balance: Long // millisatoshis + val id: String = "", + val name: String = "", + val balance: Long = 0L // millisatoshis ) // ── Create invoice ──────────────────────────────────────────────────────────── +@Serializable data class CreateInvoiceRequest( val out: Boolean, val amount: Long, // satoshis val memo: String ) +@Serializable data class CreateInvoiceResponse( - @SerializedName("payment_hash") val paymentHash: String, - @SerializedName("payment_request") val paymentRequest: String, - @SerializedName("expiry") val expiry : String? + @SerialName("payment_hash") val paymentHash: String, + @SerialName("payment_request") val paymentRequest: String, + @SerialName("expiry") val expiry: String? ) // ── Pay invoice ─────────────────────────────────────────────────────────────── +@Serializable data class PayInvoiceRequest( val out: Boolean, val bolt11: String ) +@Serializable data class PayInvoiceResponse( - @SerializedName("payment_hash") val paymentHash: String, - @SerializedName("checking_id") val checkingId: String + @SerialName("payment_hash") val paymentHash: String, + @SerialName("checking_id") val checkingId: String ) // ── Check payment ───────────────────────────────────────────────────────────── +@Serializable data class PaymentStatusResponse( val paid: Boolean, val details: PaymentDetails? = null ) +@Serializable data class PaymentDetails( val amount: Long?, val fee: Long?, @@ -52,82 +68,80 @@ data class PaymentDetails( ) // ── Decode BOLT-11 ──────────────────────────────────────────────────────────── +@Serializable data class DecodeInvoiceRequest( val data: String ) +@Serializable data class DecodeInvoiceResponse( - @SerializedName("payment_hash") val paymentHash : String? = null, - @SerializedName("amount_msat") val amountMsat : Long? = null, - @SerializedName("description") val description : String? = null, - @SerializedName("payee") val payee : String? = null, - @SerializedName("route_hints") val routeHints : List>? = null + @SerialName("payment_hash") val paymentHash: String? = null, + @SerialName("amount_msat") val amountMsat: Long? = null, + @SerialName("description") val description: String? = null, + @SerialName("payee") val payee: String? = null, + @SerialName("route_hints") val routeHints: List>? = null ) - +@Serializable 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 + @SerialName("public_key") val publicKey: String, + @SerialName("short_channel_id") val shortChannelId: String? = null, + @SerialName("base_fee") val baseFeeMsat: Long = 0, + @SerialName("ppm_fee") val ppmFee: Long = 0, + @SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0 ) // ── LNURL scan ──────────────────────────────────────────────────────────────── +@Serializable 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" + // ── payRequest fields ───────────────────────────────────────────────────── + val callback: String? = null, + @SerialName("minSendable") val minSendable: Long? = null, + @SerialName("maxSendable") val maxSendable: Long? = null, + val metadata: String? = null, + @SerialName("commentAllowed") val commentAllowed: Int? = null, + @SerialName("allowsNostr") val allowsNostr: Boolean? = null, + @SerialName("nostrPubkey") val nostrPubkey: String? = null, + val bolt11: String? = null, + @SerialName("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, + @SerialName("defaultDescription") val defaultDescription: String? = null, + @SerialName("minWithdrawable") val minWithdrawable: Long? = null, + @SerialName("maxWithdrawable") val maxWithdrawable: Long? = null, + @SerialName("balanceCheck") val balanceCheck: String? = null, + @SerialName("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) ───────────────────────────────────── +@Serializable data class LnurlCallbackResponse( - val pr : String, // BOLT-11 invoice to pay - val routes : List? = null, - @SerializedName("successAction") - val successAction : SuccessAction? = null, - // Error fields (server may return status=ERROR instead of pr) - val status : String? = null, - val reason : String? = null + val pr: String, + val routes: List? = null, + @SerialName("successAction") val successAction: SuccessAction? = null, + val status: String? = null, + val reason: String? = null ) - // ── LNURL pay ───────────────────────────────────────────────────────────────── +@Serializable data class LnurlPayRes( val tag: String?, val callback: String?, - @SerializedName("minSendable") val minSendable: Long?, - @SerializedName("maxSendable") val maxSendable: Long?, + @SerialName("minSendable") val minSendable: Long?, + @SerialName("maxSendable") val maxSendable: Long?, val metadata: String?, - @SerializedName("commentAllowed") val commentAllowed: Int?, - @SerializedName("allowsNostr") val allowsNostr: Boolean? = null, - @SerializedName("nostrPubkey") val nostrPubkey: String? = null + @SerialName("commentAllowed") val commentAllowed: Int?, + @SerialName("allowsNostr") val allowsNostr: Boolean? = null, + @SerialName("nostrPubkey") val nostrPubkey: String? = null ) +@Serializable data class PayLnurlRequest( val res: LnurlPayRes, val lnurl: String, @@ -135,45 +149,48 @@ data class PayLnurlRequest( val comment: String? = null ) +@Serializable data class PayLnurlResponse( - @SerializedName("payment_hash") val paymentHash: String, - @SerializedName("checking_id") val checkingId: String + @SerialName("payment_hash") val paymentHash: String, + @SerialName("checking_id") val checkingId: String ) +@Serializable data class WithdrawCallbackResponse( - val status : String, - val reason : String? = null + val status: String, + val reason: String? = null ) // ── LNURLp pay link (lightning address) ────────────────────────────────────── +@Serializable 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 + val id: String, + val wallet: String, + val username: String?, // the part before the @ in the lightning address + val description: String?, + @Serializable(with = LongAsDoubleSerializer::class) val min: Long, + @Serializable(with = LongAsDoubleSerializer::class) val max: Long ) - // ── Payment history ─────────────────────────────────────────────────────────── @Immutable +@Serializable 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?, + @SerialName("payment_hash") val paymentHash: String = "", + @SerialName("checking_id") val checkingId: String = "", + @SerialName("amount") val amountMsat: Long = 0L, + @SerialName("fee") val feeMsat: Long = 0L, + @SerialName("memo") val memo: String? = null, + @SerialName("time") val time: String = "", + @SerialName("status") val status: String = "", + @SerialName("bolt11") val bolt11: String? = null, + @SerialName("pending") val pending: Boolean = false, + @SerialName("preimage") val preimage: String? = null, + @SerialName("extra") val extra: PaymentExtra? = null, // Not from the API — populated only when loaded from Room via toDomain() val createdAt: Long? = null, - val pubkey: String? = null, - val alias: String? = null + val pubkey: String? = null, + val alias: String? = null ) { val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L @@ -181,13 +198,13 @@ data class PaymentRecord( companion object { fun fromPayment( - paymentHash : String, - amountSats : Long, - feeSats : Long?, - bolt11 : String?, - memo : String?, - pubkey : String?, - alias : String?, + paymentHash: String, + amountSats: Long, + feeSats: Long?, + bolt11: String?, + memo: String?, + pubkey: String?, + alias: String?, ): PaymentRecord = PaymentRecord( paymentHash = paymentHash, checkingId = paymentHash, @@ -207,23 +224,25 @@ data class PaymentRecord( } @Immutable - +@Serializable data class PaymentExtra( - @SerializedName("tag") val tag: String?, - @JsonAdapter(FlexibleStringAdapter::class) - 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? + @SerialName("tag") val tag: String? = null, + @Serializable(with = FlexibleStringSerializer::class) + val comment: String? = null, + @SerialName("success_action") @Serializable(with = SuccessActionSerializer::class) + val successAction: SuccessAction? = null, + @SerialName("wallet_id") val walletId: String? = null, + @SerialName("destination") val destination: String? = null, + @SerialName("expires_at") val expiresAt: String? = null ) @Immutable +@Serializable(with = SuccessActionSerializer::class) data class SuccessAction( - @SerializedName("tag") val tag: String?, - @SerializedName("message") val message: String?, - @SerializedName("url") val url: String?, - @SerializedName("description") val description: String? + @SerialName("tag") val tag: String?, + @SerialName("message") val message: String?, + @SerialName("url") val url: String?, + @SerialName("description") val description: String? ) /** @@ -232,99 +251,101 @@ data class SuccessAction( * - 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. + * kotlinx.serialization's default deserialiser throws when it sees a + * string where it expects {. This serializer handles all three cases. * - * Registered centrally in [GsonProvider] — applies to all deserialization - * paths automatically. + * Applied via @Serializable(with = SuccessActionSerializer::class) on + * the SuccessAction class and on PaymentExtra.successAction. */ -class SuccessActionAdapter : TypeAdapter() { +object SuccessActionSerializer : KSerializer { - 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 val descriptor: SerialDescriptor = + buildClassSerialDescriptor("SuccessAction") + + override fun serialize(encoder: Encoder, value: SuccessAction?) { + val jsonEncoder = encoder as JsonEncoder + if (value == null) { + jsonEncoder.encodeJsonElement(JsonNull) + } 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? { - return when (reader.peek()) { - JsonToken.NULL -> { reader.nextNull(); null } - - // Plain string — treat as a message-type success action - JsonToken.STRING -> { - val text = reader.nextString() + override fun deserialize(decoder: Decoder): SuccessAction? { + val jsonDecoder = decoder as JsonDecoder + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonNull -> null + is JsonPrimitive -> { + // Plain string — treat as a message-type success action + val text = element.jsonPrimitive.content 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) + is JsonObject -> { + SuccessAction( + tag = element.jsonObject["tag"]?.jsonPrimitive?.content, + message = element.jsonObject["message"]?.jsonPrimitive?.content, + url = element.jsonObject["url"]?.jsonPrimitive?.content, + description = element.jsonObject["description"]?.jsonPrimitive?.content + ) } - - else -> { reader.skipValue(); null } + else -> null } } } + data class PaymentDetailResponse( val paid: Boolean, val details: PaymentRecord? ) // ── WebSocket payment message ───────────────────────────────────────────────── +@Serializable data class WsPaymentMessage( - @SerializedName("wallet_balance") val walletBalance: Long?, + @SerialName("wallet_balance") val walletBalance: Long?, val payment: WsPayment? ) +@Serializable data class WsPayment( - @SerializedName("payment_hash") val paymentHash: String, - @SerializedName("checking_id") val checkingId: String, - @SerializedName("amount") val amount: Long, - @SerializedName("fee") val fee: 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? + @SerialName("payment_hash") val paymentHash: String, + @SerialName("checking_id") val checkingId: String, + @SerialName("amount") val amount: Long, + @SerialName("fee") val fee: Long, + @SerialName("memo") val memo: String?, + @SerialName("time") val time: String, + @SerialName("status") val status: String, + @SerialName("bolt11") val bolt11: String?, + @SerialName("pending") val pending: Boolean, + @SerialName("preimage") val preimage: String?, + @SerialName("extra") val extra: PaymentExtra? ) - -// ── Fiat rate ──────────────────────────────────────────────────────────────── +// ── Fiat rate ───────────────────────────────────────────────────────────────── +@Serializable data class FiatRateResponse( 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( - @SerializedName("data") val data: List, - @SerializedName("total") val total: Int + @SerialName("data") val data: List, + @SerialName("total") val total: Int ) // ── NWC Provider models ─────────────────────────────────────────────────────── - +@Serializable data class NwcKey( val pubkey: String, val wallet: String, @@ -335,6 +356,7 @@ data class NwcKey( val last_used: Long ) +@Serializable data class NwcBudget( val id: Int, val pubkey: String, @@ -344,22 +366,41 @@ data class NwcBudget( val used_budget_msats: Long = 0 ) -/** Response for GET /nwc and GET /nwc/{pubkey} */ +@Serializable data class NwcGetResponse( val data: NwcKey, val budgets: List ) +@Serializable data class NwcNewBudget( val budget_msats: Long, 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( val permissions: List, val description: String, - val expires_at: Long, // unix timestamp; 0 = no expiry + val expires_at: Long, // unix timestamp; 0 = no expiry val budgets: List = 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 { + 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 + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasApi.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasApi.kt index 82e4058..52a267d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasApi.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasApi.kt @@ -1,10 +1,12 @@ package com.bitcointxoko.gudariwallet.api +import kotlinx.serialization.Serializable import retrofit2.http.GET import retrofit2.http.Path // ── mempool.space ───────────────────────────────────────────────────────────── +@Serializable data class MempoolNodeResponse( val alias: String?, val public_key: String? @@ -17,6 +19,7 @@ interface MempoolApi { // ── 1ml.com ─────────────────────────────────────────────────────────────────── +@Serializable data class OneMlNodeResponse( val alias: String?, val pub_key: String? diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasClient.kt.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasClient.kt.kt index c9f4596..89ea426 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasClient.kt.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/NodeAliasClient.kt.kt @@ -1,8 +1,9 @@ package com.bitcointxoko.gudariwallet.api +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit /** @@ -17,17 +18,20 @@ class NodeAliasClient { .readTimeout(5, TimeUnit.SECONDS) .build() + private val converter = JsonProvider.json + .asConverterFactory("application/json; charset=UTF-8".toMediaType()) + private val mempoolApi: MempoolApi = Retrofit.Builder() .baseUrl("https://mempool.space/") .client(client) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(converter) .build() .create(MempoolApi::class.java) private val oneMlApi: OneMlApi = Retrofit.Builder() .baseUrl("https://1ml.com/") .client(client) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(converter) .build() .create(OneMlApi::class.java) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt index 2b0f424..d5ca4ac 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt @@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import com.bitcointxoko.gudariwallet.api.JsonProvider import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer import java.io.IOException private const val TAG = "FiatDataStore" @@ -27,13 +28,13 @@ private val Context.fiatDataStore: DataStore by preferencesDataStor class FiatDataStore(context: Context) { private val dataStore = context.applicationContext.fiatDataStore - private val gson = Gson() + private val json = JsonProvider.json // ── 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 val currenciesKey = stringPreferencesKey("fiat_currencies_json") + private val currenciesKey = stringPreferencesKey("fiat_currencies_json") // ── Rate ────────────────────────────────────────────────────────────────── @@ -87,10 +88,9 @@ class FiatDataStore(context: Context) { } else throw e } .first() - val json = prefs[currenciesKey] ?: return null + val jsonString = prefs[currenciesKey] ?: return null return runCatching { - val type = object : TypeToken>() {}.type - val list: List = gson.fromJson(json, type) + val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString) Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)") list }.getOrElse { e -> @@ -106,7 +106,7 @@ class FiatDataStore(context: Context) { if (currencies.isEmpty()) return runCatching { dataStore.edit { prefs -> - prefs[currenciesKey] = gson.toJson(currencies) + prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies) } }.onFailure { e -> Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt index 4a91f94..bbb14c3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt @@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data import android.content.Context 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.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity @@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository" class LnurlCacheRepository(context: Context) { 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 // this session. Mirrors the pattern used in PaymentCacheRepository. @@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) { */ suspend fun put(key: String, response: LnurlScanResponse) { val disposable = response.disposable - if ( disposable == true) { + if (disposable == true) { Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching") return } val entity = LnurlCacheEntity( key = key, - responseJson = gson.toJson(response), + responseJson = json.encodeToString(LnurlScanResponse.serializer(), response), savedAt = System.currentTimeMillis() ) runCatching { @@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) { runCatching { dao.deleteByKey(key) } } - private fun deserialise(json: String): LnurlScanResponse? = - runCatching { gson.fromJson(json, LnurlScanResponse::class.java) } + private fun deserialise(jsonString: String): LnurlScanResponse? = + runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) } .getOrNull() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt index 1ab845a..8e1aaf9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt @@ -1,19 +1,19 @@ package com.bitcointxoko.gudariwallet.data.db import androidx.room.TypeConverter +import com.bitcointxoko.gudariwallet.api.JsonProvider import com.bitcointxoko.gudariwallet.api.PaymentExtra -import com.bitcointxoko.gudariwallet.api.GsonProvider object PaymentConverters { - private val gson = GsonProvider.gson + private val json = JsonProvider.json @TypeConverter @JvmStatic fun fromExtra(extra: PaymentExtra?): String? = - extra?.let { gson.toJson(it) } + extra?.let { json.encodeToString(PaymentExtra.serializer(), it) } @TypeConverter @JvmStatic - fun toExtra(json: String?): PaymentExtra? = - json?.let { gson.fromJson(it, PaymentExtra::class.java) } + fun toExtra(string: String?): PaymentExtra? = + string?.let { json.decodeFromString(PaymentExtra.serializer(), it) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt index 4397b69..276e042 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt @@ -1,12 +1,12 @@ package com.bitcointxoko.gudariwallet.data.db 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.data.db.PaymentRecordEntity private const val TAG = "PaymentMappers" - +private val json = JsonProvider.json // ── Parse helper — runs once at write time, never again ────────────────────── @@ -36,10 +36,10 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity = bolt11 = bolt11, pending = pending, preimage = preimage, - extra = extra?.let { gson.toJson(it) }, + extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }, savedAt = savedAt, - pubkey = pubkey, - alias = alias + pubkey = pubkey, + alias = alias ) // ── PaymentRecordEntity → PaymentRecord ────────────────────────────────────── @@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord = bolt11 = bolt11, pending = pending, preimage = preimage, - extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) }, - pubkey = pubkey, - alias = alias + extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) }, + pubkey = pubkey, + alias = alias ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt index baa8053..f726980 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt @@ -7,7 +7,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder 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.PaymentRecord import com.bitcointxoko.gudariwallet.api.WsPaymentMessage @@ -98,7 +98,7 @@ class WalletNotificationService : Service() { private var reconnectAttempts = 0 private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS - private val gson = GsonProvider.gson + private val json = JsonProvider.json // ── Lifecycle ───────────────────────────────────────────────────────────── override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -193,7 +193,7 @@ class WalletNotificationService : Service() { private fun handleMessage(text: String) { val msg = runCatching { - gson.fromJson(text, WsPaymentMessage::class.java) + json.decodeFromString(WsPaymentMessage.serializer(), text) }.getOrNull() ?: return val payment = msg.payment ?: return diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 172c1b8..ab20988 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,7 @@ [versions] agp = "9.2.1" biometric = "1.2.0-alpha05" -converterGson = "3.0.0" coreKtx = "1.10.1" -gson = "2.14.0" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" @@ -34,14 +32,13 @@ benchmark = "1.4.1" runner = "1.5.2" androidx-test-ext = "1.2.1" timber = "5.0.1" +kotlinx-serialization = "1.8.1" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -83,6 +80,9 @@ androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-ju 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" } 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] 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" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }