Nip17 dms to extend the mesh (#313)

* favorite each other

* show favorites as system messsages

* wip show glove when offline but mayb edoesnt work

* send and receive works

* wip kinda works but no

* getting there

* kinda

* show offline peers in peer list

* kinda wonky but almost works

* fixes

* nostr user goes offline works

* nostr -> mesh works

* handoff works

* background message processing

* read works

* seen message store was missing
This commit is contained in:
callebtc
2025-08-25 18:38:10 +02:00
committed by GitHub
parent 941be1b98f
commit 6fe6e049ad
15 changed files with 1153 additions and 173 deletions
@@ -16,6 +16,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
/**
@@ -75,7 +77,7 @@ class NostrGeohashService(
private val processedNostrEvents = mutableSetOf<String>()
private val processedNostrEventOrder = mutableListOf<String>()
private val maxProcessedNostrEvents = 2000
private val processedNostrAcks = mutableSetOf<String>()
// removed unused processedNostrAcks
private val nostrKeyMapping = mutableMapOf<String, String>() // senderPeerID -> nostrPubkey
// MARK: - Geohash Participant Tracking Properties
@@ -221,9 +223,11 @@ class NostrGeohashService(
* Handle incoming Nostr message (gift wrap)
*/
private fun handleNostrMessage(giftWrap: NostrEvent) {
// Simple deduplication
if (processedNostrEvents.contains(giftWrap.id)) return
processedNostrEvents.add(giftWrap.id)
// Offload processing to avoid blocking UI
coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) {
// Simple deduplication
if (processedNostrEvents.contains(giftWrap.id)) return@launch
processedNostrEvents.add(giftWrap.id)
// Manage deduplication cache size
processedNostrEventOrder.add(giftWrap.id)
@@ -235,16 +239,18 @@ class NostrGeohashService(
// Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 173700) { // 48 hours + 15 minutes
return
return@launch
}
Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...")
Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...")
// Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID
val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (currentIdentity == null) {
Log.w(TAG, "No Nostr identity available for decryption")
return
}
val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (currentIdentity == null) {
Log.w(TAG, "No Nostr identity available for decryption")
return@launch
}
try {
val decryptResult = NostrProtocol.decryptPrivateMessage(
@@ -254,7 +260,7 @@ class NostrGeohashService(
if (decryptResult == null) {
Log.w(TAG, "Failed to decrypt Nostr message")
return
return@launch
}
val (content, senderPubkey, rumorTimestamp) = decryptResult
@@ -262,26 +268,26 @@ class NostrGeohashService(
// Expect embedded BitChat packet content
if (!content.startsWith("bitchat1:")) {
Log.d(TAG, "Ignoring non-embedded Nostr DM content")
return
return@launch
}
val base64Content = content.removePrefix("bitchat1:")
val packetData = base64URLDecode(base64Content)
if (packetData == null) {
Log.e(TAG, "Failed to decode base64url BitChat packet")
return
return@launch
}
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData)
if (packet == null) {
Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM")
return
return@launch
}
// Only process noiseEncrypted envelope for private messages/receipts
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) {
Log.w(TAG, "Unsupported embedded packet type: ${packet.type}")
return
return@launch
}
// Validate recipient if present
@@ -295,24 +301,50 @@ class NostrGeohashService(
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload)
if (noisePayload == null) {
Log.e(TAG, "Failed to parse embedded NoisePayload")
return
return@launch
}
// Map sender by Nostr pubkey to Noise key when possible
val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey)
val messageTimestamp = Date(rumorTimestamp * 1000L)
val senderNickname = if (senderNoiseKey != null) {
// Get nickname from favorites
getFavoriteNickname(senderNoiseKey) ?: "Unknown"
// If we know the Noise key, try to resolve a currently connected mesh peer ID for it
val targetPeerID: String = if (senderNoiseKey != null) {
val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey)
if (meshPeerId != null) {
// Also unify existing noise-hex/nostr-temp chats into this mesh peer
val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
val tempKey = "nostr_${senderPubkey.take(16)}"
unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey))
// If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer
val selected = state.getSelectedPrivateChatPeerValue()
if (selected == noiseHex || selected == tempKey) {
state.setSelectedPrivateChatPeer(meshPeerId)
}
meshPeerId
} else {
senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
}
} else {
"nostr_${senderPubkey.take(16)}"
}
// Prefer nickname from mesh when connected; else fallback to favorites
val senderNickname: String = if (senderNoiseKey != null) {
val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey)
if (meshPeerId != null) {
// Use live mesh nickname if available
meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname
?: getFavoriteNickname(senderNoiseKey)
?: "Unknown"
} else {
getFavoriteNickname(senderNoiseKey) ?: "Unknown"
}
} else {
"Unknown"
}
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
val targetPeerID = senderNoiseKey?.let {
it.joinToString("") { byte -> "%02x".format(byte) }
} ?: "nostr_${senderPubkey.take(16)}"
// Store Nostr key mapping
nostrKeyMapping[targetPeerID] = senderPubkey
@@ -323,17 +355,19 @@ class NostrGeohashService(
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
pm?.let { pmsg ->
val nostrTransport = NostrTransport.getInstance(application)
// Prefer mapped peer route; fallback to direct Nostr using sender pubkey
if (senderNoiseKey != null) {
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
} else {
// Fallback: direct to senders Nostr pubkey (geohash-style)
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (identity != null) {
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
if (!seen.hasDelivered(pmsg.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
if (senderNoiseKey != null) {
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
} else {
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (identity != null) {
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
}
}
seen.markDelivered(pmsg.messageID)
}
}
}
@@ -341,12 +375,13 @@ class NostrGeohashService(
} catch (e: Exception) {
Log.e(TAG, "Error processing Nostr message: ${e.message}")
}
}
}
/**
* Process NoisePayload from Nostr message
*/
private fun processNoisePayload(
private suspend fun processNoisePayload(
noisePayload: com.bitchat.android.model.NoisePayload,
targetPeerID: String,
senderNickname: String,
@@ -362,6 +397,8 @@ class NostrGeohashService(
val messageId = pm.messageID
val messageContent = pm.content
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
val suppressUnread = seen.hasRead(messageId)
// Handle favorite/unfavorite notifications
if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) {
@@ -399,13 +436,18 @@ class NostrGeohashService(
)
)
// Add to private chats
privateChatManager.handleIncomingPrivateMessage(message)
// Send read receipt if viewing
if (isViewingThisChat) {
// Note: meshService needs to be passed as parameter
// privateChatManager.sendReadReceiptsForPeer(targetPeerID, meshService)
// Add to private chats on Main
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
}
// Send read receipt if viewing (only once across restarts)
if (isViewingThisChat && !seen.hasRead(messageId)) {
try {
val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId)
NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID)
seen.markRead(messageId)
} catch (_: Exception) { }
}
Log.i(TAG, "📥 Processed Nostr private message from $senderNickname")
@@ -413,15 +455,19 @@ class NostrGeohashService(
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
val messageId = String(noisePayload.data, Charsets.UTF_8)
// Use the existing delegate to handle delivery acknowledgment
meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID)
// Use the existing delegate to handle delivery acknowledgment on Main
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID)
}
Log.d(TAG, "📥 Processed Nostr delivery ACK for message $messageId")
}
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
val messageId = String(noisePayload.data, Charsets.UTF_8)
// Use the existing delegate to handle read receipt
meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID)
// Use the existing delegate to handle read receipt on Main
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID)
}
Log.d(TAG, "📥 Processed Nostr read receipt for message $messageId")
}
}
@@ -440,6 +486,28 @@ class NostrGeohashService(
private fun getFavoriteNickname(noiseKey: ByteArray): String? {
return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname
}
/**
* Resolve a currently-connected mesh peer ID for a given Noise public key
* by matching against the mesh service's peer info noisePublicKey values.
*/
private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? {
return try {
val peers: List<String> = state.getConnectedPeersValue()
peers.firstOrNull { peerId: String ->
val info = meshDelegateHandler.getPeerInfo(peerId)
info?.noisePublicKey?.contentEquals(noiseKey) == true
}
} catch (_: Exception) { null }
}
/**
* Merge any chats stored under the given keys into the target peer's chat entry
* so messages received while offline appear in the same chat when the peer connects.
*/
private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
}
/**
* Handle favorite/unfavorite notification
@@ -447,16 +515,72 @@ class NostrGeohashService(
private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) {
val isFavorite = content.startsWith("[FAVORITED]")
val action = if (isFavorite) "favorited" else "unfavorited"
// Try to extract npub after colon, if present
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
// Resolve noise key if possible and persist relationship + npub mapping
try {
var noiseKey: ByteArray? = null
// If fromPeerID looks like hex (noise key), decode
val hexRegex = Regex("^[0-9a-fA-F]+$")
if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) {
// Expect 64 hex chars for full Curve25519 key; accept others best-effort
val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Use only if length is plausible
if (bytes.isNotEmpty()) noiseKey = bytes
} else {
// fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey
val senderPubkey = nostrKeyMapping[fromPeerID]
if (senderPubkey != null) {
noiseKey = findNoiseKeyForNostrPubkey(senderPubkey)
}
}
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
if (npub != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
}
}
} catch (_: Exception) {
// Best-effort
}
// Determine guidance text based on mutual status (iOS-style)
val guidance = try {
val rel = run {
// Try to resolve via noise key directly or mapping
var key: ByteArray? = null
val hexRegex = Regex("^[0-9a-fA-F]+$")
if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) {
key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else {
val mappedNostr = nostrKeyMapping[fromPeerID]
key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) }
}
key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) }
}
if (isFavorite) {
if (rel?.isFavorite == true) {
" — mutual! You can continue DMs via Nostr when out of mesh."
} else {
" — favorite back to continue DMs later."
}
} else {
". DMs over Nostr will pause unless you both favorite again."
}
} catch (_: Exception) { "" }
// Show system message
val systemMessage = BitchatMessage(
sender = "system",
content = "$senderNickname $action you",
content = "$senderNickname $action you$guidance",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you")
}
@@ -953,25 +1077,26 @@ class NostrGeohashService(
* Handles participant tracking, nickname caching, message display, and teleport state
*/
private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) {
coroutineScope.launch(Dispatchers.Default) {
try {
Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...")
// Only handle ephemeral kind 20000 events
if (event.kind != 20000) {
Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})")
return
return@launch
}
// Check if this user is blocked in geohash channels BEFORE any processing
if (isGeohashUserBlocked(event.pubkey)) {
Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...")
return
return@launch
}
// Deduplicate events
if (processedNostrEvents.contains(event.id)) {
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
return
return@launch
}
processedNostrEvents.add(event.id)
@@ -984,7 +1109,7 @@ class NostrGeohashService(
// STEP 1: Always update participant activity for all geohashes (for location channel list)
val timestamp = Date(event.createdAt * 1000L)
updateGeohashParticipant(geohash, event.pubkey, timestamp)
withContext(Dispatchers.Main) { updateGeohashParticipant(geohash, event.pubkey, timestamp) }
// STEP 2: Always cache nickname from tag if present (for all geohashes)
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag ->
@@ -996,7 +1121,7 @@ class NostrGeohashService(
// If this is a new nickname or nickname change for current geohash, refresh people list
if (previousNick != nick && currentGeohash == geohash) {
refreshGeohashPeople()
withContext(Dispatchers.Main) { refreshGeohashPeople() }
}
}
@@ -1006,7 +1131,7 @@ class NostrGeohashService(
val currentTeleported = state.getTeleportedGeoValue().toMutableSet()
if (!currentTeleported.contains(key)) {
currentTeleported.add(key)
state.setTeleportedGeo(currentTeleported)
withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) }
Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...")
}
}
@@ -1017,7 +1142,7 @@ class NostrGeohashService(
context = application
)
if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) {
return
return@launch
}
// STEP 5: Store mapping for potential geohash DM initiation
@@ -1032,7 +1157,7 @@ class NostrGeohashService(
event.content.trim().isEmpty()
if (isTeleportPresence) {
Log.v(TAG, "Skipping empty teleport presence event")
return
return@launch
}
val senderName = displayNameForNostrPubkey(event.pubkey)
@@ -1065,7 +1190,7 @@ class NostrGeohashService(
selectedLocationChannel.channel.geohash == geohash)
if (shouldShowMessage) {
messageManager.addMessage(message)
withContext(Dispatchers.Main) { messageManager.addMessage(message) }
}
// NOTIFICATION LOGIC: Check for mentions and first messages
@@ -1076,12 +1201,13 @@ class NostrGeohashService(
} catch (e: Exception) {
Log.e(TAG, "Error handling unified geohash event: ${e.message}")
}
}
}
/**
* Check and trigger geohash notifications for mentions and first messages
*/
private fun checkAndTriggerGeohashNotifications(
private suspend fun checkAndTriggerGeohashNotifications(
geohash: String,
senderName: String,
content: String,
@@ -1104,13 +1230,15 @@ class NostrGeohashService(
if (isMention || isFirstMessage) {
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
notificationManager.showGeohashNotification(
geohash = geohash,
senderNickname = senderName,
messageContent = content,
isMention = isMention,
isFirstMessage = isFirstMessage
)
withContext(Dispatchers.Main) {
notificationManager.showGeohashNotification(
geohash = geohash,
senderNickname = senderName,
messageContent = content,
isMention = isMention,
isFirstMessage = isFirstMessage
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking geohash notifications: ${e.message}")
@@ -1184,10 +1312,13 @@ class NostrGeohashService(
geohash: String,
identity: NostrIdentity
) {
coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) {
try {
// Deduplicate
if (processedNostrEvents.contains(giftWrap.id)) return
if (processedNostrEvents.contains(giftWrap.id)) return@launch
processedNostrEvents.add(giftWrap.id)
// Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID
// Decrypt with per-geohash identity
val decryptResult = NostrProtocol.decryptPrivateMessage(
@@ -1197,28 +1328,28 @@ class NostrGeohashService(
if (decryptResult == null) {
Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)")
return
return@launch
}
val (content, senderPubkey, rumorTimestamp) = decryptResult
// Only process BitChat embedded messages
if (!content.startsWith("bitchat1:")) return
if (!content.startsWith("bitchat1:")) return@launch
val base64Content = content.removePrefix("bitchat1:")
val packetData = base64URLDecode(base64Content) ?: return
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return
val packetData = base64URLDecode(base64Content) ?: return@launch
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
val messageTimestamp = Date(rumorTimestamp * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}"
nostrKeyMapping[convKey] = senderPubkey
when (noisePayload.type) {
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return@launch
val messageId = pm.messageID
Log.d(TAG, "📥 Received geohash DM from ${senderPubkey.take(8)}...")
@@ -1232,7 +1363,7 @@ class NostrGeohashService(
break
}
}
if (messageExists) return
if (messageExists) return@launch
val senderName = displayNameForNostrPubkey(senderPubkey)
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
@@ -1252,17 +1383,23 @@ class NostrGeohashService(
)
)
// Add to private chats
privateChatManager.handleIncomingPrivateMessage(message)
// Add to private chats (suppress unread if already read)
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId))
// Always send delivery ACK for geohash DMs
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
// Send delivery ACK for geohash DMs only once
if (!seen.hasDelivered(messageId)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
seen.markDelivered(messageId)
}
// Send read receipt if viewing this chat
if (isViewingThisChat) {
if (isViewingThisChat && !seen.hasRead(messageId)) {
// Send read receipt via Nostr for geohash DM
sendGeohashReadReceipt(messageId, senderPubkey, geohash)
// Mark message as read persistently
try { seen.markRead(messageId) } catch (_: Exception) { }
}
}
@@ -1280,6 +1417,7 @@ class NostrGeohashService(
} catch (e: Exception) {
Log.e(TAG, "Error handling geohash DM event: ${e.message}")
}
}
}
/**
@@ -70,7 +70,7 @@ object NostrProtocol {
return null
}
Log.v(TAG, "Successfully unwrapped gift wrap")
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
// 2. Open the seal
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
@@ -479,28 +479,11 @@ class NostrTransport(
}
/**
* Convert hex string to byte array (8 bytes)
* Convert full hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
if (hexString.length % 2 != 0) {
return ByteArray(8) // Return 8-byte array filled with zeros
}
val result = ByteArray(8) { 0 }
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun cleanup() {