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" }