Skip to content

Commit 451da20

Browse files
authored
Merge pull request #2400 from switchifyapp/fix/pc-discovery-permissions-2399
Fix PC discovery: request local network permissions before NSD
2 parents abaeb90 + ca2f581 commit 451da20

5 files changed

Lines changed: 247 additions & 18 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- Required for camera service wake lock -->
2424
<uses-permission android:name="android.permission.INTERNET" /> <!-- Required for downloading the Reply Drafter model -->
2525
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- Required for local PC discovery -->
26+
<uses-permission
27+
android:name="android.permission.NEARBY_WIFI_DEVICES"
28+
android:usesPermissionFlags="neverForLocation" /> <!-- Required for local PC discovery on Android 13+ -->
29+
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <!-- Required for mDNS multicast during PC discovery -->
2630

2731
<application
2832
android:name=".SwitchifyApplication"

app/src/main/java/com/enaboapps/switchify/pc/PcDiscoveryService.kt

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.enaboapps.switchify.pc
33
import android.content.Context
44
import android.net.nsd.NsdManager
55
import android.net.nsd.NsdServiceInfo
6+
import android.net.wifi.WifiManager
67
import android.os.Build
78
import kotlinx.coroutines.flow.MutableStateFlow
89
import kotlinx.coroutines.flow.StateFlow
@@ -23,8 +24,55 @@ interface PcDiscovery {
2324
fun stopDiscovery()
2425
}
2526

