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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 1.1.0 - 2026-05-20

- Added structured stream performance samples from the video renderer so NovaHUD can consume typed metrics without reparsing the legacy overlay text path.
- Reworked the NovaHUD sparkline path around a fixed primitive ring buffer and focused snapshots to reduce stream-loop allocation pressure.
- Routed structured performance samples into NovaHUD while preserving the legacy performance overlay behavior.
- Expanded Baseline Profile coverage for library detail, settings, and launch-adjacent Compose surfaces.
- Documented the JNI bridge measurement gate for future `@FastNative` and `@CriticalNative` work instead of annotating JNI calls before profiling proves they are safe.
- Kept the lock-screen overlay retryable by treating Polaris unlock responses as successful only when the host reports `success: true`.
- Normalized raw `idle` stream progress into the initializing overlay state so handheld users do not see an internal state label.
- Added saved-host port recovery so Nova retries a stale local address on the default Polaris HTTP port and persists the corrected port after a successful poll.
- Verified the debug ARM64 APK on a Retroid Pocket 6 over wireless ADB with Polaris library launch, HEVC stream resume, NovaHUD enablement, Command Center disconnect, and clean log/crash-buffer checks.

## 1.0.10 - 2026-05-19

- Migrated upgraded installs that still carried the old Balanced 720p stream resolution so Shield, Retroid, and Android TV clients request 1080p after updating.
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ tuning instead of hiding everything behind a generic game grid.
[![License](https://img.shields.io/github/license/papi-ux/nova?style=for-the-badge&color=4c5265&labelColor=1a1a2e)](LICENSE.txt)
[![Release](https://img.shields.io/github/v/release/papi-ux/nova?style=for-the-badge&color=4ade80&labelColor=1a1a2e&label=latest)](https://github.com/papi-ux/nova/releases/latest)

[Quick Start](#quick-start) · [What's New](#whats-new-in-v1010) · [Install](#install) · [Compatibility](#compatibility) · [Tour](#tour) · [Polaris](#use-with-polaris) · [Docs](#docs) · [FAQ](#faq) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md)
[Quick Start](#quick-start) · [What's New](#whats-new-in-v110) · [Install](#install) · [Compatibility](#compatibility) · [Tour](#tour) · [Polaris](#use-with-polaris) · [Docs](#docs) · [FAQ](#faq) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md)

**Support**: [Issues](https://github.com/papi-ux/nova/issues) · [Discussions](https://github.com/papi-ux/nova/discussions)

Expand Down Expand Up @@ -51,15 +51,15 @@ tuning instead of hiding everything behind a generic game grid.

If a sleeping host does not report a MAC address, open the host menu and choose **Edit Wake-on-LAN MAC**. Nova stores that address and reuses it for future wake requests, which helps VPN and routed setups where discovery metadata is incomplete.

## What's New in v1.0.10
## What's New in v1.1.0

Nova `v1.0.10` is an upgraded-install resolution hotfix for Polaris-backed streams.
Nova `v1.1.0` is a stream performance and release-hardening update for Polaris-backed play.

- **Upgraded-install repair**: Shield, Retroid, and Android TV installs that still had the old Balanced 720p stream setting are migrated to 1080p after updating.
- **Performance stays explicit**: Nova keeps the Performance preset at 720p and only repairs settings that still match the old Balanced default shape.
- **Cached Auto Safe guard**: Polaris cached launch profiles can no longer force 1080p-capable clients down to 720p unless a confirmed recovery profile is active.
- **Polaris launch validation**: the fix targets direct Polaris-backed launches as well as library launches, so the host sees a 1080p client request.
- **Regression coverage**: unit tests now cover the Balanced migration, Performance-preset guard, and cached Auto Safe 1080p floor.
- **Lower-overhead HUD metrics**: NovaHUD now consumes structured stream samples from the video renderer while preserving the legacy overlay path.
- **Hot-path allocation cleanup**: HUD sparkline samples use a fixed primitive buffer instead of rebuilding collection state during a stream.
- **Smoother first-run surfaces**: Baseline Profile generation now covers library detail, settings, and launch-adjacent Compose paths.
- **Measured JNI policy**: the JNI bridge now has a documented profiling gate for future `@FastNative` and `@CriticalNative` work.
- **Retroid 6 validation**: the ARM64 debug APK was smoke tested over wireless ADB with Polaris library launch, HEVC stream resume, NovaHUD, Command Center disconnect, and clean crash checks.

See the [changelog](CHANGELOG.md) for the full release history.

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ android {
minSdk 21
targetSdk 36

versionName "1.0.10"
versionCode = 25
versionName "1.1.0"
versionCode = 26

buildConfigField "boolean", "FDROID_BUILD", fdroidBuild.toString()

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/papi/nova/Game.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.papi.nova.binding.video.CrashListener
import com.papi.nova.binding.video.MediaCodecDecoderRenderer
import com.papi.nova.binding.video.MediaCodecHelper
import com.papi.nova.binding.video.PerfOverlayListener
import com.papi.nova.binding.video.PerfOverlaySample
import com.papi.nova.nvstream.NvConnection
import com.papi.nova.nvstream.NvConnectionListener
import com.papi.nova.nvstream.StreamConfiguration
Expand Down Expand Up @@ -4808,6 +4809,16 @@ novaHud!!.updateFromPerfText(text)
}
})
}
override fun onPerfSample(sample:PerfOverlaySample) {
runOnUiThread(object : Runnable {
override fun run() {
if (novaHud != null && novaHud!!.isShowing)
{
novaHud!!.updateFromPerfSample(sample)
}
}
})
}
override fun onUsbPermissionPromptStarting() {
// Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents
// us from entering PiP while the user is interacting with the OS permission dialog.
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/com/papi/nova/api/PolarisApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ class PolarisApiClient @JvmOverloads constructor(
)
}

@JvmStatic
fun parseUnlockResponse(json: JSONObject): Boolean =
json.optBoolean("success", false)

@JvmStatic
fun parseCapabilitiesResponse(json: JSONObject): PolarisCapabilities {
val features = json.optJSONObject("features")
Expand Down Expand Up @@ -1322,7 +1326,9 @@ class PolarisApiClient @JvmOverloads constructor(
execute(request).use { response ->
if (response.code != 200) return false

true
val body = response.body.string()
if (body.isBlank()) return false
parseUnlockResponse(JSONObject(body))
}
} catch (e: Exception) {
LimeLog.warning("Nova: Unlock request failed: ${errorMessage(e)}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,24 @@ class MediaCodecDecoderRenderer(
}
sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs))
}
val packetLossPct = if (lastTwo.totalFrames > 0) {
lastTwo.framesLost.toDouble() / lastTwo.totalFrames.toDouble() * 100.0
} else {
0.0
}
val perfSample = PerfOverlaySample(
fps = fps.totalFps.toDouble(),
incomingFps = fps.receivedFps.toDouble(),
renderedFps = fps.renderedFps.toDouble(),
width = initialWidth,
height = initialHeight,
codec = decoder,
rttMs = (rttInfo shr 32).toInt(),
rttVarianceMs = rttInfo.toInt(),
decodeTimeMs = decodeTimeMs.toDouble(),
packetLossPct = packetLossPct
)
perfListener.onPerfSample(perfSample)
val fullLog = sb.toString()
perfListener.onPerfUpdate(fullLog)
val targetFpsMatched = fps.totalFps.toInt() == prefs.fps.toInt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ package com.papi.nova.binding.video

interface PerfOverlayListener {
fun onPerfUpdate(text: String)

fun onPerfSample(sample: PerfOverlaySample) {
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/papi/nova/binding/video/PerfOverlaySample.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.papi.nova.binding.video

data class PerfOverlaySample(
val fps: Double,
val incomingFps: Double,
val renderedFps: Double,
val width: Int,
val height: Int,
val codec: String,
val rttMs: Int,
val rttVarianceMs: Int,
val decodeTimeMs: Double,
val packetLossPct: Double
)
95 changes: 39 additions & 56 deletions app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -686,74 +686,35 @@ class ComputerManagerService : Service() {

@Throws(InterruptedException::class)
private fun parallelPollPc(details: ComputerDetails): ComputerDetails? {
val localInfo = ParallelPollTuple(details.localAddress, details)
val manualInfo = ParallelPollTuple(details.manualAddress, details)
val remoteInfo = ParallelPollTuple(details.remoteAddress, details)
val ipv6Info = ParallelPollTuple(details.ipv6Address, details)
val pollTuples = buildParallelPollAddresses(details)
.map { address -> ParallelPollTuple(address, details) }

// These must be started in order of precedence for the deduplication algorithm
// to result in the correct behavior.
val uniqueAddresses = HashSet<ComputerDetails.AddressTuple>()
startParallelPollThread(localInfo, uniqueAddresses)
startParallelPollThread(manualInfo, uniqueAddresses)
startParallelPollThread(remoteInfo, uniqueAddresses)
startParallelPollThread(ipv6Info, uniqueAddresses)
for (tuple in pollTuples) {
startParallelPollThread(tuple, uniqueAddresses)
}

try {
// Check local first
synchronized(localInfo.completionLock) {
while (!localInfo.complete) {
localInfo.completionLock.wait()
}

if (localInfo.returnedDetails != null) {
localInfo.returnedDetails!!.activeAddress = localInfo.address
return localInfo.returnedDetails
}
}

// Now manual
synchronized(manualInfo.completionLock) {
while (!manualInfo.complete) {
manualInfo.completionLock.wait()
}

if (manualInfo.returnedDetails != null) {
manualInfo.returnedDetails!!.activeAddress = manualInfo.address
return manualInfo.returnedDetails
}
}

// Now remote IPv4
synchronized(remoteInfo.completionLock) {
while (!remoteInfo.complete) {
remoteInfo.completionLock.wait()
}

if (remoteInfo.returnedDetails != null) {
remoteInfo.returnedDetails!!.activeAddress = remoteInfo.address
return remoteInfo.returnedDetails
}
}

// Now global IPv6
synchronized(ipv6Info.completionLock) {
while (!ipv6Info.complete) {
ipv6Info.completionLock.wait()
}
for (tuple in pollTuples) {
synchronized(tuple.completionLock) {
while (!tuple.complete) {
tuple.completionLock.wait()
}

if (ipv6Info.returnedDetails != null) {
ipv6Info.returnedDetails!!.activeAddress = ipv6Info.address
return ipv6Info.returnedDetails
if (tuple.returnedDetails != null) {
tuple.returnedDetails!!.activeAddress = tuple.address
return tuple.returnedDetails
}
}
}
} finally {
// Stop any further polling if we've found a working address or we've been
// interrupted by an attempt to stop polling.
localInfo.interrupt()
manualInfo.interrupt()
remoteInfo.interrupt()
ipv6Info.interrupt()
for (tuple in pollTuples) {
tuple.interrupt()
}
}

return null
Expand Down Expand Up @@ -1007,6 +968,28 @@ class ComputerManagerService : Service() {
private const val INITIAL_POLL_TRIES = 2
private const val EMPTY_LIST_THRESHOLD = 3
private const val POLL_DATA_TTL_MS = 30000

@JvmStatic
fun buildParallelPollAddresses(details: ComputerDetails): List<ComputerDetails.AddressTuple> {
val addresses = ArrayList<ComputerDetails.AddressTuple>()

fun addUnique(address: ComputerDetails.AddressTuple?) {
if (address != null && !addresses.contains(address)) {
addresses.add(address)
}
}

val localAddress = details.localAddress
addUnique(localAddress)
if (localAddress != null && localAddress.port != NvHTTP.DEFAULT_HTTP_PORT) {
addUnique(ComputerDetails.AddressTuple(localAddress.address, NvHTTP.DEFAULT_HTTP_PORT))
}
addUnique(details.manualAddress)
addUnique(details.remoteAddress)
addUnique(details.ipv6Address)

return addresses
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/papi/nova/ui/NovaHudSparklineBuffer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.papi.nova.ui

internal class NovaHudSparklineBuffer(private val capacity: Int = 60) {
private val values = FloatArray(capacity)
private val scratch = FloatArray(capacity)
private var nextIndex = 0
private var sampleCount = 0

fun add(value: Float) {
values[nextIndex] = value
nextIndex = (nextIndex + 1) % capacity
if (sampleCount < capacity) {
sampleCount++
}
}

fun clear() {
nextIndex = 0
sampleCount = 0
}

fun snapshot(): List<Float> {
val output = ArrayList<Float>(sampleCount)
for (i in 0 until sampleCount) {
output.add(valueAt(i))
}
return output
}

fun lowOnePercent(): Double {
if (sampleCount < 3) {
return 0.0
}
for (i in 0 until sampleCount) {
scratch[i] = valueAt(i)
}
java.util.Arrays.sort(scratch, 0, sampleCount)
val index = (sampleCount * 0.01f).toInt().coerceIn(0, sampleCount - 1)
return scratch[index].toDouble()
}

private fun valueAt(offset: Int): Float {
val start = if (sampleCount == capacity) nextIndex else 0
return values[(start + offset) % capacity]
}
}
24 changes: 18 additions & 6 deletions app/src/main/java/com/papi/nova/ui/NovaStreamHud.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.preference.PreferenceManager
import com.papi.nova.api.PolarisSessionStatus
import com.papi.nova.binding.video.PerfOverlaySample
import com.papi.nova.ui.compose.NovaComposeTheme
import kotlin.math.abs

Expand Down Expand Up @@ -41,7 +42,7 @@ class NovaStreamHud(private val activity: Activity) {
private var degradedFrames = 0
private var recoveredFrames = 0
private var bitrateReduced = false
private val sparklineData = mutableListOf<Float>()
private val sparklineData = NovaHudSparklineBuffer()

var onBitrateAdjust: ((Int) -> Unit)? = null

Expand Down Expand Up @@ -163,6 +164,19 @@ class NovaStreamHud(private val activity: Activity) {
}
}

fun updateFromPerfSample(sample: PerfOverlaySample) {
activity.runOnUiThread {
if (hudView == null) return@runOnUiThread
updateFps(sample.fps)
width = sample.width
height = sample.height
updateLatency(sample.rttMs)
applyCodecLabel(sample.codec)
sessionStats.recordPacketLoss(sample.packetLossPct)
publishState()
}
}

fun setTargetBitrateKbps(bitrateKbps: Int) {
currentBitrateKbps = bitrateKbps
lastBitrateKbps = bitrateKbps
Expand Down Expand Up @@ -229,12 +243,9 @@ class NovaStreamHud(private val activity: Activity) {
}
lastFps = fps
sparklineData.add(fps.toFloat())
if (sparklineData.size > 60) {
sparklineData.removeAt(0)
}
sessionStats.recordFps(
fps = fps,
lowOnePercentFps = NovaHudUiState.calculateLowOnePercent(sparklineData)
lowOnePercentFps = sparklineData.lowOnePercent()
)

if (hostAdaptiveBitrateActive) {
Expand Down Expand Up @@ -302,7 +313,8 @@ class NovaStreamHud(private val activity: Activity) {
width = width,
height = height,
status = lastSessionStatus,
sparklineSamples = sparklineData
sparklineSamples = sparklineData.snapshot(),
lowOnePercentFps = sparklineData.lowOnePercent()
)
}

Expand Down
Loading
Loading