117 lines
4.9 KiB
Kotlin
117 lines
4.9 KiB
Kotlin
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<String>,
|
|
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<String>) =
|
|
QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true)
|
|
|
|
private fun QueuedMessage.matches(text: String, attachments: List<String>) =
|
|
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<QueuedMessage>,
|
|
event: SessionEvent.MessageQueued,
|
|
): List<QueuedMessage> {
|
|
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<QueuedMessage>,
|
|
event: SessionEvent.UserMessage,
|
|
): List<QueuedMessage> {
|
|
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<QueuedMessage>,
|
|
id: String,
|
|
failure: String,
|
|
): List<QueuedMessage> = 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<QueuedMessage>, id: String): List<QueuedMessage> =
|
|
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<QueuedMessage> {
|
|
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<QueuedMessage>) {
|
|
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(),
|
|
)
|
|
}
|
|
}
|
|
}
|