178 lines
7.8 KiB
Kotlin
178 lines
7.8 KiB
Kotlin
package com.m57.hermescontrol.notification
|
|
|
|
import android.app.Notification
|
|
import android.app.NotificationManager
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.app.RemoteInput
|
|
import com.m57.hermescontrol.R
|
|
import com.m57.hermescontrol.data.local.AuthManager
|
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
|
import com.m57.hermescontrol.data.ws.WsMethods
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.coroutines.withTimeout
|
|
|
|
open class NotificationReplyReceiver : BroadcastReceiver() {
|
|
companion object {
|
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
|
const val EXTRA_SESSION_ID = "extra_session_id"
|
|
private const val REPLY_TIMEOUT_MS = 5_000L
|
|
|
|
/** Overall deadline for persisting and submitting one notification reply. */
|
|
private const val PERSISTENCE_TIMEOUT_MS = 5_000L
|
|
|
|
/**
|
|
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
|
|
* the probe sits on the reply path, so a slow or unreachable gateway must
|
|
* cost the user's reply only a sliver of its 5s deadline.
|
|
*/
|
|
private const val BOUNDARY_TIMEOUT_MS = 250L
|
|
}
|
|
|
|
// Reusable scope for async reply processing — avoids creating a new
|
|
// unmanaged CoroutineScope per broadcast fire. (PERF-15)
|
|
private val replyScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
|
|
/**
|
|
* Test-friendly wrapper for [BroadcastReceiver.goAsync] which is `final`
|
|
* (Java) and cannot be mocked or overridden directly. Tests override this
|
|
* via anonymous subclass to inject a fake [PendingResult].
|
|
*/
|
|
internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync()
|
|
|
|
/** Overridable so tests can prove the timeout path without waiting 5s. */
|
|
internal open val persistenceTimeoutMs: Long get() = PERSISTENCE_TIMEOUT_MS
|
|
|
|
/**
|
|
* Test-friendly wrapper for notification creation. Override in tests to
|
|
* avoid [NotificationCompat.Builder.build()] calling Android framework
|
|
* methods that throw "not mocked" in unit tests.
|
|
*/
|
|
internal open fun buildReplyNotification(context: Context): Notification =
|
|
NotificationCompat
|
|
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
|
|
.setSmallIcon(R.drawable.ic_notification)
|
|
.setContentTitle("Hermes")
|
|
.setContentText("Replied")
|
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
.setAutoCancel(true)
|
|
.addExtras(
|
|
android.os.Bundle().apply {
|
|
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
|
|
},
|
|
).build()
|
|
|
|
override fun onReceive(
|
|
context: Context,
|
|
intent: Intent,
|
|
) {
|
|
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
|
val replyText = remoteInput?.getCharSequence(KEY_TEXT_REPLY)?.toString()
|
|
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
|
|
|
if (!replyText.isNullOrBlank() && !sessionId.isNullOrBlank()) {
|
|
val pendingResult = goAsyncCompat()
|
|
replyScope.launch {
|
|
try {
|
|
withTimeout(persistenceTimeoutMs) {
|
|
withContext(Dispatchers.IO) {
|
|
val db =
|
|
com.m57.hermescontrol.data.local.HermesDatabase
|
|
.get(context)
|
|
val dao = db.chatMessageDao()
|
|
if (!dao.sessionExists(sessionId)) {
|
|
android.util.Log.w(
|
|
"NotificationReply",
|
|
"Ignoring reply for unknown session: $sessionId",
|
|
)
|
|
return@withContext
|
|
}
|
|
|
|
// Mobile-originated follow-up turn: arm its durable
|
|
// boundary BEFORE prompt.submit. Budgeted tighter
|
|
// than the chat composer's — this receiver has its
|
|
// own 5s deadline and a failed capture must only
|
|
// make the reply uncorrelatable, never lose it.
|
|
captureTurnBoundary(
|
|
scopeId = correlationScopeId(),
|
|
sessionId = sessionId,
|
|
timeoutMs = BOUNDARY_TIMEOUT_MS,
|
|
)
|
|
|
|
val runtimeSessionId =
|
|
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
|
?: resumeSession(sessionId)
|
|
HermesWsClient
|
|
.request(
|
|
WsMethods.PROMPT_SUBMIT,
|
|
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
|
timeoutMs = REPLY_TIMEOUT_MS,
|
|
).await()
|
|
|
|
val entity =
|
|
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
|
id =
|
|
java.util.UUID
|
|
.randomUUID()
|
|
.toString(),
|
|
sessionId = sessionId,
|
|
role = "USER",
|
|
content = replyText,
|
|
timestamp = System.currentTimeMillis(),
|
|
)
|
|
dao.upsert(entity)
|
|
|
|
val repliedNotification = buildReplyNotification(context)
|
|
ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
|
|
|
|
// The follow-up turn is now pending — keep the
|
|
// foreground service alive if it retired after the
|
|
// previous reply completed, so the next
|
|
// MessageComplete still notifies while backgrounded
|
|
// (issue #794). No-op when the app is foreground.
|
|
if (!NotificationHelper.isAppInForeground()) {
|
|
NotificationHelper.start(context)
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
android.util.Log.e("NotificationReply", "Failed to process reply", e)
|
|
} finally {
|
|
pendingResult.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun resumeSession(storedSessionId: String): String {
|
|
val profile = AuthManager.activeProfileId.value
|
|
val params =
|
|
mutableMapOf<String, Any>(
|
|
"session_id" to storedSessionId,
|
|
"omit_messages" to true,
|
|
)
|
|
if (!profile.isNullOrBlank()) {
|
|
params["profile"] = profile
|
|
}
|
|
val result =
|
|
HermesWsClient
|
|
.request(
|
|
WsMethods.SESSION_RESUME,
|
|
params,
|
|
timeoutMs = REPLY_TIMEOUT_MS,
|
|
).await() as? Map<*, *>
|
|
val runtimeSessionId =
|
|
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
|
?: error("Resume returned no runtime session id")
|
|
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
|
return runtimeSessionId
|
|
}
|
|
}
|