Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions app/src/main/java/com/papi/nova/api/PolarisApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -849,15 +849,18 @@ class PolarisApiClient @JvmOverloads constructor(
?: health?.optString("primary_issue", "")
?: ""
val likelyCause = explanation?.optString("likely_cause", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("simple_state", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("summary", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("simple_state", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("diagnosis", "")?.takeIf { it.isNotBlank() }
?: health?.optString("summary", "")
?: ""
val evidence = parseStringArray(explanation?.optJSONArray("evidence")).takeIf { it.isNotEmpty() }
?: parseDoctorEvidence(doctor)
val recommendation = doctor?.optJSONObject("recommendation")
val safeAction = doctor?.optJSONObject("safe_recovery_action")
val actionPayload = safeAction?.optJSONObject("payload_preview")
val actionVerification = safeAction?.optJSONObject("verification")
val actionUndo = safeAction?.optJSONObject("undo")
val tryFirst = parseStringArray(explanation?.optJSONArray("try_first")).takeIf { it.isNotEmpty() }
?: listOfNotNull(
recommendation?.optString("body", "")?.takeIf { it.isNotBlank() },
Expand All @@ -866,10 +869,13 @@ class PolarisApiClient @JvmOverloads constructor(
).takeIf { it.isNotEmpty() }
?: parseStringArray(health?.optJSONArray("recommendations"))
val confidence = explanation?.optString("confidence", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("confidence", "")?.takeIf { it.isNotBlank() }
?: doctor?.optJSONObject("confidence")?.optString("level", "")?.takeIf { it.isNotBlank() }
?: doctor?.optString("confidence", "")?.takeIf { it.isNotBlank() && !it.startsWith("{") }
?: if (doctor != null) "deterministic" else if (primaryIssue.isNotBlank() || likelyCause.isNotBlank()) "fallback" else ""
return PolarisSessionStatus.DoctorStatus(
available = doctor != null,
version = doctor?.optInt("version", 0) ?: 0,
resultId = doctor?.optString("result_id", "") ?: "",
classification = classifyDoctorIssue(primaryIssue),
likelyCause = likelyCause,
evidence = evidence,
Expand All @@ -879,6 +885,14 @@ class PolarisApiClient @JvmOverloads constructor(
?: doctor?.optJSONObject("advanced_evidence")?.optString("summary", "")
?: "",
primaryIssue = primaryIssue,
actionId = safeAction?.optString("id", "") ?: "",
actionLabel = safeAction?.optString("label", "") ?: "",
actionKind = safeAction?.optString("kind", "") ?: "",
targetBitrateKbps = actionPayload?.optInt("target_bitrate_kbps", 0) ?: 0,
verificationDelaySeconds = actionVerification?.optInt("delay_seconds", 0) ?: 0,
undoSupported = actionUndo?.optBoolean("supported", false) ?: false,
packetLossPct = parseDoctorEvidenceNumber(doctor, "packet_loss"),
latencyMs = parseDoctorEvidenceNumber(doctor, "latency"),
destructiveActionAllowed = false
)
}
Expand All @@ -895,6 +909,16 @@ class PolarisApiClient @JvmOverloads constructor(
}
}

private fun parseDoctorEvidenceNumber(doctor: JSONObject?, id: String): Double? {
val array = doctor?.optJSONArray("evidence") ?: return null
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
if (item.optString("id", "") != id || !item.has("value")) continue
return item.optDouble("value").takeIf { !it.isNaN() }
}
return null
}

private fun classifyDoctorIssue(issue: String): String {
val normalized = issue.lowercase()
return when {
Expand Down Expand Up @@ -1835,6 +1859,56 @@ class PolarisApiClient @JvmOverloads constructor(
}
}

/**
* Execute an evidence-gated Doctor action on Polaris.
*/
fun runDoctorAction(
actionId: String,
sourceResultId: String = "",
targetBitrateKbps: Int = 0,
runId: String = ""
): PolarisDoctorActionResult? {
return try {
val body = JSONObject().apply {
put("action_id", actionId)
if (sourceResultId.isNotBlank()) put("source_result_id", sourceResultId)
if (targetBitrateKbps > 0) put("target_bitrate_kbps", targetBitrateKbps)
if (runId.isNotBlank()) put("run_id", runId)
}
val request = Request.Builder()
.url("$baseUrl/doctor/action")
.post(okhttp3.RequestBody.create(
"application/json".toMediaTypeOrNull(),
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() }
)
}
} catch (e: Exception) {
LimeLog.warning("Nova: Doctor action failed: ${errorMessage(e)}")
null
}
}

/**
* Set the stream bitrate mid-session without reconnecting.
*/
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -254,16 +254,34 @@ data class PolarisSessionStatus(

data class DoctorStatus(
val available: Boolean = false,
val version: Int = 0,
val resultId: String = "",
val classification: String = "UNKNOWN",
val likelyCause: String = "",
val evidence: List<String> = emptyList(),
val tryFirst: List<String> = emptyList(),
val confidence: String = "",
val advancedDetail: String = "",
val primaryIssue: String = "",
val actionId: String = "",
val actionLabel: String = "",
val actionKind: String = "",
val targetBitrateKbps: Int = 0,
val verificationDelaySeconds: Int = 0,
val undoSupported: Boolean = false,
val packetLossPct: Double? = null,
val latencyMs: Double? = null,
val destructiveActionAllowed: Boolean = false
) {
val firstTry get() = tryFirst.firstOrNull().orEmpty()
val networkPressureConfirmed get() =
(packetLossPct ?: 0.0) > 2.0 || (latencyMs ?: 0.0) >= 45.0
val canExecuteAction get() = when (actionId) {
"recheck_network" -> version >= 2
"lower_bitrate" -> version >= 2 && primaryIssue == "network_jitter" && networkPressureConfirmed
"restore_quality" -> version >= 2 && primaryIssue == "quality_capped_by_history" && targetBitrateKbps > 0
else -> false
}
}

data class HealthStatus(
Expand Down Expand Up @@ -413,3 +431,18 @@ data class PolarisSessionStatus(
else -> "Stable"
}
}

data class PolarisDoctorActionResult(
val status: Boolean,
val changed: Boolean = false,
val state: String = "",
val message: String = "",
val error: String = "",
val runId: String = "",
val verificationDelaySeconds: Int = 0,
val verificationActionId: String = "",
val undoAvailable: Boolean = false,
val undoActionId: String = "",
val evidencePacketLossPct: Double? = null,
val evidenceLatencyMs: Double? = null
)
121 changes: 120 additions & 1 deletion app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.papi.nova.LimeLog
import com.papi.nova.R
import com.papi.nova.api.PolarisApiClient
import com.papi.nova.api.PolarisCapabilities
import com.papi.nova.api.PolarisDoctorActionResult
import com.papi.nova.api.PolarisSessionStatus
import com.papi.nova.binding.input.GameInputDevice
import com.papi.nova.binding.input.KeyboardTranslator
Expand Down Expand Up @@ -90,6 +91,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks {
var advancedTuningVisible = false
var profileClearInProgress = false
var hostStateUnavailable = false
var doctorActionPending = false

fun syncSessionDerivedState() {
adaptiveEnabled = sessionStatus?.tuning?.adaptiveBitrateEnabled == true ||
Expand Down Expand Up @@ -180,6 +182,121 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks {
sendKeysWithFocus(quickKeys)
}

fun doctorResultMessage(result: PolarisDoctorActionResult): String {
if (result.message.isNotBlank()) return result.message
return when (result.state) {
"stable" -> game.getString(R.string.nova_quick_menu_doctor_stable)
"confirmed_pressure" -> game.getString(R.string.nova_quick_menu_doctor_confirmed)
"watching" -> game.getString(R.string.nova_quick_menu_doctor_watching)
"resolved" -> game.getString(R.string.nova_quick_menu_doctor_resolved)
"needs_attention" -> game.getString(R.string.nova_quick_menu_doctor_needs_attention)
"undone" -> game.getString(R.string.nova_quick_menu_doctor_undone)
else -> result.error.takeIf { it.isNotBlank() }
?: game.getString(R.string.nova_quick_menu_doctor_failed)
}
}

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()
}
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
)
}
refreshState()
}
}
}

fun presentDoctorResult(result: PolarisDoctorActionResult) {
val message = doctorResultMessage(result)
if (!result.status) {
NovaSnackbar.showError(game, message, anchor = composeView)
return
}
if (result.undoAvailable && result.runId.isNotBlank()) {
NovaSnackbar.showSuccessWithAction(
activity = game,
message = message,
actionLabel = game.getString(R.string.nova_quick_menu_doctor_undo),
anchor = composeView,
onAction = { undoDoctorRun(result.runId) }
)
} 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
)
if (verification?.status == true) {
sessionStatus = apiClient.getSessionStatus() ?: sessionStatus
syncSessionDerivedState()
}
game.runOnMainIfRuntimeActive {
verification?.let {
presentDoctorResult(it)
scheduleDoctorVerification(it)
}
refreshState()
}
}
}, delayMs)
}

