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)) + } +}