diff --git a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt index 5f5391ad..8298ec70 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt @@ -135,6 +135,14 @@ class PolarisApiClient @JvmOverloads constructor( .readTimeout(ARTWORK_REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() + @JvmStatic + internal fun buildNonRetryableHttpClient(base: OkHttpClient): OkHttpClient = + base.newBuilder() + .retryOnConnectionFailure(false) + .followRedirects(false) + .followSslRedirects(false) + .build() + // TLS session resumption against a server that intermittently rejects it (e.g. a // missing server-side session id context) surfaces as an SSLException on an // otherwise-healthy link; one fresh-handshake retry is the correct recovery. Only @@ -929,6 +937,52 @@ class PolarisApiClient @JvmOverloads constructor( } } + @JvmStatic + internal fun parseDoctorActionResponse(json: JSONObject): PolarisDoctorActionResult { + val verification = json.optJSONObject("verification") + val undo = json.optJSONObject("undo") + val evidence = json.optJSONObject("evidence") + val undoAvailable = undo?.let { value -> + if (!value.has("available") || value.isNull("available")) { + null + } else { + value.opt("available") as? Boolean + } + } + return PolarisDoctorActionResult( + status = json.optBoolean("status", false), + changed = json.optBoolean("changed", false), + state = json.optString("state", ""), + message = json.optString("message", ""), + error = json.optString("error", ""), + runId = json.optString("run_id", ""), + verificationDelaySeconds = verification?.optInt("delay_seconds", 0) ?: 0, + verificationActionId = verification?.optString("action_id", "") ?: "", + undoAvailable = undoAvailable, + undoActionId = undo?.optString("action_id", "") ?: "", + evidencePacketLossPct = evidence?.optDouble("packet_loss_pct")?.takeIf { !it.isNaN() }, + evidenceLatencyMs = evidence?.optDouble("latency_ms")?.takeIf { !it.isNaN() } + ) + } + + @JvmStatic + internal fun parseDoctorActionHttpResponse( + statusCode: Int, + responseBody: String + ): PolarisDoctorActionResult { + val parsed = runCatching { + parseDoctorActionResponse(JSONObject(responseBody.ifBlank { "{}" })) + }.getOrNull() + if (statusCode == 200 && parsed != null) return parsed + + return (parsed ?: PolarisDoctorActionResult(status = false)).copy( + status = false, + error = parsed?.error?.takeIf { it.isNotBlank() } ?: "Doctor action rejected", + undoAvailable = false, + undoActionId = "" + ) + } + @JvmStatic fun parseSessionStatusResponse(json: JSONObject): PolarisSessionStatus { val controls = json.optJSONObject("controls") @@ -990,7 +1044,13 @@ class PolarisApiClient @JvmOverloads constructor( aiOptimizerEnabled = json.optBoolean("ai_optimizer_enabled", false), mangohudConfigured = json.optBoolean("mangohud_configured", false), controls = PolarisSessionStatus.ControlsStatus( - hostTuningAllowed = controls?.optBoolean("host_tuning_allowed", false) ?: false, + hostTuningAllowed = controls?.let { value -> + if (!value.has("host_tuning_allowed") || value.isNull("host_tuning_allowed")) { + null + } else { + value.opt("host_tuning_allowed") as? Boolean + } + }, quitAllowed = controls?.optBoolean("quit_allowed", false) ?: false, shutdownInProgress = controls?.optBoolean("shutdown_in_progress", false) ?: false, clientCommandsEnabled = controls?.optBoolean("client_commands_enabled", false) ?: false, @@ -1292,6 +1352,13 @@ class PolarisApiClient @JvmOverloads constructor( private fun executeWithTransientRetry(request: Request): okhttp3.Response = runWithTransientTlsRetry(onTransient = { resetCallClient() }) { execute(request) } + private fun executeNonRetryable(request: Request): okhttp3.Response = + buildNonRetryableHttpClient(clientForCall()).newCall( + request.newBuilder() + .header("Connection", "close") + .build() + ).execute() + private fun execute(request: Request) = clientForCall().newCall( request.newBuilder() .header("Connection", "close") @@ -1882,25 +1949,12 @@ class PolarisApiClient @JvmOverloads constructor( body.toString() )) .build() - executeWithTransientRetry(request).use { response -> - if (response.code != 200) return null - val json = JSONObject(response.body?.string() ?: "{}") - val verification = json.optJSONObject("verification") - val undo = json.optJSONObject("undo") - val evidence = json.optJSONObject("evidence") - PolarisDoctorActionResult( - status = json.optBoolean("status", false), - changed = json.optBoolean("changed", false), - state = json.optString("state", ""), - message = json.optString("message", ""), - error = json.optString("error", ""), - runId = json.optString("run_id", ""), - verificationDelaySeconds = verification?.optInt("delay_seconds", 0) ?: 0, - verificationActionId = verification?.optString("action_id", "") ?: "", - undoAvailable = undo?.optBoolean("available", false) ?: false, - undoActionId = undo?.optString("action_id", "") ?: "", - evidencePacketLossPct = evidence?.optDouble("packet_loss_pct")?.takeIf { !it.isNaN() }, - evidenceLatencyMs = evidence?.optDouble("latency_ms")?.takeIf { !it.isNaN() } + // Doctor apply, verification, and Undo advance host-side run state. + // A lost response cannot be retried safely without an idempotency key. + executeNonRetryable(request).use { response -> + parseDoctorActionHttpResponse( + statusCode = response.code, + responseBody = response.body?.string().orEmpty() ) } } catch (e: Exception) { diff --git a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt index 405683dd..7f5b4d43 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt @@ -37,7 +37,7 @@ data class PolarisSessionStatus( val doctor: DoctorStatus = DoctorStatus() ) { data class ControlsStatus( - val hostTuningAllowed: Boolean = false, + val hostTuningAllowed: Boolean? = null, val quitAllowed: Boolean = false, val shutdownInProgress: Boolean = false, val clientCommandsEnabled: Boolean = false, @@ -374,7 +374,7 @@ data class PolarisSessionStatus( get() = listOf(sessionModeLabel, capturePathLabel).filter { it.isNotBlank() }.joinToString(" ยท ") val isViewer get() = clientRole.equals("viewer", ignoreCase = true) val hasExplicitDisplayModeChoice get() = displayMode.explicitChoice - val canAdjustHostTuning get() = controls.hostTuningAllowed || (ownedByClient && !isViewer) + val canAdjustHostTuning get() = controls.hostTuningAllowed ?: (ownedByClient && !isViewer) val canQuit get() = controls.quitAllowed || (ownedByClient && !isViewer) private fun normalizeSessionModeLabel(label: String): String = when (label.trim().lowercase()) { @@ -441,7 +441,7 @@ data class PolarisDoctorActionResult( val runId: String = "", val verificationDelaySeconds: Int = 0, val verificationActionId: String = "", - val undoAvailable: Boolean = false, + val undoAvailable: Boolean? = null, val undoActionId: String = "", val evidencePacketLossPct: Double? = null, val evidenceLatencyMs: Double? = null diff --git a/app/src/main/java/com/papi/nova/ui/DoctorActionReceiptStore.kt b/app/src/main/java/com/papi/nova/ui/DoctorActionReceiptStore.kt new file mode 100644 index 00000000..cb4290ed --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/DoctorActionReceiptStore.kt @@ -0,0 +1,393 @@ +package com.papi.nova.ui + +import android.content.SharedPreferences +import com.papi.nova.api.PolarisDoctorActionResult +import org.json.JSONObject +import java.security.MessageDigest + +/** + * Durable, session-scoped receipt for one reversible Doctor action. + * + * [scopeId] is a SHA-256 fingerprint. The host session token is used to bind the + * receipt to one owner session, but is never persisted. + */ +data class DoctorActionReceipt( + val scopeId: String, + val runId: String, + val state: String, + val message: String, + val verificationActionId: String = "", + val verificationDueAtEpochMs: Long = 0L, + val verificationFailureCount: Int = 0, + val verificationAttemptCount: Int = 0, + val undoAvailable: Boolean = false, + val undoActionId: String = "", + val updatedAtEpochMs: Long = 0L +) { + val isTerminal: Boolean + get() = state in DoctorActionReceiptStore.TERMINAL_STATES + + val verificationPending: Boolean + get() = !isTerminal && runId.isNotBlank() && verificationActionId.isNotBlank() +} + +data class DoctorActionRequestIdentity( + val scopeId: String, + val runId: String, + val generation: Long +) + +internal class DoctorMenuRefreshRegistry { + private var generation = 0L + private var refresh: (() -> Unit)? = null + + @Synchronized + fun open(): Long { + generation += 1L + refresh = null + return generation + } + + @Synchronized + fun isCurrent(candidateGeneration: Long): Boolean = generation == candidateGeneration + + @Synchronized + fun attach(candidateGeneration: Long, callback: () -> Unit): Boolean { + if (generation != candidateGeneration) return false + refresh = callback + return true + } + + @Synchronized + fun close(candidateGeneration: Long): Boolean { + if (generation != candidateGeneration) return false + generation += 1L + refresh = null + return true + } + + @Synchronized + fun runIfCurrent(candidateGeneration: Long, action: () -> T): T? { + if (generation != candidateGeneration) return null + return action() + } + + fun dispatch(): Boolean { + val callback = synchronized(this) { refresh } ?: return false + callback() + return true + } +} + +internal class DoctorActionPendingRegistry { + private var generation: Long? = null + + @Synchronized + fun begin(requestGeneration: Long): Boolean { + if (generation != null) return false + generation = requestGeneration + return true + } + + @Synchronized + fun clearIfOwned(requestGeneration: Long): Boolean { + if (generation != requestGeneration) return false + generation = null + return true + } + + @Synchronized + fun reset() { + generation = null + } + + @Synchronized + fun isPending(): Boolean = generation != null +} + +object DoctorActionReceiptStore { + internal val TERMINAL_STATES = setOf("stable", "resolved", "needs_attention", "undone") + + private const val RECEIPT_KEY_PREFIX = "nova_doctor_action_receipt_v2_" + private const val RETRY_DELAY_MS = 1_000L + private const val MAX_VERIFICATION_FAILURES = 4 + private const val MAX_VERIFICATION_ATTEMPTS = 12 + private const val MAX_FIELD_LENGTH = 2_048 + private const val MAX_RECEIPTS = 8 + private val HEX = "0123456789abcdef".toCharArray() + + fun scopeId( + host: String, + httpsPort: Int, + sessionToken: String, + gameUuid: String + ): String? { + val normalizedHost = host.trim().lowercase() + if (normalizedHost.isBlank() || sessionToken.isBlank() || gameUuid.isBlank()) return null + if (httpsPort !in 1..65_535) return null + + val material = listOf( + normalizedHost, + httpsPort.toString(), + sessionToken, + gameUuid + ).joinToString("\u0000") + val digest = MessageDigest.getInstance("SHA-256").digest(material.toByteArray(Charsets.UTF_8)) + return buildString(digest.size * 2) { + digest.forEach { value -> + val byte = value.toInt() and 0xff + append(HEX[byte ushr 4]) + append(HEX[byte and 0x0f]) + } + } + } + + fun applyResult( + previous: DoctorActionReceipt?, + scopeId: String, + result: PolarisDoctorActionResult, + nowEpochMs: Long + ): DoctorActionReceipt { + val previousInScope = previous?.takeIf { it.scopeId == scopeId } + val runId = result.runId.ifBlank { previousInScope?.runId.orEmpty() } + val sameRun = previousInScope?.takeIf { it.runId == runId } + val responseState = result.state.ifBlank { sameRun?.state.orEmpty() } + val terminal = responseState in TERMINAL_STATES + val verificationActionId = when { + terminal -> "" + result.verificationActionId.isNotBlank() -> result.verificationActionId + responseState == "watching" -> sameRun?.verificationActionId.orEmpty() + else -> "" + } + val verificationAttemptCount = if (sameRun?.verificationPending == true) { + (sameRun.verificationAttemptCount + 1).coerceAtMost(MAX_VERIFICATION_ATTEMPTS) + } else { + 0 + } + val verificationExhausted = !terminal && + verificationActionId.isNotBlank() && + verificationAttemptCount >= MAX_VERIFICATION_ATTEMPTS + val state = if (verificationExhausted) "needs_attention" else responseState + val verificationDelayMs = when { + verificationActionId.isBlank() -> 0L + result.verificationActionId.isNotBlank() -> + result.verificationDelaySeconds.coerceAtLeast(1) * 1_000L + else -> RETRY_DELAY_MS + } + val undone = state == "undone" + val undoAvailable = when { + undone -> false + result.undoAvailable != null -> result.undoAvailable + else -> sameRun?.undoAvailable == true + } + val undoActionId = when { + !undoAvailable -> "" + result.undoActionId.isNotBlank() -> result.undoActionId + else -> sameRun?.undoActionId.orEmpty() + } + val safeNow = nowEpochMs.coerceAtLeast(0L) + + return DoctorActionReceipt( + scopeId = scopeId, + runId = runId.take(MAX_FIELD_LENGTH), + state = state.take(MAX_FIELD_LENGTH), + message = result.message.ifBlank { sameRun?.message.orEmpty() }.take(MAX_FIELD_LENGTH), + verificationActionId = if (verificationExhausted) "" else verificationActionId.take(MAX_FIELD_LENGTH), + verificationDueAtEpochMs = if (!verificationExhausted && verificationDelayMs > 0L) { + safeNow + verificationDelayMs + } else { + 0L + }, + verificationFailureCount = 0, + verificationAttemptCount = verificationAttemptCount, + undoAvailable = undoAvailable, + undoActionId = undoActionId.take(MAX_FIELD_LENGTH), + updatedAtEpochMs = safeNow + ) + } + + fun deferVerification(receipt: DoctorActionReceipt, nowEpochMs: Long): DoctorActionReceipt { + if (!receipt.verificationPending) return receipt + val safeNow = nowEpochMs.coerceAtLeast(0L) + val nextFailureCount = (receipt.verificationFailureCount + 1).coerceAtMost(MAX_VERIFICATION_FAILURES) + if (nextFailureCount >= MAX_VERIFICATION_FAILURES) { + return receipt.copy( + state = "needs_attention", + verificationActionId = "", + verificationDueAtEpochMs = 0L, + verificationFailureCount = nextFailureCount, + updatedAtEpochMs = safeNow + ) + } + val retryDelayMs = RETRY_DELAY_MS * (1L shl (nextFailureCount - 1)) + return receipt.copy( + verificationDueAtEpochMs = safeNow + retryDelayMs, + verificationFailureCount = nextFailureCount, + updatedAtEpochMs = safeNow + ) + } + + fun stopVerification( + receipt: DoctorActionReceipt, + result: PolarisDoctorActionResult, + nowEpochMs: Long + ): DoctorActionReceipt { + return receipt.copy( + state = result.state.takeIf { it in TERMINAL_STATES } ?: "needs_attention", + message = result.message.ifBlank { result.error.ifBlank { receipt.message } }.take(MAX_FIELD_LENGTH), + verificationActionId = "", + verificationDueAtEpochMs = 0L, + verificationFailureCount = MAX_VERIFICATION_FAILURES, + undoAvailable = false, + undoActionId = "", + updatedAtEpochMs = nowEpochMs.coerceAtLeast(0L) + ) + } + + fun retireUndo( + receipt: DoctorActionReceipt, + result: PolarisDoctorActionResult, + nowEpochMs: Long + ): DoctorActionReceipt = receipt.copy( + state = "needs_attention", + message = result.message.ifBlank { result.error.ifBlank { receipt.message } }.take(MAX_FIELD_LENGTH), + verificationActionId = "", + verificationDueAtEpochMs = 0L, + verificationFailureCount = MAX_VERIFICATION_FAILURES, + undoAvailable = false, + undoActionId = "", + updatedAtEpochMs = nowEpochMs.coerceAtLeast(0L) + ) + + fun nextVerificationDelayMs(receipt: DoctorActionReceipt, nowEpochMs: Long): Long { + if (!receipt.verificationPending) return -1L + return (receipt.verificationDueAtEpochMs - nowEpochMs).coerceAtLeast(0L) + } + + fun requestIsCurrent( + current: DoctorActionReceipt?, + activeScopeId: String?, + activeGeneration: Long, + request: DoctorActionRequestIdentity + ): Boolean { + if (activeScopeId != request.scopeId || activeGeneration != request.generation) return false + if (request.runId.isBlank()) return true + return current?.scopeId == request.scopeId && current.runId == request.runId + } + + fun responseMatches( + current: DoctorActionReceipt?, + activeScopeId: String?, + activeGeneration: Long, + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): Boolean { + if (!result.status || !responseIdentityMatches(current, activeScopeId, activeGeneration, request, result)) { + return false + } + return true + } + + fun responseIdentityMatches( + current: DoctorActionReceipt?, + activeScopeId: String?, + activeGeneration: Long, + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): Boolean { + if (!requestIsCurrent(current, activeScopeId, activeGeneration, request)) return false + val responseRunId = result.runId.trim() + return if (request.runId.isBlank()) { + responseRunId.isNotBlank() + } else { + responseRunId.isBlank() || responseRunId == request.runId + } + } + + fun successfulLegacyNewRunResult( + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): Boolean = request.runId.isBlank() && result.status && result.runId.isBlank() + + fun validationGenerationIsCurrent(activeGeneration: Long, requestGeneration: Long): Boolean = + activeGeneration == requestGeneration + + fun visibleReceipt( + receipt: DoctorActionReceipt?, + activeScopeId: String?, + validatedScopeId: String? + ): DoctorActionReceipt? { + if (activeScopeId.isNullOrBlank() || activeScopeId != validatedScopeId) return null + return receipt?.takeIf { it.scopeId == activeScopeId } + } + + fun save(preferences: SharedPreferences, receipt: DoctorActionReceipt) { + if (receipt.scopeId.isBlank() || receipt.runId.isBlank()) return + val savedAt = System.currentTimeMillis().coerceAtLeast(0L) + val json = JSONObject().apply { + put("scope_id", receipt.scopeId) + put("run_id", receipt.runId) + put("state", receipt.state) + put("message", receipt.message) + put("verification_action_id", receipt.verificationActionId) + put("verification_due_at_epoch_ms", receipt.verificationDueAtEpochMs) + put("verification_failure_count", receipt.verificationFailureCount) + put("verification_attempt_count", receipt.verificationAttemptCount) + put("undo_available", receipt.undoAvailable) + put("undo_action_id", receipt.undoActionId) + put("updated_at_epoch_ms", receipt.updatedAtEpochMs) + put("saved_at_epoch_ms", savedAt) + } + val currentKey = keyForScope(receipt.scopeId) + val editor = preferences.edit().putString(currentKey, json.toString()) + + val savedKeys = preferences.all.keys + .filter { it.startsWith(RECEIPT_KEY_PREFIX) && it != currentKey } + .map { key -> + val timestamp = runCatching { + JSONObject(preferences.getString(key, "{}") ?: "{}") + .optLong("saved_at_epoch_ms", 0L) + }.getOrDefault(0L) + key to timestamp + } + .plus(currentKey to savedAt) + .sortedBy { it.second } + savedKeys.take((savedKeys.size - MAX_RECEIPTS).coerceAtLeast(0)).forEach { (key, _) -> + if (key != currentKey) editor.remove(key) + } + editor.commit() + } + + fun load(preferences: SharedPreferences, scopeId: String): DoctorActionReceipt? { + if (scopeId.isBlank()) return null + val encoded = preferences.getString(keyForScope(scopeId), null) ?: return null + return runCatching { + val json = JSONObject(encoded) + val storedScope = json.optString("scope_id").take(MAX_FIELD_LENGTH) + if (storedScope != scopeId) return@runCatching null + val runId = json.optString("run_id").take(MAX_FIELD_LENGTH) + if (runId.isBlank()) return@runCatching null + DoctorActionReceipt( + scopeId = storedScope, + runId = runId, + state = json.optString("state").take(MAX_FIELD_LENGTH), + message = json.optString("message").take(MAX_FIELD_LENGTH), + verificationActionId = json.optString("verification_action_id").take(MAX_FIELD_LENGTH), + verificationDueAtEpochMs = json.optLong("verification_due_at_epoch_ms", 0L).coerceAtLeast(0L), + verificationFailureCount = json.optInt("verification_failure_count", 0) + .coerceIn(0, MAX_VERIFICATION_FAILURES), + verificationAttemptCount = json.optInt("verification_attempt_count", 0) + .coerceIn(0, MAX_VERIFICATION_ATTEMPTS), + undoAvailable = json.optBoolean("undo_available", false), + undoActionId = json.optString("undo_action_id").take(MAX_FIELD_LENGTH), + updatedAtEpochMs = json.optLong("updated_at_epoch_ms", 0L).coerceAtLeast(0L) + ) + }.getOrNull() + } + + fun clear(preferences: SharedPreferences, scopeId: String) { + if (scopeId.isNotBlank()) preferences.edit().remove(keyForScope(scopeId)).apply() + } + + private fun keyForScope(scopeId: String): String = RECEIPT_KEY_PREFIX + scopeId +} diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt index 21b0f361..c5e89b96 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt @@ -34,9 +34,23 @@ import com.papi.nova.utils.DeviceUtils */ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { private var dialog: Dialog? = null + private val doctorActionLock = Any() + private var doctorReceipt: DoctorActionReceipt? = null + private var doctorReceiptScopeId: String? = null + private var doctorReceiptValidatedScopeId: String? = null + private var doctorActionGeneration: Long = 0L + private val doctorMenuRefreshRegistry = DoctorMenuRefreshRegistry() + private val doctorActionPendingRegistry = DoctorActionPendingRegistry() + private var doctorVerificationRunnable: Runnable? = null override fun showMenu(device: GameInputDevice?) { if (dialog?.isShowing == true) return + val menuValidationGeneration = doctorMenuRefreshRegistry.open() + synchronized(doctorActionLock) { + doctorReceiptValidatedScopeId = null + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + } val overlay = Dialog(game) overlay.requestWindowFeature(Window.FEATURE_NO_TITLE) @@ -45,7 +59,16 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { composeView.setViewTreeSavedStateRegistryOwner(game) composeView.setBackgroundColor(Color.TRANSPARENT) overlay.setContentView(composeView) - overlay.setOnDismissListener { dialog = null } + overlay.setOnDismissListener { + if (doctorMenuRefreshRegistry.close(menuValidationGeneration)) { + synchronized(doctorActionLock) { + doctorReceiptValidatedScopeId = null + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + } + } + if (dialog === overlay) dialog = null + } overlay.setOnShowListener { overlay.window?.apply { setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) @@ -91,7 +114,10 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { var advancedTuningVisible = false var profileClearInProgress = false var hostStateUnavailable = false - var doctorActionPending = false + lateinit var scheduleDoctorVerification: (DoctorActionReceipt?) -> Unit + + fun menuValidationIsCurrent(): Boolean = + doctorMenuRefreshRegistry.isCurrent(menuValidationGeneration) fun syncSessionDerivedState() { adaptiveEnabled = sessionStatus?.tuning?.adaptiveBitrateEnabled == true || @@ -106,6 +132,184 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { sessionStatus?.mangohudConfigured == true } + fun syncDoctorReceiptScope() { + val nextScope = DoctorActionReceiptStore.scopeId( + host = getServerAddress().orEmpty(), + httpsPort = getHttpsPort(), + sessionToken = sessionStatus?.sessionToken.orEmpty(), + gameUuid = sessionStatus?.gameUuid.orEmpty() + ) + synchronized(doctorActionLock) { + if (nextScope != doctorReceiptScopeId) { + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + doctorActionPendingRegistry.reset() + doctorActionGeneration += 1L + doctorReceiptScopeId = nextScope + doctorReceipt = nextScope?.let { DoctorActionReceiptStore.load(prefs, it) } + } + doctorReceiptValidatedScopeId = nextScope + } + } + + fun acceptRefreshedSessionStatus(refreshed: PolarisSessionStatus?): Boolean { + if (refreshed == null) return false + return doctorMenuRefreshRegistry.runIfCurrent(menuValidationGeneration) { + sessionStatus = refreshed + syncSessionDerivedState() + syncDoctorReceiptScope() + true + } ?: false + } + + fun requestIdentity(runId: String): DoctorActionRequestIdentity? = synchronized(doctorActionLock) { + val scope = doctorReceiptScopeId ?: return@synchronized null + DoctorActionRequestIdentity(scope, runId, doctorActionGeneration) + } + + fun currentDoctorReceipt(): DoctorActionReceipt? = synchronized(doctorActionLock) { + doctorReceipt + } + + fun requestIsCurrent(request: DoctorActionRequestIdentity): Boolean = synchronized(doctorActionLock) { + DoctorActionReceiptStore.requestIsCurrent( + current = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + activeGeneration = doctorActionGeneration, + request = request + ) + } + + fun beginNewDoctorRequest(scopeId: String): DoctorActionRequestIdentity? = synchronized(doctorActionLock) { + if (doctorReceiptScopeId != scopeId || + doctorReceiptValidatedScopeId != scopeId || + doctorActionPendingRegistry.isPending() + ) { + return@synchronized null + } + doctorActionGeneration += 1L + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + DoctorActionRequestIdentity(scopeId, runId = "", generation = doctorActionGeneration).also { + check(doctorActionPendingRegistry.begin(it.generation)) + } + } + + fun beginDoctorUndo( + receipt: DoctorActionReceipt, + canAdjustHostTuning: Boolean + ): DoctorActionRequestIdentity? = synchronized(doctorActionLock) { + val scopeId = doctorReceiptScopeId ?: return@synchronized null + val current = doctorReceipt ?: return@synchronized null + if (!canAdjustHostTuning || + doctorReceiptValidatedScopeId != scopeId || + current.scopeId != scopeId || + current.runId != receipt.runId || + !current.undoAvailable || + current.undoActionId.isBlank() || + current.undoActionId != receipt.undoActionId || + doctorActionPendingRegistry.isPending() + ) { + return@synchronized null + } + doctorActionGeneration += 1L + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + DoctorActionRequestIdentity(scopeId, current.runId, doctorActionGeneration).also { + check(doctorActionPendingRegistry.begin(it.generation)) + } + } + + fun storeDoctorResult( + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): DoctorActionReceipt? = synchronized(doctorActionLock) { + if (!DoctorActionReceiptStore.responseMatches( + current = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + activeGeneration = doctorActionGeneration, + request = request, + result = result + )) { + return@synchronized null + } + val updated = DoctorActionReceiptStore.applyResult( + previous = doctorReceipt, + scopeId = request.scopeId, + result = result, + nowEpochMs = System.currentTimeMillis() + ) + doctorReceipt = updated + DoctorActionReceiptStore.save(prefs, updated) + updated + } + + fun deferDoctorVerification(request: DoctorActionRequestIdentity): DoctorActionReceipt? = synchronized(doctorActionLock) { + if (!DoctorActionReceiptStore.requestIsCurrent( + current = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + activeGeneration = doctorActionGeneration, + request = request + )) { + return@synchronized null + } + val pending = doctorReceipt?.takeIf { it.verificationPending } ?: return@synchronized null + val deferred = DoctorActionReceiptStore.deferVerification(pending, System.currentTimeMillis()) + doctorReceipt = deferred + DoctorActionReceiptStore.save(prefs, deferred) + deferred + } + + fun stopDoctorVerification( + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): DoctorActionReceipt? = synchronized(doctorActionLock) { + if (!DoctorActionReceiptStore.responseIdentityMatches( + current = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + activeGeneration = doctorActionGeneration, + request = request, + result = result + )) { + return@synchronized null + } + val pending = doctorReceipt?.takeIf { it.verificationPending } ?: return@synchronized null + val stopped = DoctorActionReceiptStore.stopVerification( + receipt = pending, + result = result, + nowEpochMs = System.currentTimeMillis() + ) + doctorReceipt = stopped + DoctorActionReceiptStore.save(prefs, stopped) + stopped + } + + fun retireDoctorUndo( + request: DoctorActionRequestIdentity, + result: PolarisDoctorActionResult + ): DoctorActionReceipt? = synchronized(doctorActionLock) { + if (!DoctorActionReceiptStore.responseIdentityMatches( + current = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + activeGeneration = doctorActionGeneration, + request = request, + result = result + )) { + return@synchronized null + } + val current = doctorReceipt?.takeIf { + it.scopeId == request.scopeId && it.runId == request.runId + } ?: return@synchronized null + val retired = DoctorActionReceiptStore.retireUndo( + receipt = current, + result = result, + nowEpochMs = System.currentTimeMillis() + ) + doctorReceipt = retired + DoctorActionReceiptStore.save(prefs, retired) + retired + } + fun currentProfileGameName(): String? { return sessionStatus?.game ?.takeIf { it.isNotBlank() } @@ -158,7 +362,12 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { allowChangeMouseMode = game.allowChangeMouseMode, isOnExternalDisplay = game.isOnExternalDisplay, fallbackBitrateKbps = game.prefConfig.bitrate, - fallbackTargetFps = game.configuredHudTargetFps.toDouble() + fallbackTargetFps = game.configuredHudTargetFps.toDouble(), + doctorReceipt = DoctorActionReceiptStore.visibleReceipt( + receipt = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + validatedScopeId = doctorReceiptValidatedScopeId + ) ) } @@ -196,103 +405,196 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { } } - fun undoDoctorRun(runId: String) { - if (apiClient == null || runId.isBlank() || doctorActionPending) return - doctorActionPending = true - game.launchRuntimeIo("NovaQuickMenuDoctorUndo") { - val result = apiClient.runDoctorAction(actionId = "undo", runId = runId) - if (result?.status == true) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + fun undoDoctorRun(receipt: DoctorActionReceipt) { + val client = apiClient ?: return + val canAdjustHostTuning = sessionStatus?.canAdjustHostTuning == true + if (!canAdjustHostTuning || receipt.runId.isBlank() || receipt.undoActionId.isBlank() || + doctorActionPendingRegistry.isPending() + ) { + return + } + val undoRequest = beginDoctorUndo(receipt, canAdjustHostTuning) ?: return + game.launchReplacingRuntimeIo("NovaQuickMenuDoctorUndo") { + val latestStatus = client.getSessionStatus() + if (!acceptRefreshedSessionStatus(latestStatus) || + latestStatus?.canAdjustHostTuning != true || + !requestIsCurrent(undoRequest) + ) { + game.runOnMainIfRuntimeActive { + doctorActionPendingRegistry.clearIfOwned(undoRequest.generation) + doctorMenuRefreshRegistry.dispatch() + } + return@launchReplacingRuntimeIo + } + val result = client.runDoctorAction( + actionId = receipt.undoActionId, + runId = receipt.runId + ) + val updated = when { + result == null -> null + result.status -> storeDoctorResult(undoRequest, result) + else -> retireDoctorUndo(undoRequest, result) + } + if (result?.status == true && updated != null) { + acceptRefreshedSessionStatus(client.getSessionStatus()) } game.runOnMainIfRuntimeActive { - doctorActionPending = false - if (result?.status == true) { - NovaSnackbar.showSuccess(game, doctorResultMessage(result), anchor = composeView) - } else { - NovaSnackbar.showError( - game, - result?.error?.takeIf { it.isNotBlank() } - ?: game.getString(R.string.nova_quick_menu_doctor_failed), - anchor = composeView - ) + doctorActionPendingRegistry.clearIfOwned(undoRequest.generation) + val canPresentHere = requestIsCurrent(undoRequest) && + menuValidationIsCurrent() && dialog === overlay && overlay.isShowing + if (canPresentHere) { + if (result?.status == true && updated != null) { + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + NovaSnackbar.showSuccess(game, doctorResultMessage(result), anchor = composeView) + } else { + NovaSnackbar.showError( + game, + result?.error?.takeIf { it.isNotBlank() } + ?: game.getString(R.string.nova_quick_menu_doctor_failed), + anchor = composeView + ) + } } - refreshState() + doctorMenuRefreshRegistry.dispatch() } } } - fun presentDoctorResult(result: PolarisDoctorActionResult) { + fun presentDoctorResult(result: PolarisDoctorActionResult, receipt: DoctorActionReceipt?) { val message = doctorResultMessage(result) if (!result.status) { NovaSnackbar.showError(game, message, anchor = composeView) return } - if (result.undoAvailable && result.runId.isNotBlank()) { + if (sessionStatus?.canAdjustHostTuning == true && + receipt?.undoAvailable == true && + receipt.runId.isNotBlank() && + receipt.undoActionId.isNotBlank() + ) { NovaSnackbar.showSuccessWithAction( activity = game, message = message, actionLabel = game.getString(R.string.nova_quick_menu_doctor_undo), anchor = composeView, - onAction = { undoDoctorRun(result.runId) } + onAction = { undoDoctorRun(receipt) } ) } else { NovaSnackbar.showSuccess(game, message, anchor = composeView) } } - fun scheduleDoctorVerification(result: PolarisDoctorActionResult) { - if (!result.status || result.runId.isBlank() || result.verificationActionId.isBlank()) return - val delayMs = (result.verificationDelaySeconds.coerceAtLeast(1) * 1000L) - game.window.decorView.postDelayed({ - if (apiClient == null) return@postDelayed - game.launchRuntimeIo("NovaQuickMenuDoctorVerify") { - val verification = apiClient.runDoctorAction( - actionId = result.verificationActionId, - runId = result.runId + scheduleDoctorVerification = fun(receipt: DoctorActionReceipt?) { + doctorVerificationRunnable?.let(game.window.decorView::removeCallbacks) + doctorVerificationRunnable = null + if (!menuValidationIsCurrent() || dialog?.isShowing != true) return + val client = apiClient ?: return + val pending = receipt?.takeIf { it.verificationPending } ?: return + val scopeIsValidated = synchronized(doctorActionLock) { + doctorReceiptScopeId == pending.scopeId && doctorReceiptValidatedScopeId == pending.scopeId + } + if (!scopeIsValidated) return + val request = requestIdentity(pending.runId) ?: return + val delayMs = DoctorActionReceiptStore.nextVerificationDelayMs( + pending, + System.currentTimeMillis() + ) + if (delayMs < 0L) return + + val runnable = Runnable { + doctorVerificationRunnable = null + if (!menuValidationIsCurrent() || + dialog?.isShowing != true || + !requestIsCurrent(request) || + !doctorActionPendingRegistry.begin(request.generation) + ) { + return@Runnable + } + game.launchReplacingRuntimeIo("NovaQuickMenuDoctorVerify") { + val verification = client.runDoctorAction( + actionId = pending.verificationActionId, + runId = pending.runId ) - if (verification?.status == true) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + val updated = when { + verification == null -> deferDoctorVerification(request) + !verification.status -> stopDoctorVerification(request, verification) + else -> storeDoctorResult(request, verification) + } + if (verification?.status == true && updated != null) { + acceptRefreshedSessionStatus(client.getSessionStatus()) } game.runOnMainIfRuntimeActive { - verification?.let { - presentDoctorResult(it) - scheduleDoctorVerification(it) + doctorActionPendingRegistry.clearIfOwned(request.generation) + if (!requestIsCurrent(request) || !menuValidationIsCurrent()) { + doctorMenuRefreshRegistry.dispatch() + return@runOnMainIfRuntimeActive } - refreshState() + if (verification != null && updated != null && dialog?.isShowing == true) { + presentDoctorResult(verification, updated) + } + doctorMenuRefreshRegistry.dispatch() } } - }, delayMs) + } + doctorVerificationRunnable = runnable + game.window.decorView.postDelayed(runnable, delayMs) + } + + doctorMenuRefreshRegistry.attach(menuValidationGeneration) { + if (menuValidationIsCurrent() && dialog?.isShowing == true) { + scheduleDoctorVerification(currentDoctorReceipt()) + refreshState() + } } fun runDoctorAction() { val status = sessionStatus val doctor = status?.doctor - if (apiClient == null || status == null || doctor == null || !doctor.canExecuteAction || doctorActionPending) { + val client = apiClient + if (client == null || status == null || doctor == null || !doctor.canExecuteAction || + doctorActionPendingRegistry.isPending() + ) { game.copyNovaHudDiagnostics() return } - doctorActionPending = true - game.launchRuntimeIo("NovaQuickMenuDoctorAction") { - val result = apiClient.runDoctorAction( + val scope = doctorReceiptScopeId ?: return + val request = beginNewDoctorRequest(scope) ?: return + game.launchReplacingRuntimeIo("NovaQuickMenuDoctorAction") { + val result = client.runDoctorAction( actionId = doctor.actionId, sourceResultId = doctor.resultId, targetBitrateKbps = doctor.targetBitrateKbps ) - if (result?.status == true) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + val legacySuccess = result?.let { + DoctorActionReceiptStore.successfulLegacyNewRunResult(request, it) + } == true + val receipt = result + ?.takeUnless { legacySuccess } + ?.let { storeDoctorResult(request, it) } + if (receipt != null || legacySuccess) { + acceptRefreshedSessionStatus(client.getSessionStatus()) } game.runOnMainIfRuntimeActive { - doctorActionPending = false - if (result == null) { - NovaSnackbar.showError(game, game.getString(R.string.nova_quick_menu_doctor_failed), anchor = composeView) - } else { - presentDoctorResult(result) - scheduleDoctorVerification(result) + doctorActionPendingRegistry.clearIfOwned(request.generation) + val canPresentHere = requestIsCurrent(request) && + menuValidationIsCurrent() && dialog === overlay && overlay.isShowing + if (canPresentHere) { + if (result == null) { + NovaSnackbar.showError(game, game.getString(R.string.nova_quick_menu_doctor_failed), anchor = composeView) + } else if (receipt != null) { + presentDoctorResult(result, receipt) + } else if (legacySuccess) { + presentDoctorResult(result, receipt = null) + } else { + NovaSnackbar.showError( + game, + result.error.takeIf { it.isNotBlank() } + ?: game.getString(R.string.nova_quick_menu_doctor_failed), + anchor = composeView + ) + } } - refreshState() + doctorMenuRefreshRegistry.dispatch() } } } @@ -359,8 +661,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { success = apiClient.setBitrate(safeBitrate) && success } if (success) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + acceptRefreshedSessionStatus(apiClient.getSessionStatus()) } game.runOnMainIfRuntimeActive { if (!success) { @@ -393,9 +694,8 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { return@haptic } game.launchRuntimeIo("NovaQuickMenuSyncStatus") { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus + acceptRefreshedSessionStatus(apiClient.getSessionStatus()) game.runOnMainIfRuntimeActive { - syncSessionDerivedState() hostStateUnavailable = false refreshState() } @@ -420,8 +720,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { game.launchRuntimeIo("NovaQuickMenuAiAutoQuality") { val success = apiClient.setAiAutoQualityEnabled(next) if (success) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + acceptRefreshedSessionStatus(apiClient.getSessionStatus()) } game.runOnMainIfRuntimeActive { if (!success) { @@ -445,8 +744,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { game.launchRuntimeIo("NovaQuickMenuClearProfile") { val cleared = apiClient.clearOptimizerProfile(DeviceUtils.getModel(), gameName) if (cleared == true) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + acceptRefreshedSessionStatus(apiClient.getSessionStatus()) } game.runOnMainIfRuntimeActive { profileClearInProgress = false @@ -482,8 +780,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { game.launchRuntimeIo("NovaQuickMenuMangoHud") { val success = apiClient.setMangoHud(gameUuid, next) if (success) { - sessionStatus = apiClient.getSessionStatus() ?: sessionStatus - syncSessionDerivedState() + acceptRefreshedSessionStatus(apiClient.getSessionStatus()) } game.runOnMainIfRuntimeActive { if (!success) { @@ -533,6 +830,18 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { refreshState() } }, + onDoctorUndo = { + haptic { + if (sessionStatus?.canAdjustHostTuning != true) return@haptic + DoctorActionReceiptStore.visibleReceipt( + receipt = doctorReceipt, + activeScopeId = doctorReceiptScopeId, + validatedScopeId = doctorReceiptValidatedScopeId + )?.takeIf { + it.undoAvailable && it.runId.isNotBlank() && it.undoActionId.isNotBlank() + }?.let(::undoDoctorRun) + } + }, onHudOpacityChange = { percent -> haptic { game.launchRuntimeIo("NovaQuickMenuHudOpacity") { @@ -605,35 +914,57 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { } } + dialog = overlay + overlay.show() + if (apiClient != null) { - game.launchRuntimeIo("NovaQuickMenuStateRefresh") { + game.launchReplacingRuntimeIo("NovaQuickMenuStateRefresh") { try { - capabilities = apiClient.getCapabilities() - sessionStatus = apiClient.getSessionStatus() - - val polarisSessionApiAvailable = sessionStatus != null - adaptiveSupported = capabilities?.features?.adaptiveBitrateControl == true || polarisSessionApiAvailable - aiSupported = capabilities?.features?.aiAutoQualityControl == true || - capabilities?.features?.aiOptimizerControl == true || - polarisSessionApiAvailable - hostStateUnavailable = false - syncSessionDerivedState() - - game.runOnMainIfRuntimeActive { refreshState() } + val refreshedCapabilities = apiClient.getCapabilities() + val refreshedSessionStatus = apiClient.getSessionStatus() + val accepted = doctorMenuRefreshRegistry.runIfCurrent(menuValidationGeneration) { + synchronized(doctorActionLock) { + capabilities = refreshedCapabilities + sessionStatus = refreshedSessionStatus + val polarisSessionApiAvailable = sessionStatus != null + adaptiveSupported = capabilities?.features?.adaptiveBitrateControl == true || polarisSessionApiAvailable + aiSupported = capabilities?.features?.aiAutoQualityControl == true || + capabilities?.features?.aiOptimizerControl == true || + polarisSessionApiAvailable + hostStateUnavailable = false + syncSessionDerivedState() + syncDoctorReceiptScope() + true + } + } ?: false + if (!accepted) return@launchReplacingRuntimeIo + + game.runOnMainIfRuntimeActive { + if (!menuValidationIsCurrent()) return@runOnMainIfRuntimeActive + scheduleDoctorVerification(doctorReceipt) + refreshState() + } } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { LimeLog.warning("Nova: Quick menu state refresh failed: ${e.message}") - hostStateUnavailable = true - game.runOnMainIfRuntimeActive { refreshState() } + val accepted = doctorMenuRefreshRegistry.runIfCurrent(menuValidationGeneration) { + synchronized(doctorActionLock) { + hostStateUnavailable = true + true + } + } ?: false + if (accepted) { + game.runOnMainIfRuntimeActive { + if (menuValidationIsCurrent()) refreshState() + } + } } } } else { refreshState() } - dialog = overlay - overlay.show() } override fun hideMenu() { diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt index ef6c9c0e..03613a40 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt @@ -87,6 +87,7 @@ data class NovaQuickMenuCallbacks( val onProfilePreference: (String) -> Unit = {}, val onQuickKey: (NovaQuickMenuActionId) -> Unit = {}, val onOverlayAction: (NovaQuickMenuActionId) -> Unit = {}, + val onDoctorUndo: () -> Unit = {}, val onHudOpacityChange: (Int) -> Unit = {}, val onMenuOpacityChange: (Int) -> Unit = {}, val onControlAction: (NovaQuickMenuActionId) -> Unit = {}, @@ -113,6 +114,7 @@ data class NovaQuickMenuCallbacks( NovaQuickMenuActionId.PERF_STATS, NovaQuickMenuActionId.DIAGNOSE_STREAM, NovaQuickMenuActionId.COPY_HUD_DIAGNOSTICS -> onOverlayAction(action.id) + NovaQuickMenuActionId.DOCTOR_UNDO -> onDoctorUndo() NovaQuickMenuActionId.MOUSE_MODE, NovaQuickMenuActionId.CONTROLLER, NovaQuickMenuActionId.KEYBOARD -> onControlAction(action.id) @@ -309,6 +311,13 @@ fun NovaQuickMenuContent( NovaQuickMenuSessionStrip(state) Spacer(Modifier.height(10.dp)) NovaQuickMenuDiagnosisCard(state.diagnosis, callbacks, initialFocusRequester) + if (state.doctorReceiptAction.visible) { + Spacer(Modifier.height(10.dp)) + NovaQuickMenuInfoCard( + action = state.doctorReceiptAction, + callbacks = callbacks + ) + } Spacer(Modifier.height(10.dp)) NovaQuickMenuStabilityCard(state.stability, callbacks) if (state.postSessionReport.visible) { diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt index 7016f605..2f96cac8 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt @@ -32,6 +32,7 @@ enum class NovaQuickMenuActionId { NOVA_HUD, PERF_STATS, DIAGNOSE_STREAM, + DOCTOR_UNDO, COPY_HUD_DIAGNOSTICS, MOUSE_MODE, CONTROLLER, @@ -116,6 +117,7 @@ data class NovaQuickMenuUiState( val advancedRows: List, val quickKeys: List, val diagnosis: NovaQuickMenuDiagnosisState, + val doctorReceiptAction: NovaQuickMenuAction, val postSessionReport: NovaPostSessionReportUiState, val hudOpacity: NovaQuickMenuHudOpacityState, val menuOpacity: NovaQuickMenuMenuOpacityState, @@ -151,7 +153,8 @@ data class NovaQuickMenuUiState( allowChangeMouseMode: Boolean, isOnExternalDisplay: Boolean, fallbackBitrateKbps: Int, - fallbackTargetFps: Double + fallbackTargetFps: Double, + doctorReceipt: DoctorActionReceipt? = null ): NovaQuickMenuUiState { val viewerSession = status?.isViewer == true val canAdjustHostTuning = status?.canAdjustHostTuning == true @@ -339,6 +342,11 @@ data class NovaQuickMenuUiState( presets = NovaMenuPreferences.OPACITY_PRESETS ) val diagnosis = diagnosisState(status) + val doctorReceiptAction = doctorReceiptAction( + context = context, + receipt = doctorReceipt, + canAdjustHostTuning = canAdjustHostTuning + ) val overlays = listOf( diagnoseAction(context, status, diagnosis), @@ -434,6 +442,7 @@ data class NovaQuickMenuUiState( advancedRows = listOf(aiRow, clearRow, mangoRow), quickKeys = quickKeyActions(context), diagnosis = diagnosis, + doctorReceiptAction = doctorReceiptAction, postSessionReport = postSessionReport, hudOpacity = hudOpacity, menuOpacity = menuOpacity, @@ -498,6 +507,50 @@ data class NovaQuickMenuUiState( ) } + private fun doctorReceiptAction( + context: Context, + receipt: DoctorActionReceipt?, + canAdjustHostTuning: Boolean + ): NovaQuickMenuAction { + if (receipt == null) { + return NovaQuickMenuAction( + id = NovaQuickMenuActionId.DOCTOR_UNDO, + label = context.getString(R.string.nova_quick_menu_doctor_receipt_title), + visible = false, + enabled = false + ) + } + val watching = !receipt.isTerminal + val canUndo = canAdjustHostTuning && + receipt.undoAvailable && + receipt.runId.isNotBlank() && + receipt.undoActionId.isNotBlank() + val chip = when { + watching -> chip(context.getString(R.string.nova_quick_menu_doctor_receipt_watching), NovaQuickMenuTone.INFO) + receipt.state == "resolved" || receipt.state == "stable" -> + chip(context.getString(R.string.nova_quick_menu_doctor_receipt_verified), NovaQuickMenuTone.ACTIVE) + receipt.state == "needs_attention" -> + chip(context.getString(R.string.nova_quick_menu_doctor_receipt_attention), NovaQuickMenuTone.WARNING) + else -> chip(context.getString(R.string.nova_quick_menu_done), NovaQuickMenuTone.INACTIVE) + } + val caption = buildList { + receipt.message.takeIf { it.isNotBlank() }?.let(::add) + if (canUndo) add(context.getString(R.string.nova_quick_menu_doctor_receipt_undo_caption)) + }.joinToString(" ") + return NovaQuickMenuAction( + id = NovaQuickMenuActionId.DOCTOR_UNDO, + label = if (canUndo) { + context.getString(R.string.nova_quick_menu_doctor_undo) + } else { + context.getString(R.string.nova_quick_menu_doctor_receipt_title) + }, + caption = caption, + chip = chip, + enabled = canUndo, + visible = true + ) + } + private fun diagnosisState(status: PolarisSessionStatus?): NovaQuickMenuDiagnosisState { val doctor = status?.doctor return NovaQuickMenuDiagnosisState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab6e7097..7f6449dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -970,6 +970,11 @@ Doctor could not apply this action. Undo Doctor restored the previous bitrate and Auto Quality state. + Doctor result + Watching + Verified + Needs attention + Undo remains available here to restore the previous bitrate and Auto Quality state. Copy HUD Diagnostics Privacy-safe stream summary for bug reports. Nova HUD diagnostics copied diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index b5144d31..2253b5de 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -20,6 +20,66 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class PolarisApiClientParsingTest { + @Test + fun doctorActionUndoAvailabilityRequiresLiteralBoolean() { + fun parsed(value: String): PolarisDoctorActionResult = PolarisApiClient.parseDoctorActionResponse( + JSONObject("{\"status\":true,\"run_id\":\"run-1\",\"undo\":{\"available\":$value}}") + ) + + assertEquals(true, parsed("true").undoAvailable) + assertEquals(false, parsed("false").undoAvailable) + assertNull(parsed("null").undoAvailable) + assertNull(parsed("\"false\"").undoAvailable) + assertNull( + PolarisApiClient.parseDoctorActionResponse( + JSONObject("{\"status\":true,\"run_id\":\"run-1\",\"undo\":{}}") + ).undoAvailable + ) + } + + @Test + fun doctorActionHttpFailureIsPermanentAndSanitized() { + val rejected = PolarisApiClient.parseDoctorActionHttpResponse( + statusCode = 409, + responseBody = "{\"status\":false,\"run_id\":\"run-1\",\"error\":\"expired\"," + + "\"undo\":{\"available\":true,\"action_id\":\"restore_quality\"}}" + ) + + assertFalse(rejected.status) + assertEquals("run-1", rejected.runId) + assertEquals("expired", rejected.error) + assertEquals(false, rejected.undoAvailable) + assertEquals("", rejected.undoActionId) + assertFalse(rejected.error.contains("409")) + } + + @Test + fun doctorActionHttpClientDisablesAutomaticConnectionReplay() { + val base = OkHttpClient.Builder().retryOnConnectionFailure(true).build() + val nonRetryable = PolarisApiClient.buildNonRetryableHttpClient(base) + + assertTrue(base.retryOnConnectionFailure) + assertFalse(nonRetryable.retryOnConnectionFailure) + assertFalse(nonRetryable.followRedirects) + assertFalse(nonRetryable.followSslRedirects) + } + + @Test + fun explicitHostTuningRevocationOverridesLegacyOwnershipFallback() { + val explicitRevocation = PolarisApiClient.parseSessionStatusResponse( + JSONObject( + "{\"state\":\"streaming\",\"owned_by_client\":true,\"client_role\":\"owner\"," + + "\"controls\":{\"host_tuning_allowed\":false}}" + ) + ) + val legacyOmission = PolarisApiClient.parseSessionStatusResponse( + JSONObject("{\"state\":\"streaming\",\"owned_by_client\":true,\"client_role\":\"owner\"}") + ) + + assertFalse(explicitRevocation.canAdjustHostTuning) + assertTrue(legacyOmission.canAdjustHostTuning) + } + @Test fun artworkHttpClientDisablesRedirectsAndAllowsBoundedProviderWorkflows() { val base = OkHttpClient.Builder().build() diff --git a/app/src/test/java/com/papi/nova/ui/DoctorActionReceiptStoreTest.kt b/app/src/test/java/com/papi/nova/ui/DoctorActionReceiptStoreTest.kt new file mode 100644 index 00000000..d8b34dbe --- /dev/null +++ b/app/src/test/java/com/papi/nova/ui/DoctorActionReceiptStoreTest.kt @@ -0,0 +1,472 @@ +package com.papi.nova.ui + +import android.content.Context +import com.papi.nova.api.PolarisDoctorActionResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Config(sdk = [33]) +@RunWith(RobolectricTestRunner::class) +class DoctorActionReceiptStoreTest { + private val context: Context + get() = RuntimeEnvironment.getApplication() + + private val scopeA: String + get() = requireNotNull( + DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "session-a", "control") + ) + + @Test + fun watchingVerificationWithoutRepeatDirectiveKeepsPollingSameRun() { + val applied = DoctorActionReceiptStore.applyResult( + previous = null, + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + changed = true, + state = "watching", + message = "Fix applied. Doctor is watching live loss and latency.", + runId = "doctor-run-1", + verificationDelaySeconds = 8, + verificationActionId = "verify", + undoAvailable = true, + undoActionId = "restore_quality" + ), + nowEpochMs = 1_000L + ) + + val earlyVerification = DoctorActionReceiptStore.applyResult( + previous = applied, + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "watching", + runId = "doctor-run-1" + ), + nowEpochMs = 9_000L + ) + + assertEquals("doctor-run-1", earlyVerification.runId) + assertEquals("verify", earlyVerification.verificationActionId) + assertTrue(earlyVerification.verificationDueAtEpochMs > 9_000L) + assertTrue(earlyVerification.undoAvailable) + assertEquals("restore_quality", earlyVerification.undoActionId) + assertFalse(earlyVerification.isTerminal) + } + + @Test + fun blankRunIdInVerificationInheritsCapturedRun() { + val watching = watchingReceipt() + + val updated = DoctorActionReceiptStore.applyResult( + previous = watching, + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "watching", + runId = "" + ), + nowEpochMs = 2_000L + ) + + assertEquals("doctor-run-1", updated.runId) + assertEquals("verify", updated.verificationActionId) + assertTrue(updated.verificationPending) + } + + @Test + fun resolvedVerificationStopsPollingButKeepsUndoReceipt() { + val resolved = DoctorActionReceiptStore.applyResult( + previous = watchingReceipt(), + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "resolved", + message = "Doctor verified that network pressure cleared.", + runId = "doctor-run-1", + undoAvailable = true, + undoActionId = "restore_quality" + ), + nowEpochMs = 10_000L + ) + + assertTrue(resolved.isTerminal) + assertEquals("", resolved.verificationActionId) + assertEquals(0L, resolved.verificationDueAtEpochMs) + assertTrue(resolved.undoAvailable) + assertEquals("restore_quality", resolved.undoActionId) + } + + @Test + fun explicitHostUndoRevocationOverridesPreviousAvailability() { + val revoked = DoctorActionReceiptStore.applyResult( + previous = watchingReceipt(), + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "needs_attention", + runId = "doctor-run-1", + undoAvailable = false + ), + nowEpochMs = 10_000L + ) + + assertFalse(revoked.undoAvailable) + assertEquals("", revoked.undoActionId) + } + + @Test + fun successfulUndoClearsTheActionableReceipt() { + val undone = DoctorActionReceiptStore.applyResult( + previous = watchingReceipt().copy(state = "resolved", verificationActionId = ""), + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "undone", + message = "Doctor restored the previous bitrate and Auto Quality state.", + runId = "doctor-run-1" + ), + nowEpochMs = 11_000L + ) + + assertTrue(undone.isTerminal) + assertFalse(undone.undoAvailable) + assertEquals("", undone.undoActionId) + assertFalse(undone.verificationPending) + } + + @Test + fun transientVerificationFailureDefersTheSamePendingRun() { + val deferred = DoctorActionReceiptStore.deferVerification( + watchingReceipt(verificationDueAtEpochMs = 1_000L), + nowEpochMs = 5_000L + ) + + assertEquals("doctor-run-1", deferred.runId) + assertEquals("verify", deferred.verificationActionId) + assertTrue(deferred.verificationDueAtEpochMs > 5_000L) + assertTrue(deferred.verificationPending) + } + + @Test + fun repeatedTransientVerificationFailuresBackOffAndStopAfterBound() { + val first = DoctorActionReceiptStore.deferVerification( + watchingReceipt(verificationDueAtEpochMs = 1_000L), + nowEpochMs = 5_000L + ) + val second = DoctorActionReceiptStore.deferVerification(first, nowEpochMs = 6_000L) + val third = DoctorActionReceiptStore.deferVerification(second, nowEpochMs = 8_000L) + val stopped = DoctorActionReceiptStore.deferVerification(third, nowEpochMs = 12_000L) + + assertEquals(6_000L, first.verificationDueAtEpochMs) + assertEquals(8_000L, second.verificationDueAtEpochMs) + assertEquals(12_000L, third.verificationDueAtEpochMs) + assertEquals("needs_attention", stopped.state) + assertEquals("", stopped.verificationActionId) + assertEquals(0L, stopped.verificationDueAtEpochMs) + assertFalse(stopped.verificationPending) + } + + @Test + fun successfulWatchingRepliesStopAfterTotalVerificationBound() { + var receipt = watchingReceipt() + repeat(12) { attempt -> + receipt = DoctorActionReceiptStore.applyResult( + previous = receipt, + scopeId = scopeA, + result = PolarisDoctorActionResult( + status = true, + state = "watching", + runId = "doctor-run-1" + ), + nowEpochMs = 10_000L + attempt + ) + } + + assertEquals("needs_attention", receipt.state) + assertFalse(receipt.verificationPending) + assertEquals("", receipt.verificationActionId) + assertEquals(12, receipt.verificationAttemptCount) + } + + @Test + fun permanentVerificationRejectionStopsPollingAndHonorsUndoRevocation() { + val stopped = DoctorActionReceiptStore.stopVerification( + receipt = watchingReceipt(), + result = PolarisDoctorActionResult( + status = false, + error = "Doctor run expired", + runId = "doctor-run-1", + undoAvailable = false + ), + nowEpochMs = 10_000L + ) + + assertEquals("needs_attention", stopped.state) + assertEquals("Doctor run expired", stopped.message) + assertFalse(stopped.verificationPending) + assertFalse(stopped.undoAvailable) + assertEquals("", stopped.undoActionId) + } + + @Test + fun permanentVerificationRejectionWithoutUndoMetadataFailsClosed() { + val stopped = DoctorActionReceiptStore.stopVerification( + receipt = watchingReceipt(), + result = PolarisDoctorActionResult( + status = false, + error = "Doctor run expired", + runId = "doctor-run-1" + ), + nowEpochMs = 10_000L + ) + + assertFalse(stopped.undoAvailable) + assertEquals("", stopped.undoActionId) + } + + @Test + fun permanentUndoRejectionRetiresDurableUndo() { + val retired = DoctorActionReceiptStore.retireUndo( + receipt = watchingReceipt().copy(state = "resolved", verificationActionId = ""), + result = PolarisDoctorActionResult( + status = false, + error = "Doctor run expired", + runId = "doctor-run-1" + ), + nowEpochMs = 12_000L + ) + + assertEquals("needs_attention", retired.state) + assertEquals("Doctor run expired", retired.message) + assertFalse(retired.undoAvailable) + assertEquals("", retired.undoActionId) + } + + @Test + fun responseGuardRejectsStaleScopeGenerationAndRunButAcceptsBlankRunId() { + val request = DoctorActionRequestIdentity(scopeA, "doctor-run-1", generation = 7L) + val current = watchingReceipt() + val blankRunResponse = PolarisDoctorActionResult(status = true, state = "watching", runId = "") + + assertTrue( + DoctorActionReceiptStore.responseMatches( + current = current, + activeScopeId = scopeA, + activeGeneration = 7L, + request = request, + result = blankRunResponse + ) + ) + assertFalse( + DoctorActionReceiptStore.responseMatches( + current = current, + activeScopeId = "other-scope", + activeGeneration = 7L, + request = request, + result = blankRunResponse + ) + ) + assertFalse( + DoctorActionReceiptStore.responseMatches( + current = current, + activeScopeId = scopeA, + activeGeneration = 8L, + request = request, + result = blankRunResponse + ) + ) + assertFalse( + DoctorActionReceiptStore.responseMatches( + current = current.copy(runId = "doctor-run-2"), + activeScopeId = scopeA, + activeGeneration = 7L, + request = request, + result = blankRunResponse + ) + ) + assertFalse( + DoctorActionReceiptStore.responseMatches( + current = current, + activeScopeId = scopeA, + activeGeneration = 7L, + request = request, + result = blankRunResponse.copy(runId = "doctor-run-2") + ) + ) + } + + @Test + fun newRunResponseMustProvideANewRunId() { + val request = DoctorActionRequestIdentity(scopeA, runId = "", generation = 9L) + + assertFalse( + DoctorActionReceiptStore.responseMatches( + current = watchingReceipt(), + activeScopeId = scopeA, + activeGeneration = 9L, + request = request, + result = PolarisDoctorActionResult(status = true, state = "watching", runId = "") + ) + ) + assertTrue( + DoctorActionReceiptStore.responseMatches( + current = watchingReceipt(), + activeScopeId = scopeA, + activeGeneration = 9L, + request = request, + result = PolarisDoctorActionResult(status = true, state = "watching", runId = "doctor-run-2") + ) + ) + } + + @Test + fun successfulLegacyNewRunCanBePresentedWithoutDurableReceipt() { + val request = DoctorActionRequestIdentity(scopeA, runId = "", generation = 9L) + val result = PolarisDoctorActionResult( + status = true, + changed = true, + state = "stable", + message = "Applied", + runId = "" + ) + + assertTrue(DoctorActionReceiptStore.successfulLegacyNewRunResult(request, result)) + assertFalse( + DoctorActionReceiptStore.successfulLegacyNewRunResult( + request.copy(runId = "doctor-run-1"), + result + ) + ) + assertFalse(DoctorActionReceiptStore.successfulLegacyNewRunResult(request, result.copy(status = false))) + } + + @Test + fun staleMenuValidationGenerationIsRejected() { + assertTrue(DoctorActionReceiptStore.validationGenerationIsCurrent(7L, 7L)) + assertFalse(DoctorActionReceiptStore.validationGenerationIsCurrent(8L, 7L)) + } + + @Test + fun receiptIsVisibleOnlyAfterExactScopeValidation() { + val receipt = watchingReceipt() + + assertNull(DoctorActionReceiptStore.visibleReceipt(receipt, scopeA, validatedScopeId = null)) + assertNull(DoctorActionReceiptStore.visibleReceipt(receipt, scopeA, validatedScopeId = "other-scope")) + assertEquals(receipt, DoctorActionReceiptStore.visibleReceipt(receipt, scopeA, scopeA)) + } + + @Test + fun scopedPersistenceDoesNotOverwriteOtherSessionOrStoreRawIdentity() { + val prefs = context.getSharedPreferences("doctor-receipt-test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val scopeB = requireNotNull( + DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "session-b", "control") + ) + val receiptA = watchingReceipt() + val receiptB = watchingReceipt().copy(scopeId = scopeB, runId = "doctor-run-2") + + DoctorActionReceiptStore.save(prefs, receiptA) + DoctorActionReceiptStore.save(prefs, receiptB) + + val encoded = prefs.all.values.joinToString("\n") + assertFalse(encoded.contains("session-a")) + assertFalse(encoded.contains("session-b")) + assertFalse(encoded.contains("10.0.0.232")) + assertEquals(receiptA, DoctorActionReceiptStore.load(prefs, scopeA)) + assertEquals(receiptB, DoctorActionReceiptStore.load(prefs, scopeB)) + assertNull(DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "", "control")) + } + + @Test + fun receiptScopeRequiresExactSessionTokenAndNonBlankGameIdentity() { + assertNull(DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "session-a", "")) + assertNull(DoctorActionReceiptStore.scopeId("10.0.0.232", 0, "session-a", "control")) + assertNotEquals( + DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "session-a", "control"), + DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, " session-a", "control") + ) + } + + @Test + fun receiptPersistenceRetainsOnlyEightMostRecentScopes() { + val prefs = context.getSharedPreferences("doctor-receipt-retention-test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + var lastScope = "" + repeat(9) { index -> + lastScope = requireNotNull( + DoctorActionReceiptStore.scopeId("10.0.0.232", 47984, "session-$index", "game-$index") + ) + DoctorActionReceiptStore.save( + prefs, + watchingReceipt().copy(scopeId = lastScope, runId = "doctor-run-$index") + ) + Thread.sleep(2L) + } + + assertEquals(8, prefs.all.size) + assertEquals("doctor-run-8", DoctorActionReceiptStore.load(prefs, lastScope)?.runId) + } + + @Test + fun completionAfterReopenRefreshesOnlyTheCurrentMenu() { + val registry = DoctorMenuRefreshRegistry() + val refreshed = mutableListOf() + val first = registry.open() + assertTrue(registry.attach(first) { refreshed += "first" }) + assertTrue(registry.close(first)) + val second = registry.open() + assertTrue(registry.attach(second) { refreshed += "second" }) + + assertFalse(registry.close(first)) + assertNull(registry.runIfCurrent(first) { refreshed += "stale" }) + assertTrue(registry.runIfCurrent(second) { refreshed += "current" } == Unit) + assertTrue(registry.dispatch()) + assertEquals(listOf("current", "second"), refreshed) + } + + @Test + fun staleCompletionCannotClearANewerPendingDoctorRequest() { + val pending = DoctorActionPendingRegistry() + assertTrue(pending.begin(1L)) + pending.reset() + assertTrue(pending.begin(2L)) + + assertFalse(pending.clearIfOwned(1L)) + assertTrue(pending.isPending()) + assertTrue(pending.clearIfOwned(2L)) + assertFalse(pending.isPending()) + } + + @Test + fun sameGenerationVerificationCannotBeClaimedTwiceConcurrently() { + val pending = DoctorActionPendingRegistry() + + assertTrue(pending.begin(7L)) + assertFalse(pending.begin(7L)) + assertTrue(pending.isPending()) + assertTrue(pending.clearIfOwned(7L)) + assertTrue(pending.begin(7L)) + } + + private fun watchingReceipt( + verificationDueAtEpochMs: Long = 9_000L + ) = DoctorActionReceipt( + scopeId = scopeA, + runId = "doctor-run-1", + state = "watching", + message = "Watching", + verificationActionId = "verify", + verificationDueAtEpochMs = verificationDueAtEpochMs, + undoAvailable = true, + undoActionId = "restore_quality" + ) +} diff --git a/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt index 7ce87401..f5c4573c 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt @@ -415,6 +415,53 @@ class NovaQuickMenuUiStateTest { assertEquals(listOf(0, 25, 64, 90, 100), state.menuOpacity.presets) } + @Test + fun durableDoctorReceiptStaysVisibleWithUndoAfterCommandCenterReopen() { + val receipt = DoctorActionReceipt( + scopeId = "scope-a", + runId = "doctor-run-1", + state = "resolved", + message = "Doctor verified that network pressure cleared.", + undoAvailable = true, + undoActionId = "undo" + ) + + val state = quickState(status = status(), doctorReceipt = receipt) + + assertTrue(state.doctorReceiptAction.visible) + assertTrue(state.doctorReceiptAction.enabled) + assertEquals(NovaQuickMenuActionId.DOCTOR_UNDO, state.doctorReceiptAction.id) + assertEquals("Verified", state.doctorReceiptAction.chip?.label) + assertTrue(state.doctorReceiptAction.caption.contains("restore", ignoreCase = true)) + } + + @Test + fun durableDoctorUndoRequiresHostActionIdAndCurrentTuningPermission() { + val receipt = DoctorActionReceipt( + scopeId = "scope-a", + runId = "doctor-run-1", + state = "resolved", + message = "Verified", + undoAvailable = true, + undoActionId = "" + ) + + val missingAction = quickState(status = status(), doctorReceipt = receipt) + val viewer = quickState( + status = status( + clientRole = "viewer", + ownedByClient = false, + controls = PolarisSessionStatus.ControlsStatus(hostTuningAllowed = false) + ), + doctorReceipt = receipt.copy(undoActionId = "restore_quality") + ) + + assertTrue(missingAction.doctorReceiptAction.visible) + assertFalse(missingAction.doctorReceiptAction.enabled) + assertTrue(viewer.doctorReceiptAction.visible) + assertFalse(viewer.doctorReceiptAction.enabled) + } + @Test fun commandCenterStateClampsNonPresetMenuOpacityValues() { val state = quickState(status = status(), menuOpacityPercent = 150) @@ -439,7 +486,8 @@ class NovaQuickMenuUiStateTest { hudShowing: Boolean = false, hudOpacityPercent: Int = 90, menuOpacityPercent: Int = NovaMenuPreferences.DEFAULT_OPACITY_PERCENT, - fallbackTargetFps: Double = 60.0 + fallbackTargetFps: Double = 60.0, + doctorReceipt: DoctorActionReceipt? = null ) = NovaQuickMenuUiState.from( context = context, status = status, @@ -465,7 +513,8 @@ class NovaQuickMenuUiStateTest { allowChangeMouseMode = true, isOnExternalDisplay = false, fallbackBitrateKbps = 50000, - fallbackTargetFps = fallbackTargetFps + fallbackTargetFps = fallbackTargetFps, + doctorReceipt = doctorReceipt ) private fun status(