Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

<!-- Android 17+ gates LAN discovery/connect; older OSes imply it via INTERNET. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />

<uses-permission android:name="android.permission.WAKE_LOCK" />

<!-- CONNECTED_DEVICE (not DATA_SYNC) fits a user-facing controller-to-host session. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -309,7 +325,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() {

override fun onStart() {
super.onStart()
satellite.startDiscovery()
ensureLocalNetworkThenDiscover()
}

private fun setupList() {
Expand All @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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() }
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-bs/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@
<string name="notif_no_network_body">Povežite se na isti Wi-Fi kao i vaš satelit.</string>
<string name="notif_cellular_only_title">Samo mobilni podaci</string>
<string name="notif_cellular_only_body">Sateliti žive na vašem Wi-Fi LAN-u, ne na mobilnim podacima.</string>
<string name="notif_local_network_title">Potreban pristup lokalnoj mreži</string>
<string name="notif_local_network_body">Dish pronalazi satelite na vašem Wi-Fi-ju i povezuje se s njima. Dozvolite pristup lokalnoj mreži za skeniranje.</string>
<string name="notif_bind_first_title">Prvo povežite jednu vezu</string>
<string name="notif_pairing_needed_title">Potrebno uparivanje</string>
<string name="notif_pairing_needed_body">Otvorite Veze da unesete PIN za %1$s.</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-bs/strings_setup.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<string name="setup_conn_status_needs_pairing">Potrebno uparivanje, PIN</string>
<string name="setup_conn_status_connected">Povezan</string>
<string name="setup_conn_status_reconnecting">Ponovno povezivanje</string>
<string name="setup_conn_local_network_denied">Dish treba pristup lokalnoj mreži da pronađe satelite na vašem Wi-Fi-ju. Dozvolite ga za nastavak.</string>
<string name="setup_conn_missing_title">Ne vidite svoj PC?</string>
<string name="setup_conn_missing_body">Instalirajte besplatnu Satellite aplikaciju na njega.</string>
<string name="setup_conn_get_satellite">Preuzmi Satellite na GitHub-u</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@
<string name="notif_no_network_body">Verbinde dich mit demselben WLAN wie dein Satellit.</string>
<string name="notif_cellular_only_title">Nur mobile Daten</string>
<string name="notif_cellular_only_body">Satelliten leben in deinem WLAN-LAN, nicht in den mobilen Daten.</string>
<string name="notif_local_network_title">Zugriff auf lokales Netzwerk nötig</string>
<string name="notif_local_network_body">Dish findet Satelliten in deinem WLAN und verbindet sich mit ihnen. Erlaube den Zugriff auf das lokale Netzwerk, um zu suchen.</string>
<string name="notif_bind_first_title">Erst eine Verbindung verknüpfen</string>
<string name="notif_pairing_needed_title">Kopplung nötig</string>
<string name="notif_pairing_needed_body">Öffne Verbindungen, um den PIN für %1$s einzugeben.</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings_setup.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<string name="setup_conn_status_needs_pairing">Kopplung nötig, PIN</string>
<string name="setup_conn_status_connected">Verbunden</string>
<string name="setup_conn_status_reconnecting">Verbindet erneut</string>
<string name="setup_conn_local_network_denied">Dish braucht Zugriff auf das lokale Netzwerk, um Satelliten in deinem WLAN zu finden. Erlaube ihn, um fortzufahren.</string>
<string name="setup_conn_missing_title">Siehst du deinen PC nicht?</string>
<string name="setup_conn_missing_body">Installiere die kostenlose Satellite-App darauf.</string>
<string name="setup_conn_get_satellite">Satellite auf GitHub holen</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@
<string name="notif_no_network_body">Conéctate a la misma Wi-Fi que tu satélite.</string>
<string name="notif_cellular_only_title">Solo datos móviles</string>
<string name="notif_cellular_only_body">Los satélites viven en tu LAN Wi-Fi, no en los datos móviles.</string>
<string name="notif_local_network_title">Se necesita acceso a la red local</string>
<string name="notif_local_network_body">Dish encuentra satélites en tu Wi-Fi y se conecta a ellos. Permite el acceso a la red local para buscar.</string>
<string name="notif_bind_first_title">Enlaza una conexión primero</string>
<string name="notif_pairing_needed_title">Emparejamiento necesario</string>
<string name="notif_pairing_needed_body">Abre Conexiones para introducir el PIN de %1$s.</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-es/strings_setup.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<string name="setup_conn_status_needs_pairing">Necesita emparejamiento, PIN</string>
<string name="setup_conn_status_connected">Conectado</string>
<string name="setup_conn_status_reconnecting">Reconectando</string>
<string name="setup_conn_local_network_denied">Dish necesita acceso a la red local para encontrar satélites en tu Wi-Fi. Permítelo para continuar.</string>
<string name="setup_conn_missing_title">¿No ves tu PC?</string>
<string name="setup_conn_missing_body">Instala en él la app gratuita Satellite.</string>
<string name="setup_conn_get_satellite">Consigue Satellite en GitHub</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@
<string name="notif_no_network_body">Connectez-vous au même Wi-Fi que votre satellite.</string>
<string name="notif_cellular_only_title">Données mobiles uniquement</string>
<string name="notif_cellular_only_body">Les satellites vivent sur votre LAN Wi-Fi, pas sur les données mobiles.</string>
<string name="notif_local_network_title">Accès au réseau local requis</string>
<string name="notif_local_network_body">Dish trouve les satellites sur votre Wi-Fi et s\'y connecte. Autorisez l\'accès au réseau local pour scanner.</string>
<string name="notif_bind_first_title">Liez d\'abord une connexion</string>
<string name="notif_pairing_needed_title">Appairage requis</string>
<string name="notif_pairing_needed_body">Ouvrez Connexions pour saisir le PIN de %1$s.</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-fr/strings_setup.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<string name="setup_conn_status_needs_pairing">Appairage requis, PIN</string>
<string name="setup_conn_status_connected">Connecté</string>
<string name="setup_conn_status_reconnecting">Reconnexion…</string>
<string name="setup_conn_local_network_denied">Dish a besoin d\'un accès au réseau local pour trouver des satellites sur votre Wi-Fi. Autorisez-le pour continuer.</string>
<string name="setup_conn_missing_title">Vous ne voyez pas votre PC ?</string>
<string name="setup_conn_missing_body">Installez-y l\'app Satellite gratuite.</string>
<string name="setup_conn_get_satellite">Obtenir Satellite sur GitHub</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-pt-rBR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@
<string name="notif_no_network_body">Conecte-se ao mesmo Wi-Fi do seu satélite.</string>
<string name="notif_cellular_only_title">Somente dados móveis</string>
<string name="notif_cellular_only_body">Satélites vivem na sua LAN Wi-Fi, não nos dados móveis.</string>
<string name="notif_local_network_title">Acesso à rede local necessário</string>
<string name="notif_local_network_body">O Dish encontra satélites no seu Wi-Fi e se conecta a eles. Permita o acesso à rede local para buscar.</string>
<string name="notif_bind_first_title">Vincule uma conexão primeiro</string>
<string name="notif_pairing_needed_title">Pareamento necessário</string>
<string name="notif_pairing_needed_body">Abra Conexões para digitar o PIN de %1$s.</string>
Expand Down
Loading
Loading