From fa1c35e461fe2c1c1be2b5086ba65a3a4cfac419 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Fri, 17 Jul 2026 18:57:13 -0400 Subject: [PATCH 1/8] fix: silence libsodium page-size and FetchContent CMP0135 native warnings Define HAVE_SYSCONF for the hand-configured libsodium build so it resolves the page size via sysconf(_SC_PAGESIZE) -- the branch ./configure selects on Android, where _SC_PAGESIZE is a macro -- instead of falling through to the '#warning Unknown page size' path and a hardcoded fallback. Adopt CMP0135 (guarded for CMake < 3.24) so the FetchContent release-tarball download stops emitting the DOWNLOAD_EXTRACT_TIMESTAMP developer warning. --- app/src/main/cpp/CMakeLists.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 50dfefbd..64ae80bf 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,6 +1,14 @@ cmake_minimum_required(VERSION 3.22.1) project(satellite) +# libsodium is fetched below as a release tarball. Adopt CMP0135 (CMake >= 3.24) +# so extracted files are stamped at extraction time — the modern default — which +# also silences FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP developer warning. The +# guard keeps the pinned CMake 3.22.1 (whose policy set predates CMP0135) working. +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() + find_package(game-activity REQUIRED CONFIG) # ── libsodium (built from source via FetchContent) ────────────────────────── @@ -52,6 +60,10 @@ target_compile_definitions(sodium_static PRIVATE HAVE_POSIX_MEMALIGN=1 HAVE_ARC4RANDOM=0 HAVE_SYS_MMAN_H=1 + # bionic exposes sysconf(_SC_PAGESIZE) (a macro), so let libsodium query the + # page size at runtime — the branch ./configure normally selects. Without this + # it fell through to `#warning Unknown page size` and a hardcoded fallback. + HAVE_SYSCONF=1 __STDC_LIMIT_MACROS=1 __STDC_CONSTANT_MACROS=1 ) From 9bd9cac80a15bf6c4a9f3acb8682f049abc9ffd0 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Fri, 17 Jul 2026 18:57:29 -0400 Subject: [PATCH 2/8] fix: clear Kotlin compiler and Android Lint warnings Root-cause fixes surfaced by an uncached compile and the lint gate: - drop redundant safe calls and an always-true guard exposed by K2 smart-casts (ControllerAdapter, GamepadOverlayActivity) - name the DefaultLifecycleObserver overrides 'owner' to match the supertype - bind the reused HID scratch buffer non-null before arraycopy - replace the no-op combine transform '{ Unit }' with '{ }' - extend the mandatory Drawable.getOpacity() suppressions with OVERRIDE_DEPRECATION - drop the always-true SDK_INT >= LOLLIPOP branch and the empty super.onCleared(); use the isEmpty() KTX extension - suppress the deliberate SDK-guarded legacy-path platform deprecations in the vibrator and getParcelableExtra tests, mirroring the production convention The @Deprecated PhysicalReachability -> Composer migration is left visible. --- .../dish/composer/PhysicalReachabilityComposer.kt | 4 ++-- .../dish/hotpath/input/PhysicalSlotBindingObserver.kt | 2 +- .../dish/source/bluetooth/AndroidHidProxyClient.kt | 2 +- .../dish/source/notification/DishNotifications.kt | 4 ++-- .../com/tinkernorth/dish/source/usb/UsbGamepadManager.kt | 2 +- .../main/java/com/tinkernorth/dish/ui/common/DishLoaders.kt | 6 +++--- .../tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt | 3 ++- .../java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt | 4 ++-- .../com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt | 2 +- .../dish/ui/setup/SetupBluetoothHostViewModel.kt | 1 - .../com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt | 5 +++++ .../dish/source/bluetooth/BluetoothConnectionsTest.kt | 4 ++++ .../dish/source/bluetooth/BluetoothDeviceScannerTest.kt | 4 ++++ .../dish/source/system/BluetoothBondMonitorTest.kt | 4 ++++ 14 files changed, 32 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/composer/PhysicalReachabilityComposer.kt b/app/src/main/java/com/tinkernorth/dish/composer/PhysicalReachabilityComposer.kt index e99ff901..c5350ab1 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/PhysicalReachabilityComposer.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/PhysicalReachabilityComposer.kt @@ -31,7 +31,7 @@ class PhysicalReachabilityComposer // registrations (every auto-reconnect) are picked up. val slotFlows = conns.values.map { it.slots } val slotsTrigger: Flow = - if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit } + if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { } combine( registry.devices, hub.bindings, @@ -91,7 +91,7 @@ internal object PhysicalReachability { connections.flatMapLatest { conns -> val slotFlows = conns.values.map { it.slots } val slotsTrigger: Flow = - if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit } + if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { } combine(devices, bindings, summaries, slotsTrigger) { devs, binds, summ, _ -> PhysicalReachabilityComposer.resolve(devs.keys, binds, summ, conns) } diff --git a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt index 46632c3b..6caf0817 100644 --- a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt @@ -200,7 +200,7 @@ class PhysicalSlotBindingObserver // Outer Map only re-emits on session add/remove; slotsTrigger re-pushes when a session's slot flips `registered`. val slotFlows = conns.values.map { it.slots } val slotsTrigger: Flow = - if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit } + if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { } combine( registry.devices, hub.bindings, diff --git a/app/src/main/java/com/tinkernorth/dish/source/bluetooth/AndroidHidProxyClient.kt b/app/src/main/java/com/tinkernorth/dish/source/bluetooth/AndroidHidProxyClient.kt index b39bad65..3d66ce1a 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/bluetooth/AndroidHidProxyClient.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/bluetooth/AndroidHidProxyClient.kt @@ -121,7 +121,7 @@ class AndroidHidProxyClient( val hid = hidDevice ?: return false val device = connectedDevice ?: return false // Strip report-id byte into per-thread scratch: sendReport takes it separately from the payload. - val payload = payloadScratch.get() + val payload = payloadScratch.get() ?: return false System.arraycopy(report, 1, payload, 0, REPORT_SIZE - 1) hid.sendReport(device, REPORT_ID, payload) }.getOrDefault(false) diff --git a/app/src/main/java/com/tinkernorth/dish/source/notification/DishNotifications.kt b/app/src/main/java/com/tinkernorth/dish/source/notification/DishNotifications.kt index 504c7e94..3101638f 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/notification/DishNotifications.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/notification/DishNotifications.kt @@ -190,11 +190,11 @@ class DishNotifications owner.lifecycle.addObserver( object : DefaultLifecycleObserver { - override fun onResume(o: LifecycleOwner) { + override fun onResume(owner: LifecycleOwner) { activate(attachment) } - override fun onDestroy(o: LifecycleOwner) { + override fun onDestroy(owner: LifecycleOwner) { drop(attachment) attachment.dismissAll() } diff --git a/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt b/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt index 59545e1f..0a800e42 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt @@ -484,7 +484,7 @@ class UsbGamepadManager private fun friendlyName(device: UsbDevice): String { val known = native.lookupKnownModelName(device.vendorId, device.productId) if (known.isNotEmpty()) return known - val product = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) device.productName else null + val product = device.productName return product?.takeIf { it.isNotBlank() } ?: device.deviceName } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/common/DishLoaders.kt b/app/src/main/java/com/tinkernorth/dish/ui/common/DishLoaders.kt index b0acd145..c9ced0ac 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/common/DishLoaders.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/common/DishLoaders.kt @@ -91,7 +91,7 @@ class DishSpinnerDrawable( strokePaint.color = tinted } - @Suppress("DEPRECATION") + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun start() { @@ -173,7 +173,7 @@ class DishDotsDrawable( fillPaint.color = tinted } - @Suppress("DEPRECATION") + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun start() { @@ -269,7 +269,7 @@ class DishBarDrawable( trackPaint.alpha = (0.22f * 255f).toInt() } - @Suppress("DEPRECATION") + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun start() { diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt index d60734bd..96b6f899 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.viewModels import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.view.isEmpty import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -151,7 +152,7 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { fc.inflateBindingPill(getString(R.string.binding_func_touchpad), R.drawable.ic_touchpad, PillTone.CAP), ) } - if (fc.childCount == 0) fc.addView(noneValue(fc)) + if (fc.isEmpty()) fc.addView(noneValue(fc)) } private fun bindDestinationSection( diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt index 86cbed65..9de02756 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt @@ -79,7 +79,7 @@ internal fun motionRateUserFacingOn( cap.inputOk(Feature.MOTION) && cap.userWants(Feature.MOTION) && boundStatus?.kind == ConnectionKind.SATELLITE && - boundStatus?.live == LinkState.Connected && + boundStatus.live == LinkState.Connected && cap.typeOk(Feature.MOTION) && Feature.MOTION !in cap.runtimeDown @@ -261,7 +261,7 @@ class ControllerAdapter( } val specs = mutableListOf(PillSpec(ctx.getString(label), icon, PillTone.FACT)) // The Direct/Standard mode chip only applies once a USB controller is on a known path. - if (isUsb && kind != null && card != null) specs.add(usbModeSpec(card)) + if (isUsb && kind != null) specs.add(usbModeSpec(card)) return specs } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt index 314deb59..5726b268 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt @@ -180,7 +180,7 @@ class GamepadOverlayActivity : capability.inputOk(Feature.MOTION) && capability.userWants(Feature.MOTION) && summary?.kind == ConnectionKind.SATELLITE && - summary?.live == LinkState.Connected + summary.live == LinkState.Connected if (effective && !motionSource.isStreaming) { motionSource.start { sample, deltaUs -> inputRateStore.recordMotionSample(VIRTUAL_SLOT_ID) diff --git a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupBluetoothHostViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupBluetoothHostViewModel.kt index 5deed23e..cfd597f7 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupBluetoothHostViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupBluetoothHostViewModel.kt @@ -249,7 +249,6 @@ class SetupBluetoothHostViewModel // Don't keep advertising once the wizard goes away unless we already // bonded and finished (the dashboard owns the live session from here). if (!proceeded) stopActive() - super.onCleared() } private fun emitDone( diff --git a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt index 71533a57..d660878b 100644 --- a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt @@ -22,6 +22,11 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +// The JVM unit-test stub reports SDK_INT = 0, so the router — and therefore these +// doubles/verifications — drive the legacy single-vibrator path (Context.VIBRATOR_SERVICE, +// Vibrator.vibrate(Long)). Production RumbleRouter suppresses the same platform +// deprecations at each guarded call site; mirror that convention for the test. +@Suppress("DEPRECATION") class RumbleRouterTest { private fun slot(index: Int) = SatelliteConnection.SlotBinding(controllerIndex = index, controllerType = 0, registered = true) diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt index fac2cda5..96c44044 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt @@ -19,6 +19,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM +// unit-test stub reports SDK_INT = 0). Production BluetoothConnections suppresses the +// same platform deprecation at its guarded call site; mirror that convention here. +@Suppress("DEPRECATION") class BluetoothConnectionsTest { private val context = mockk(relaxed = true) private val receiverSlot = slot() diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt index 58b2bd2f..e674bed8 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt @@ -24,7 +24,11 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM +// unit-test stub reports SDK_INT = 0). Production BluetoothDeviceScanner suppresses the +// same platform deprecation at its guarded call site; mirror that convention here. @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("DEPRECATION") class BluetoothDeviceScannerTest { private lateinit var context: Context private lateinit var adapter: BluetoothAdapter diff --git a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt index 60237e16..d7c4fa59 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt @@ -19,6 +19,10 @@ import org.junit.After import org.junit.Before import org.junit.Test +// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM +// unit-test stub reports SDK_INT = 0). Production BluetoothBondMonitor suppresses the +// same platform deprecation at its guarded call site; mirror that convention here. +@Suppress("DEPRECATION") class BluetoothBondMonitorTest { private lateinit var context: Context private lateinit var store: ConnectionStore From f8979489437a375ba348b605365fca4ccf2b513b Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Fri, 17 Jul 2026 18:57:35 -0400 Subject: [PATCH 3/8] chore(ci): cap workflow job runtimes with timeout-minutes Add timeout-minutes to every job that lacked a cap (android-ci build, play-listing, play-reviews, and the release build/publish-play/harden/publish jobs) so a hung step fails fast instead of running to the 6h default. --- .github/workflows/android-ci.yml | 1 + .github/workflows/play-listing.yml | 1 + .github/workflows/play-reviews.yml | 1 + .github/workflows/release.yml | 5 +++++ 4 files changed, 8 insertions(+) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index d53acdfa..ab8e1721 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -21,6 +21,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code diff --git a/.github/workflows/play-listing.yml b/.github/workflows/play-listing.yml index 1641af14..1b485794 100644 --- a/.github/workflows/play-listing.yml +++ b/.github/workflows/play-listing.yml @@ -31,6 +31,7 @@ jobs: sync-listing: name: Sync listing to Play runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Gate on Play credentials id: gate diff --git a/.github/workflows/play-reviews.yml b/.github/workflows/play-reviews.yml index 4ab7f0ce..cbe07f7b 100644 --- a/.github/workflows/play-reviews.yml +++ b/.github/workflows/play-reviews.yml @@ -22,6 +22,7 @@ jobs: digest: name: Reviews + vitals digest runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Gate on Play credentials id: gate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b113c29b..f00c5d2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,7 @@ jobs: name: Required release secrets gate needs: [gates] runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Verify keystore secrets are present for tagged releases env: @@ -124,6 +125,7 @@ jobs: name: Build + sign APK/AAB needs: [gates, required-secrets] runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -264,6 +266,7 @@ jobs: name: Upload AAB to Google Play needs: [release] runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: contents: read steps: @@ -366,6 +369,7 @@ jobs: name: Harden artifacts (scan, SBOM, sign) needs: [release] runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: contents: read id-token: write @@ -502,6 +506,7 @@ jobs: name: Publish to GitHub Releases needs: [harden, provenance] runs-on: ubuntu-24.04 + timeout-minutes: 15 permissions: contents: write steps: From da24efe879b99168184a65c9095db66ae9796c48 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Fri, 17 Jul 2026 19:30:24 -0400 Subject: [PATCH 4/8] style: trim warning-sweep comments to terse why-only --- app/src/main/cpp/CMakeLists.txt | 9 ++------- .../tinkernorth/dish/hotpath/input/RumbleRouterTest.kt | 5 +---- .../dish/source/bluetooth/BluetoothConnectionsTest.kt | 4 +--- .../dish/source/bluetooth/BluetoothDeviceScannerTest.kt | 4 +--- .../dish/source/system/BluetoothBondMonitorTest.kt | 4 +--- 5 files changed, 6 insertions(+), 20 deletions(-) diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 64ae80bf..d43d358c 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,10 +1,7 @@ cmake_minimum_required(VERSION 3.22.1) project(satellite) -# libsodium is fetched below as a release tarball. Adopt CMP0135 (CMake >= 3.24) -# so extracted files are stamped at extraction time — the modern default — which -# also silences FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP developer warning. The -# guard keeps the pinned CMake 3.22.1 (whose policy set predates CMP0135) working. +# Adopt CMP0135 where available to silence FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP warning. if(POLICY CMP0135) cmake_policy(SET CMP0135 NEW) endif() @@ -60,9 +57,7 @@ target_compile_definitions(sodium_static PRIVATE HAVE_POSIX_MEMALIGN=1 HAVE_ARC4RANDOM=0 HAVE_SYS_MMAN_H=1 - # bionic exposes sysconf(_SC_PAGESIZE) (a macro), so let libsodium query the - # page size at runtime — the branch ./configure normally selects. Without this - # it fell through to `#warning Unknown page size` and a hardcoded fallback. + # HAVE_SYSCONF: let libsodium query the page size, silencing its "Unknown page size" warning. HAVE_SYSCONF=1 __STDC_LIMIT_MACROS=1 __STDC_CONSTANT_MACROS=1 diff --git a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt index d660878b..701485b5 100644 --- a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt @@ -22,10 +22,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -// The JVM unit-test stub reports SDK_INT = 0, so the router — and therefore these -// doubles/verifications — drive the legacy single-vibrator path (Context.VIBRATOR_SERVICE, -// Vibrator.vibrate(Long)). Production RumbleRouter suppresses the same platform -// deprecations at each guarded call site; mirror that convention for the test. +// JVM stub reports SDK_INT=0, driving the legacy vibrator path; production suppresses the same. @Suppress("DEPRECATION") class RumbleRouterTest { private fun slot(index: Int) = SatelliteConnection.SlotBinding(controllerIndex = index, controllerType = 0, registered = true) diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt index 96c44044..806ad51b 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt @@ -19,9 +19,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM -// unit-test stub reports SDK_INT = 0). Production BluetoothConnections suppresses the -// same platform deprecation at its guarded call site; mirror that convention here. +// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. @Suppress("DEPRECATION") class BluetoothConnectionsTest { private val context = mockk(relaxed = true) diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt index e674bed8..a97bd92b 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt @@ -24,9 +24,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM -// unit-test stub reports SDK_INT = 0). Production BluetoothDeviceScanner suppresses the -// same platform deprecation at its guarded call site; mirror that convention here. +// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. @OptIn(ExperimentalCoroutinesApi::class) @Suppress("DEPRECATION") class BluetoothDeviceScannerTest { diff --git a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt index d7c4fa59..6a038f27 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt @@ -19,9 +19,7 @@ import org.junit.After import org.junit.Before import org.junit.Test -// Exercises the SDK-guarded legacy Intent.getParcelableExtra(String) path (the JVM -// unit-test stub reports SDK_INT = 0). Production BluetoothBondMonitor suppresses the -// same platform deprecation at its guarded call site; mirror that convention here. +// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. @Suppress("DEPRECATION") class BluetoothBondMonitorTest { private lateinit var context: Context From c802f8eee8d20d1cbb0767753f77df3af68c36fd Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Mon, 20 Jul 2026 14:24:41 -0400 Subject: [PATCH 5/8] fix: never wrap the UDP send counter into ChaCha20 nonce reuse The JNI send counter was a uint32 fetch_add with no exhaustion guard: at 2^32 packets in one unbroken session it wrapped and kept sealing ciphertexts under reused (key, nonce) pairs - an on-wire keystream-reuse leak of input reports until heartbeat death forced a reconnect (contract Crypto forbids exactly this; horizon ~50 days at a sustained 1 kHz). Mirror the dish-mac / dish-linux design: - sendEncrypted draws from a 64-bit counter (send_counter.h) and goes SILENT past 2^32-1 instead of wrapping; getSendCounter() clamps at the wire max so the poll can never read back under the threshold. - The 1 Hz Kotlin alive-poll fires a single-shot onRekeyNeeded once the counter crosses 0xF0000000 (counterNeedsRepush); the manager's rekey re-PUTs the session for fresh token/salt/key, restarting the counter at 1 long before exhaustion. Reconcile cannot carry this: its matched-view early-exit adopts the epoch without rotating anything. Host gtest pins the guard across the exhaustion boundary (goes silent, no value ever repeats under one key, clamped view); JVM tests pin the threshold predicate, the single-shot latch and its re-arm, and the manager re-PUT installing fresh params exactly once. runMgrTest now tears sessions down in a finally so an assertion failure can no longer spin the virtual-time drain into OOM. Appended to fix/warning-sweep per the maintainer's one-branch preference for this wave. --- app/src/main/cpp/satellite_jni.cpp | 14 +++- app/src/main/cpp/send_counter.h | 31 +++++++ .../dish/core/jni/ControllerRepository.kt | 2 + .../dish/core/jni/SatelliteNative.kt | 4 + .../source/connection/SatelliteConnection.kt | 26 +++++- .../connection/SatelliteConnectionManager.kt | 15 ++++ app/src/test/cpp/CMakeLists.txt | 10 +++ app/src/test/cpp/send_counter_test.cpp | 84 +++++++++++++++++++ .../SatelliteConnectionManagerTest.kt | 57 +++++++++++-- .../connection/SatelliteConnectionTest.kt | 59 +++++++++++++ 10 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 app/src/main/cpp/send_counter.h create mode 100644 app/src/test/cpp/send_counter_test.cpp diff --git a/app/src/main/cpp/satellite_jni.cpp b/app/src/main/cpp/satellite_jni.cpp index 4f6d1a1c..fa719d6d 100644 --- a/app/src/main/cpp/satellite_jni.cpp +++ b/app/src/main/cpp/satellite_jni.cpp @@ -32,6 +32,7 @@ #include "dispatch.h" #include "gamepad_input.h" #include "hotpath_latency.h" +#include "send_counter.h" #include "thread_priority.h" #include "usb_host.h" #include "usb_parsers.h" @@ -83,7 +84,8 @@ struct Session { struct sockaddr_in dest = {}; uint8_t token[4] = {}; uint8_t key[32] = {}; // per-session key (HKDF-derived in Kotlin), never the pairing key - std::atomic counter{1}; + // 64-bit so exhaustion goes silent instead of wrapping (send_counter.h). + std::atomic counter{1}; // Linux UDP sendto is thread-safe per-socket; userspace lock would only serialise stalls. std::thread heartbeatThread; @@ -462,7 +464,8 @@ static bool sendEncrypted(Session* s, uint16_t msgType, const uint8_t* payload, putBE16(inner + 2, payloadLen); if (payloadLen > 0) memcpy(inner + 4, payload, payloadLen); - uint32_t ctr = s->counter.fetch_add(1, std::memory_order_relaxed); + uint32_t ctr = 0; + if (!dish_counter::acquireSendCounter(s->counter, &ctr)) return false; // Nonce: dir(1) | 0×7 | counter(4 BE). The direction byte keeps this // direction's nonces disjoint from the server's under the shared key. @@ -757,6 +760,13 @@ JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_getSes return s->closeReason.load(std::memory_order_acquire); } +JNIEXPORT jlong JNICALL +Java_com_tinkernorth_dish_core_jni_SatelliteNative_getSendCounter(JNIEnv*, jobject, jint handle) { + auto s = getSession(handle); + if (!s) return 0; + return (jlong)dish_counter::sendCounterView(s->counter); +} + JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_getVigemAvailable( JNIEnv*, jobject, jint handle) { auto s = getSession(handle); diff --git a/app/src/main/cpp/send_counter.h b/app/src/main/cpp/send_counter.h new file mode 100644 index 00000000..2d8104a8 --- /dev/null +++ b/app/src/main/cpp/send_counter.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include +#include + +namespace dish_counter { + +// Counters never wrap (contract §Crypto): sealing two plaintexts under one +// (key, nonce) would be catastrophic. 64-bit storage so exhaustion parks the +// sender silent instead of wrapping the 32-bit wire field into nonce reuse. +inline constexpr uint64_t kCounterMaxWire = 0xFFFFFFFFull; + +// Draws the next wire counter; false once the 32-bit space is exhausted (the +// caller must go silent, never send). A drawn value is never repeated. +inline bool acquireSendCounter(std::atomic& counter, uint32_t* out) { + const uint64_t seq = counter.fetch_add(1, std::memory_order_relaxed); + if (seq > kCounterMaxWire) return false; + *out = static_cast(seq); + return true; +} + +// Clamped, not truncated, for the Kotlin re-key poll: past exhaustion it must +// keep reading re-PUT needed, never wrap under the threshold. +inline uint32_t sendCounterView(const std::atomic& counter) { + const uint64_t v = counter.load(std::memory_order_relaxed); + return v > kCounterMaxWire ? static_cast(kCounterMaxWire) : static_cast(v); +} + +} // namespace dish_counter diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/ControllerRepository.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/ControllerRepository.kt index 8ec84294..c222990c 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/ControllerRepository.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/ControllerRepository.kt @@ -50,6 +50,8 @@ class ControllerRepository fun getSessionCloseReason(handle: Int): Int = SatelliteNative.getSessionCloseReason(handle) + fun getSendCounter(handle: Int): Long = SatelliteNative.getSendCounter(handle) + @Suppress("LongParameterList") fun sendMotion( handle: Int, diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt index 51ead5a5..dc721cc7 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt @@ -90,6 +90,10 @@ object SatelliteNative { // Terminal: the session is gone server-side the moment this is non-negative. external fun getSessionCloseReason(handle: Int): Int + // Send counter for the proactive re-key poll. Clamped at 0xFFFFFFFF past + // exhaustion so it can never read below the re-PUT threshold again. + external fun getSendCounter(handle: Int): Long + external fun getVigemAvailable(handle: Int): Int external fun getActiveControllerCount(handle: Int): Int diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnection.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnection.kt index 4d8b4fd2..54d7a502 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnection.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnection.kt @@ -20,6 +20,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +// Contract §Crypto: counters never wrap; clients SHOULD re-PUT once their send +// counter crosses 0xF0000000. Rotating token/salt/key restarts the counter at 1. +internal const val COUNTER_REPUSH_THRESHOLD = 0xF000_0000L + +internal fun counterNeedsRepush(sendCounter: Long): Boolean = sendCounter >= COUNTER_REPUSH_THRESHOLD + /** * One satellite session. Slots are DECLARATIVE: this class holds the desired * descriptor per slot plus the applied state the satellite last confirmed; @@ -105,7 +111,8 @@ class SatelliteConnection( * response. [onDead] fires on heartbeat death; [onClosedByServer] on an * authenticated close-notify (immediate, no death-timeout wait); * [onReconcileNeeded] when the heartbeat-ack epoch/bitmap stops matching - * what we believe is applied. + * what we believe is applied; [onRekeyNeeded] once per session when the + * send counter crosses the re-PUT threshold. */ internal fun markConnected( handle: Int, @@ -116,6 +123,7 @@ class SatelliteConnection( onDead: () -> Unit, onClosedByServer: (reason: Int) -> Unit = { onDead() }, onReconcileNeeded: () -> Unit = {}, + onRekeyNeeded: () -> Unit = {}, onApplyFailures: (failures: List) -> Unit = {}, ) { if (_state.value != SatelliteSessionState.Linking) return @@ -139,6 +147,7 @@ class SatelliteConnection( aliveJob = scope.launch { var consecutiveMisses = 0 + var rekeyRequested = false while (isActive) { delay(ALIVE_POLL_MS) // An authenticated close-notify is terminal NOW: the @@ -156,6 +165,7 @@ class SatelliteConnection( } consecutiveMisses = 0 checkReconcile(onReconcileNeeded) + rekeyRequested = checkRekey(rekeyRequested, onRekeyNeeded) continue } consecutiveMisses += 1 @@ -186,6 +196,20 @@ class SatelliteConnection( } } + // Proactive re-key before the send counter can exhaust (contract §Crypto: + // re-PUT past 0xF0000000). Single-shot per crossing: the re-PUT rotates the + // token/key and restarts the counter, which re-arms the latch. A session + // that exhausts anyway goes silent natively and heals via heartbeat death. + private fun checkRekey( + alreadyRequested: Boolean, + onRekeyNeeded: () -> Unit, + ): Boolean { + val snap = live ?: return alreadyRequested + if (!counterNeedsRepush(controllerRepo.getSendCounter(snap.handle))) return false + if (!alreadyRequested) onRekeyNeeded() + return true + } + private fun registeredBitmap(): Int { var bitmap = 0 for (binding in _slots.value.values) { diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManager.kt index c5538f3e..7be55798 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManager.kt @@ -542,6 +542,7 @@ class SatelliteConnectionManager }, onClosedByServer = { reason -> handleServerClose(conn, server, reason) }, onReconcileNeeded = { scope.launch(ioDispatcher) { reconcile(conn, server) } }, + onRekeyNeeded = { scope.launch(ioDispatcher) { rekey(conn, server) } }, onApplyFailures = { failures -> scope.launch { _events.emit( @@ -663,6 +664,20 @@ class SatelliteConnectionManager } } + // The send counter crossed the re-PUT threshold: converge with a fresh + // session PUT for new token/salt/key (counter back to 1). Reconcile + // can't carry this — its matched-view early-exit adopts the epoch + // without rotating anything. + private suspend fun rekey( + conn: SatelliteConnection, + server: DiscoveredServer, + ) { + if (conn.state.value != SatelliteSessionState.Live) return + conn.markDisconnected() + conn.markConnecting() + openSession(conn, server, ConnectIntent.RETRY_AFTER_DEATH) + } + // Single-slot converge while the session is live (PUT .../controllers/{idx}). // The session (and its UDP keys) never churn for a toggle. @Suppress("ReturnCount") // converge guard-chain: every early return is a distinct no-op case diff --git a/app/src/test/cpp/CMakeLists.txt b/app/src/test/cpp/CMakeLists.txt index 1ee5de39..ecc577d5 100644 --- a/app/src/test/cpp/CMakeLists.txt +++ b/app/src/test/cpp/CMakeLists.txt @@ -67,8 +67,18 @@ target_include_directories(usb_hid_descriptor_test PRIVATE "${SATELLITE_CPP}") target_compile_options(usb_hid_descriptor_test PRIVATE -Wall -Wextra -Wpedantic) target_link_libraries(usb_hid_descriptor_test PRIVATE GTest::gtest_main) +# UDP send-counter exhaustion guard: the header the JNI send path draws every +# wire counter from (no wrap into ChaCha20 nonce reuse, contract §Crypto). +add_executable(send_counter_test + send_counter_test.cpp +) +target_include_directories(send_counter_test PRIVATE "${SATELLITE_CPP}") +target_compile_options(send_counter_test PRIVATE -Wall -Wextra -Wpedantic) +target_link_libraries(send_counter_test PRIVATE GTest::gtest_main) + include(GoogleTest) gtest_discover_tests(gamepad_input_test) gtest_discover_tests(wire_encoders_test) gtest_discover_tests(usb_parsers_test) gtest_discover_tests(usb_hid_descriptor_test) +gtest_discover_tests(send_counter_test) diff --git a/app/src/test/cpp/send_counter_test.cpp b/app/src/test/cpp/send_counter_test.cpp new file mode 100644 index 00000000..6da4b51d --- /dev/null +++ b/app/src/test/cpp/send_counter_test.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "send_counter.h" + +#include + +#include +#include +#include + +// The send path (sendEncrypted) draws every wire counter through +// acquireSendCounter and returns without sending when it refuses, so these +// pins ARE the no-nonce-reuse guarantee: no wrapped counter can reach the +// wire because no wrapped counter is ever handed out. + +TEST(AcquireSendCounter, DrawsSequentialValuesStartingAtSessionInitial) { + std::atomic counter{1}; + uint32_t ctr = 0; + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 1u); + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 2u); + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 3u); +} + +TEST(AcquireSendCounter, UsesTheFullWireSpaceThenGoesSilent) { + std::atomic counter{0xFFFFFFFEull}; + uint32_t ctr = 0; + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 0xFFFFFFFEu); + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 0xFFFFFFFFu); // last usable wire value + EXPECT_FALSE(dish_counter::acquireSendCounter(counter, &ctr)); +} + +TEST(AcquireSendCounter, NeverRepeatsAValueUnderOneKeyAcrossExhaustion) { + std::atomic counter{0xFFFFFFFDull}; + std::vector drawn; + for (int i = 0; i < 8; i++) { + uint32_t ctr = 0; + if (dish_counter::acquireSendCounter(counter, &ctr)) drawn.push_back(ctr); + } + // The unguarded u32 fetch_add would have kept drawing here: 0, 1, 2 … + // reusing (key, nonce) pairs from the start of the session. + ASSERT_EQ(drawn.size(), 3u); + for (size_t i = 1; i < drawn.size(); i++) EXPECT_GT(drawn[i], drawn[i - 1]); +} + +TEST(AcquireSendCounter, RefusalLeavesTheOutputUntouched) { + std::atomic counter{0x100000000ull}; + uint32_t ctr = 0xDEADBEEFu; + EXPECT_FALSE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 0xDEADBEEFu); +} + +TEST(SendCounterView, ReportsTheLiveValueBelowExhaustion) { + std::atomic counter{5}; + EXPECT_EQ(dish_counter::sendCounterView(counter), 5u); + counter.store(0xF0000000ull); + EXPECT_EQ(dish_counter::sendCounterView(counter), 0xF0000000u); +} + +TEST(SendCounterView, ClampsAtWireMaxPastExhaustionSoRekeyStaysDue) { + std::atomic counter{0xFFFFFFFFull}; + uint32_t ctr = 0; + ASSERT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + // Keep drawing past exhaustion: the view must clamp at the wire max, never + // wrap back under the 0xF0000000 re-key threshold the Kotlin poll compares + // against. + for (int i = 0; i < 4; i++) { + EXPECT_FALSE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(dish_counter::sendCounterView(counter), 0xFFFFFFFFu); + } +} + +TEST(SendCounterView, RestartsAtOneAfterARekeyReset) { + std::atomic counter{0x100000007ull}; + counter.store(1); // setConnectionParams: counters restart per (token, key) + EXPECT_EQ(dish_counter::sendCounterView(counter), 1u); + uint32_t ctr = 0; + EXPECT_TRUE(dish_counter::acquireSendCounter(counter, &ctr)); + EXPECT_EQ(ctr, 1u); +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManagerTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManagerTest.kt index b12e8fe5..d00e547c 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManagerTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionManagerTest.kt @@ -123,14 +123,18 @@ class SatelliteConnectionManagerTest { val mgr = manager() val events = mutableListOf() val collector = scope.launch { mgr.events.collect { events += it } } - block(mgr, events) - // A live session's heartbeat poll reschedules itself forever, so - // the scheduler can never go idle while one exists. Tear all - // sessions down before the final drain. - mgr.connections.value.keys - .forEach(mgr::disconnect) - scope.testScheduler.advanceUntilIdle() - collector.cancel() + try { + block(mgr, events) + } finally { + // A live session's heartbeat poll reschedules itself forever, so + // the scheduler can never go idle while one exists. Tear all + // sessions down before the final drain — on assertion failure + // too, or the drain spins virtual time into OOM. + mgr.connections.value.keys + .forEach(mgr::disconnect) + scope.testScheduler.advanceUntilIdle() + collector.cancel() + } } @Test @@ -675,6 +679,43 @@ class SatelliteConnectionManagerTest { coVerify(exactly = 1) { discoveryRepo.putSession(any(), any(), any(), any(), any(), any(), any()) } } + @Test + fun `crossing the re-key threshold re-PUTs the session for fresh token and keys`() = + runMgrTest { mgr, _ -> + every { store.satelliteSharedKey(serverId) } returns "aa".repeat(32) + coEvery { + discoveryRepo.putSession(any(), any(), any(), any(), any(), any(), any()) + } returns + ok( + """{"connectionId":"conn_1","token":"00000001","sessionSalt":"0102030405060708",""" + + """"epoch":1,"controllers":[],"hostFeatures":{"mouseControl":{"granted":false}}}""", + ) + every { controllerRepo.openSocket(any(), any()) } returns 5 + // Mirror the native contract: installing fresh params restarts the counter. + var sendCounter = 1L + every { controllerRepo.getSendCounter(any()) } answers { sendCounter } + every { controllerRepo.setConnectionParams(any(), any(), any()) } answers { sendCounter = 1L } + + mgr.connect(server) + scope.testScheduler.runCurrent() + assertEquals(SatelliteSessionState.Live, mgr.get(serverId)?.state?.value) + coVerify(exactly = 1) { discoveryRepo.putSession(any(), any(), any(), any(), any(), any(), any()) } + + sendCounter = COUNTER_REPUSH_THRESHOLD + scope.testScheduler.advanceTimeBy(1100) // one alive-poll tick + scope.testScheduler.runCurrent() + + // Exactly one full re-PUT: fresh token/salt/key installed, session Live again. + coVerify(exactly = 2) { discoveryRepo.putSession(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 2) { controllerRepo.setConnectionParams(5, any(), any()) } + assertEquals(SatelliteSessionState.Live, mgr.get(serverId)?.state?.value) + + // The rotated counter sits back under the threshold: no re-PUT storm. + scope.testScheduler.advanceTimeBy(5000) + scope.testScheduler.runCurrent() + coVerify(exactly = 2) { discoveryRepo.putSession(any(), any(), any(), any(), any(), any(), any()) } + } + @Test fun `an unpaired close-notify is terminal - key dropped, row stale, no silent retry`() = runMgrTest { mgr, _ -> diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionTest.kt index 281e27de..754e7e62 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/SatelliteConnectionTest.kt @@ -109,6 +109,7 @@ class SatelliteConnectionTest { clearAllMocks() } + @Suppress("LongParameterList") private fun connectLive( target: SatelliteConnection = conn, handle: Int = 7, @@ -118,6 +119,7 @@ class SatelliteConnectionTest { onDead: () -> Unit = {}, onClosedByServer: (Int) -> Unit = {}, onReconcileNeeded: () -> Unit = {}, + onRekeyNeeded: () -> Unit = {}, onApplyFailures: (List) -> Unit = {}, ) { target.markConnecting() @@ -130,6 +132,7 @@ class SatelliteConnectionTest { onDead = onDead, onClosedByServer = onClosedByServer, onReconcileNeeded = onReconcileNeeded, + onRekeyNeeded = onRekeyNeeded, onApplyFailures = onApplyFailures, ) } @@ -559,6 +562,62 @@ class SatelliteConnectionTest { assertEquals(0, reconciles) } + @Test + fun `counterNeedsRepush fires once the send counter crosses 0xF0000000`() { + assertFalse(counterNeedsRepush(1L)) + assertFalse(counterNeedsRepush(0xEFFF_FFFFL)) + assertTrue(counterNeedsRepush(0xF000_0000L)) + assertTrue(counterNeedsRepush(0xFFFF_FFFFL)) + } + + @Test + fun `send counter at the re-PUT threshold fires onRekeyNeeded exactly once`() = + connTest { + every { repo.isConnectionAlive(any()) } returns true + every { repo.getSendCounter(any()) } returns COUNTER_REPUSH_THRESHOLD + + var rekeys = 0 + connectLive(onRekeyNeeded = { rekeys++ }) + + // Several alive-poll ticks: the latch must not re-fire while the + // rotation is still in flight (a re-PUT per tick would storm the server). + scope.advanceTimeBy(4100) + assertEquals(1, rekeys) + } + + @Test + fun `send counter below the re-PUT threshold never requests a re-key`() = + connTest { + every { repo.isConnectionAlive(any()) } returns true + every { repo.getSendCounter(any()) } returns COUNTER_REPUSH_THRESHOLD - 1 + + var rekeys = 0 + connectLive(onRekeyNeeded = { rekeys++ }) + + scope.advanceTimeBy(3100) + assertEquals(0, rekeys) + } + + @Test + fun `re-key latch re-arms only after the rotation restarts the counter`() = + connTest { + every { repo.isConnectionAlive(any()) } returns true + var counter = COUNTER_REPUSH_THRESHOLD + every { repo.getSendCounter(any()) } answers { counter } + + var rekeys = 0 + connectLive(onRekeyNeeded = { rekeys++ }) + + scope.advanceTimeBy(2100) + assertEquals(1, rekeys) + + counter = 1L // rotation installed fresh params, counter restarted + scope.advanceTimeBy(1100) + counter = COUNTER_REPUSH_THRESHOLD + scope.advanceTimeBy(1100) + assertEquals(2, rekeys) + } + @Test fun `close-notify fires onClosedByServer with the reason immediately`() = connTest { From 090574be38cb08911de71c8596f6ea947945fd6a Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Mon, 20 Jul 2026 14:24:55 -0400 Subject: [PATCH 6/8] test: narrow class-wide deprecation suppressions to their call sites A file-level @Suppress("DEPRECATION") would silently mask any NEW deprecation introduced anywhere in the class. Production scopes these to the exact legacy call, so the tests now do the same: the one-arg getParcelableExtra, Vibrator.vibrate(Long) and VIBRATOR_SERVICE sites the JVM stub's SDK_INT=0 forces onto the legacy paths. --- .../com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt | 6 ++++-- .../dish/source/bluetooth/BluetoothConnectionsTest.kt | 3 +-- .../dish/source/bluetooth/BluetoothDeviceScannerTest.kt | 4 ++-- .../dish/source/system/BluetoothBondMonitorTest.kt | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt index 701485b5..c9e542ca 100644 --- a/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/hotpath/input/RumbleRouterTest.kt @@ -22,8 +22,6 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -// JVM stub reports SDK_INT=0, driving the legacy vibrator path; production suppresses the same. -@Suppress("DEPRECATION") class RumbleRouterTest { private fun slot(index: Int) = SatelliteConnection.SlotBinding(controllerIndex = index, controllerType = 0, registered = true) @@ -192,6 +190,7 @@ class RumbleRouterTest { val rumbleEnabled = mockk { every { isEnabled(any()) } returns rumbleOn } + @Suppress("DEPRECATION") // VIBRATOR_SERVICE: the SDK_INT=0 legacy path resolves it private val context = mockk(relaxed = true) { every { getSystemService(Context.VIBRATOR_SERVICE) } returns vibrator @@ -221,6 +220,7 @@ class RumbleRouterTest { } @Test + @Suppress("DEPRECATION") // Vibrator.vibrate(Long): what the SDK_INT=0 legacy path calls fun `dispatch suppresses the phone vibrator when the virtual slot is rumble-off`() { val h = DispatchHarness(slotId = VIRTUAL_SLOT_ID, controllerIndex = 0, rumbleOn = false) @@ -232,6 +232,7 @@ class RumbleRouterTest { } @Test + @Suppress("DEPRECATION") // Vibrator.vibrate(Long): what the SDK_INT=0 legacy path calls fun `dispatch actuates the phone vibrator when the virtual slot is rumble-on`() { val h = DispatchHarness(slotId = VIRTUAL_SLOT_ID, controllerIndex = 0, rumbleOn = true) @@ -262,6 +263,7 @@ class RumbleRouterTest { } @Test + @Suppress("DEPRECATION") // Vibrator.vibrate(Long): what the SDK_INT=0 legacy path calls fun `dispatch consults the gate with the framework device id and suppresses when off`() { val h = DispatchHarness(slotId = "1234", controllerIndex = 0, rumbleOn = false) diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt index 806ad51b..d5e5ad4e 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothConnectionsTest.kt @@ -19,8 +19,6 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. -@Suppress("DEPRECATION") class BluetoothConnectionsTest { private val context = mockk(relaxed = true) private val receiverSlot = slot() @@ -41,6 +39,7 @@ class BluetoothConnectionsTest { unmockkAll() } + // One-arg getParcelableExtra: the JVM stub's SDK_INT=0 drives the legacy path. @Suppress("DEPRECATION") private fun aclEvent( action: String, diff --git a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt index a97bd92b..8a670662 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/bluetooth/BluetoothDeviceScannerTest.kt @@ -24,9 +24,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("DEPRECATION") class BluetoothDeviceScannerTest { private lateinit var context: Context private lateinit var adapter: BluetoothAdapter @@ -60,6 +58,8 @@ class BluetoothDeviceScannerTest { return device } + // One-arg getParcelableExtra: the JVM stub's SDK_INT=0 drives the legacy path. + @Suppress("DEPRECATION") private fun foundIntent(device: BluetoothDevice?): Intent { val intent = mockk(relaxed = true) every { intent.action } returns BluetoothDevice.ACTION_FOUND diff --git a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt index 6a038f27..a7d8e37a 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/system/BluetoothBondMonitorTest.kt @@ -19,8 +19,6 @@ import org.junit.After import org.junit.Before import org.junit.Test -// JVM stub reports SDK_INT=0, driving the legacy getParcelableExtra path; production suppresses the same. -@Suppress("DEPRECATION") class BluetoothBondMonitorTest { private lateinit var context: Context private lateinit var store: ConnectionStore @@ -69,6 +67,8 @@ class BluetoothBondMonitorTest { return intent } + // One-arg getParcelableExtra: the JVM stub's SDK_INT=0 drives the legacy path. + @Suppress("DEPRECATION") private fun intentForAction( action: String, mac: String, @@ -114,6 +114,7 @@ class BluetoothBondMonitorTest { } @Test + @Suppress("DEPRECATION") // one-arg getParcelableExtra, as in intentForAction fun `KEY_MISSING with no EXTRA_DEVICE is ignored`() { val intent = mockk(relaxed = true) { From bbd5d4a4644dcfd872c49a8213592677a7bf8579 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Mon, 20 Jul 2026 14:24:55 -0400 Subject: [PATCH 7/8] chore(ci): cap the shared security jobs with timeout-minutes The sweep claims every workflow job carries a runtime cap, but the five jobs in _security.yml had none. Warm runtimes are 5-10 s; a 10 min cap leaves cold-install and full-history-scan headroom while cutting a hung runner off 36x sooner than the 360 min default. --- .github/workflows/_security.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index eb188b8d..43149808 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -74,6 +74,7 @@ jobs: if: ${{ inputs.action_pin_lint_enabled }} name: Action pin lint (40-char SHA required) runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -123,6 +124,7 @@ jobs: if: ${{ inputs.allowlist_expiry_enabled }} name: Vulnerability allowlist expiry check runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -166,6 +168,7 @@ jobs: if: ${{ inputs.osv_scan_paths != '' || inputs.osv_config != '' }} name: OSV-Scanner (vendored + manifest deps) runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -231,6 +234,7 @@ jobs: if: ${{ inputs.dependency_review_enabled && github.event_name == 'pull_request' }} name: Dependency review (GitHub advisory DB) runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -249,6 +253,7 @@ jobs: if: ${{ inputs.gitleaks_enabled }} name: Secret scan (gitleaks) runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout (full history) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 From 7bacf0918274959093afa422144bab6d07b84351 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Mon, 20 Jul 2026 14:24:55 -0400 Subject: [PATCH 8/8] docs(native): justify HAVE_SYSCONF by page-size correctness The sweep flipped this libsodium feature macro citing only its silenced build warning, but it is a behavior change to a security dependency: with sysconf available, sodium_mlock/guarded allocations round to the runtime page size instead of a compiled-in guess - what Android 15's 16 KB-page devices need. Say that where the flag is set. Not exercisable in the host gtest harness (the Android-tailored sodium build only exists in the NDK build); on-device coverage rides the emulator integration suite, which initialises sodium and runs every encrypted-session path. --- app/src/main/cpp/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index d43d358c..e3c9de62 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -57,7 +57,10 @@ target_compile_definitions(sodium_static PRIVATE HAVE_POSIX_MEMALIGN=1 HAVE_ARC4RANDOM=0 HAVE_SYS_MMAN_H=1 - # HAVE_SYSCONF: let libsodium query the page size, silencing its "Unknown page size" warning. + # HAVE_SYSCONF: sodium_mlock/guarded allocations round to the RUNTIME page + # size via sysconf(_SC_PAGESIZE) instead of a compiled-in guess — required + # for Android 15's 16 KB-page devices (and clears the "Unknown page size" + # build warning). HAVE_SYSCONF=1 __STDC_LIMIT_MACROS=1 __STDC_CONSTANT_MACROS=1