fun runDoctorAction() {
val status = sessionStatus
val doctor = status?.doctor
if (apiClient == null || status == null || doctor == null || !doctor.canExecuteAction || doctorActionPending) {
game.copyNovaHudDiagnostics()
return
}
doctorActionPending = true
game.launchRuntimeIo("NovaQuickMenuDoctorAction") {
val result = apiClient.runDoctorAction(
actionId = doctor.actionId,
sourceResultId = doctor.resultId,
targetBitrateKbps = doctor.targetBitrateKbps
)
if (result?.status == true) {
sessionStatus = apiClient.getSessionStatus() ?: sessionStatus
syncSessionDerivedState()
}
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)
}
refreshState()
}
}
}

val callbacks = NovaQuickMenuCallbacks(
onDismiss = { dismiss() },
onDisconnect = {
Expand Down Expand Up @@ -405,7 +522,9 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks {
}
game.toggleHUD()
}
NovaQuickMenuActionId.DIAGNOSE_STREAM,
NovaQuickMenuActionId.DIAGNOSE_STREAM -> {
runDoctorAction()
}
NovaQuickMenuActionId.COPY_HUD_DIAGNOSTICS -> {
game.copyNovaHudDiagnostics()
}
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,14 @@ private fun NovaQuickMenuDiagnosisCard(
},
action = NovaQuickMenuAction(
id = NovaQuickMenuActionId.DIAGNOSE_STREAM,
label = "${diagnosis.classification.takeIf { it in setOf("HOST", "NET", "CLIENT") } ?: "DIAG"}: ${diagnosis.likelyCause}",
caption = detail.ifBlank { "HOST / NET / CLIENT self-service diagnostics" },
label = diagnosis.actionLabel.takeIf { diagnosis.actionExecutable && it.isNotBlank() }
?: "${diagnosis.classification.takeIf { it in setOf("HOST", "NET", "CLIENT") } ?: "DIAG"}: ${diagnosis.likelyCause}",
caption = buildList {
diagnosis.likelyCause.takeIf { diagnosis.actionExecutable && it.isNotBlank() }?.let { add(it) }
detail.takeIf { it.isNotBlank() }?.let { add(it) }
}.joinToString(" · ").ifBlank { "HOST / NET / CLIENT self-service diagnostics" },
chip = NovaQuickMenuChip(
label = if (diagnosis.available) "Doctor" else "Fallback",
label = if (diagnosis.actionExecutable) "One click" else if (diagnosis.available) "Doctor" else "Fallback",
tone = if (diagnosis.available) NovaQuickMenuTone.INFO else NovaQuickMenuTone.MUTED
),
enabled = diagnosis.available
Expand Down
19 changes: 16 additions & 3 deletions app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ data class NovaQuickMenuDiagnosisState(
val evidence: List<String>,
val tryFirst: String,
val confidence: String,
val available: Boolean
val available: Boolean,
val actionId: String,
val actionLabel: String,
val actionExecutable: Boolean,
val targetBitrateKbps: Int,
val verificationDelaySeconds: Int,
val undoSupported: Boolean
)

data class NovaQuickMenuUiState(
Expand Down Expand Up @@ -500,7 +506,13 @@ data class NovaQuickMenuUiState(
evidence = doctor?.evidence ?: emptyList(),
tryFirst = doctor?.firstTry.orEmpty(),
confidence = doctor?.confidence.orEmpty(),
available = status != null && (doctor?.likelyCause?.isNotBlank() == true || doctor?.primaryIssue?.isNotBlank() == true)
available = status != null && (doctor?.likelyCause?.isNotBlank() == true || doctor?.primaryIssue?.isNotBlank() == true),
actionId = doctor?.actionId.orEmpty(),
actionLabel = doctor?.actionLabel.orEmpty(),
actionExecutable = doctor?.canExecuteAction == true && status?.canAdjustHostTuning == true,
targetBitrateKbps = doctor?.targetBitrateKbps ?: 0,
verificationDelaySeconds = doctor?.verificationDelaySeconds ?: 0,
undoSupported = doctor?.undoSupported == true
)
}

Expand All @@ -516,7 +528,8 @@ data class NovaQuickMenuUiState(
}
return NovaQuickMenuAction(
id = NovaQuickMenuActionId.DIAGNOSE_STREAM,
label = context.getString(R.string.nova_quick_menu_diagnose_stream),
label = diagnosis.actionLabel.takeIf { diagnosis.actionExecutable && it.isNotBlank() }
?: context.getString(R.string.nova_quick_menu_diagnose_stream),
caption = diagnosis.likelyCause,
chip = chip(classification, tone),
enabled = status != null
Expand Down
Loading
Loading