Files
aiturk-mobil/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt
T

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
}
}