diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 332e929f..8f553bee 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
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 7be55798..3b3f6544 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
@@ -22,6 +22,7 @@ import com.tinkernorth.dish.di.IoDispatcher
import com.tinkernorth.dish.repository.ConnectionStore
import com.tinkernorth.dish.repository.RememberedSatellite
import com.tinkernorth.dish.source.store.SatelliteMotionBackendStatusStore
+import com.tinkernorth.dish.source.system.LocalNetworkAccess
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -213,6 +214,8 @@ class SatelliteConnectionManager
server: DiscoveredServer,
intent: ConnectIntent = ConnectIntent.USER_INITIATED,
) {
+ // Only user-initiated connects (which prompt) may open LAN sockets before the Android 17 grant.
+ if (intent != ConnectIntent.USER_INITIATED && !LocalNetworkAccess.isGranted(context)) return
val id = SatelliteConnection.idFor(server)
if (intent == ConnectIntent.USER_INITIATED) retryAttempts.remove(id)
// Atomic find-or-create: prevents two concurrent first-time connects allocating duplicates.
diff --git a/app/src/main/java/com/tinkernorth/dish/source/system/LocalNetworkAccess.kt b/app/src/main/java/com/tinkernorth/dish/source/system/LocalNetworkAccess.kt
new file mode 100644
index 00000000..afa2a388
--- /dev/null
+++ b/app/src/main/java/com/tinkernorth/dish/source/system/LocalNetworkAccess.kt
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+package com.tinkernorth.dish.source.system
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.content.ContextCompat
+
+// Android 17 (API 37) gates LAN access; older OSes imply it via INTERNET, so this no-ops there.
+object LocalNetworkAccess {
+ const val PERMISSION: String = "android.permission.ACCESS_LOCAL_NETWORK"
+
+ private const val ENFORCED_SDK = 37
+
+ fun isEnforced(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = sdkInt >= ENFORCED_SDK
+
+ fun isGranted(
+ context: Context,
+ sdkInt: Int = Build.VERSION.SDK_INT,
+ ): Boolean =
+ !isEnforced(sdkInt) ||
+ ContextCompat.checkSelfPermission(context, PERMISSION) == PackageManager.PERMISSION_GRANTED
+}
diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt
index 07665b79..e96002ef 100644
--- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt
+++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt
@@ -54,6 +54,7 @@ import com.tinkernorth.dish.source.system.BluetoothAdapterStateObserver
import com.tinkernorth.dish.source.system.BluetoothPermissionBannerDecision
import com.tinkernorth.dish.source.system.BluetoothPermissionBannerVariant
import com.tinkernorth.dish.source.system.BluetoothPermissionStateObserver
+import com.tinkernorth.dish.source.system.LocalNetworkAccess
import com.tinkernorth.dish.source.system.NetworkState
import com.tinkernorth.dish.source.system.NetworkStateObserver
import com.tinkernorth.dish.ui.common.BaseGamepadHostActivity
@@ -151,6 +152,9 @@ class ConnectionsActivity : BaseGamepadHostActivity() {
private var btAdapterBannerId: Long? = null
private var networkBannerId: Long? = null
+ private var localNetworkBannerId: Long? = null
+
+ private var localNetworkPrompted = false
private var btPermissionSnackbar: Snackbar? = null
private var btPermissionShownVariant: BluetoothPermissionBannerVariant? = null
@@ -206,6 +210,18 @@ class ConnectionsActivity : BaseGamepadHostActivity() {
armDiscoverabilityExpiryTimer(pending.tempId)
}
+ private val localNetworkPermissionLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ if (granted) {
+ dismissLocalNetworkBanner()
+ satellite.startDiscovery()
+ } else {
+ showLocalNetworkBanner()
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityConnectionsBinding.inflate(layoutInflater)
@@ -309,7 +325,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() {
override fun onStart() {
super.onStart()
- satellite.startDiscovery()
+ ensureLocalNetworkThenDiscover()
}
private fun setupList() {
@@ -320,7 +336,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() {
R.string.action_scan,
secondaryActionLabel = R.string.action_add,
onSecondaryAction = ::showAddSatelliteDialog,
- ) { satellite.startDiscovery() }
+ ) { ensureLocalNetworkThenDiscover(userInitiated = true) }
bluetoothHeader =
SectionHeaderAdapter(
R.drawable.ic_bluetooth,
@@ -966,6 +982,50 @@ class ConnectionsActivity : BaseGamepadHostActivity() {
}
}
+ private fun ensureLocalNetworkThenDiscover(userInitiated: Boolean = false) {
+ if (LocalNetworkAccess.isGranted(this)) {
+ dismissLocalNetworkBanner()
+ satellite.startDiscovery()
+ return
+ }
+ if (userInitiated || !localNetworkPrompted) {
+ localNetworkPrompted = true
+ localNetworkPermissionLauncher.launch(LocalNetworkAccess.PERMISSION)
+ } else {
+ showLocalNetworkBanner()
+ }
+ }
+
+ private fun showLocalNetworkBanner() {
+ if (localNetworkBannerId != null) return
+ localNetworkBannerId =
+ notifications.warn(
+ glyph = R.drawable.ic_satellite_off,
+ title = getString(R.string.notif_local_network_title),
+ body = getString(R.string.notif_local_network_body),
+ action =
+ DishNotification.Action(
+ label = getString(R.string.action_open_settings),
+ ) { openAppDetailsSettings() },
+ key = "local-network-permission",
+ durationMs = DishNotification.DURATION_PERSISTENT,
+ )
+ }
+
+ private fun dismissLocalNetworkBanner() {
+ localNetworkBannerId?.let { notifications.dismiss(it) }
+ localNetworkBannerId = null
+ }
+
+ private fun openAppDetailsSettings() {
+ val intent =
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = "package:$packageName".toUri()
+ }
+ runCatching { startActivity(intent) }
+ .onFailure { startActivity(Intent(Settings.ACTION_SETTINGS)) }
+ }
+
private fun armDiscoverabilityExpiryTimer(connId: String) {
discoverabilityExpiryJob?.cancel()
discoverabilityExpiryJob =
diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt
index b128f192..60bcf4b8 100644
--- a/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.widget.LinearLayout
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible
@@ -28,6 +29,7 @@ import com.tinkernorth.dish.source.connection.SatelliteConnectionManager
import com.tinkernorth.dish.source.lowpower.LowPowerSignal
import com.tinkernorth.dish.source.notification.DishNotifications
import com.tinkernorth.dish.source.store.OnboardingPreferenceStore
+import com.tinkernorth.dish.source.system.LocalNetworkAccess
import com.tinkernorth.dish.source.usb.UsbGamepadManager
import com.tinkernorth.dish.ui.common.DishNavigator
import com.tinkernorth.dish.ui.common.DishSpinnerDrawable
@@ -72,6 +74,26 @@ class MainActivity :
DishSpinnerDrawable(this, resources.getDimensionPixelSize(R.dimen.icon_battery))
}
+ private var localNetworkRequested = false
+
+ private val localNetworkPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) {
+ hub.autoReconnectAll()
+ } else {
+ notifications.warn(
+ glyph = R.drawable.ic_satellite_off,
+ title = getString(R.string.notif_local_network_title),
+ body = getString(R.string.notif_local_network_body),
+ action =
+ DishNotification.Action(
+ label = getString(R.string.action_open),
+ ) { nav.toConnections() },
+ key = "local-network-permission",
+ )
+ }
+ }
+
// Held by installSplashScreen()'s keep-on-screen gate; cleared either by the first
// MainUiState render or the SPLASH_HOLD_MAX_MS fallback so a stalled ViewModel can't pin
// the splash forever.
@@ -127,9 +149,18 @@ class MainActivity :
super.onResume()
if (!com.tinkernorth.dish.DishApplication.nativeLoadFailed) {
usbGamepadManager.reconcileForeground()
+ ensureLocalNetworkForReconnect()
}
}
+ // The reconnect observer runs off an Activity and can't prompt; ask here or Android 17 fails it silently.
+ private fun ensureLocalNetworkForReconnect() {
+ if (localNetworkRequested || LocalNetworkAccess.isGranted(this)) return
+ if (satellite.remembered().isEmpty()) return
+ localNetworkRequested = true
+ localNetworkPermissionLauncher.launch(LocalNetworkAccess.PERMISSION)
+ }
+
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (::binding.isInitialized) applyPaneLayout(newConfig)
diff --git a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt
index 26b7d1d8..f464f8e6 100644
--- a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt
+++ b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt
@@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -21,6 +22,7 @@ import com.tinkernorth.dish.databinding.SetupChoiceRowBinding
import com.tinkernorth.dish.databinding.SetupHostRowBinding
import com.tinkernorth.dish.source.connection.PairingApproval
import com.tinkernorth.dish.source.store.OnboardingPreferenceStore
+import com.tinkernorth.dish.source.system.LocalNetworkAccess
import com.tinkernorth.dish.ui.common.BaseGamepadHostActivity
import com.tinkernorth.dish.ui.common.DishNavigator
import com.tinkernorth.dish.ui.common.setupDishToolbar
@@ -52,6 +54,21 @@ class SetupConnectionActivity : BaseGamepadHostActivity() {
private var pinDialog: PairPinDialog? = null
private var pairingServer: DiscoveredServer? = null
+ private var onLocalNetworkGranted: (() -> Unit)? = null
+
+ private val localNetworkPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ val resume = onLocalNetworkGranted
+ onLocalNetworkGranted = null
+ if (granted) {
+ resume?.invoke()
+ } else {
+ SetupErrorDialog.show(this, getString(R.string.setup_conn_local_network_denied)) {
+ withLocalNetwork(resume ?: { viewModel.startDiscovery() })
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = setScaffoldContent(ActivitySetupConnectionBinding::inflate)
@@ -66,7 +83,7 @@ class SetupConnectionActivity : BaseGamepadHostActivity() {
R.string.setup_conn_satellite_title,
R.string.setup_conn_satellite_body,
R.string.setup_conn_satellite_badge,
- ) { viewModel.chooseSatellite() }
+ ) { withLocalNetwork { viewModel.chooseSatellite() } }
bindChoice(
binding.cardBluetoothHost,
R.drawable.ic_bluetooth,
@@ -76,7 +93,7 @@ class SetupConnectionActivity : BaseGamepadHostActivity() {
) { nav.toSetupBluetoothHost(inputType, slotId) }
binding.btnBack.setOnClickListener { handleBack() }
- binding.btnRescan.setOnClickListener { viewModel.startDiscovery() }
+ binding.btnRescan.setOnClickListener { withLocalNetwork { viewModel.startDiscovery() } }
binding.btnGetSatellite.setOnClickListener { openGitHub() }
onBackPressedDispatcher.addCallback(this) { handleBack() }
@@ -202,13 +219,23 @@ class SetupConnectionActivity : BaseGamepadHostActivity() {
dialog.showError(message)
return
}
- SetupErrorDialog.show(this, message) { viewModel.startDiscovery() }
+ SetupErrorDialog.show(this, message) { withLocalNetwork { viewModel.startDiscovery() } }
}
private fun openGitHub() {
startActivity(Intent(Intent.ACTION_VIEW, getString(R.string.url_github).toUri()))
}
+ // Request before scanning: a pre-grant blocked scan would shadow the real one via the single-flight guard.
+ private fun withLocalNetwork(action: () -> Unit) {
+ if (LocalNetworkAccess.isGranted(this)) {
+ action()
+ return
+ }
+ onLocalNetworkGranted = action
+ localNetworkPermissionLauncher.launch(LocalNetworkAccess.PERMISSION)
+ }
+
private fun bindChoice(
row: SetupChoiceRowBinding,
@DrawableRes icon: Int,
diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml
index 7ac650ca..fb197c23 100644
--- a/app/src/main/res/values-bs/strings.xml
+++ b/app/src/main/res/values-bs/strings.xml
@@ -214,6 +214,8 @@
Povežite se na isti Wi-Fi kao i vaš satelit.
Samo mobilni podaci
Sateliti žive na vašem Wi-Fi LAN-u, ne na mobilnim podacima.
+ Potreban pristup lokalnoj mreži
+ Dish pronalazi satelite na vašem Wi-Fi-ju i povezuje se s njima. Dozvolite pristup lokalnoj mreži za skeniranje.
Prvo povežite jednu vezu
Potrebno uparivanje
Otvorite Veze da unesete PIN za %1$s.
diff --git a/app/src/main/res/values-bs/strings_setup.xml b/app/src/main/res/values-bs/strings_setup.xml
index 106f454a..a0cf9db1 100644
--- a/app/src/main/res/values-bs/strings_setup.xml
+++ b/app/src/main/res/values-bs/strings_setup.xml
@@ -69,6 +69,7 @@
Potrebno uparivanje, PIN
Povezan
Ponovno povezivanje
+ Dish treba pristup lokalnoj mreži da pronađe satelite na vašem Wi-Fi-ju. Dozvolite ga za nastavak.
Ne vidite svoj PC?
Instalirajte besplatnu Satellite aplikaciju na njega.
Preuzmi Satellite na GitHub-u
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index aad41d81..eaa6dcac 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -215,6 +215,8 @@
Verbinde dich mit demselben WLAN wie dein Satellit.
Nur mobile Daten
Satelliten leben in deinem WLAN-LAN, nicht in den mobilen Daten.
+ Zugriff auf lokales Netzwerk nötig
+ Dish findet Satelliten in deinem WLAN und verbindet sich mit ihnen. Erlaube den Zugriff auf das lokale Netzwerk, um zu suchen.
Erst eine Verbindung verknüpfen
Kopplung nötig
Öffne Verbindungen, um den PIN für %1$s einzugeben.
diff --git a/app/src/main/res/values-de/strings_setup.xml b/app/src/main/res/values-de/strings_setup.xml
index 1f8bcf7b..9397d265 100644
--- a/app/src/main/res/values-de/strings_setup.xml
+++ b/app/src/main/res/values-de/strings_setup.xml
@@ -75,6 +75,7 @@
Kopplung nötig, PIN
Verbunden
Verbindet erneut
+ Dish braucht Zugriff auf das lokale Netzwerk, um Satelliten in deinem WLAN zu finden. Erlaube ihn, um fortzufahren.
Siehst du deinen PC nicht?
Installiere die kostenlose Satellite-App darauf.
Satellite auf GitHub holen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 7fa424cd..49ae545a 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -217,6 +217,8 @@
Conéctate a la misma Wi-Fi que tu satélite.
Solo datos móviles
Los satélites viven en tu LAN Wi-Fi, no en los datos móviles.
+ Se necesita acceso a la red local
+ Dish encuentra satélites en tu Wi-Fi y se conecta a ellos. Permite el acceso a la red local para buscar.
Enlaza una conexión primero
Emparejamiento necesario
Abre Conexiones para introducir el PIN de %1$s.
diff --git a/app/src/main/res/values-es/strings_setup.xml b/app/src/main/res/values-es/strings_setup.xml
index 4ff9e957..c7573a9e 100644
--- a/app/src/main/res/values-es/strings_setup.xml
+++ b/app/src/main/res/values-es/strings_setup.xml
@@ -69,6 +69,7 @@
Necesita emparejamiento, PIN
Conectado
Reconectando
+ Dish necesita acceso a la red local para encontrar satélites en tu Wi-Fi. Permítelo para continuar.
¿No ves tu PC?
Instala en él la app gratuita Satellite.
Consigue Satellite en GitHub
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 98de285d..0d27b87a 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -216,6 +216,8 @@
Connectez-vous au même Wi-Fi que votre satellite.
Données mobiles uniquement
Les satellites vivent sur votre LAN Wi-Fi, pas sur les données mobiles.
+ Accès au réseau local requis
+ Dish trouve les satellites sur votre Wi-Fi et s\'y connecte. Autorisez l\'accès au réseau local pour scanner.
Liez d\'abord une connexion
Appairage requis
Ouvrez Connexions pour saisir le PIN de %1$s.
diff --git a/app/src/main/res/values-fr/strings_setup.xml b/app/src/main/res/values-fr/strings_setup.xml
index 4798041c..34c12903 100644
--- a/app/src/main/res/values-fr/strings_setup.xml
+++ b/app/src/main/res/values-fr/strings_setup.xml
@@ -74,6 +74,7 @@
Appairage requis, PIN
Connecté
Reconnexion…
+ Dish a besoin d\'un accès au réseau local pour trouver des satellites sur votre Wi-Fi. Autorisez-le pour continuer.
Vous ne voyez pas votre PC ?
Installez-y l\'app Satellite gratuite.
Obtenir Satellite sur GitHub
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 6f46bb94..3644a210 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -217,6 +217,8 @@
Conecte-se ao mesmo Wi-Fi do seu satélite.
Somente dados móveis
Satélites vivem na sua LAN Wi-Fi, não nos dados móveis.
+ Acesso à rede local necessário
+ O Dish encontra satélites no seu Wi-Fi e se conecta a eles. Permita o acesso à rede local para buscar.
Vincule uma conexão primeiro
Pareamento necessário
Abra Conexões para digitar o PIN de %1$s.
diff --git a/app/src/main/res/values-pt-rBR/strings_setup.xml b/app/src/main/res/values-pt-rBR/strings_setup.xml
index 2d569987..9d0b0951 100644
--- a/app/src/main/res/values-pt-rBR/strings_setup.xml
+++ b/app/src/main/res/values-pt-rBR/strings_setup.xml
@@ -69,6 +69,7 @@
Precisa parear, PIN
Conectado
Reconectando
+ O Dish precisa de acesso à rede local para encontrar satélites no seu Wi-Fi. Permita para continuar.
Não vê seu PC?
Instale o app gratuito Satellite nele.
Obter o Satellite no GitHub
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 19c51317..1e0229d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -252,6 +252,8 @@
Connect to the same Wi-Fi as your satellite.
Mobile data only
Satellites live on your Wi-Fi LAN, not on mobile data.
+ Local network access needed
+ Dish finds and connects to satellites on your Wi-Fi. Allow local network access to scan.
Bind a connection first
Pairing needed
Open Connections to enter the PIN for %1$s.
diff --git a/app/src/main/res/values/strings_setup.xml b/app/src/main/res/values/strings_setup.xml
index d05ba508..c4e141bd 100644
--- a/app/src/main/res/values/strings_setup.xml
+++ b/app/src/main/res/values/strings_setup.xml
@@ -75,6 +75,7 @@
Needs pairing, PIN
Connected
Reconnecting
+ Dish needs local network access to find satellites on your Wi-Fi. Allow it to continue.
Don\'t see your PC?
Install the free Satellite app on it.
Get Satellite on GitHub
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 d00e547c..2b2e1c86 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
@@ -12,10 +12,13 @@ import com.tinkernorth.dish.core.net.HttpReply
import com.tinkernorth.dish.repository.ConnectionStore
import com.tinkernorth.dish.repository.RememberedSatellite
import com.tinkernorth.dish.source.store.SatelliteMotionBackendStatusStore
+import com.tinkernorth.dish.source.system.LocalNetworkAccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
@@ -430,6 +433,23 @@ class SatelliteConnectionManagerTest {
assertTrue(events.any { it is ConnectionEvent.Error })
}
+ @Test
+ fun `AUTO_RECONNECT is refused before any handshake when local network access is missing`() =
+ runMgrTest { mgr, _ ->
+ mockkObject(LocalNetworkAccess)
+ every { LocalNetworkAccess.isGranted(any(), any()) } returns false
+ try {
+ mgr.connect(server, ConnectIntent.AUTO_RECONNECT)
+ scope.testScheduler.advanceUntilIdle()
+
+ coVerify(exactly = 0) { discoveryRepo.pair(any(), any(), any(), any(), any()) }
+ verify(exactly = 0) { controllerRepo.openSocket(any(), any()) }
+ assertNull(mgr.get(serverId))
+ } finally {
+ unmockkObject(LocalNetworkAccess)
+ }
+ }
+
@Test
fun `AUTO_RECONNECT marks Stale when server rejects empty PIN`() =
runMgrTest { mgr, events ->
diff --git a/app/src/test/java/com/tinkernorth/dish/source/system/LocalNetworkAccessTest.kt b/app/src/test/java/com/tinkernorth/dish/source/system/LocalNetworkAccessTest.kt
new file mode 100644
index 00000000..2c280ef6
--- /dev/null
+++ b/app/src/test/java/com/tinkernorth/dish/source/system/LocalNetworkAccessTest.kt
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+package com.tinkernorth.dish.source.system
+
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalNetworkAccessTest {
+ private val context = mockk(relaxed = true)
+
+ @After
+ fun tearDown() {
+ unmockkStatic(ContextCompat::class)
+ }
+
+ @Test
+ fun `permission is the platform ACCESS_LOCAL_NETWORK string`() {
+ assertEquals("android.permission.ACCESS_LOCAL_NETWORK", LocalNetworkAccess.PERMISSION)
+ }
+
+ @Test
+ fun `enforcement boundary is API 37`() {
+ assertFalse(LocalNetworkAccess.isEnforced(24))
+ assertFalse(LocalNetworkAccess.isEnforced(36))
+ assertTrue(LocalNetworkAccess.isEnforced(37))
+ assertTrue(LocalNetworkAccess.isEnforced(40))
+ }
+
+ @Test
+ fun `pre-enforcement OS is granted without checking the runtime permission`() {
+ mockkStatic(ContextCompat::class)
+ assertTrue(LocalNetworkAccess.isGranted(context, sdkInt = 36))
+ verify(exactly = 0) { ContextCompat.checkSelfPermission(any(), any()) }
+ }
+
+ @Test
+ fun `enforcing OS is granted when the permission is held`() {
+ mockkStatic(ContextCompat::class)
+ every { ContextCompat.checkSelfPermission(context, LocalNetworkAccess.PERMISSION) } returns
+ PackageManager.PERMISSION_GRANTED
+ assertTrue(LocalNetworkAccess.isGranted(context, sdkInt = 37))
+ }
+
+ @Test
+ fun `enforcing OS is not granted when the permission is denied`() {
+ mockkStatic(ContextCompat::class)
+ every { ContextCompat.checkSelfPermission(context, LocalNetworkAccess.PERMISSION) } returns
+ PackageManager.PERMISSION_DENIED
+ assertFalse(LocalNetworkAccess.isGranted(context, sdkInt = 37))
+ }
+}