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
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ jobs:
apk_name="$(basename "$apk")"
case "$apk_name" in
*arm64-v8a*) abi="arm64-v8a" ;;
*armeabi-v7a*) abi="armeabi-v7a" ;;
*x86_64*) abi="x86_64" ;;
*)
echo "Could not determine ABI for ${apk_name}" >&2
Expand Down Expand Up @@ -242,10 +243,11 @@ jobs:
set -euo pipefail

asset_count=$(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets | length')
if [ "${asset_count}" -lt 4 ]; then
echo "Expected at least 4 release assets on ${GITHUB_REF_NAME}, found ${asset_count}" >&2
if [ "${asset_count}" -lt 6 ]; then
echo "Expected at least 6 release assets on ${GITHUB_REF_NAME}, found ${asset_count}" >&2
exit 1
fi

gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | grep -Fx 'Nova-Android-arm64-v8a.apk'
gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | grep -Fx 'Nova-Android-armeabi-v7a.apk'
gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | grep -Fx 'Nova-Android-x86_64.apk'
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.0.7 - 2026-05-18

- Added a public `armeabi-v7a` APK split for Chromecast with Google TV, Google TV Streamer, and other Android TV devices that expose only 32-bit ARM app support.
- Recognized the Steam Controller 2026 Bluetooth keyboard/mouse HID shape on Google TV as controller input so Nova advertises a host gamepad and routes compatible D-pad/button events through the controller path.
- Updated the release workflow to name, upload, and verify the 32-bit ARM APK plus checksum alongside the existing ARM64 and x86_64 assets.
- Refreshed README install guidance so users can choose the correct APK for ARM64 Android TV, Chromecast/32-bit ARM Android TV, and x86_64 Android devices.

## 1.0.6 - 2026-05-18

- Completed the Kotlin migration for the Android client runtime, UI/support layers, streaming contracts, and regression tests.
Expand Down
35 changes: 18 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@ Built for [Polaris](https://github.com/papi-ux/polaris), compatible with other M

1. Download the latest release from GitHub Releases, add Nova to Obtainium, or open it in GitHub Store on Android.
2. Install the APK that matches your Android device.
Most phones, handhelds, and Android TV devices use `Nova-Android-arm64-v8a.apk`; x86_64 Android devices and emulators use `Nova-Android-x86_64.apk`.
Most phones, handhelds, and Android TV devices use `Nova-Android-arm64-v8a.apk`; Chromecast with Google TV, Google TV Streamer, and other 32-bit ARM Android TV devices use `Nova-Android-armeabi-v7a.apk`; x86_64 Android devices and emulators use `Nova-Android-x86_64.apk`.
3. Open Nova, add or discover your host, then pair it.

| Public release asset | Use it for |
|---|---|
| `Nova-Android-arm64-v8a.apk` | Recommended Android install for phones, handhelds, and Android TV devices such as NVIDIA Shield |
| `Nova-Android-armeabi-v7a.apk` | Chromecast with Google TV, Google TV Streamer, and other Android TV devices that expose only 32-bit ARM app support |
| `Nova-Android-x86_64.apk` | Android x86_64 devices and emulators |
| `*.apk.sha256` | Integrity checks for the public APKs |

The latest direct APKs are always available through GitHub's latest-release URLs: `https://github.com/papi-ux/nova/releases/latest/download/Nova-Android-arm64-v8a.apk` and `https://github.com/papi-ux/nova/releases/latest/download/Nova-Android-x86_64.apk`. The Obtainium link above is preconfigured for the ARM64 public asset so updates resolve to one APK cleanly. The GitHub Store link opens Nova's public release repo for users who prefer that installer; GitHub Store filters assets for the device it is running on, so its desktop app may show Nova as unavailable because Nova ships Android APKs.
The latest direct APKs are always available through GitHub's latest-release URLs: `https://github.com/papi-ux/nova/releases/latest/download/Nova-Android-arm64-v8a.apk`, `https://github.com/papi-ux/nova/releases/latest/download/Nova-Android-armeabi-v7a.apk`, and `https://github.com/papi-ux/nova/releases/latest/download/Nova-Android-x86_64.apk`. The Obtainium link above is preconfigured for the ARM64 public asset so updates resolve to one APK cleanly; Chromecast and other 32-bit ARM Android TV users should choose the `armeabi-v7a` asset manually or configure Obtainium to match `Nova-Android-armeabi-v7a.apk`. The GitHub Store link opens Nova's public release repo for users who prefer that installer; GitHub Store filters assets for the device it is running on, so its desktop app may show Nova as unavailable because Nova ships Android APKs.

F-Droid and IzzyOnDroid packaging notes are tracked in [docs/fdroid.md](docs/fdroid.md), including current status, APK scan notes, and source-build blockers.

Expand All @@ -66,22 +67,20 @@ sha256sum -c Nova-Android-arm64-v8a.apk.sha256
> If you distribute Nova from a private GitHub fork, Obtainium needs a Personal Access Token with `repo` scope. Public release repos do not.

> [!NOTE]
> `v1.0.0` is the first public Nova release line, `v1.0.1` adds the first store-packaging pass, `v1.0.2` hardens the security surfaces found during public scanner review, `v1.0.3` adds manual Wake-on-LAN MAC entry for hosts that do not report a MAC address, `v1.0.4` polishes Android TV navigation, library presentation, and Polaris Sync UX, `v1.0.5` adds the first unified Auto Quality experience with Polaris, and `v1.0.6` hardens the Kotlin runtime, stream lifecycle, HUD telemetry, and Polaris launch profile handoff. Nova is already usable, but this is still an early public release and you should expect bugs, regressions, and rough edges while the Android client and Polaris integration continue to harden. `app/` is the only shipping client today.
> `v1.0.0` is the first public Nova release line, `v1.0.1` adds the first store-packaging pass, `v1.0.2` hardens the security surfaces found during public scanner review, `v1.0.3` adds manual Wake-on-LAN MAC entry for hosts that do not report a MAC address, `v1.0.4` polishes Android TV navigation, library presentation, and Polaris Sync UX, `v1.0.5` adds the first unified Auto Quality experience with Polaris, `v1.0.6` hardens the Kotlin runtime, stream lifecycle, HUD telemetry, and Polaris launch profile handoff, and `v1.0.7` adds a 32-bit ARM release APK for Chromecast with Google TV, Google TV Streamer, and similar Android TV devices plus a Steam Controller 2026 Bluetooth HID compatibility pass. Nova is already usable, but this is still an early public release and you should expect bugs, regressions, and rough edges while the Android client and Polaris integration continue to harden. `app/` is the only shipping client today.

**Built and tested most heavily on:** Retroid Pocket 6, Retroid Pocket Flip 2, Pixel 10 Pro.

**Android TV:** Nova ships Android TV support in the same APK. On NVIDIA Shield and similar ARM64 Android TV devices, install `Nova-Android-arm64-v8a.apk`; Nova appears in the TV launcher and uses D-pad/controller-friendly focus behavior on the server browser, library, launch sheets, and Polaris Sync surfaces.
**Android TV:** Nova ships Android TV support in the public APKs. On NVIDIA Shield and similar ARM64 Android TV devices, install `Nova-Android-arm64-v8a.apk`; on Chromecast with Google TV, Google TV Streamer, and similar 32-bit ARM Android TV devices, install `Nova-Android-armeabi-v7a.apk`. Nova appears in the TV launcher and uses D-pad/controller-friendly focus behavior on the server browser, library, launch sheets, and Polaris Sync surfaces. On Google TV devices that expose the Steam Controller 2026 over Bluetooth as a Valve keyboard/mouse HID, Nova recognizes that device as controller input for host gamepad presence and compatible D-pad/button events while the right trackpad continues to behave like a mouse.

## What's New in v1.0.6
## What's New in v1.0.7

Nova `v1.0.6` is a reliability release for the Kotlin Android client and the Polaris-backed streaming path.
Nova `v1.0.7` adds a public 32-bit ARM APK split for Chromecast with Google TV, Google TV Streamer, and similar Android TV devices, with a focused Steam Controller 2026 Bluetooth HID compatibility pass for Google TV.

- **Kotlin runtime hardening**: the Android client runtime, stream helpers, input paths, video helpers, and guard tests have been migrated and tightened around Kotlin-first contracts.
- **Safer stream lifecycle**: runtime cleanup, cursor visibility sync, controller button release scheduling, and disconnect/resume behavior now avoid blocking stream-critical threads.
- **Polaris launch alignment**: Nova honors the paired RTSP launch profile it receives from Polaris so the requested stream path matches the host-side launch decision.
- **Better HUD evidence**: latency samples, sanitized session summaries, Auto Quality state, target FPS, 1% low FPS, and video diagnostics are easier to inspect when validating a stream.
- **Video guard coverage**: decoder watchdog, crash diagnostic, and video metrics paths now have regression coverage around the existing public counters.
- **Release hygiene**: dependency submission, CodeQL path guards, Gradle task wiring, and public release documentation were refreshed for the `v1.0.6` line.
- **Google TV-compatible APK**: GitHub Releases now publish `Nova-Android-armeabi-v7a.apk` for Android TV devices that expose only 32-bit ARM app support.
- **Steam Controller 2026 on Google TV**: Nova recognizes Valve's Bluetooth keyboard/mouse HID presentation as controller input so streams advertise a host gamepad and compatible D-pad/button events use the controller path.
- **Release verification**: the tag workflow names, uploads, and verifies all three public APK splits plus their checksums.
- **Install guidance**: README guidance now calls out which APK to use for ARM64 Android TV, Chromecast/32-bit ARM Android TV, and x86_64 Android devices.

## Quick Start

Expand Down Expand Up @@ -122,19 +121,21 @@ Nova still works as a standard Moonlight client. Pair normally, launch normally,
|---|---|---|
| Android handhelds | Primary target | Designed first for landscape handheld use |
| Android phones and tablets | Supported | Works well, but the UX is tuned most heavily for handhelds |
| Android TV | Supported | Uses the normal ARM64 APK with Leanback launcher support and D-pad/controller-friendly browsing |
| Android TV | Supported | Uses ARM64 or 32-bit ARM APKs with Leanback launcher support and D-pad/controller-friendly browsing |
| Polaris | Best experience | Full launch-mode, watch-mode, tuning, library, and live-session integration |
| Other Moonlight-compatible hosts | Compatible | Standard Moonlight-compatible client flow |
| Steam Controller 2026 | Partial Android HID support | Google TV may expose Bluetooth mode as a Valve keyboard/mouse HID instead of a standard gamepad; Nova recognizes that HID for host gamepad presence and compatible D-pad/button events |
| Wake-on-LAN | Supported | Sends UDP magic packets directly from Android and supports manual MAC entry when the host does not report one |
| High refresh devices | Supported | Nova can request 90/120 Hz when the device display and host both support it |
| Official release assets | `arm64-v8a`, `x86_64` | Public GitHub Releases ship separate APKs per Android ABI |
| Official release assets | `arm64-v8a`, `armeabi-v7a`, `x86_64` | Public GitHub Releases ship separate APKs per Android ABI |

## Known Limitations

- Advanced launch modes, watch mode, live host tuning, and richer session telemetry are Polaris-specific.
- Nova is not on the Play Store; the public install paths are GitHub Releases, Obtainium, and GitHub Store.
- High refresh streaming is limited by the real display panel on the Android device, not just the selected setting in Nova.
- Public releases currently ship `arm64-v8a` and `x86_64` APKs. Other ABIs are available from local source builds.
- Public releases currently ship `arm64-v8a`, `armeabi-v7a`, and `x86_64` APKs. Other ABIs are available from local source builds.
- Steam Controller 2026 Bluetooth support depends on the HID shape Android exposes. Nova can recognize the Google TV keyboard/mouse presentation and route compatible controller-like keys, but Android does not expose full Steam Input profiles or hidden analog controls through a standard gamepad source.
- Today, only the Android client ships.

## Why Nova
Expand Down Expand Up @@ -261,10 +262,10 @@ Android is the only public release target today.
./gradlew assembleNonRoot_gameDebug
```

By default, local source builds produce split APKs for `arm64-v8a` and `x86_64`.
By default, local source builds produce split APKs for `arm64-v8a`, `armeabi-v7a`, and `x86_64`.

> [!TIP]
> Official GitHub releases ship a signed `arm64-v8a` APK for real devices as `Nova-Android-arm64-v8a.apk`.
> Official GitHub releases ship signed APKs for ARM64 devices, 32-bit ARM Android TV devices such as Chromecast with Google TV and Google TV Streamer, and x86_64 devices.
>
> If you want a different ABI set locally:
> `./gradlew assembleNonRoot_gameDebug -PnovaAbis=arm64-v8a,armeabi-v7a,x86,x86_64`
Expand Down
6 changes: 3 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ if (signingPropsFile.exists()) {
signingProps.load(new FileInputStream(signingPropsFile))
}

def targetAbis = (project.findProperty("novaAbis") ?: "arm64-v8a,x86_64")
def targetAbis = (project.findProperty("novaAbis") ?: "arm64-v8a,armeabi-v7a,x86_64")
.toString()
.split(',')
.collect { it.trim() }
Expand Down Expand Up @@ -38,8 +38,8 @@ android {
minSdk 21
targetSdk 36

versionName "1.0.6"
versionCode = 21
versionName "1.0.7"
versionCode = 22

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

Expand Down
33 changes: 22 additions & 11 deletions app/src/main/java/com/papi/nova/binding/input/ControllerHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,8 @@ class ControllerHandler(
val deadzonePercentage = prefConfig.deadzonePercentage
for (id in InputDevice.getDeviceIds()) {
val dev = InputDevice.getDevice(id) ?: continue
if ((dev.sources and InputDevice.SOURCE_JOYSTICK) != 0 ||
(dev.sources and InputDevice.SOURCE_GAMEPAD) != 0
) {
if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null &&
getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null
) {
hasGameController = true
}
if (hasJoystickAxes(dev) || isSteamControllerKeyboardMouseDevice(dev)) {
hasGameController = true
}
}

Expand Down Expand Up @@ -2719,6 +2713,9 @@ class ControllerHandler(
}

companion object {
private const val VALVE_VENDOR_ID = 0x28de
private const val STEAM_CONTROLLER_BLUETOOTH_PRODUCT_ID = 0x1303
private const val STEAM_CONTROLLER_DEVICE_NAME_PREFIX = "Steam Ctrl"
private const val MAXIMUM_BUMPER_UP_DELAY_MS = 100
private const val START_DOWN_TIME_MOUSE_MODE_MS = 750
private const val MINIMUM_BUTTON_DOWN_TIME_MS = 25
Expand Down Expand Up @@ -2773,20 +2770,34 @@ class ControllerHandler(
private fun hasGamepadButtons(device: InputDevice): Boolean =
(device.sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD

private fun isSteamControllerKeyboardMouseDevice(device: InputDevice): Boolean =
device.vendorId == VALVE_VENDOR_ID &&
device.productId == STEAM_CONTROLLER_BLUETOOTH_PRODUCT_ID &&
device.name.contains(STEAM_CONTROLLER_DEVICE_NAME_PREFIX, ignoreCase = true) &&
(device.sources and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD &&
(device.sources and InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK &&
(device.sources and InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD

@JvmStatic
fun isGameControllerDevice(device: InputDevice?): Boolean {
if (device == null) {
return true
}

if (hasJoystickAxes(device) || hasGamepadButtons(device)) {
if (hasJoystickAxes(device) ||
hasGamepadButtons(device) ||
isSteamControllerKeyboardMouseDevice(device)
) {
return true
}

if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R && device.id == -1) {
for (id in InputDevice.getDeviceIds()) {
val dev = InputDevice.getDevice(id) ?: continue
if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) {
if (hasJoystickAxes(dev) ||
hasGamepadButtons(dev) ||
isSteamControllerKeyboardMouseDevice(dev)
) {
return true
}
}
Expand All @@ -2803,7 +2814,7 @@ class ControllerHandler(
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
for (id in im.inputDeviceIds) {
val dev = im.getInputDevice(id) ?: continue
if (hasJoystickAxes(dev)) {
if (hasJoystickAxes(dev) || isSteamControllerKeyboardMouseDevice(dev)) {
LimeLog.info("Counting InputDevice: " + dev.name)
mask = (mask.toInt() or (1 shl count++)).toShort()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.InputDeviceBuilder

@Config(sdk = [33])
@RunWith(RobolectricTestRunner::class)
Expand Down Expand Up @@ -181,6 +182,25 @@ class KotlinControllerHandlerMigrationTest {
assertEquals(0, releaseCount)
}

@Test
@Config(sdk = [35])
fun steamControllerBluetoothKeyboardMouseHidIsAcceptedAsControllerInput() {
val steamController = steamControllerKeyboardMouseDevice(id = 901)

assertTrue(ControllerHandler.isGameControllerDevice(steamController))
}

@Test
fun attachedControllerMaskKeepsSteamControllerKeyboardMouseCompatibilityCheck() {
val source = String(
Files.readAllBytes(Path.of("src/main/java/com/papi/nova/binding/input/ControllerHandler.kt")),
StandardCharsets.UTF_8
)

assertTrue(source.contains("isSteamControllerKeyboardMouseDevice(dev)"))
assertTrue(source.contains("hasJoystickAxes(dev) || isSteamControllerKeyboardMouseDevice(dev)"))
}

@Test
fun controllerButtonUpPathDoesNotUseThreadSleep() {
val source = String(
Expand All @@ -190,4 +210,14 @@ class KotlinControllerHandlerMigrationTest {

assertFalse(source.contains("Thread.sleep((MINIMUM_BUTTON_DOWN_TIME_MS"))
}

private fun steamControllerKeyboardMouseDevice(id: Int): InputDevice =
InputDeviceBuilder.newBuilder()
.setId(id)
.setName("Steam Ctrl (BT) FXA9960600962 Mouse")
.setVendorId(0x28de)
.setProductId(0x1303)
.setSources(InputDevice.SOURCE_KEYBOARD or InputDevice.SOURCE_MOUSE or InputDevice.SOURCE_STYLUS)
.setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC)
.build()
}
Loading
Loading