From 1c5fde906443a92467a967f8c94b087d5d589fc4 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:59:27 -0700 Subject: [PATCH 1/2] feat(settings): add toggle for sync device location --- .../MockLocationGuideScreen.kt | 23 ++++-- .../network/ui/settings/SettingsScreen.kt | 75 +++++++++++++++---- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt index aec2bfa3f..2b54e9607 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt @@ -48,6 +48,7 @@ import com.bringyour.network.location.openAboutPhone import com.bringyour.network.location.openDeveloperOptions import com.bringyour.network.location.openLocationSettings import com.bringyour.network.ui.components.URButton +import com.bringyour.network.ui.components.URSwitch import com.bringyour.network.ui.theme.Black import com.bringyour.network.ui.theme.Green import com.bringyour.network.ui.theme.MainTintedBackgroundBase @@ -159,11 +160,23 @@ fun MockLocationGuideScreen( Spacer(modifier = Modifier.height(24.dp)) if (state.setupComplete) { - Text( - stringResource(id = R.string.mock_location_ready), - style = MaterialTheme.typography.bodyMedium, - color = Green, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.mock_location_ready), + style = MaterialTheme.typography.bodyMedium, + color = Green, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + URSwitch( + checked = state.enabled, + toggle = { viewModel.setEnabled(!state.enabled) }, + ) + } Spacer(modifier = Modifier.height(24.dp)) } diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt index e50a8c497..21d1e3230 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt @@ -125,7 +125,9 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.bringyour.network.TAG import com.bringyour.network.ui.components.ProvideCellPicker import com.bringyour.network.ui.components.ProvideControlModePicker +import com.bringyour.network.ui.connect.providerlocations.MockLocationViewModel import com.bringyour.network.ui.login.SeedphraseDisplayScreen +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -137,8 +139,25 @@ fun SettingsScreen( overlayViewModel: OverlayViewModel, activityResultSender: ActivityResultSender?, earningsViewModel: EarningsViewModel, - isPro: Boolean + isPro: Boolean, + mockLocationViewModel: MockLocationViewModel = hiltViewModel(), ) { + val lifecycleOwner = LocalLifecycleOwner.current + val mockLocationState by mockLocationViewModel.state.collectAsState() + + LaunchedEffect(Unit) { + mockLocationViewModel.refreshEligibility() + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + mockLocationViewModel.refreshEligibility() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val notificationsAllowed = settingsViewModel.permissionGranted.collectAsState().value val showDeleteAccountDialog = settingsViewModel.showDeleteAccountDialog.collectAsState().value @@ -289,6 +308,14 @@ fun SettingsScreen( isGeneratingSeedphrase = isGeneratingSeedphrase, isRegeneratingSeedphrase = isRegeneratingSeedphrase, onSeedphraseActionClick = { action -> pendingSeedphraseAction = action }, + mockLocationEnabled = mockLocationState.enabled, + onToggleMockLocation = { + val enabled = !mockLocationState.enabled + mockLocationViewModel.setEnabled(enabled) + if (enabled && !mockLocationState.setupComplete) { + navController.navigate(Route.MockLocationGuide) + } + }, ) if (isPresentingRenameDevice) { @@ -508,6 +535,8 @@ private fun SettingsScreen( isGeneratingSeedphrase: Boolean, isRegeneratingSeedphrase: Boolean, onSeedphraseActionClick: (SeedphraseAction) -> Unit, + mockLocationEnabled: Boolean = false, + onToggleMockLocation: () -> Unit = {}, ) { val context = LocalContext.current @@ -921,23 +950,37 @@ private fun SettingsScreen( */ Row( modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate(Route.MockLocationGuide) - } - .padding(vertical = 6.dp) - , - horizontalArrangement = Arrangement.SpaceBetween + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - stringResource(id = R.string.mock_location_settings_row), - style = MaterialTheme.typography.bodyMedium, - ) + Row( + modifier = Modifier + .weight(1f) + .clickable { + navController.navigate(Route.MockLocationGuide) + } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.mock_location_settings_row), + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Keyboard Arrow Right", - tint = TextMuted + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Keyboard Arrow Right", + tint = TextMuted + ) + } + + URSwitch( + checked = mockLocationEnabled, + toggle = onToggleMockLocation, ) } From c42b2fc5e62f94b28dab58f14886970a7ced01b0 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:24:48 -0700 Subject: [PATCH 2/2] feat(mocklocation): decouple SDK feeder from ViewModel and harden settings status UX --- .../com/bringyour/network/MainApplication.kt | 5 + .../network/location/MockLocationFeeder.kt | 86 ++++++++++++++++++ .../MockLocationGuideScreen.kt | 11 ++- .../MockLocationViewModel.kt | 86 +----------------- .../network/ui/settings/SettingsScreen.kt | 91 +++++++++++++------ 5 files changed, 168 insertions(+), 111 deletions(-) create mode 100644 app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt diff --git a/app/app/src/main/java/com/bringyour/network/MainApplication.kt b/app/app/src/main/java/com/bringyour/network/MainApplication.kt index 909ea936b..8f6939d07 100644 --- a/app/app/src/main/java/com/bringyour/network/MainApplication.kt +++ b/app/app/src/main/java/com/bringyour/network/MainApplication.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkManager import com.bringyour.network.location.MockLocationController +import com.bringyour.network.location.MockLocationFeeder import com.bringyour.network.ui.shared.models.ProvideNetworkMode import com.bringyour.sdk.DeviceLocal import com.bringyour.sdk.LocalState @@ -167,6 +168,9 @@ class MainApplication : Application() { @Inject lateinit var mockLocationController: MockLocationController + @Inject + lateinit var mockLocationFeeder: MockLocationFeeder + var vpnRequestStart: Boolean = false private set @@ -471,6 +475,7 @@ class MainApplication : Application() { // from the feature UI) so a previous process's leftovers are cleared // even when the user never opens the provider locations sheet. mockLocationController.start() + mockLocationFeeder.start() networkSpaceManagerProvider.init(filesDir.absolutePath) diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt new file mode 100644 index 000000000..acd76ed09 --- /dev/null +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt @@ -0,0 +1,86 @@ +package com.bringyour.network.location + +import com.bringyour.network.DeviceManager +import com.bringyour.sdk.DeviceLocal +import com.bringyour.sdk.Sub +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Feeds MockLocationController with tunnel lifecycle and exit provider location updates + * from the SDK DeviceLocal instance across the entire application lifecycle. + */ +@Singleton +class MockLocationFeeder @Inject constructor( + private val deviceManager: DeviceManager, + private val controller: MockLocationController, +) { + private var removeDeviceChangeListener: (() -> Unit)? = null + private var connectSub: Sub? = null + private var providerSub: Sub? = null + + fun start() { + if (removeDeviceChangeListener != null) return + removeDeviceChangeListener = deviceManager.addDeviceChangeListener { device -> + attach(device) + } + attach(deviceManager.device) + } + + private fun attach(device: DeviceLocal?) { + connectSub?.close() + connectSub = null + providerSub?.close() + providerSub = null + + if (device != null) { + connectSub = device.addConnectChangeListener { enabled -> + controller.onTunnelChanged(enabled) + } + providerSub = device.addConnectedProviderLocationChangeListener { + pushTarget(device) + } + controller.onTunnelChanged(device.connectEnabled) + pushTarget(device) + } else { + controller.onTunnelChanged(false) + controller.onTargetChanged(null) + } + } + + private fun pushTarget(device: DeviceLocal) { + controller.onTunnelChanged(device.connectEnabled) + val locations = device.connectedProviderLocations + var target: MockLocationTarget? = null + if (locations != null) { + for (i in 0 until locations.len()) { + val location = locations.get(i) + val lat: Double + val lon: Double + when { + location.hasCityCoordinates -> { + lat = location.cityLat + lon = location.cityLon + } + location.hasRegionCoordinates -> { + lat = location.regionLat + lon = location.regionLon + } + else -> continue + } + val label = listOf(location.city, location.region, location.country) + .filter { it.isNotEmpty() } + .take(2) + .joinToString(", ") + target = MockLocationTarget( + clientId = location.clientId?.idStr ?: "", + label = label, + lat = lat, + lon = lon, + ) + break + } + } + controller.onTargetChanged(target) + } +} diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt index 2b54e9607..cb136bb5e 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt @@ -165,8 +165,17 @@ fun MockLocationGuideScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + val statusText = when { + state.status == MockLocationStatus.ACTIVE && state.target != null -> + stringResource(id = R.string.mock_location_active, state.target.label) + state.enabled -> + stringResource(id = R.string.mock_location_waiting_for_provider) + else -> + stringResource(id = R.string.mock_location_ready) + } + Text( - stringResource(id = R.string.mock_location_ready), + statusText, style = MaterialTheme.typography.bodyMedium, color = Green, modifier = Modifier.weight(1f), diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt index 3745b42cd..5fa86efb2 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt @@ -1,109 +1,29 @@ package com.bringyour.network.ui.connect.providerlocations import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.bringyour.network.DeviceManager import com.bringyour.network.location.MockLocationController import com.bringyour.network.location.MockLocationState -import com.bringyour.network.location.MockLocationTarget -import com.bringyour.sdk.DeviceLocal -import com.bringyour.sdk.Sub import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import javax.inject.Inject /** - * Feeds the mock location controller from the connected provider window: the - * target is the oldest connected provider that has coordinates. The controller - * owns all Android location state; this only translates sdk events. + * UI bridge for the mock location controller: exposes state and UI actions. + * SDK event feeds are managed at process lifetime by MockLocationFeeder. */ @HiltViewModel class MockLocationViewModel @Inject constructor( - private val deviceManager: DeviceManager, private val controller: MockLocationController, ) : ViewModel() { val state: StateFlow = controller.state - private var sub: Sub? = null - private var removeDeviceChangeListener: (() -> Unit)? = null - - init { - removeDeviceChangeListener = deviceManager.addDeviceChangeListener { device -> - attach(device) - } - attach(deviceManager.device) - } - - private fun attach(device: DeviceLocal?) { - sub?.close() - sub = device?.addConnectedProviderLocationChangeListener { - viewModelScope.launch { pushTarget() } - } - viewModelScope.launch { - controller.onTunnelChanged(device?.connectEnabled == true) - pushTarget() - } - } - - private fun pushTarget() { - val device = deviceManager.device - controller.onTunnelChanged(device?.connectEnabled == true) - - val locations = device?.connectedProviderLocations - var target: MockLocationTarget? = null - if (locations != null) { - // Read from the DEVICE, not the provider-locations view controller: - // the device getter is the raw window, still sorted oldest - // connected first, while the controller reorders it west to east - // for the list and the globe. Take the first one that actually has - // coordinates. - for (i in 0 until locations.len()) { - val location = locations.get(i) - val lat: Double - val lon: Double - when { - location.hasCityCoordinates -> { - lat = location.cityLat - lon = location.cityLon - } - location.hasRegionCoordinates -> { - lat = location.regionLat - lon = location.regionLon - } - else -> continue - } - val label = listOf(location.city, location.region, location.country) - .filter { it.isNotEmpty() } - .take(2) - .joinToString(", ") - target = MockLocationTarget( - clientId = location.clientId?.idStr ?: "", - label = label, - lat = lat, - lon = lon, - ) - break - } - } - controller.onTargetChanged(target) - } - fun setEnabled(enabled: Boolean) { controller.setEnabled(enabled) - pushTarget() } fun refreshEligibility() { controller.refreshEligibility() } - - override fun onCleared() { - super.onCleared() - sub?.close() - sub = null - removeDeviceChangeListener?.invoke() - removeDeviceChangeListener = null - } } + diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt index 21d1e3230..63c3b28de 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt @@ -125,6 +125,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.bringyour.network.TAG import com.bringyour.network.ui.components.ProvideCellPicker import com.bringyour.network.ui.components.ProvideControlModePicker +import com.bringyour.network.location.MockLocationStatus +import com.bringyour.network.location.MockLocationTarget import com.bringyour.network.ui.connect.providerlocations.MockLocationViewModel import com.bringyour.network.ui.login.SeedphraseDisplayScreen import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -309,13 +311,19 @@ fun SettingsScreen( isRegeneratingSeedphrase = isRegeneratingSeedphrase, onSeedphraseActionClick = { action -> pendingSeedphraseAction = action }, mockLocationEnabled = mockLocationState.enabled, + mockLocationSetupComplete = mockLocationState.setupComplete, + mockLocationStatus = mockLocationState.status, + mockLocationTarget = mockLocationState.target, onToggleMockLocation = { val enabled = !mockLocationState.enabled mockLocationViewModel.setEnabled(enabled) - if (enabled && !mockLocationState.setupComplete) { + if (mockLocationState.status == MockLocationStatus.ORPHANED || (enabled && !mockLocationState.setupComplete)) { navController.navigate(Route.MockLocationGuide) } }, + onOpenMockLocationGuide = { + navController.navigate(Route.MockLocationGuide) + }, ) if (isPresentingRenameDevice) { @@ -536,7 +544,11 @@ private fun SettingsScreen( isRegeneratingSeedphrase: Boolean, onSeedphraseActionClick: (SeedphraseAction) -> Unit, mockLocationEnabled: Boolean = false, + mockLocationSetupComplete: Boolean = false, + mockLocationStatus: MockLocationStatus = MockLocationStatus.DISABLED, + mockLocationTarget: MockLocationTarget? = null, onToggleMockLocation: () -> Unit = {}, + onOpenMockLocationGuide: () -> Unit = {}, ) { val context = LocalContext.current @@ -948,40 +960,65 @@ private fun SettingsScreen( /** * Device location sync (the mock location provider setup guide) */ - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .weight(1f) - .clickable { - navController.navigate(Route.MockLocationGuide) - } - .padding(vertical = 6.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - stringResource(id = R.string.mock_location_settings_row), - style = MaterialTheme.typography.bodyMedium, - color = Color.White - ) + Row( + modifier = Modifier + .weight(1f) + .clickable { + onOpenMockLocationGuide() + } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.mock_location_settings_row), + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Keyboard Arrow Right", - tint = TextMuted + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Keyboard Arrow Right", + tint = TextMuted + ) + } + + URSwitch( + checked = mockLocationEnabled, + toggle = onToggleMockLocation, ) } - URSwitch( - checked = mockLocationEnabled, - toggle = onToggleMockLocation, - ) + val statusSubtitle = when { + mockLocationStatus == MockLocationStatus.ORPHANED -> + stringResource(id = R.string.mock_location_error_cleanup_required) + mockLocationEnabled && !mockLocationSetupComplete -> + stringResource(id = R.string.mock_location_needs_setup) + mockLocationStatus == MockLocationStatus.ACTIVE && mockLocationTarget != null -> + stringResource(id = R.string.mock_location_active, mockLocationTarget.label) + mockLocationStatus == MockLocationStatus.ELIGIBLE && mockLocationEnabled -> + stringResource(id = R.string.mock_location_waiting_for_provider) + else -> null + } + + if (statusSubtitle != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + statusSubtitle, + style = MaterialTheme.typography.bodySmall, + color = if (mockLocationStatus == MockLocationStatus.ORPHANED) + MaterialTheme.colorScheme.error + else + TextMuted + ) + } } Spacer(modifier = Modifier.height(18.dp))