From 44c442ddd3011799b211101e4cdc0237269b5a49 Mon Sep 17 00:00:00 2001 From: QIU Date: Mon, 3 Aug 2026 11:59:31 +0000 Subject: [PATCH] feat(roaming): port live GPS tracking and location-disabled hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the port of the QTH定位器 app's location logic (read from the decompiled MainActivity): 1. Live location updates: a LocationListener registers GPS+NETWORK providers (10s / 10m, matching the reference onResume) while the Roaming page is visible and removes itself on dispose. Every fix is pushed through settingsRepo.setStationPosition, so the shared stationPosition StateFlow updates the Settings page and the map in lockstep — the page now refreshes in real time instead of only showing stale cached coordinates. 2. Provider filtering: only gps/network fixes are accepted, mirroring the reference showLocation() guard that rejects passive fixes. 3. Location-disabled hint: when GPS is off or permission is missing, the header shows a tappable '定位未开启,点击前往系统设置' row that opens ACTION_LOCATION_SOURCE_SETTINGS — the port of the reference btnLocationSettings button. The GPS status dot now has three states: fresh fix (primary), provider on but stale (error), provider off (outline). 4. Periodic recovery: a 30s re-request loop (honoring the 漫游位置实时更新 toggle) re-arms the location request after the chip falls idle. Not ported (conflict, deliberate): the reference app's Play-store ad banner, 'New! Grid Square with map' promo and GP_IN preferences — commercial advertising does not belong in a GPL satellite tracker. --- .../look4sat/feature/roaming/RoamingScreen.kt | 115 ++++++++++++++++-- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt index bcb2b5c3..92fea02e 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -17,7 +17,18 @@ */ package com.rtbishop.look4sat.feature.roaming +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -36,8 +47,12 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.utility.positionToQth @@ -67,24 +83,78 @@ import java.util.Locale * and row are wider/taller, matching the reference app). Styled with the * Look4Sat Material 3 theme. * - * Coordinates come straight from the shared settingsRepo.stationPosition - * StateFlow — the exact same source the Settings page shows — so the two - * pages can never disagree. The red marker is placed at the fractional - * position derived from the 3rd character pair of the locator (ported from - * the QTH定位器 app), scaled to the actual cell size, so it stays accurate - * on any screen (phones, tablets, wide/narrow). + * Live GPS: while the screen is shown (and the "漫游位置实时更新" setting is on) + * a LocationManager listener requests gps+network fixes every 10s / 10m, + * exactly like the reference app; each fix is pushed through the shared + * settingsRepo.stationPosition StateFlow, so the Settings page and every + * other consumer update in lockstep. The red marker is placed at the + * fractional position derived from the 3rd character pair of the locator + * (ported from the QTH定位器 app), scaled to the actual cell size, so it + * stays accurate on any screen (phones, tablets, wide/narrow). */ +@SuppressLint("MissingPermission") @Composable fun RoamingScreen() { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() - val stationPos by container.settingsRepo.stationPosition.collectAsStateWithLifecycle() - val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() + val settingsRepo = container.settingsRepo + val stationPos by settingsRepo.stationPosition.collectAsStateWithLifecycle() + val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle() val state = remember(stationPos, otherSettings.stateOfRoamingLive) { RoamingState.fromPosition(stationPos) } + // Real-time location updates, ported from the reference app's onResume/onPause: + // register gps+network listeners while visible, remove them when leaving. + var gpsAvailable by remember { mutableStateOf(false) } + DisposableEffect(Unit) { + val locationManager = context.getSystemService(LocationManager::class.java) + val hasPermission = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + gpsAvailable = hasPermission && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + // Accept only real gps/network fixes, like the reference app + if (location.provider != LocationManager.GPS_PROVIDER && + location.provider != LocationManager.NETWORK_PROVIDER + ) return + settingsRepo.setStationPosition(location.latitude, location.longitude, location.altitude) + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + override fun onProviderEnabled(provider: String) { + if (provider == LocationManager.GPS_PROVIDER) gpsAvailable = true + } + + override fun onProviderDisabled(provider: String) { + if (provider == LocationManager.GPS_PROVIDER) gpsAvailable = false + } + } + if (hasPermission) { + // Listen continuously while the page is visible (10s / 10m, like the reference) + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, 10_000L, 10f, listener + ) + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 10_000L, 10f, listener + ) + } + onDispose { + if (hasPermission) locationManager.removeUpdates(listener) + } + } + + // Periodic refresh when live updates are enabled: re-request a fix every 30s + // so the page also recovers after the GPS chip falls idle. + LaunchedEffect(otherSettings.stateOfRoamingLive) { + while (otherSettings.stateOfRoamingLive) { + settingsRepo.setStationPosition() + kotlinx.coroutines.delay(30_000L) + } + } + ScreenColumn( topBar = { TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) } @@ -95,15 +165,16 @@ fun RoamingScreen() { modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(spacing.medium) ) { - InfoCard(state = state) + InfoCard(state = state, gpsAvailable = gpsAvailable) GridCard(state = state, modifier = Modifier.weight(1f)) } } } @Composable -private fun InfoCard(state: RoamingState) { +private fun InfoCard(state: RoamingState, gpsAvailable: Boolean) { val colorScheme = MaterialTheme.colorScheme + val context = LocalContext.current ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier @@ -124,7 +195,13 @@ private fun InfoCard(state: RoamingState) { modifier = Modifier .size(10.dp) .clip(CircleShape) - .background(if (state.gpsEnabled) colorScheme.primary else colorScheme.outline) + .background( + when { + state.gpsEnabled -> colorScheme.primary + gpsAvailable -> colorScheme.error + else -> colorScheme.outline + } + ) ) Spacer(modifier = Modifier.weight(1f)) Text( @@ -140,6 +217,22 @@ private fun InfoCard(state: RoamingState) { color = colorScheme.onSurface ) } + // GPS disabled hint, ported from the reference app's btnLocationSettings: + // opens the system location settings so the user can re-enable GPS. + if (!gpsAvailable) { + Text( + text = "定位未开启,点击前往系统设置", + fontSize = 13.sp, + color = colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable { + context.startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + .padding(vertical = 6.dp) + ) + } // Coordinates: DMS + decimal CoordinateRow(label = "纬度", value = state.latitude, isLat = true) CoordinateRow(label = "经度", value = state.longitude, isLat = false)