diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca87d8dce..aa1ad9bf6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) } @@ -82,13 +82,13 @@ dependencies { // Cryptography implementation(libs.bundles.cryptography) - - // JSON - implementation(libs.gson) - + // Coroutines implementation(libs.kotlinx.coroutines.android) + // Serialization + implementation(libs.kotlinx.serialization.json) + // Bluetooth implementation(libs.nordic.ble) diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index 12cd3e9f4..0c05a7b72 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -3,8 +3,10 @@ package com.bitchat.android.favorites import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import com.bitchat.android.util.JsonUtil import java.util.* /** @@ -82,7 +84,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte } private val stateManager = SecureIdentityStateManager(context) - private val gson = Gson() + private val favorites = mutableMapOf() // noiseHex -> relationship // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub @@ -258,14 +260,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte try { val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) if (favoritesJson != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(favoritesJson, type) - - favorites.clear() - data.forEach { (key, relationshipData) -> - favorites[key] = relationshipData.toFavoriteRelationship() + val data = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), FavoriteRelationshipData.serializer()), favoritesJson) + if (data != null) { + favorites.clear() + data.forEach { (key, relationshipData) -> + favorites[key] = relationshipData.toFavoriteRelationship() + } + Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } - Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { Log.e(TAG, "Failed to load favorites: ${e.message}") @@ -277,7 +279,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) + val favoritesJson = JsonUtil.toJson(MapSerializer(String.serializer(), FavoriteRelationshipData.serializer()), data) stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { @@ -289,10 +291,11 @@ class FavoritesPersistenceService private constructor(private val context: Conte try { val json = stateManager.getSecureValue(PEERID_INDEX_KEY) if (json != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(json, type) - peerIdIndex.clear() - peerIdIndex.putAll(data) + val data = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), String.serializer()), json) + if (data != null) { + peerIdIndex.clear() + peerIdIndex.putAll(data) + } Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") } } catch (e: Exception) { @@ -302,7 +305,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun savePeerIdIndex() { try { - val json = gson.toJson(peerIdIndex) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), peerIdIndex) stateManager.storeSecureValue(PEERID_INDEX_KEY, json) Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") } catch (e: Exception) { @@ -336,6 +339,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte } /** Serializable data for JSON storage */ +@Serializable private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index b498dd833..c30ca71b9 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -7,8 +7,10 @@ import android.location.LocationManager import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import com.bitchat.android.util.JsonUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -41,7 +43,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } } - private val gson = Gson() + private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE) private val membership = mutableSetOf() @@ -96,19 +98,20 @@ class GeohashBookmarksStore private constructor(private val context: Context) { try { val arrJson = prefs.getString(STORE_KEY, null) if (!arrJson.isNullOrEmpty()) { - val listType = object : TypeToken>() {}.type - val arr = gson.fromJson>(arrJson, listType) - val seen = mutableSetOf() - val ordered = mutableListOf() - arr.forEach { raw -> + val arr = JsonUtil.fromJsonOrNull(ListSerializer(String.serializer()), arrJson) + if (arr != null) { + val seen = mutableSetOf() + val ordered = mutableListOf() + arr.forEach { raw -> val gh = normalize(raw) if (gh.isNotEmpty() && !seen.contains(gh)) { seen.add(gh) ordered.add(gh) } + } + membership.clear(); membership.addAll(seen) + _bookmarks.postValue(ordered) } - membership.clear(); membership.addAll(seen) - _bookmarks.postValue(ordered) } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmarks: ${e.message}") @@ -116,9 +119,10 @@ class GeohashBookmarksStore private constructor(private val context: Context) { try { val namesJson = prefs.getString(NAMES_STORE_KEY, null) if (!namesJson.isNullOrEmpty()) { - val mapType = object : TypeToken>() {}.type - val dict = gson.fromJson>(namesJson, mapType) - _bookmarkNames.postValue(dict) + val dict = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), String.serializer()), namesJson) + if (dict != null) { + _bookmarkNames.postValue(dict) + } } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmark names: ${e.message}") @@ -127,14 +131,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist() { try { - val json = gson.toJson(_bookmarks.value ?: emptyList()) + val json = JsonUtil.toJson(ListSerializer(String.serializer()), _bookmarks.value ?: emptyList()) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames() { try { - val json = gson.toJson(_bookmarkNames.value ?: emptyMap()) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), _bookmarkNames.value ?: emptyMap()) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } @@ -235,14 +239,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist(list: List) { try { - val json = gson.toJson(list) + val json = JsonUtil.toJson(ListSerializer(String.serializer()), list) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames(map: Map) { try { - val json = gson.toJson(map) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), map) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index b8da1fee3..04da5e339 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -14,8 +14,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* -import com.google.gson.Gson -import com.google.gson.JsonSyntaxException +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. @@ -49,7 +49,7 @@ class LocationChannelManager private constructor(private val context: Context) { private var lastLocation: Location? = null private var refreshTimer: Job? = null private var isGeocoding: Boolean = false - private val gson = Gson() + private var dataManager: com.bitchat.android.ui.DataManager? = null // Published state for UI bindings (matching iOS @Published properties) @@ -544,16 +544,20 @@ class LocationChannelManager private constructor(private val context: Context) { try { val channelData = when (channel) { is ChannelID.Mesh -> { - gson.toJson(mapOf("type" to "mesh")) + val jsonObject = buildJsonObject { + put("type", "mesh") + } + JsonUtil.json.encodeToString(JsonObject.serializer(), jsonObject) } is ChannelID.Location -> { - gson.toJson(mapOf( - "type" to "location", - "level" to channel.channel.level.name, - "precision" to channel.channel.level.precision, - "geohash" to channel.channel.geohash, - "displayName" to channel.channel.level.displayName - )) + val jsonObject = buildJsonObject { + put("type", "location") + put("level", channel.channel.level.name) + put("precision", channel.channel.level.precision) + put("geohash", channel.channel.geohash) + put("displayName", channel.channel.level.displayName) + } + JsonUtil.json.encodeToString(JsonObject.serializer(), jsonObject) } } dataManager?.saveLastGeohashChannel(channelData) @@ -570,15 +574,17 @@ class LocationChannelManager private constructor(private val context: Context) { try { val channelData = dataManager?.loadLastGeohashChannel() if (channelData != null) { - val channelMap = gson.fromJson(channelData, Map::class.java) as? Map - if (channelMap != null) { - val channel = when (channelMap["type"] as? String) { + val channelObject = try { + JsonUtil.json.parseToJsonElement(channelData).jsonObject + } catch (e: Exception) { null } + if (channelObject != null) { + val channel = when (channelObject["type"]?.jsonPrimitive?.contentOrNull) { "mesh" -> ChannelID.Mesh "location" -> { - val levelName = channelMap["level"] as? String - val precision = (channelMap["precision"] as? Double)?.toInt() - val geohash = channelMap["geohash"] as? String - val displayName = channelMap["displayName"] as? String + val levelName = channelObject["level"]?.jsonPrimitive?.contentOrNull + val precision = channelObject["precision"]?.jsonPrimitive?.doubleOrNull?.toInt() + val geohash = channelObject["geohash"]?.jsonPrimitive?.contentOrNull + val displayName = channelObject["displayName"]?.jsonPrimitive?.contentOrNull if (levelName != null && precision != null && geohash != null && displayName != null) { try { @@ -595,7 +601,7 @@ class LocationChannelManager private constructor(private val context: Context) { } } else -> { - Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}") + Log.w(TAG, "Unknown channel type in persisted data: ${channelObject["type"]}") null } } @@ -615,9 +621,6 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "No persisted channel found, defaulting to Mesh") _selectedChannel.postValue(ChannelID.Mesh) } - } catch (e: JsonSyntaxException) { - Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) } catch (e: Exception) { Log.e(TAG, "Failed to load persisted channel: ${e.message}") _selectedChannel.postValue(ChannelID.Mesh) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1a..895c2fb5c 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -1,14 +1,14 @@ package com.bitchat.android.model -import android.os.Parcelable -import com.google.gson.GsonBuilder -import kotlinx.parcelize.Parcelize +import com.bitchat.android.util.DateSerializer +import com.bitchat.android.util.ByteArraySerializer +import kotlinx.serialization.Serializable import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.* -@Parcelize -enum class BitchatMessageType : Parcelable { +@Serializable +enum class BitchatMessageType { Message, Audio, Image, @@ -18,23 +18,24 @@ enum class BitchatMessageType : Parcelable { /** * Delivery status for messages - exact same as iOS version */ -sealed class DeliveryStatus : Parcelable { - @Parcelize +@Serializable +sealed class DeliveryStatus { + @Serializable object Sending : DeliveryStatus() - @Parcelize + @Serializable object Sent : DeliveryStatus() - @Parcelize - data class Delivered(val to: String, val at: Date) : DeliveryStatus() + @Serializable + data class Delivered(val to: String, @Serializable(with = DateSerializer::class) val at: Date) : DeliveryStatus() - @Parcelize - data class Read(val by: String, val at: Date) : DeliveryStatus() + @Serializable + data class Read(val by: String, @Serializable(with = DateSerializer::class) val at: Date) : DeliveryStatus() - @Parcelize + @Serializable data class Failed(val reason: String) : DeliveryStatus() - @Parcelize + @Serializable data class PartiallyDelivered(val reached: Int, val total: Int) : DeliveryStatus() fun getDisplayText(): String { @@ -52,13 +53,13 @@ sealed class DeliveryStatus : Parcelable { /** * BitchatMessage - 100% compatible with iOS version */ -@Parcelize +@Serializable data class BitchatMessage( val id: String = UUID.randomUUID().toString().uppercase(), val sender: String, val content: String, val type: BitchatMessageType = BitchatMessageType.Message, - val timestamp: Date, + @Serializable(with = DateSerializer::class) val timestamp: Date, val isRelay: Boolean = false, val originalSender: String? = null, val isPrivate: Boolean = false, @@ -66,11 +67,11 @@ data class BitchatMessage( val senderPeerID: String? = null, val mentions: List? = null, val channel: String? = null, - val encryptedContent: ByteArray? = null, + @Serializable(with = ByteArraySerializer::class) val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, val powDifficulty: Int? = null -) : Parcelable { +) { /** * Convert message to binary payload format - exactly same as iOS version diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c23..f47056ea7 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -1,19 +1,18 @@ package com.bitchat.android.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import com.bitchat.android.util.* +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer /** * Identity announcement structure with TLV encoding * Compatible with iOS AnnouncementPacket TLV format */ -@Parcelize +@Serializable data class IdentityAnnouncement( val nickname: String, - val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) - val signingPublicKey: ByteArray // Ed25519 public key for signing -) : Parcelable { + @Serializable(with = ByteArraySerializer::class) val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) + @Serializable(with = ByteArraySerializer::class) val signingPublicKey: ByteArray // Ed25519 public key for signing +) { /** * TLV types matching iOS implementation diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index 7f691a9cc..55367ecfd 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -1,7 +1,8 @@ package com.bitchat.android.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer + /** * Noise encrypted payload types and handling - 100% compatible with iOS SimplifiedBluetoothService @@ -35,11 +36,11 @@ enum class NoisePayloadType(val value: UByte) { * Helper class for creating and parsing Noise payloads * Matches iOS NoisePayload helper exactly */ -@Parcelize +@Serializable data class NoisePayload( val type: NoisePayloadType, - val data: ByteArray -) : Parcelable { + @Serializable(with = ByteArraySerializer::class) val data: ByteArray +) { /** * Encode payload with type prefix - exactly like iOS @@ -97,11 +98,11 @@ data class NoisePayload( /** * Private message packet with TLV encoding - matches iOS PrivateMessagePacket exactly */ -@Parcelize +@Serializable data class PrivateMessagePacket( val messageID: String, val content: String -) : Parcelable { +) { /** * TLV types matching iOS implementation exactly @@ -197,8 +198,8 @@ data class PrivateMessagePacket( /** * Read receipt data class for transport compatibility */ -@Parcelize +@Serializable data class ReadReceipt( val originalMessageID: String, val readerPeerID: String? = null -) : Parcelable +) diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt index dce6dd938..1bc5c11d1 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt @@ -1,6 +1,10 @@ package com.bitchat.android.noise import android.util.Log +import com.bitchat.android.util.JsonUtil +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import javax.crypto.Cipher @@ -203,14 +207,14 @@ class NoiseChannelEncryption { fun createChannelKeyPacket(password: String, channel: String): ByteArray? { return try { // Create key packet with channel and password - val packet = mapOf( - "channel" to channel, - "password" to password, - "timestamp" to System.currentTimeMillis() - ) + val jsonObject = buildJsonObject { + put("channel", channel) + put("password", password) + put("timestamp", System.currentTimeMillis()) + } // Simple JSON encoding for now (could be replaced with more efficient format) - val json = com.google.gson.Gson().toJson(packet) + val json = JsonUtil.json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), jsonObject) json.toByteArray(Charsets.UTF_8) } catch (e: Exception) { Log.e(TAG, "Failed to create channel key packet: ${e.message}") @@ -225,7 +229,14 @@ class NoiseChannelEncryption { fun processChannelKeyPacket(data: ByteArray): Pair? { return try { val json = String(data, Charsets.UTF_8) - val packet = com.google.gson.Gson().fromJson(json, Map::class.java) as Map + val packet = try { + JsonUtil.json.parseToJsonElement(json).jsonObject.mapValues { + when (val value = it.value) { + is kotlinx.serialization.json.JsonPrimitive -> if (value.isString) value.content else value.toString() + else -> value.toString() + } + } + } catch (e: Exception) { return null } val channel = packet["channel"] as? String val password = packet["password"] as? String diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 785b41f37..c29665d07 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -1,18 +1,21 @@ package com.bitchat.android.nostr -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import com.bitchat.android.util.JsonUtil import java.security.MessageDigest /** * Nostr Event structure following NIP-01 * Compatible with iOS implementation */ +@Serializable data class NostrEvent( var id: String = "", val pubkey: String, - @SerializedName("created_at") val createdAt: Int, + @SerialName("created_at") val createdAt: Int, val kind: Int, val tags: List>, val content: String, @@ -43,12 +46,7 @@ data class NostrEvent( * Create from JSON string */ fun fromJsonString(jsonString: String): NostrEvent? { - return try { - val gson = Gson() - gson.fromJson(jsonString, NostrEvent::class.java) - } catch (e: Exception) { - null - } + return JsonUtil.fromJsonOrNull(jsonString) } /** @@ -121,18 +119,23 @@ data class NostrEvent( */ private fun calculateEventId(): Pair { // Create serialized array for hashing according to NIP-01 - val serialized = listOf( - 0, - pubkey, - createdAt, - kind, - tags, - content - ) + val jsonArray = buildJsonArray { + add(0) + add(pubkey) + add(createdAt) + add(kind) + add(buildJsonArray { + tags.forEach { tag -> + add(buildJsonArray { + tag.forEach { add(it) } + }) + } + }) + add(content) + } // Convert to JSON without escaping slashes (compact format) - val gson = GsonBuilder().disableHtmlEscaping().create() - val jsonString = gson.toJson(serialized) + val jsonString = JsonUtil.json.encodeToString(kotlinx.serialization.json.JsonArray.serializer(), jsonArray) // SHA256 hash of the JSON string val digest = MessageDigest.getInstance("SHA-256") @@ -161,8 +164,7 @@ data class NostrEvent( * Convert to JSON string */ fun toJsonString(): String { - val gson = Gson() - return gson.toJson(this) + return JsonUtil.toJson(this) } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index b6313ea7a..cc52b2989 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -1,13 +1,13 @@ package com.bitchat.android.nostr -import com.google.gson.* -import com.google.gson.annotations.SerializedName -import java.lang.reflect.Type +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* /** * Nostr event filter for subscriptions * Compatible with iOS implementation */ +@Serializable data class NostrFilter( val ids: List? = null, val authors: List? = null, @@ -17,125 +17,33 @@ data class NostrFilter( val limit: Int? = null, private val tagFilters: Map>? = null ) { - - companion object { - /** - * Create filter for NIP-17 gift wraps - */ - fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.GIFT_WRAP), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("p" to listOf(pubkey)), - limit = 100 - ) - } - - /** - * Create filter for geohash-scoped ephemeral events (kind 20000) - */ - fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.EPHEMERAL_EVENT), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("g" to listOf(geohash)), - limit = limit - ) - } - - /** - * Create filter for text notes from specific authors - */ - fun textNotesFrom(authors: List, since: Long? = null, limit: Int = 50): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.TEXT_NOTE), - authors = authors, - since = since?.let { (it / 1000).toInt() }, - limit = limit - ) - } - - /** - * Create filter for geohash-scoped text notes (kind=1 with g tag) - */ - fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.TEXT_NOTE), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("g" to listOf(geohash)), - limit = limit - ) - } - - /** - * Create filter for specific event IDs - */ - fun forEvents(ids: List): NostrFilter { - return NostrFilter(ids = ids) - } + /** + * Convert NostrFilter to JsonElement for serialization + */ + fun toJsonElement(filter: NostrFilter): JsonElement { + return filter.toJsonElement() } - + /** - * Custom JSON serializer to handle tag filters properly + * Convert this filter to JsonElement for serialization */ - class FilterSerializer : JsonSerializer { - override fun serialize(src: NostrFilter, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - val jsonObject = JsonObject() - + fun toJsonElement(): JsonElement { + return buildJsonObject { // Standard fields - src.ids?.let { jsonObject.add("ids", context.serialize(it)) } - src.authors?.let { jsonObject.add("authors", context.serialize(it)) } - src.kinds?.let { jsonObject.add("kinds", context.serialize(it)) } - src.since?.let { jsonObject.addProperty("since", it) } - src.until?.let { jsonObject.addProperty("until", it) } - src.limit?.let { jsonObject.addProperty("limit", it) } - + ids?.let { put("ids", JsonArray(it.map { JsonPrimitive(it) })) } + authors?.let { put("authors", JsonArray(it.map { JsonPrimitive(it) })) } + kinds?.let { put("kinds", JsonArray(it.map { JsonPrimitive(it) })) } + since?.let { put("since", JsonPrimitive(it)) } + until?.let { put("until", JsonPrimitive(it)) } + limit?.let { put("limit", JsonPrimitive(it)) } + // Tag filters with # prefix - src.tagFilters?.forEach { (tag, values) -> - jsonObject.add("#$tag", context.serialize(values)) + tagFilters?.forEach { (tag, values) -> + put("#$tag", JsonArray(values.map { JsonPrimitive(it) })) } - - return jsonObject - } - } - - /** - * Create builder for complex filters - */ - class Builder { - private var ids: List? = null - private var authors: List? = null - private var kinds: List? = null - private var since: Int? = null - private var until: Int? = null - private var limit: Int? = null - private val tagFilters = mutableMapOf>() - - fun ids(vararg ids: String) = apply { this.ids = ids.toList() } - fun authors(vararg authors: String) = apply { this.authors = authors.toList() } - fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() } - fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() } - fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() } - fun limit(count: Int) = apply { this.limit = count } - - fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() } - fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() } - fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() } - fun tag(name: String, vararg values: String) = apply { tagFilters[name] = values.toList() } - - fun build(): NostrFilter { - return NostrFilter( - ids = ids, - authors = authors, - kinds = kinds, - since = since, - until = until, - limit = limit, - tagFilters = tagFilters.toMap() - ) } } - + /** * Check if this filter matches an event */ @@ -144,26 +52,26 @@ data class NostrFilter( if (ids != null && !ids.contains(event.id)) { return false } - + // Check authors if (authors != null && !authors.contains(event.pubkey)) { return false } - + // Check kinds if (kinds != null && !kinds.contains(event.kind)) { return false } - + // Check time bounds if (since != null && event.createdAt < since) { return false } - + if (until != null && event.createdAt > until) { return false } - + // Check tag filters if (tagFilters != null) { for ((tagName, requiredValues) in tagFilters) { @@ -171,26 +79,26 @@ data class NostrFilter( val eventValues = eventTags.mapNotNull { tag -> if (tag.size > 1) tag[1] else null } - + val hasMatch = requiredValues.any { requiredValue -> eventValues.contains(requiredValue) } - + if (!hasMatch) { return false } } } - + return true } - + /** * Get debug description */ fun getDebugDescription(): String { val parts = mutableListOf() - + ids?.let { parts.add("ids=${it.size}") } authors?.let { parts.add("authors=${it.size}") } kinds?.let { parts.add("kinds=$it") } @@ -202,10 +110,105 @@ data class NostrFilter( parts.add("#$tag=${values.size}") } } - + return "NostrFilter(${parts.joinToString(", ")})" } + companion object { + /** + * Create filter for NIP-17 gift wraps + */ + fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.GIFT_WRAP), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("p" to listOf(pubkey)), + limit = 100 + ) + } + + /** + * Create filter for geohash-scoped ephemeral events (kind 20000) + */ + fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for text notes from specific authors + */ + fun textNotesFrom(authors: List, since: Long? = null, limit: Int = 50): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + authors = authors, + since = since?.let { (it / 1000).toInt() }, + limit = limit + ) + } + + /** + * Create filter for geohash-scoped text notes (kind=1 with g tag) + */ + fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for specific event IDs + */ + fun forEvents(ids: List): NostrFilter { + return NostrFilter(ids = ids) + } + + /** + * Create builder for complex filters + */ + class Builder { + private var ids: List? = null + private var authors: List? = null + private var kinds: List? = null + private var since: Int? = null + private var until: Int? = null + private var limit: Int? = null + private val tagFilters = mutableMapOf>() + + fun ids(vararg ids: String) = apply { this.ids = ids.toList() } + fun authors(vararg authors: String) = apply { this.authors = authors.toList() } + fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() } + fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() } + fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() } + fun limit(count: Int) = apply { this.limit = count } + + fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() } + fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() } + fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() } + fun tag(name: String, vararg values: String) = + apply { tagFilters[name] = values.toList() } + + fun build(): NostrFilter { + return NostrFilter( + ids = ids, + authors = authors, + kinds = kinds, + since = since, + until = until, + limit = limit, + tagFilters = tagFilters.toMap() + ) + } + } + } + /** * Get geohash value from g tag filter (if present) * Returns the first geohash in the filter or null if none diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 0b94bf78c..b5fe8f321 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -1,8 +1,8 @@ package com.bitchat.android.nostr import android.util.Log -import com.google.gson.Gson -import com.google.gson.JsonParser +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext object NostrProtocol { private const val TAG = "NostrProtocol" - private val gson = Gson() + /** * Create NIP-17 private message gift-wrap (receiver copy only per iOS) @@ -189,7 +189,7 @@ object NostrProtocol { senderPrivateKey: String, senderPublicKey: String ): NostrEvent { - val rumorJSON = gson.toJson(rumor) + val rumorJSON = JsonUtil.toJson(rumor) val encrypted = NostrCrypto.encryptNIP44( plaintext = rumorJSON, @@ -213,7 +213,7 @@ object NostrProtocol { seal: NostrEvent, recipientPubkey: String ): NostrEvent { - val sealJSON = gson.toJson(seal) + val sealJSON = JsonUtil.toJson(seal) // Create new ephemeral key for gift wrap val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() @@ -251,21 +251,21 @@ object NostrProtocol { recipientPrivateKeyHex = recipientPrivateKey ) - val jsonElement = JsonParser.parseString(decrypted) - if (!jsonElement.isJsonObject) { + val jsonElement = JsonUtil.json.parseToJsonElement(decrypted) + if (jsonElement !is JsonObject) { Log.w(TAG, "Decrypted gift wrap is not a JSON object") return null } - val jsonObject = jsonElement.asJsonObject + val jsonObject = jsonElement val seal = NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray) ?: emptyList(), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}") @@ -287,21 +287,21 @@ object NostrProtocol { recipientPrivateKeyHex = recipientPrivateKey ) - val jsonElement = JsonParser.parseString(decrypted) - if (!jsonElement.isJsonObject) { + val jsonElement = JsonUtil.json.parseToJsonElement(decrypted) + if (jsonElement !is JsonObject) { Log.w(TAG, "Decrypted seal is not a JSON object") return null } - val jsonObject = jsonElement.asJsonObject + val jsonObject = jsonElement NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray) ?: emptyList(), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) } catch (e: Exception) { Log.w(TAG, "Failed to open seal: ${e.message}") @@ -309,14 +309,13 @@ object NostrProtocol { } } - private fun parseTagsFromJson(tagsArray: com.google.gson.JsonArray?): List>? { + private fun parseTagsFromJson(tagsArray: JsonArray?): List>? { if (tagsArray == null) return emptyList() return try { tagsArray.map { tagElement -> - if (tagElement.isJsonArray) { - val tagArray = tagElement.asJsonArray - tagArray.map { it.asString } + if (tagElement is JsonArray) { + tagElement.map { it.jsonPrimitive.content } } else { emptyList() } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index eb5806bb5..3ed252cd0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -3,9 +3,8 @@ package com.bitchat.android.nostr import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.gson.Gson -import com.google.gson.JsonArray -import com.google.gson.JsonParser +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import kotlinx.coroutines.* import okhttp3.* import java.util.concurrent.ConcurrentHashMap @@ -117,7 +116,7 @@ class NostrRelayManager private constructor() { private val httpClient: OkHttpClient get() = com.bitchat.android.net.OkHttpProvider.webSocketClient() - private val gson by lazy { NostrRequest.createGson() } + // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs @@ -331,7 +330,7 @@ class NostrRelayManager private constructor() { */ private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) // DEBUG: Log the actual serialized message format Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message") @@ -383,7 +382,7 @@ class NostrRelayManager private constructor() { Log.d(TAG, "🚫 Unsubscribing from subscription: $id") val request = NostrRequest.Close(id) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) scope.launch { connections.forEach { (relayUrl, webSocket) -> @@ -631,7 +630,7 @@ class NostrRelayManager private constructor() { private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { try { val request = NostrRequest.Event(event) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl") @@ -651,13 +650,13 @@ class NostrRelayManager private constructor() { private fun handleMessage(message: String, relayUrl: String) { try { - val jsonElement = JsonParser.parseString(message) - if (!jsonElement.isJsonArray) { + val jsonElement = JsonUtil.json.parseToJsonElement(message) + if (jsonElement !is JsonArray) { Log.w(TAG, "Received non-array message from $relayUrl") return } - val response = NostrResponse.fromJsonArray(jsonElement.asJsonArray) + val response = NostrResponse.fromJsonArray(jsonElement) when (response) { is NostrResponse.Event -> { @@ -828,7 +827,7 @@ class NostrRelayManager private constructor() { subscriptionsToRestore.forEach { subscriptionInfo -> try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) val success = webSocket.send(message) if (success) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt index 668e046e8..b669d1270 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt @@ -1,7 +1,7 @@ package com.bitchat.android.nostr -import com.google.gson.* -import java.lang.reflect.Type +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil /** * Nostr protocol request messages @@ -27,54 +27,34 @@ sealed class NostrRequest { */ data class Close(val subscriptionId: String) : NostrRequest() - /** - * Custom JSON serializer for NostrRequest - */ - class RequestSerializer : JsonSerializer { - override fun serialize(src: NostrRequest, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - val array = JsonArray() - - when (src) { - is Event -> { - array.add("EVENT") - array.add(context.serialize(src.event)) - } - - is Subscribe -> { - array.add("REQ") - array.add(src.subscriptionId) - src.filters.forEach { filter -> - array.add(context.serialize(filter, NostrFilter::class.java)) - } - } - - is Close -> { - array.add("CLOSE") - array.add(src.subscriptionId) - } - } - - return array - } - } - companion object { - /** - * Create Gson instance with proper serializers - */ - fun createGson(): Gson { - return GsonBuilder() - .registerTypeAdapter(NostrRequest::class.java, RequestSerializer()) - .registerTypeAdapter(NostrFilter::class.java, NostrFilter.FilterSerializer()) - .disableHtmlEscaping() - .create() - } - /** * Serialize request to JSON string */ fun toJson(request: NostrRequest): String { - return createGson().toJson(request) + val jsonArray = buildJsonArray { + when (request) { + is Event -> { + add("EVENT") + add(Json.encodeToJsonElement(request.event)) + } + + is Subscribe -> { + add("REQ") + add(request.subscriptionId) + request.filters.forEach { filter -> + add(filter.toJsonElement()) + } + } + + is Close -> { + add("CLOSE") + add(request.subscriptionId) + } + } + } + + return JsonUtil.json.encodeToString(JsonArray.serializer(), jsonArray) } } } @@ -129,11 +109,11 @@ sealed class NostrResponse { */ fun fromJsonArray(jsonArray: JsonArray): NostrResponse { return try { - when (val type = jsonArray[0].asString) { + when (val type = jsonArray[0].jsonPrimitive.content) { "EVENT" -> { - if (jsonArray.size() >= 3) { - val subscriptionId = jsonArray[1].asString - val eventJson = jsonArray[2].asJsonObject + if (jsonArray.size >= 3) { + val subscriptionId = jsonArray[1].jsonPrimitive.content + val eventJson = jsonArray[2].jsonObject val event = parseEventFromJson(eventJson) Event(subscriptionId, event) } else { @@ -142,8 +122,8 @@ sealed class NostrResponse { } "EOSE" -> { - if (jsonArray.size() >= 2) { - val subscriptionId = jsonArray[1].asString + if (jsonArray.size >= 2) { + val subscriptionId = jsonArray[1].jsonPrimitive.content EndOfStoredEvents(subscriptionId) } else { Unknown(jsonArray.toString()) @@ -151,11 +131,11 @@ sealed class NostrResponse { } "OK" -> { - if (jsonArray.size() >= 3) { - val eventId = jsonArray[1].asString - val accepted = jsonArray[2].asBoolean - val message = if (jsonArray.size() >= 4) { - jsonArray[3].asString + if (jsonArray.size >= 3) { + val eventId = jsonArray[1].jsonPrimitive.content + val accepted = jsonArray[2].jsonPrimitive.boolean + val message = if (jsonArray.size >= 4) { + jsonArray[3].jsonPrimitive.content } else null Ok(eventId, accepted, message) } else { @@ -164,8 +144,8 @@ sealed class NostrResponse { } "NOTICE" -> { - if (jsonArray.size() >= 2) { - val message = jsonArray[1].asString + if (jsonArray.size >= 2) { + val message = jsonArray[1].jsonPrimitive.content Notice(message) } else { Unknown(jsonArray.toString()) @@ -181,13 +161,13 @@ sealed class NostrResponse { private fun parseEventFromJson(jsonObject: JsonObject): NostrEvent { return NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) } @@ -196,9 +176,8 @@ sealed class NostrResponse { return try { tagsArray.map { tagElement -> - if (tagElement.isJsonArray) { - val tagArray = tagElement.asJsonArray - tagArray.map { it.asString } + if (tagElement is JsonArray) { + tagElement.map { it.jsonPrimitive.content } } else { emptyList() } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 2d15b86e4..81b959e88 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -1,10 +1,12 @@ package com.bitchat.android.protocol -import android.os.Parcelable -import kotlinx.parcelize.Parcelize import java.nio.ByteBuffer import java.nio.ByteOrder import android.util.Log +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer +import com.bitchat.android.util.UByteSerializer +import com.bitchat.android.util.ULongSerializer /** * Message types - exact same as iOS version with Noise Protocol support @@ -50,17 +52,17 @@ object SpecialRecipients { * - Payload: Variable length (includes original size if compressed) * - Signature: 64 bytes (if hasSignature flag set) */ -@Parcelize +@Serializable data class BitchatPacket( - val version: UByte = 1u, - val type: UByte, - val senderID: ByteArray, - val recipientID: ByteArray? = null, - val timestamp: ULong, - val payload: ByteArray, - var signature: ByteArray? = null, // Changed from val to var for packet signing - var ttl: UByte -) : Parcelable { + @Serializable(with = UByteSerializer::class) val version: UByte = 1u, + @Serializable(with = UByteSerializer::class) val type: UByte, + @Serializable(with = ByteArraySerializer::class) val senderID: ByteArray, + @Serializable(with = ByteArraySerializer::class) val recipientID: ByteArray? = null, + @Serializable(with = ULongSerializer::class) val timestamp: ULong, + @Serializable(with = ByteArraySerializer::class) val payload: ByteArray, + @Serializable(with = ByteArraySerializer::class) var signature: ByteArray? = null, // Changed from val to var for packet signing + @Serializable(with = UByteSerializer::class) var ttl: UByte +) { constructor( type: UByte, diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index a6cd23784..5acbf85c4 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -3,7 +3,8 @@ package com.bitchat.android.services import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager -import com.google.gson.Gson +import kotlinx.serialization.Serializable +import com.bitchat.android.util.JsonUtil /** * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. @@ -23,7 +24,7 @@ class SeenMessageStore private constructor(private val context: Context) { } } - private val gson = Gson() + private val secure = SecureIdentityStateManager(context) private val delivered = LinkedHashSet(MAX_IDS) @@ -61,7 +62,7 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun load() { try { val json = secure.getSecureValue(STORAGE_KEY) ?: return - val data = gson.fromJson(json, StorePayload::class.java) ?: return + val data = JsonUtil.fromJsonOrNull(json) ?: return delivered.clear(); read.clear() data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } data.read.takeLast(MAX_IDS).forEach { read.add(it) } @@ -74,13 +75,14 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun persist() { try { val payload = StorePayload(delivered.toList(), read.toList()) - val json = gson.toJson(payload) + val json = JsonUtil.toJson(payload) secure.storeSecureValue(STORAGE_KEY, json) } catch (e: Exception) { Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}") } } + @Serializable private data class StorePayload( val delivered: List = emptyList(), val read: List = emptyList() diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 091cf630e..3dde31a0c 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Security diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c8645..26d07bc80 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -3,7 +3,8 @@ package com.bitchat.android.ui import android.content.Context import android.content.SharedPreferences import android.util.Log -import com.google.gson.Gson +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import kotlin.random.Random /** @@ -16,7 +17,7 @@ class DataManager(private val context: Context) { } private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) - private val gson = Gson() + // Channel-related maps that need to persist state private val _channelCreators = mutableMapOf() @@ -85,7 +86,11 @@ class DataManager(private val context: Context) { // Load channel creators val creatorsJson = prefs.getString("channel_creators", "{}") try { - val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map + val creatorsMap = try { + JsonUtil.json.parseToJsonElement(creatorsJson ?: "{}").jsonObject.mapValues { + it.value.jsonPrimitive.content + } + } catch (e: Exception) { null } creatorsMap?.let { _channelCreators.putAll(it) } } catch (e: Exception) { // Ignore parsing errors @@ -105,7 +110,7 @@ class DataManager(private val context: Context) { prefs.edit().apply { putStringSet("joined_channels", joinedChannels) putStringSet("password_protected_channels", passwordProtectedChannels) - putString("channel_creators", gson.toJson(_channelCreators)) + putString("channel_creators", JsonUtil.toJson(_channelCreators)) apply() } } diff --git a/app/src/main/java/com/bitchat/android/util/JsonUtil.kt b/app/src/main/java/com/bitchat/android/util/JsonUtil.kt new file mode 100644 index 000000000..168810ea0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/JsonUtil.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.util + +import kotlinx.serialization.json.Json +import kotlinx.serialization.KSerializer + +/** + * JSON utility using kotlinx.serialization to replace Gson + */ +object JsonUtil { + + val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + prettyPrint = false + } + + /** + * Serialize object to JSON string + */ + inline fun toJson(value: T): String { + return json.encodeToString(value) + } + + /** + * Serialize object to JSON string with custom serializer + */ + fun toJson(serializer: KSerializer, value: T): String { + return json.encodeToString(serializer, value) + } + + /** + * Deserialize JSON string to object + */ + inline fun fromJson(jsonString: String): T { + return json.decodeFromString(jsonString) + } + + /** + * Deserialize JSON string to object with custom serializer + */ + fun fromJson(serializer: KSerializer, jsonString: String): T { + return json.decodeFromString(serializer, jsonString) + } + + /** + * Safe deserialization that returns null on error + */ + inline fun fromJsonOrNull(jsonString: String): T? { + return try { + json.decodeFromString(jsonString) + } catch (e: Exception) { + null + } + } + + /** + * Safe deserialization with custom serializer that returns null on error + */ + fun fromJsonOrNull(serializer: KSerializer, jsonString: String): T? { + return try { + json.decodeFromString(serializer, jsonString) + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/Serializers.kt b/app/src/main/java/com/bitchat/android/util/Serializers.kt new file mode 100644 index 000000000..de6a540c1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/Serializers.kt @@ -0,0 +1,72 @@ +package com.bitchat.android.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.* +import android.util.Base64 + +//TODO DELETE WHEN MIGRATE TO KOTLINX DATE TIME + +/** + * Serializer for Date objects using milliseconds since epoch + */ +object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Date) { + encoder.encodeLong(value.time) + } + + override fun deserialize(decoder: Decoder): Date { + return Date(decoder.decodeLong()) + } +} + +/** + * Serializer for ByteArray using Base64 encoding + */ +object ByteArraySerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(Base64.encodeToString(value, Base64.NO_WRAP)) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return Base64.decode(decoder.decodeString(), Base64.NO_WRAP) + } +} + +/** + * Serializer for UByte values + */ +object UByteSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UByte", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: UByte) { + encoder.encodeInt(value.toInt()) + } + + override fun deserialize(decoder: Decoder): UByte { + return decoder.decodeInt().toUByte() + } +} + +/** + * Serializer for ULong values + */ +object ULongSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ULong", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: ULong) { + encoder.encodeLong(value.toLong()) + } + + override fun deserialize(decoder: Decoder): ULong { + return decoder.decodeLong().toULong() + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 440826843..e7f5ce3ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false } tasks.whenTaskAdded { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224a..84bce465b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,12 +25,14 @@ accompanist-permissions = "0.37.3" bouncycastle = "1.70" tink-android = "1.10.0" -# JSON -gson = "2.13.1" + # Coroutines kotlinx-coroutines = "1.10.2" +# Serialization +kotlinx-serialization = "1.9.0" + # Bluetooth nordic-ble = "2.6.1" @@ -85,12 +87,13 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } -# JSON -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } # Coroutines kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +# Serialization +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + # Bluetooth nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" } @@ -121,7 +124,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-parcelize = { id = "kotlin-parcelize" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } [bundles]