package com.example.aiapp import android.content.Context import androidx.core.content.edit import java.util.UUID import org.json.JSONArray import org.json.JSONObject private const val PENDING_MESSAGES = "pending-messages" /** A quiet user bubble below the durable transcript. */ internal data class QueuedMessage( val id: String, val text: String, val attachments: List, val refusal: String? = null, /** This phone is still waiting for any durable event that says the server accepted it. */ val local: Boolean = false, /** The HTTP request returned successfully; the provider event is still outstanding. */ val serverAccepted: Boolean = false, ) internal fun localPendingMessage(text: String, attachments: List) = QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true) private fun QueuedMessage.matches(text: String, attachments: List) = this.text == text && this.attachments == attachments /** Replaces the local bridge with the server's durable waiting message, without drawing both. */ internal fun reconcileQueuedMessage( queued: List, event: SessionEvent.MessageQueued, ): List { if (queued.any { !it.local && it.id == event.id }) return queued val at = queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) } if (at < 0) return queued + QueuedMessage(event.id, event.text, event.attachments) return queued.mapIndexed { index, message -> if (index == at) QueuedMessage(event.id, event.text, event.attachments) else message } } /** Removes exactly the pending bubble that became a provider-received user message. */ internal fun reconcileUserMessage( queued: List, event: SessionEvent.UserMessage, ): List { val at = event.id?.let { id -> queued.indexOfFirst { !it.local && it.id == id }.takeIf { it >= 0 } } ?: queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) } return if (at < 0) queued else queued.filterIndexed { index, _ -> index != at } } /** Keeps a failed send in place and puts its actionable failure in that message's bubble. */ internal fun markPendingFailure( queued: List, id: String, failure: String, ): List = queued.map { message -> if (message.local && message.id == id) message.copy(refusal = failure) else message } /** Stops persisting a send once the server owns it, while its bubble awaits the provider event. */ internal fun markPendingAccepted(queued: List, id: String): List = queued.map { message -> if (message.local && message.id == id) message.copy(serverAccepted = true) else message } /** Restores sends for which this phone has not yet seen a durable server event. */ internal fun loadPendingMessages(context: Context, key: String): List { val encoded = context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).getString(key, null) ?: return emptyList() return try { val messages = JSONArray(encoded) List(messages.length()) { index -> val message = messages.getJSONObject(index) val attachments = message.optJSONArray("attachments") ?: JSONArray() QueuedMessage( id = message.getString("id"), text = message.getString("text"), attachments = List(attachments.length()) { attachments.getString(it) }, refusal = message.optString("refusal").takeIf { it.isNotEmpty() }, local = true, ) } } catch (_: org.json.JSONException) { // A corrupt local outbox is not useful on the next open either. Remove it rather than // repeatedly pretending it decoded to an intentionally empty one. context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { remove(key) } emptyList() } } /** Stores only sends the server has not confirmed; everything accepted is the server's to keep. */ internal fun savePendingMessages(context: Context, key: String, queued: List) { val local = queued.filter { it.local && !it.serverAccepted } context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { if (local.isEmpty()) { remove(key) } else { putString( key, JSONArray( local.map { message -> JSONObject() .put("id", message.id) .put("text", message.text) .put("attachments", JSONArray(message.attachments)) .put("refusal", message.refusal ?: "") } ) .toString(), ) } } }