26-
class PcDiscoveryService(context: Context) : PcDiscovery {
27+
/**
28+
* Holds a Wi-Fi multicast lock while discovery is active so mDNS responses
29+
* are not dropped by the Wi-Fi driver's multicast filter.
30+
*/
31+
interface PcMulticastLock {
32+
fun acquire()
33+
fun release()
34+
}
35+
36+
/**
37+
* Idempotent acquire/release wrapper so discovery restarts and repeated stops
38+
* never unbalance the underlying lock.
39+
*/
40+
class PcMulticastLockSession(private val lock: PcMulticastLock) {
41+
private var held = false
42+
43+
fun acquire() {
44+
if (held) return
45+
runCatching { lock.acquire() }
46+
held = true
47+
}
48+
49+
fun release() {
50+
if (!held) return
51+
runCatching { lock.release() }
52+
held = false
53+
}
54+
}
55+
56+
private class WifiMulticastLock(context: Context) : PcMulticastLock {
57+
private val multicastLock = (context.getSystemService(Context.WIFI_SERVICE) as WifiManager)
58+
.createMulticastLock("switchify_pc_discovery")
59+
.apply { setReferenceCounted(false) }
60+
61+
override fun acquire() {
62+
multicastLock.acquire()
63+
}
64+
65+
override fun release() {
66+
if (multicastLock.isHeld) multicastLock.release()
67+
}
68+
}
69+
70+
class PcDiscoveryService(
71+
context: Context,
72+
multicastLock: PcMulticastLock = WifiMulticastLock(context.applicationContext)
73+
) : PcDiscovery {
2774
private val nsdManager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
75+
private val multicastLockSession = PcMulticastLockSession(multicastLock)
2876
private val discovered = linkedMapOf<String, DiscoveredPc>()
2977
private var discoveryListener: NsdManager.DiscoveryListener? = null
3078
private val resolveSequencer = PcResolveSequencer<NsdServiceInfo>()
@@ -74,11 +122,13 @@ class PcDiscoveryService(context: Context) : PcDiscovery {
74122
}
75123
}
76124
discoveryListener = listener
125+
multicastLockSession.acquire()
77126
nsdManager.discoverServices(SWITCHIFY_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
78127
}
79128

80129
override fun stopDiscovery() {
81130
resolveSequencer.clear()
131+
multicastLockSession.release()
82132
val listener = discoveryListener ?: return
83133
runCatching { nsdManager.stopServiceDiscovery(listener) }
84134
discoveryListener = null

app/src/main/java/com/enaboapps/switchify/screens/pc/PcConnectionScreen.kt

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package com.enaboapps.switchify.screens.pc
22

3+
import android.Manifest
4+
import android.content.Intent
5+
import android.net.Uri
6+
import android.os.Build
7+
import android.provider.Settings
38
import androidx.compose.foundation.layout.Arrangement
49
import androidx.compose.foundation.layout.BoxWithConstraints
510
import androidx.compose.foundation.layout.Column
@@ -16,6 +21,9 @@ import androidx.compose.runtime.Composable
1621
import androidx.compose.runtime.LaunchedEffect
1722
import androidx.compose.runtime.collectAsState
1823
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableStateOf
25+
import androidx.compose.runtime.saveable.rememberSaveable
26+
import androidx.compose.runtime.setValue
1927
import androidx.compose.ui.Modifier
2028
import androidx.compose.ui.platform.LocalContext
2129
import androidx.compose.ui.res.stringResource
@@ -25,6 +33,7 @@ import androidx.compose.ui.unit.dp
2533
import androidx.lifecycle.viewmodel.compose.viewModel
2634
import androidx.navigation.NavController
2735
import com.enaboapps.switchify.R
36+
import com.enaboapps.switchify.components.ActionButton
2837
import com.enaboapps.switchify.components.BaseView
2938
import com.enaboapps.switchify.components.PreferenceComponentBase
3039
import com.enaboapps.switchify.components.ScrollableView
@@ -34,17 +43,34 @@ import com.enaboapps.switchify.pc.PcConnectionViewModel
3443
import com.enaboapps.switchify.pc.PcRowState
3544
import com.enaboapps.switchify.pc.PcRowStatus
3645
import com.enaboapps.switchify.theme.Dimens
46+
import com.google.accompanist.permissions.ExperimentalPermissionsApi
47+
import com.google.accompanist.permissions.PermissionState
48+
import com.google.accompanist.permissions.isGranted
49+
import com.google.accompanist.permissions.rememberPermissionState
50+
import com.google.accompanist.permissions.shouldShowRationale
3751

3852
private val PcConnectionCompactRowWidth = 380.dp
3953

54+
@OptIn(ExperimentalPermissionsApi::class)
4055
@Composable
4156
fun PcConnectionScreen(navController: NavController) {
4257
val context = LocalContext.current
4358
val viewModel: PcConnectionViewModel = viewModel { PcConnectionViewModel(context.applicationContext) }
4459
val uiState by viewModel.uiState.collectAsState()
4560

46-
LaunchedEffect(Unit) {
47-
viewModel.startDiscovery()
61+
val permissionState: PermissionState? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
62+
rememberPermissionState(Manifest.permission.NEARBY_WIFI_DEVICES)
63+
} else {
64+
null
65+
}
66+
val permissionGranted = permissionState?.status?.isGranted ?: true
67+
var hasRequestedPermission by rememberSaveable { mutableStateOf(false) }
68+
69+
LaunchedEffect(permissionGranted) {
70+
viewModel.setPermissionRequired(!permissionGranted)
71+
if (permissionGranted) {
72+
viewModel.startDiscovery()
73+
}
4874
}
4975

5076
BaseView(
@@ -54,25 +80,57 @@ fun PcConnectionScreen(navController: NavController) {
5480
) {
5581
ScrollableView {
5682
Column(verticalArrangement = Arrangement.spacedBy(Dimens.spaceM)) {
57-
Section(titleResId = R.string.pc_connection_nearby_section) {
58-
Column(modifier = Modifier.padding(vertical = Dimens.spaceS)) {
59-
Text(
60-
text = uiState.discoveryStatusText,
61-
style = MaterialTheme.typography.bodyMedium,
62-
color = MaterialTheme.colorScheme.onSurfaceVariant,
63-
modifier = Modifier.padding(horizontal = Dimens.spaceM, vertical = Dimens.spaceS)
64-
)
65-
uiState.discoveredPcs.forEach { row ->
66-
PcConnectionPreferenceRow(
67-
title = row.title,
68-
summary = row.summary,
69-
onClick = { row.perform(viewModel) },
70-
actions = {
71-
PcNearbyRowActions(row = row, viewModel = viewModel)
83+
if (uiState.permissionRequired) {
84+
Section(titleResId = R.string.pc_connection_permission_section) {
85+
Column(modifier = Modifier.padding(vertical = Dimens.spaceS)) {
86+
Text(
87+
text = stringResource(R.string.pc_connection_permission_message),
88+
style = MaterialTheme.typography.bodyMedium,
89+
color = MaterialTheme.colorScheme.onSurfaceVariant,
90+
modifier = Modifier.padding(horizontal = Dimens.spaceM, vertical = Dimens.spaceS)
91+
)
92+
ActionButton(
93+
textResId = R.string.pc_connection_permission_action,
94+
applyPadding = false,
95+
modifier = Modifier
96+
.fillMaxWidth()
97+
.padding(horizontal = Dimens.spaceM, vertical = Dimens.spaceS),
98+
onClick = {
99+
val state = permissionState ?: return@ActionButton
100+
if (!hasRequestedPermission || state.status.shouldShowRationale) {
101+
hasRequestedPermission = true
102+
state.launchPermissionRequest()
103+
} else {
104+
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
105+
data = Uri.fromParts("package", context.packageName, null)
106+
}
107+
context.startActivity(intent)
108+
}
72109
}
73110
)
74111
}
75112
}
113+
} else {
114+
Section(titleResId = R.string.pc_connection_nearby_section) {
115+
Column(modifier = Modifier.padding(vertical = Dimens.spaceS)) {
116+
Text(
117+
text = uiState.discoveryStatusText,
118+
style = MaterialTheme.typography.bodyMedium,
119+
color = MaterialTheme.colorScheme.onSurfaceVariant,
120+
modifier = Modifier.padding(horizontal = Dimens.spaceM, vertical = Dimens.spaceS)
121+
)
122+
uiState.discoveredPcs.forEach { row ->
123+
PcConnectionPreferenceRow(
124+
title = row.title,
125+
summary = row.summary,
126+
onClick = { row.perform(viewModel) },
127+
actions = {
128+
PcNearbyRowActions(row = row, viewModel = viewModel)
129+
}
130+
)
131+
}
132+
}
133+
}
76134
}
77135
if (uiState.savedPairings.isNotEmpty()) {
78136
Section(titleResId = R.string.pc_connection_paired_section) {

app/src/test/java/com/enaboapps/switchify/pc/PcConnectionViewModelTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ class PcConnectionViewModelTest {
225225
assertEquals(PcConnectionState.Disconnected, PcConnectionStateHolder.connectionState.value)
226226
}
227227

228+
@Test
229+
fun setPermissionRequiredReflectsInUiState() = runTest(dispatcher) {
230+
val viewModel = viewModel(FakeDiscovery(emptyList()), FakeTokenStore(), FakeConnector())
231+
advanceUntilIdle()
232+
233+
assertEquals(false, viewModel.uiState.value.permissionRequired)
234+
235+
viewModel.setPermissionRequired(true)
236+
advanceUntilIdle()
237+
assertEquals(true, viewModel.uiState.value.permissionRequired)
238+
239+
viewModel.setPermissionRequired(false)
240+
advanceUntilIdle()
241+
assertEquals(false, viewModel.uiState.value.permissionRequired)
242+
}
243+
228244
@Test
229245
fun dismissUnpairKeepsToken() = runTest(dispatcher) {
230246
val discovery = FakeDiscovery(listOf(pc))
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.enaboapps.switchify.pc
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class PcMulticastLockSessionTest {
7+
private class FakeLock : PcMulticastLock {
8+
var acquireCalls = 0
9+
var releaseCalls = 0
10+
var throwOnAcquire = false
11+
var throwOnRelease = false
12+
13+
override fun acquire() {
14+
acquireCalls++
15+
if (throwOnAcquire) error("acquire failed")
16+
}
17+
18+
override fun release() {
19+
releaseCalls++
20+
if (throwOnRelease) error("release failed")
21+
}
22+
}
23+
24+
@Test
25+
fun acquireThenReleaseDelegatesOnce() {
26+
val lock = FakeLock()
27+
val session = PcMulticastLockSession(lock)
28+
29+
session.acquire()
30+
session.release()
31+
32+
assertEquals(1, lock.acquireCalls)
33+
assertEquals(1, lock.releaseCalls)
34+
}
35+
36+
@Test
37+
fun repeatedAcquireIsIdempotent() {
38+
val lock = FakeLock()
39+
val session = PcMulticastLockSession(lock)
40+
41+
session.acquire()
42+
session.acquire()
43+
44+
assertEquals(1, lock.acquireCalls)
45+
}
46+
47+
@Test
48+
fun releaseWithoutAcquireDoesNothing() {
49+
val lock = FakeLock()
50+
val session = PcMulticastLockSession(lock)
51+
52+
session.release()
53+
54+
assertEquals(0, lock.releaseCalls)
55+
}
56+
57+
@Test
58+
fun repeatedReleaseDelegatesOnce() {
59+
val lock = FakeLock()
60+
val session = PcMulticastLockSession(lock)
61+
62+
session.acquire()
63+
session.release()
64+
session.release()
65+
66+
assertEquals(1, lock.releaseCalls)
67+
}
68+
69+
@Test
70+
fun restartCycleBalancesAcquireAndRelease() {
71+
val lock = FakeLock()
72+
val session = PcMulticastLockSession(lock)
73+
74+
// Mirrors startDiscovery() (which stops first) -> stopDiscovery()
75+
session.release()
76+
session.acquire()
77+
session.release()
78+
session.acquire()
79+
session.release()
80+
81+
assertEquals(2, lock.acquireCalls)
82+
assertEquals(2, lock.releaseCalls)
83+
}
84+
85+
@Test
86+
fun lockFailuresDoNotPreventStateTracking() {
87+
val lock = FakeLock().apply {
88+
throwOnAcquire = true
89+
throwOnRelease = true
90+
}
91+
val session = PcMulticastLockSession(lock)
92+
93+
session.acquire()
94+
session.release()
95+
session.acquire()
96+
session.release()
97+
98+
assertEquals(2, lock.acquireCalls)
99+
assertEquals(2, lock.releaseCalls)
100+
}
101+
}

0 commit comments

Comments
 (0)