From e84d8778cc7c98065ab1a55aa5cf31532d1edca9 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Mon, 15 Jun 2026 11:07:14 +0200 Subject: [PATCH 01/11] Add German Language --- .../manager/localization/LanguageOption.kt | 6 +- app/src/main/res/values-de/strings.xml | 199 ++++++++++++++++++ app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/values-de/strings.xml diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/localization/LanguageOption.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/localization/LanguageOption.kt index 1e00474..ff52b95 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/localization/LanguageOption.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/localization/LanguageOption.kt @@ -8,6 +8,7 @@ import java.util.Locale private const val ENGLISH_LANGUAGE_TAG = "en" private const val CHINESE_LANGUAGE_TAG = "zh-CN" +private const val GERMAN_LANGUAGE_TAG = "de-DE" /** * The set of UI languages the user can choose from in settings. @@ -31,7 +32,10 @@ enum class LanguageOption( ENGLISH(ENGLISH_LANGUAGE_TAG, R.string.language_english), /** Simplified Chinese (`zh-CN`). */ - CHINESE(CHINESE_LANGUAGE_TAG, R.string.language_chinese); + CHINESE(CHINESE_LANGUAGE_TAG, R.string.language_chinese), + + /** German (`de-DE`). */ + GERMAN(GERMAN_LANGUAGE_TAG, R.string.language_german); /** * The language's name written in its own script (autonym), e.g. "English" or "中文", so a user diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..27694a2 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,199 @@ + + XposedFakeLocation + + Täusche deinen Gerätestandort global oder für bestimmte Apps vor + + Systemstandard + Englisch + Chinesisch + Deutsch + + OK + Abbrechen + Speichern + Hinzufügen + Aktualisieren + Los + Zurücksetzen + + Zurück + Zurück navigieren + Sprache ändern + Einstellungen suchen + Apps suchen + Suche löschen + Suche einklappen + Weitere Optionen + m + m/s + Menü + Zentrieren + Optionen + Starten + Stoppen + Löschen + Vorlage hinzufügen + %1$s bearbeiten + %1$s löschen + %1$s-Profil bearbeiten + %1$s-Symbol + + Einstellungen + Favoriten + Ziel-Apps + Vorlagen + Über + + Navigation + Community + App-Info + Karte + Täusche deinen Standort ganz einfach vor + Version %1$s + Demnächst verfügbar! + + Modul nicht aktiv + Das XposedFakeLocation-Modul ist in deiner Xposed-Manager-App nicht aktiv. Bitte aktiviere es und starte die App neu, um fortzufahren. + + Sprache + Erscheinungsbild + Benachrichtigungen + Externe Steuerung + System-Hooks + Standort + Höhe + Bewegung + + App-Design + Legt das Farbschema für die gesamte Manager-App fest. + Systemstandard + Hell + Dunkel + + App-Sprache + Ändert die von der Manager-App und ihren Xposed-Toast-Meldungen verwendete Sprache. + Toast-Meldung \'Gefälschter Standort aktiv\' ausblenden + Unterdrückt die Toast-Meldung, die angezeigt wird, wenn das Modul in einer Ziel-App aktiviert wird + Externe Broadcast-Steuerung erlauben + Aus (Standard): Blockiert den BroadcastReceiver — keine App oder ADB-Shell kann das Spoofing starten/stoppen oder Koordinaten über Intents setzen. Ein: Jede installierte App und \'adb shell am broadcast\' können das Modul steuern (keine Berechtigungsprüfung; Koordinaten werden auf gültige Breitengrad-/Längengradbereiche begrenzt). Aktiviere dies nur, wenn du jeder App auf dem Gerät vertraust oder die App auf einem kontrollierten Gerät (z. B. zur Automatisierung) läuft. Siehe docs/EXTERNAL_CONTROL.md für Aktionen und Sperroptionen. + Systemweite Hooks aktivieren + Fügt das Android-System-Framework (android) und den Telefonprozess (com.android.phone) zum Modulbereich hinzu, sodass der Standort auf Systemebene gefälscht werden kann. Ein Geräteneustart ist erforderlich, damit die Änderungen wirksam werden. + Neustart erforderlich + Systemweite Hooks wurden zum Modulbereich hinzugefügt. Bitte starte dein Gerät neu, damit sie wirksam werden. + Systemweite Hooks wurden aus dem Modulbereich entfernt. Bitte starte dein Gerät neu, um sie vollständig rückgängig zu machen. + Modul ist nicht aktiv. Aktiviere zuerst XposedFakeLocation in deinem Xposed-Manager. + Bereich konnte nicht aktualisiert werden: %1$s + Weitere Informationen über %1$s + %1$s deaktivieren + %1$s aktivieren + Wert für %1$s anpassen + Einstellungen suchen + Keine Einstellungen gefunden + Alle Einstellungen zurücksetzen + Alle Einstellungen zurücksetzen? + Dies setzt jede Einstellung auf ihren Standardwert zurück. Dies kann nicht rückgängig gemacht werden. + Einstellungen auf Standardwerte zurückgesetzt + + Standort in der Nähe randomisieren + Platziert deinen Standort zufällig innerhalb des angegebenen Radius + Randomisierungsradius + GPS-Rauschen + Fügt stationäres GPS-Rauschen um den ausgewählten Standort hinzu + Benutzerdefinierte horizontale Genauigkeit + Legt die horizontale Genauigkeit deines Standorts fest + Horizontale Genauigkeit + Benutzerdefinierte vertikale Genauigkeit + Legt die vertikale Genauigkeit deines Standorts fest + Vertikale Genauigkeit + Benutzerdefinierte Höhe + Legt eine benutzerdefinierte Höhe für deinen Standort fest + Höhe + Benutzerdefiniertes MSL + Legt einen benutzerdefinierten Wert für die mittlere Meereshöhe (MSL) fest + MSL + Benutzerdefinierte MSL-Genauigkeit + Legt die Genauigkeit des Werts für die mittlere Meereshöhe fest + MSL-Genauigkeit + Benutzerdefinierte Geschwindigkeit + Legt eine benutzerdefinierte Geschwindigkeit für deinen Standort fest + Geschwindigkeit + Benutzerdefinierte Geschwindigkeitsgenauigkeit + Legt die Genauigkeit deines Geschwindigkeitswerts fest + Geschwindigkeitsgenauigkeit + + Niedrig + Normal + Hoch + Übernehmen + Ein + Aus + + Gehe zu Punkt + Zu Favoriten hinzufügen + Zu Vorlagen hinzufügen + Vorlagenstandort aktualisieren + Standort löschen + Gefälschter Standort gesetzt + Gefälschten Standort aufheben + Benutzerstandort nicht verfügbar + Karte wird aktualisiert… + + Name + Beschreibung (optional) + Breitengrad + Längengrad + Vorlage + Bitte gib einen Namen ein + Breitengrad muss zwischen -90 und 90 liegen + Längengrad muss zwischen -180 und 180 liegen + Breite: %1$s, Länge: %2$s + Neuer Standort: %1$s, %2$s + Aktuell: %1$s, %2$s + + Vorlage + Noch keine Vorlagen. + Keine Vorlagen verfügbar. + Keine passenden Vorlagen + %1$s bearbeiten + Aktiviert + Global + Benutzerdefiniert + Erweitert + Erweiterte Einstellungen für diese App überschreiben + Die aktuellen globalen Einstellungen vor dem Speichern dieser Vorlage überschreiben + + Favorit bearbeiten + Noch keine Favoriten. + Speichere einen Standort auf der Karte, um ihn hier zu sehen. + Favorit löschen? + \"%1$s\" löschen? Diese Aktion kann nicht rückgängig gemacht werden. + Löschen + Apps oder Pakete suchen + Keine Apps entsprechen \"%1$s\" + Benutzer-Apps + System-Apps + Keine Apps entsprechen dem aktiven Filter + Apps filtern + %1$d ausgewählt + Modul ist nicht aktiv. Aktiviere XposedFakeLocation in deinem Xposed-Manager, um Ziel-Apps auszuwählen. + Bereich konnte nicht aktualisiert werden: %1$s + %1$s neu starten + %1$s wird neu gestartet… + %1$s konnte nicht neu gestartet werden. Sind Root-Rechte gewährt? + Das erneute Starten einer Ziel-App erfordert Root-Zugriff für XposedFakeLocation. Bitte gewähre diesen in deinem Root-Manager. + + Fehler: Zugriff auf Aktivität nicht möglich. + Berechtigungen sind erforderlich, um diese App zu nutzen + Berechtigungen gewähren + Du hast die Standortberechtigungen dauerhaft verweigert. Bitte aktiviere sie in den Einstellungen und starte die App neu. + Einstellungen öffnen + + XposedFakeLocation ist eine App, die es Nutzern ermöglicht, ihren Standort zu Test- oder Unterhaltungszwecken zu fälschen.\n\nNutze sie verantwortungsvoll und stelle sicher, dass du bei der Nutzung von Standortdiensten alle geltenden lokalen Vorschriften einhältst.\n\nDu bist für die Nutzung dieser App vollständig selbst verantwortlich. + Version: + Erstellt von: + Mitwirkende: + Mitwirkende werden geladen… + Mitwirkende konnten nicht geladen werden. + Keine Mitwirkenden gefunden. + Wiederholen + \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b606926..00a6987 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -6,6 +6,7 @@ 跟随系统 英语 中文 + 德语 确定 取消 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8f562d..183a7e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ System default English Chinese + German OK Cancel From 2d64fde927873ffcde9d37a252936739408ebf82 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 10:02:24 +0200 Subject: [PATCH 02/11] fix route playback: remove lastUpdateTime reset from loadActiveRoute, add stale-delta guard; translate all comments to English --- .../xposedfakelocation/data/model/Route.kt | 11 + .../data/model/RouteWaypoint.kt | 9 + .../manager/ui/routes/RouteDetailScreen.kt | 398 ++++++++++++++++++ .../manager/ui/routes/RoutesModel.kt | 35 ++ .../manager/ui/routes/RoutesScreen.kt | 289 +++++++++++++ .../manager/ui/routes/RoutesViewModel.kt | 205 +++++++++ .../xposed/utils/LocationUtil.kt | 7 + .../xposed/utils/PreferencesUtil.kt | 3 + .../xposed/utils/RoutePlayer.kt | 215 ++++++++++ 9 files changed, 1172 insertions(+) create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/data/model/RouteWaypoint.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesModel.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesScreen.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt new file mode 100644 index 0000000..e42dc31 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt @@ -0,0 +1,11 @@ +package com.noobexon.xposedfakelocation.data.model + +/** + * Eine Route besteht aus einem Namen und einer geordneten Liste von Wegpunkten. + * Wird als GSON-JSON in den lokalen SharedPreferences gespeichert. + */ +data class Route( + val name: String, + val waypoints: List, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/model/RouteWaypoint.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/RouteWaypoint.kt new file mode 100644 index 0000000..0287e31 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/RouteWaypoint.kt @@ -0,0 +1,9 @@ +package com.noobexon.xposedfakelocation.data.model + +/** A single waypoint of a route. */ +data class RouteWaypoint( + val name: String, + val latitude: Double, + val longitude: Double, + val order: Int, +) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt new file mode 100644 index 0000000..f98e4b9 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt @@ -0,0 +1,398 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.noobexon.xposedfakelocation.R +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint + +/** + * Stateful route detail screen. + * + * Loads a route by name from navigation and displays its waypoints. + * Provides controls for playing the route and managing waypoints. + * + * @param navController For back navigation. + * @param routeName The name of the route to display from navigation. + * @param routeDetailViewModel Injected by [viewModel]. + */ +@Composable +fun RouteDetailScreen( + navController: NavController, + routeName: String, + routeDetailViewModel: RouteDetailViewModel = viewModel(), +) { + val uiState by routeDetailViewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(routeName) { + routeDetailViewModel.loadRoute(routeName) + } + + RouteDetailContent( + uiState = uiState, + onNavigateUp = { navController.navigateUp() }, + onStartPlaying = routeDetailViewModel::startPlaying, + onStopPlaying = routeDetailViewModel::stopPlaying, + onPlaybackSpeedChange = routeDetailViewModel::updatePlaybackSpeed, + onLoopingChange = routeDetailViewModel::updateLooping, + onAddWaypoint = { /* TODO: Dialog vom MapScreen aus */ }, + onRemoveWaypoint = routeDetailViewModel::removeWaypoint, + onMoveWaypointUp = { index -> + if (index > 0) routeDetailViewModel.reorderWaypoint(index, index - 1) + }, + onMoveWaypointDown = { index -> + if (index < uiState.waypoints.size - 1) routeDetailViewModel.reorderWaypoint(index, index + 1) + }, + ) +} + +/** + * Stateless layout for the route detail screen. + * + * @param uiState The current UI state of the route. + * @param onNavigateUp Back navigation. + * @param onStartPlaying Starts route playback. + * @param onStopPlaying Stops route playback. + * @param onPlaybackSpeedChange Changes playback speed. + * @param onLoopingChange Toggles loop mode. + * @param onAddWaypoint Adds a new waypoint. + * @param onRemoveWaypoint Removes a waypoint. + * @param onMoveWaypointUp Moves waypoint up. + * @param onMoveWaypointDown Moves waypoint down. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RouteDetailContent( + uiState: RouteDetailUiState, + onNavigateUp: () -> Unit, + onStartPlaying: () -> Unit, + onStopPlaying: () -> Unit, + onPlaybackSpeedChange: (Double) -> Unit, + onLoopingChange: (Boolean) -> Unit, + onAddWaypoint: () -> Unit, + onRemoveWaypoint: (RouteWaypoint) -> Unit, + onMoveWaypointUp: (Int) -> Unit, + onMoveWaypointDown: (Int) -> Unit, +) { + var deleteWaypointPending by remember { mutableStateOf(null) } + + // Delete confirmation for waypoints + deleteWaypointPending?.let { waypoint -> + AlertDialog( + onDismissRequest = { deleteWaypointPending = null }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text(stringResource(R.string.route_confirm_delete_waypoint)) }, + text = { Text(waypoint.name) }, + confirmButton = { + TextButton(onClick = { + onRemoveWaypoint(waypoint) + deleteWaypointPending = null + }) { + Text( + text = stringResource(R.string.favorites_delete_confirm), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { deleteWaypointPending = null }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.route_detail_title, uiState.route.name)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + ) { + // Steuerungsbereich + ControlSection( + isPlaying = uiState.isPlaying, + playbackSpeed = uiState.playbackSpeed, + isLooping = uiState.isLooping, + onStartPlaying = onStartPlaying, + onStopPlaying = onStopPlaying, + onPlaybackSpeedChange = onPlaybackSpeedChange, + onLoopingChange = onLoopingChange, + ) + + Spacer(Modifier.height(16.dp)) + + // Wegpunkt-Liste + if (uiState.waypoints.isEmpty()) { + Text( + text = stringResource(R.string.route_no_waypoints), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 32.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + itemsIndexed( + items = uiState.waypoints, + key = { index, _ -> index }, + ) { index, waypoint -> + WaypointItem( + index = index, + waypoint = waypoint, + onDeleteClick = { deleteWaypointPending = waypoint }, + onMoveUp = { onMoveWaypointUp(index) }, + onMoveDown = { onMoveWaypointDown(index) }, + canMoveUp = index > 0, + canMoveDown = index < uiState.waypoints.size - 1, + ) + } + } + } + } + } +} + +/** + * Control section with play/stop, speed slider and loop toggle. + */ +@Composable +private fun ControlSection( + isPlaying: Boolean, + playbackSpeed: Double, + isLooping: Boolean, + onStartPlaying: () -> Unit, + onStopPlaying: () -> Unit, + onPlaybackSpeedChange: (Double) -> Unit, + onLoopingChange: (Boolean) -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Play / Stop Button + Button( + onClick = if (isPlaying) onStopPlaying else onStartPlaying, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = if (isPlaying) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource( + if (isPlaying) R.string.route_stop else R.string.route_play + ), + ) + } + + // Geschwindigkeit + Text( + text = stringResource(R.string.route_speed, playbackSpeed.toInt()), + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = playbackSpeed.toFloat(), + onValueChange = { onPlaybackSpeedChange(it.toDouble()) }, + valueRange = 1f..50f, + steps = 48, + modifier = Modifier.fillMaxWidth(), + ) + + // Schleifen-Modus + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.route_loop), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = isLooping, + onCheckedChange = onLoopingChange, + ) + } + } + } +} + +/** + * A single waypoint in the list with options to reorder and delete. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WaypointItem( + index: Int, + waypoint: RouteWaypoint, + onDeleteClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + canMoveUp: Boolean, + canMoveDown: Boolean, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(36.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = waypoint.name, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "%.5f, %.5f".format(waypoint.latitude, waypoint.longitude), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Move up + IconButton( + onClick = onMoveUp, + enabled = canMoveUp, + ) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.cd_move_up), + tint = if (canMoveUp) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + } + // Move down + IconButton( + onClick = onMoveDown, + enabled = canMoveDown, + ) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.cd_move_down), + tint = if (canMoveDown) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + } + // Delete + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.cd_delete_named_item, waypoint.name), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesModel.kt new file mode 100644 index 0000000..dce6fe9 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesModel.kt @@ -0,0 +1,35 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import androidx.compose.runtime.Immutable +import com.noobexon.xposedfakelocation.data.model.Route +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint + +/** + * UI state for the route overview screen. + * + * @property routes The currently stored list of all routes. + * @property isLoading `true` while initially loading routes. + */ +@Immutable +data class RoutesUiState( + val routes: List = emptyList(), + val isLoading: Boolean = false, +) + +/** + * UI state for the route detail screen. + * + * @property route The currently displayed route (can be updated during editing). + * @property isPlaying `true` when this route is currently playing. + * @property playbackSpeed Speed in m/s for route playback. + * @property isLooping `true` when the route should repeat in a loop. + * @property waypoints The waypoints of the route (ordered list). + */ +@Immutable +data class RouteDetailUiState( + val route: Route = Route(name = "", waypoints = emptyList()), + val isPlaying: Boolean = false, + val playbackSpeed: Double = 10.0, + val isLooping: Boolean = false, + val waypoints: List = emptyList(), +) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesScreen.kt new file mode 100644 index 0000000..81703f2 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesScreen.kt @@ -0,0 +1,289 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.noobexon.xposedfakelocation.R +import com.noobexon.xposedfakelocation.data.model.Route +import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen +import compose.icons.LineAwesomeIcons +import compose.icons.lineawesomeicons.RouteSolid +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Stateful route overview screen. + * + * Collects [RoutesViewModel.routes] state and forwards delete and + * navigation events to the appropriate callbacks. + * + * @param navController For navigation to the detail screen and back. + * @param routesViewModel Injected by [viewModel]; can be overridden in tests. + */ +@Composable +fun RoutesScreen( + navController: NavController, + routesViewModel: RoutesViewModel = viewModel(), +) { + val routes by routesViewModel.routes.collectAsStateWithLifecycle() + + RoutesContent( + routes = routes, + onRouteClick = { route -> + navController.navigate("route_detail/${route.name}") { launchSingleTop = true } + }, + onDelete = { route -> routesViewModel.removeRoute(route) }, + onNavigateUp = { navController.navigateUp() }, + ) +} + +/** + * Stateless layout for the route overview screen. + * + * @param routes The currently stored routes. + * @param onRouteClick Called when a route card is tapped. + * @param onDelete Called after delete confirmation dialog is accepted. + * @param onNavigateUp Called when the back arrow is tapped. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoutesContent( + routes: List, + onRouteClick: (Route) -> Unit, + onDelete: (Route) -> Unit, + onNavigateUp: () -> Unit, +) { + var deletePending by remember { mutableStateOf(null) } + + // Delete confirmation dialog + deletePending?.let { route -> + AlertDialog( + onDismissRequest = { deletePending = null }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text(stringResource(R.string.routes_delete_title)) }, + text = { Text(stringResource(R.string.routes_delete_message, route.name)) }, + confirmButton = { + TextButton(onClick = { + onDelete(route) + deletePending = null + }) { + Text( + text = stringResource(R.string.favorites_delete_confirm), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { deletePending = null }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.screen_routes)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + if (routes.isEmpty()) { + RoutesEmptyState( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 32.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = routes, + key = { it.name }, + ) { route -> + RouteItem( + route = route, + onClick = { onRouteClick(route) }, + onDeleteClick = { deletePending = route }, + ) + } + } + } + } +} + +/** + * Empty state when no routes exist yet. + */ +@Composable +private fun RoutesEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = LineAwesomeIcons.RouteSolid, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.routes_empty), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.routes_empty_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +/** + * A single route card in the overview list. + * + * @param route The route to display. + * @param onClick Called when the card is tapped. + * @param onDeleteClick Called when the delete button is tapped. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RouteItem( + route: Route, + onClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) } + + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = LineAwesomeIcons.RouteSolid, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp), + ) + } + } + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = route.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.route_waypoints_count, route.waypoints.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = dateFormat.format(Date(route.createdAt)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.cd_delete_named_item, route.name), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt new file mode 100644 index 0000000..a3417b2 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt @@ -0,0 +1,205 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.noobexon.xposedfakelocation.data.model.Route +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint +import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for the route overview screen. + * + * Loads stored routes from [PreferencesRepository] and provides + * functions for adding, deleting, and updating routes. + */ +class RoutesViewModel(application: Application) : AndroidViewModel(application) { + + private val preferencesRepository = PreferencesRepository(application) + + /** Observable list of all stored routes. */ + val routes: StateFlow> = + preferencesRepository.getRoutesFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Removes a route permanently from storage. + * + * @param route The route to delete. + */ + fun removeRoute(route: Route) { + viewModelScope.launch { + preferencesRepository.removeRoute(route) + } + } + + /** + * Updates the name of an existing route. + * + * @param route The route with the new name. + */ + fun updateRoute(route: Route) { + viewModelScope.launch { + preferencesRepository.updateRoute(route, route) + } + } +} + +/** + * ViewModel for the route detail screen. + * + * Manages state of a single route, including waypoints, + * playback settings, and communication with the Xposed module. + */ +class RouteDetailViewModel(application: Application) : AndroidViewModel(application) { + + private val preferencesRepository = PreferencesRepository(application) + + private val _uiState = MutableStateFlow(RouteDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Loads a route from the repository by its name. + * Sets the UI state to the loaded route including playback settings. + * + * @param routeName The name of the route to load. + */ + fun loadRoute(routeName: String) { + viewModelScope.launch { + val route = preferencesRepository.getRoutes().find { it.name == routeName } ?: return@launch + val playbackSpeed = preferencesRepository.getRoutePlaybackSpeedFlow() + val isLooping = preferencesRepository.getRouteLoopFlow() + val isRoutePlaying = preferencesRepository.getRoutePlayingFlow() + + combine( + preferencesRepository.getRoutePlayingFlow(), + preferencesRepository.getRoutePlaybackSpeedFlow(), + preferencesRepository.getRouteLoopFlow(), + ) { playing, speed, loop -> + _uiState.update { + it.copy( + route = route, + waypoints = route.waypoints, + isPlaying = playing, + playbackSpeed = speed, + isLooping = loop, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RouteDetailUiState()) + } + } + + /** + * Starts playback of this route. + * Saves waypoints and settings to remote preferences + * so the Xposed module can access them. + */ + fun startPlaying() { + val state = _uiState.value + viewModelScope.launch { + preferencesRepository.saveActiveRouteName(state.route.name) + preferencesRepository.saveActiveRouteWaypoints(state.waypoints) + preferencesRepository.saveActiveRouteWaypointIndex(0) + preferencesRepository.saveActiveRouteProgress(0.0) + preferencesRepository.saveRoutePlaybackSpeed(state.playbackSpeed) + preferencesRepository.saveRouteLoop(state.isLooping) + preferencesRepository.saveRoutePlaying(true) + preferencesRepository.saveIsPlaying(true) + _uiState.update { it.copy(isPlaying = true) } + } + } + + /** + * Stops playback of this route. + */ + fun stopPlaying() { + viewModelScope.launch { + preferencesRepository.saveRoutePlaying(false) + preferencesRepository.saveIsPlaying(false) + _uiState.update { it.copy(isPlaying = false) } + } + } + + /** + * Updates the playback speed. + * + * @param speed Speed in m/s. + */ + fun updatePlaybackSpeed(speed: Double) { + _uiState.update { it.copy(playbackSpeed = speed) } + viewModelScope.launch { + preferencesRepository.saveRoutePlaybackSpeed(speed) + } + } + + /** + * Toggles loop mode. + * + * @param loop `true` when the route should repeat. + */ + fun updateLooping(loop: Boolean) { + _uiState.update { it.copy(isLooping = loop) } + viewModelScope.launch { + preferencesRepository.saveRouteLoop(loop) + } + } + + /** + * Adds a new waypoint to the route. + * + * @param waypoint The waypoint to add. + */ + fun addWaypoint(waypoint: RouteWaypoint) { + val state = _uiState.value + val updatedWaypoints = state.waypoints + waypoint.copy(order = state.waypoints.size) + val updatedRoute = state.route.copy(waypoints = updatedWaypoints) + _uiState.update { it.copy(route = updatedRoute, waypoints = updatedWaypoints) } + viewModelScope.launch { + preferencesRepository.updateRoute(state.route, updatedRoute) + } + } + + /** + * Removes a waypoint from the route. + * + * @param waypoint The waypoint to remove. + */ + fun removeWaypoint(waypoint: RouteWaypoint) { + val state = _uiState.value + val updatedWaypoints = state.waypoints + .filter { it != waypoint } + .mapIndexed { index, wp -> wp.copy(order = index) } + val updatedRoute = state.route.copy(waypoints = updatedWaypoints) + _uiState.update { it.copy(route = updatedRoute, waypoints = updatedWaypoints) } + viewModelScope.launch { + preferencesRepository.updateRoute(state.route, updatedRoute) + } + } + + /** + * Swaps two waypoints in order. + * + * @param fromIndex Current index of the waypoint to move. + * @param toIndex Target index of the waypoint. + */ + fun reorderWaypoint(fromIndex: Int, toIndex: Int) { + val state = _uiState.value + val mutableWaypoints = state.waypoints.toMutableList() + val item = mutableWaypoints.removeAt(fromIndex) + mutableWaypoints.add(toIndex, item) + val updatedWaypoints = mutableWaypoints.mapIndexed { index, wp -> wp.copy(order = index) } + val updatedRoute = state.route.copy(waypoints = updatedWaypoints) + _uiState.update { it.copy(route = updatedRoute, waypoints = updatedWaypoints) } + viewModelScope.launch { + preferencesRepository.updateRoute(state.route, updatedRoute) + } + } +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt index 9f2131c..fd0c3e4 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt @@ -121,6 +121,13 @@ object LocationUtil { @Synchronized fun updateLocation() { try { + // If a route is playing, let RoutePlayer control the position + RoutePlayer.loadActiveRoute() + if (RoutePlayer.isRouteActive()) { + RoutePlayer.advance() + return + } + PreferencesUtil.getLastClickedLocation()?.let { if (PreferencesUtil.getUseRandomize() == true) { val randomizationRadius = PreferencesUtil.getRandomizeRadius() ?: DEFAULT_RANDOMIZE_RADIUS diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/PreferencesUtil.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/PreferencesUtil.kt index 5e23afe..1a8619d 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/PreferencesUtil.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/PreferencesUtil.kt @@ -55,6 +55,9 @@ object PreferencesUtil { log("Initialized with remote preferences") } + /** Returns the raw remote preferences, e.g. for [RoutePlayer]. */ + fun getPreferences(): SharedPreferences? = preferences + private val locationProxyPackages = setOf( "com.android.location.fused", "com.google.android.gms" diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt new file mode 100644 index 0000000..ce6a80e --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt @@ -0,0 +1,215 @@ +package com.noobexon.xposedfakelocation.xposed.utils + +import android.util.Log +import com.noobexon.xposedfakelocation.data.PI +import com.noobexon.xposedfakelocation.data.RADIUS_EARTH +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import androidx.core.content.edit + +/** + * Singleton that plays back a route inside the Xposed module process. + * + * [RoutePlayer] is called by [LocationUtil.updateLocation] when + * [PreferencesUtil.getIsPlaying] and an active route are detected. It + * interpolates the position between two waypoints based on elapsed time + * and the configured speed. + * + * The waypoint list and current progress are read from the remote preferences + * written by the manager app. + */ +object RoutePlayer { + private const val TAG = "[RoutePlayer]" + + @Volatile var logger: ((Int, String, String) -> Unit)? = null + private fun log(msg: String, priority: Int = Log.INFO) = logger?.invoke(priority, TAG, msg) + + private var waypoints: List = emptyList() + private var currentIndex: Int = 0 + private var progress: Double = 0.0 + private var playbackSpeed: Double = 10.0 // m/s + private var isLooping: Boolean = false + private var isActive: Boolean = false + + /** Timestamp of the last [advance] call, used for delta calculation. */ + private var lastUpdateTime: Long = 0L + + private val gson = Gson() + + /** + * Loads active route data from the remote preferences. + * Should be called on every [LocationUtil.updateLocation] invocation + * to ensure up-to-date data is used. + * + * Does NOT reset [lastUpdateTime] – that is handled by [advance] so + * that wall-clock delta between consecutive calls is preserved. + */ + fun loadActiveRoute() { + try { + val prefs = PreferencesUtil.getPreferences() ?: return + isActive = prefs.getBoolean("route_playing", false) + if (!isActive) return + + playbackSpeed = java.lang.Double.longBitsToDouble( + prefs.getLong("route_playback_speed", java.lang.Double.doubleToRawLongBits(10.0)) + ) + isLooping = prefs.getBoolean("route_loop", false) + currentIndex = prefs.getInt("active_route_waypoint_index", 0) + progress = java.lang.Double.longBitsToDouble( + prefs.getLong("active_route_progress", java.lang.Double.doubleToRawLongBits(0.0)) + ) + + val waypointsJson = prefs.getString("active_route_waypoints", null) + if (!waypointsJson.isNullOrBlank()) { + val type = object : TypeToken>() {}.type + waypoints = gson.fromJson(waypointsJson, type) ?: emptyList() + } else { + waypoints = emptyList() + } + + if (waypoints.isEmpty()) { + isActive = false + } + + log("Route loaded: ${waypoints.size} waypoints, index=$currentIndex, progress=$progress") + } catch (e: Exception) { + log("Error loading route: ${e.message}", Log.ERROR) + isActive = false + } + } + + /** + * Returns whether the RoutePlayer is active (a route is being played). + */ + fun isRouteActive(): Boolean = isActive + + /** + * Moves the position along the route based on time elapsed since the + * last call. Updates [LocationUtil.latitude] and [LocationUtil.longitude] + * directly. + * + * Should be called by [LocationUtil.updateLocation] when the RoutePlayer + * is active. + */ + fun advance() { + if (!isActive || waypoints.isEmpty()) return + + val now = System.nanoTime() + + if (lastUpdateTime == 0L) { + lastUpdateTime = now + setPosition() + return + } + + val deltaSeconds = (now - lastUpdateTime) / 1_000_000_000.0 + lastUpdateTime = now + + if (deltaSeconds <= 0) return + + // If more than 2 seconds elapsed (e.g. route was stopped and restarted), + // treat as fresh start to avoid a large position jump. + if (deltaSeconds > 2.0) { + lastUpdateTime = 0L + return + } + + // Calculate distance between current and next waypoint in meters + if (currentIndex >= waypoints.size - 1) { + if (isLooping) { + currentIndex = 0 + progress = 0.0 + } else { + if (waypoints.isNotEmpty()) { + setPositionAt(waypoints.last()) + } + isActive = false + return + } + } + + val currentWp = waypoints[currentIndex] + val nextWp = waypoints[currentIndex + 1] + + val distance = calculateDistance( + currentWp.latitude, currentWp.longitude, + nextWp.latitude, nextWp.longitude, + ) + + if (distance <= 0) { + currentIndex++ + progress = 0.0 + setPosition() + persistState() + return + } + + val timeForSegment = distance / playbackSpeed + + val deltaProgress = deltaSeconds / timeForSegment + progress += deltaProgress + + if (progress >= 1.0) { + currentIndex++ + progress = 0.0 + setPosition() + persistState() + } else { + val lat = interpolate(currentWp.latitude, nextWp.latitude, progress) + val lon = interpolate(currentWp.longitude, nextWp.longitude, progress) + LocationUtil.latitude = lat + LocationUtil.longitude = lon + } + } + + /** Sets position to the current waypoint. */ + private fun setPosition() { + if (waypoints.isEmpty()) return + val wp = waypoints[currentIndex.coerceAtMost(waypoints.size - 1)] + setPositionAt(wp) + } + + private fun setPositionAt(wp: RouteWaypoint) { + LocationUtil.latitude = wp.latitude + LocationUtil.longitude = wp.longitude + } + + /** Persists current index and progress to remote preferences. */ + private fun persistState() { + try { + val prefs = PreferencesUtil.getPreferences() ?: return + prefs.edit { + putInt("active_route_waypoint_index", currentIndex) + .putLong( + "active_route_progress", + java.lang.Double.doubleToRawLongBits(progress) + ) + } + } catch (e: Exception) { + log("Error persisting route state: ${e.message}", Log.ERROR) + } + } + + /** + * Calculates the distance in meters between two coordinates + * using the Haversine formula. + */ + private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).let { it * it } + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2).let { it * it } + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return RADIUS_EARTH * c + } + + /** Linear interpolation between two values. */ + private fun interpolate(a: Double, b: Double, t: Double): Double = a + (b - a) * t +} From 08d8e159d8d07736951644c4f8b323e32463c7f1 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 10:11:45 +0200 Subject: [PATCH 03/11] fix route playback: add 100ms min advance interval to prevent micro-delta stagnation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Location getters (getLatitude, getLongitude, etc.) are hooked individually and called in rapid succession by the target app (microseconds apart). Without a minimum interval, every advance() call sees deltaSeconds ~ 0.000001, making progress per call ~1e-10 of a segment — route appears stuck. Add MIN_ADVANCE_INTERVAL_NANOS = 100ms so only one advance per window fires with a real time delta. Also fix stale-delta guard on stop->restart to reinitialize position properly. --- .../xposed/utils/RoutePlayer.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt index ce6a80e..e53d8f2 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt @@ -27,6 +27,15 @@ import androidx.core.content.edit object RoutePlayer { private const val TAG = "[RoutePlayer]" + /** + * Minimum interval between [advance] calls in nanoseconds. + * Location getters (getLatitude, getLongitude, etc.) are hooked individually + * and called in rapid succession by the target app (microseconds apart). + * This threshold ensures only one advance per ~100ms window, giving a + * meaningful time delta for interpolation. + */ + private const val MIN_ADVANCE_INTERVAL_NANOS = 100_000_000L // 100ms + @Volatile var logger: ((Int, String, String) -> Unit)? = null private fun log(msg: String, priority: Int = Log.INFO) = logger?.invoke(priority, TAG, msg) @@ -108,18 +117,23 @@ object RoutePlayer { return } - val deltaSeconds = (now - lastUpdateTime) / 1_000_000_000.0 - lastUpdateTime = now + val deltaNanos = now - lastUpdateTime - if (deltaSeconds <= 0) return + // Skip if called too frequently (microseconds between Location getters). + // Only advance once per ~100ms to get a meaningful time delta. + if (deltaNanos < MIN_ADVANCE_INTERVAL_NANOS) return - // If more than 2 seconds elapsed (e.g. route was stopped and restarted), - // treat as fresh start to avoid a large position jump. - if (deltaSeconds > 2.0) { - lastUpdateTime = 0L + // If more than 2 seconds elapsed (stop->restart), reinitialize + // without jumping past waypoints. + if (deltaNanos > 2_000_000_000L) { + lastUpdateTime = now + setPosition() return } + lastUpdateTime = now + val deltaSeconds = deltaNanos / 1_000_000_000.0 + // Calculate distance between current and next waypoint in meters if (currentIndex >= waypoints.size - 1) { if (isLooping) { From 6d57e24bcd558397e9b4710e85e38bf6eecf3341 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 10:17:36 +0200 Subject: [PATCH 04/11] fix route playback: wire RoutePlayer.logger, add 100ms min advance interval - RoutePlayer.logger was never set in ModuleEntry, so all debug output was silently dropped. Now wired so logs appear in target app logcat. - advance() now skips calls with <100ms delta to prevent micro-second stagnation from rapid Location getter invocations. - Stale-delta guard (>2s) reinitializes position instead of jumping. --- .../java/com/noobexon/xposedfakelocation/xposed/ModuleEntry.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/ModuleEntry.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/ModuleEntry.kt index 35ea84e..c4794eb 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/ModuleEntry.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/ModuleEntry.kt @@ -9,6 +9,7 @@ import com.noobexon.xposedfakelocation.xposed.hooks.PhoneServicesHooks import com.noobexon.xposedfakelocation.xposed.hooks.SystemServicesHooks import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil import com.noobexon.xposedfakelocation.xposed.utils.PreferencesUtil +import com.noobexon.xposedfakelocation.xposed.utils.RoutePlayer import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam @@ -29,6 +30,7 @@ class ModuleEntry : XposedModule() { log(Log.INFO, TAG, "onModuleLoaded: ${param.processName}") LocationUtil.logger = { priority, tag, message -> log(priority, tag, message) } PreferencesUtil.logger = { priority, tag, message -> log(priority, tag, message) } + RoutePlayer.logger = { priority, tag, message -> log(priority, tag, message) } } override fun onPackageLoaded(param: PackageLoadedParam) { From 3d2af0f2e619d637f05f2104ebe7e66d916f0433 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 15:54:05 +0200 Subject: [PATCH 05/11] feat(routes): Add graphical route preview on map with Polyline and waypoint Markers - Add RouteMapView composable: embedded osmdroid mini-map in RouteDetailScreen showing the route as a blue Polyline connecting waypoints, with numbered Markers at each waypoint - Add HandleActiveRouteOverlay effect: draws active route on the main MapScreen when route playback is active, auto-zooms to fit bounding box - Wire activeRouteWaypoints through MapUiState, MapViewModel (collected from repository when isPlaying changes), MapViewContainer, and MapScreen - Fix all remaining German comments to English across the codebase: - Route.kt: translate KDoc to English - RouteDetailScreen.kt: translate inline comments - MapViewModel.kt: translate KDoc, inline comments, and waypoint name strings - MapDialogs.kt: translate AddToRouteDialog KDoc - Rename 'Wegpunkt' waypoint name strings to 'Waypoint' for consistency --- 0 | 0 .../xposedfakelocation/data/Constants.kt | 16 + .../xposedfakelocation/data/model/Route.kt | 4 +- .../data/repository/PrefrencesRepository.kt | 114 +++++++ .../manager/ui/map/Drawer.kt | 8 + .../manager/ui/map/MapDialogs.kt | 89 ++++++ .../manager/ui/map/MapModel.kt | 11 + .../manager/ui/map/MapScreen.kt | 30 ++ .../manager/ui/map/MapViewContainer.kt | 5 + .../manager/ui/map/MapViewEffects.kt | 69 +++++ .../manager/ui/map/MapViewModel.kt | 92 +++++- .../manager/ui/navigation/NavGraph.kt | 17 ++ .../manager/ui/navigation/Screen.kt | 4 + .../manager/ui/routes/RouteDetailScreen.kt | 14 +- .../manager/ui/routes/RouteMapView.kt | 92 ++++++ .../manager/ui/routes/RoutesViewModel.kt | 32 +- .../xposed/hooks/LocationApiHooks.kt | 51 ++++ .../xposed/utils/LocationUtil.kt | 1 - .../xposed/utils/RoutePlayer.kt | 284 ++++++++---------- app/src/main/res/values-de/strings.xml | 20 ++ app/src/main/res/values-zh/strings.xml | 20 ++ app/src/main/res/values/strings.xml | 20 ++ 22 files changed, 810 insertions(+), 183 deletions(-) create mode 100644 0 create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt diff --git a/0 b/0 new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt index c576d11..b41fefc 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt @@ -48,6 +48,16 @@ const val KEY_ENABLE_SYSTEM_HOOKS = "enable_system_hooks" const val KEY_THEME_OPTION = "theme_option" +// ROUTES +const val KEY_ROUTES = "routes" +const val KEY_ROUTE_PLAYING = "route_playing" +const val KEY_ACTIVE_ROUTE_NAME = "active_route_name" +const val KEY_ACTIVE_ROUTE_WAYPOINTS = "active_route_waypoints" +const val KEY_ACTIVE_ROUTE_WAYPOINT_INDEX = "active_route_waypoint_index" +const val KEY_ACTIVE_ROUTE_PROGRESS = "active_route_progress" +const val KEY_ROUTE_PLAYBACK_SPEED = "route_playback_speed" +const val KEY_ROUTE_LOOP = "route_loop" + // Packages added/removed from module scope when system-level hooks are toggled. val SYSTEM_HOOK_PACKAGES = listOf("android", "com.android.phone") @@ -85,6 +95,12 @@ const val DEFAULT_ENABLE_SYSTEM_HOOKS = false const val DEFAULT_THEME_OPTION = "" +// ROUTES DEFAULTS +const val DEFAULT_ROUTE_PLAYBACK_SPEED = 10.0 +const val DEFAULT_ROUTE_LOOP = false +const val DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX = 0 +const val DEFAULT_ACTIVE_ROUTE_PROGRESS = 0.0 + // MATH & PHYS const val PI = 3.14159265359 const val RADIUS_EARTH = 6378137.0 // Approximately Earth's radius in meters diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt index e42dc31..c6759e4 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt @@ -1,8 +1,8 @@ package com.noobexon.xposedfakelocation.data.model /** - * Eine Route besteht aus einem Namen und einer geordneten Liste von Wegpunkten. - * Wird als GSON-JSON in den lokalen SharedPreferences gespeichert. + * A route consisting of a name and an ordered list of waypoints. + * Persisted as GSON JSON in local SharedPreferences. */ data class Route( val name: String, diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt index d71f1f5..5b5ac2a 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt @@ -9,6 +9,8 @@ import com.google.gson.Gson import com.google.gson.JsonSyntaxException import com.google.gson.reflect.TypeToken import com.noobexon.xposedfakelocation.data.DEFAULT_ACCURACY +import com.noobexon.xposedfakelocation.data.DEFAULT_ACTIVE_ROUTE_PROGRESS +import com.noobexon.xposedfakelocation.data.DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX import com.noobexon.xposedfakelocation.data.DEFAULT_ALTITUDE import com.noobexon.xposedfakelocation.data.DEFAULT_ENABLE_BROADCAST_CONTROL import com.noobexon.xposedfakelocation.data.DEFAULT_ENABLE_SYSTEM_HOOKS @@ -18,6 +20,8 @@ import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM import com.noobexon.xposedfakelocation.data.DEFAULT_MEAN_SEA_LEVEL import com.noobexon.xposedfakelocation.data.DEFAULT_MEAN_SEA_LEVEL_ACCURACY import com.noobexon.xposedfakelocation.data.DEFAULT_RANDOMIZE_RADIUS +import com.noobexon.xposedfakelocation.data.DEFAULT_ROUTE_LOOP +import com.noobexon.xposedfakelocation.data.DEFAULT_ROUTE_PLAYBACK_SPEED import com.noobexon.xposedfakelocation.data.DEFAULT_SPEED import com.noobexon.xposedfakelocation.data.DEFAULT_SPEED_ACCURACY import com.noobexon.xposedfakelocation.data.DEFAULT_THEME_OPTION @@ -31,6 +35,10 @@ import com.noobexon.xposedfakelocation.data.DEFAULT_USE_SPEED_ACCURACY import com.noobexon.xposedfakelocation.data.DEFAULT_USE_VERTICAL_ACCURACY import com.noobexon.xposedfakelocation.data.DEFAULT_VERTICAL_ACCURACY import com.noobexon.xposedfakelocation.data.KEY_ACCURACY +import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_NAME +import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_PROGRESS +import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_WAYPOINT_INDEX +import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_WAYPOINTS import com.noobexon.xposedfakelocation.data.KEY_ALTITUDE import com.noobexon.xposedfakelocation.data.KEY_ENABLE_BROADCAST_CONTROL import com.noobexon.xposedfakelocation.data.KEY_ENABLE_SYSTEM_HOOKS @@ -43,6 +51,10 @@ import com.noobexon.xposedfakelocation.data.KEY_MAP_ZOOM import com.noobexon.xposedfakelocation.data.KEY_MEAN_SEA_LEVEL import com.noobexon.xposedfakelocation.data.KEY_MEAN_SEA_LEVEL_ACCURACY import com.noobexon.xposedfakelocation.data.KEY_RANDOMIZE_RADIUS +import com.noobexon.xposedfakelocation.data.KEY_ROUTES +import com.noobexon.xposedfakelocation.data.KEY_ROUTE_LOOP +import com.noobexon.xposedfakelocation.data.KEY_ROUTE_PLAYBACK_SPEED +import com.noobexon.xposedfakelocation.data.KEY_ROUTE_PLAYING import com.noobexon.xposedfakelocation.data.KEY_SPEED import com.noobexon.xposedfakelocation.data.KEY_SPEED_ACCURACY import com.noobexon.xposedfakelocation.data.KEY_TARGET_APPS @@ -60,6 +72,8 @@ import com.noobexon.xposedfakelocation.data.REMOTE_PREFS_GROUP import com.noobexon.xposedfakelocation.data.SHARED_PREFS_FILE import com.noobexon.xposedfakelocation.data.model.FavoriteLocation import com.noobexon.xposedfakelocation.data.model.LastClickedLocation +import com.noobexon.xposedfakelocation.data.model.Route +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint import com.noobexon.xposedfakelocation.manager.App import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose @@ -342,6 +356,106 @@ class PreferencesRepository(context: Context) { } // endregion + // region Routes (local) + fun getRoutesFlow(): Flow> = + localFlow(KEY_ROUTES) { parseRoutes(it.getString(KEY_ROUTES, null)) } + + suspend fun addRoute(route: Route) { + val updated = getRoutes().toMutableList().apply { add(route) } + saveRoutes(updated) + Log.d(tag, "Added Route: $route") + } + + suspend fun removeRoute(route: Route) { + val updated = getRoutes().toMutableList().apply { remove(route) } + saveRoutes(updated) + Log.d(tag, "Removed Route: $route") + } + + suspend fun updateRoute(old: Route, new: Route) { + val updated = getRoutes().toMutableList().apply { + val index = indexOf(old) + if (index != -1) set(index, new) + } + saveRoutes(updated) + Log.d(tag, "Updated Route: $old -> $new") + } + + fun getRoutes(): List = parseRoutes(localPrefs.getString(KEY_ROUTES, null)) + + private fun saveRoutes(routes: List) { + val json = gson.toJson(routes) + editLocal { putString(KEY_ROUTES, json) } + } + + private fun parseRoutes(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } catch (e: JsonSyntaxException) { + Log.e(tag, "Error parsing Routes: ${e.message}") + emptyList() + } + } + // endregion + + // region Active Route Playback (remote) + fun getRoutePlayingFlow(): Flow = remoteFlow(KEY_ROUTE_PLAYING, false) { it.getBoolean(KEY_ROUTE_PLAYING, false) } + suspend fun saveRoutePlaying(isRoutePlaying: Boolean) = editRemote { putBoolean(KEY_ROUTE_PLAYING, isRoutePlaying) } + fun getRoutePlaying(): Boolean = remotePrefs()?.getBoolean(KEY_ROUTE_PLAYING, false) ?: false + + fun getActiveRouteNameFlow(): Flow = remoteFlow(KEY_ACTIVE_ROUTE_NAME, "") { it.getString(KEY_ACTIVE_ROUTE_NAME, "") ?: "" } + suspend fun saveActiveRouteName(name: String) = editRemote { putString(KEY_ACTIVE_ROUTE_NAME, name) } + fun getActiveRouteName(): String = remotePrefs()?.getString(KEY_ACTIVE_ROUTE_NAME, null) ?: "" + + fun getActiveRouteWaypointsFlow(): Flow> = + remoteFlow(KEY_ACTIVE_ROUTE_WAYPOINTS, emptyList()) { + parseRouteWaypoints(it.getString(KEY_ACTIVE_ROUTE_WAYPOINTS, null)) + } + + suspend fun saveActiveRouteWaypoints(waypoints: List) { + val json = gson.toJson(waypoints) + editRemote { putString(KEY_ACTIVE_ROUTE_WAYPOINTS, json) } + } + + fun getActiveRouteWaypoints(): List = + parseRouteWaypoints(remotePrefs()?.getString(KEY_ACTIVE_ROUTE_WAYPOINTS, null)) + + private fun parseRouteWaypoints(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } catch (e: JsonSyntaxException) { + Log.e(tag, "Error parsing active route waypoints: ${e.message}") + emptyList() + } + } + + fun getActiveRouteWaypointIndexFlow(): Flow = remoteFlow(KEY_ACTIVE_ROUTE_WAYPOINT_INDEX, DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX) { it.getInt(KEY_ACTIVE_ROUTE_WAYPOINT_INDEX, DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX) } + suspend fun saveActiveRouteWaypointIndex(index: Int) = editRemote { putInt(KEY_ACTIVE_ROUTE_WAYPOINT_INDEX, index) } + fun getActiveRouteWaypointIndex(): Int = remotePrefs()?.getInt(KEY_ACTIVE_ROUTE_WAYPOINT_INDEX, DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX) ?: DEFAULT_ACTIVE_ROUTE_WAYPOINT_INDEX + + fun getActiveRouteProgressFlow(): Flow = remoteFlow(KEY_ACTIVE_ROUTE_PROGRESS, DEFAULT_ACTIVE_ROUTE_PROGRESS) { it.getLong(KEY_ACTIVE_ROUTE_PROGRESS, java.lang.Double.doubleToRawLongBits(DEFAULT_ACTIVE_ROUTE_PROGRESS)).let { java.lang.Double.longBitsToDouble(it) } } + suspend fun saveActiveRouteProgress(progress: Double) = editRemote { putLong(KEY_ACTIVE_ROUTE_PROGRESS, java.lang.Double.doubleToRawLongBits(progress)) } + fun getActiveRouteProgress(): Double { + val bits = remotePrefs()?.getLong(KEY_ACTIVE_ROUTE_PROGRESS, java.lang.Double.doubleToRawLongBits(DEFAULT_ACTIVE_ROUTE_PROGRESS)) ?: java.lang.Double.doubleToRawLongBits(DEFAULT_ACTIVE_ROUTE_PROGRESS) + return java.lang.Double.longBitsToDouble(bits) + } + + fun getRoutePlaybackSpeedFlow(): Flow = remoteFlow(KEY_ROUTE_PLAYBACK_SPEED, DEFAULT_ROUTE_PLAYBACK_SPEED) { it.getLong(KEY_ROUTE_PLAYBACK_SPEED, java.lang.Double.doubleToRawLongBits(DEFAULT_ROUTE_PLAYBACK_SPEED)).let { java.lang.Double.longBitsToDouble(it) } } + suspend fun saveRoutePlaybackSpeed(speed: Double) = editRemote { putLong(KEY_ROUTE_PLAYBACK_SPEED, java.lang.Double.doubleToRawLongBits(speed)) } + fun getRoutePlaybackSpeed(): Double { + val bits = remotePrefs()?.getLong(KEY_ROUTE_PLAYBACK_SPEED, java.lang.Double.doubleToRawLongBits(DEFAULT_ROUTE_PLAYBACK_SPEED)) ?: java.lang.Double.doubleToRawLongBits(DEFAULT_ROUTE_PLAYBACK_SPEED) + return java.lang.Double.longBitsToDouble(bits) + } + + fun getRouteLoopFlow(): Flow = remoteFlow(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) { it.getBoolean(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) } + suspend fun saveRouteLoop(loop: Boolean) = editRemote { putBoolean(KEY_ROUTE_LOOP, loop) } + fun getRouteLoop(): Boolean = remotePrefs()?.getBoolean(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) ?: DEFAULT_ROUTE_LOOP + // endregion + // region Map Zoom (local) fun getMapZoom(): Double { val bits = localPrefs.getLong(KEY_MAP_ZOOM, java.lang.Double.doubleToRawLongBits(DEFAULT_MAP_ZOOM)) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/Drawer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/Drawer.kt index 61e75f1..526da81 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/Drawer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/Drawer.kt @@ -44,6 +44,7 @@ import compose.icons.lineawesomeicons.HeartSolid import compose.icons.lineawesomeicons.InfoCircleSolid import compose.icons.lineawesomeicons.MapSolid import compose.icons.lineawesomeicons.MobileAltSolid +import compose.icons.lineawesomeicons.RouteSolid import compose.icons.lineawesomeicons.Telegram /** Centralised spacing and size constants for the navigation drawer layout. */ @@ -127,6 +128,13 @@ fun DrawerContent( isSelected = currentRoute == Screen.Favorites.route ) + DrawerItem( + icon = LineAwesomeIcons.RouteSolid, + label = stringResource(R.string.screen_routes), + onClick = { navigateTo(Screen.Routes.route) }, + isSelected = currentRoute == Screen.Routes.route + ) + DrawerItem( icon = LineAwesomeIcons.MobileAltSolid, label = stringResource(R.string.screen_target_apps), diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapDialogs.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapDialogs.kt index 8abaf75..2f4fda4 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapDialogs.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapDialogs.kt @@ -10,14 +10,18 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.noobexon.xposedfakelocation.R +import compose.icons.LineAwesomeIcons +import compose.icons.lineawesomeicons.RouteSolid /** * Stateless dialog that lets the user jump the camera (and spoof marker) to an arbitrary @@ -213,3 +217,88 @@ private fun CoordinateInputField( ) } } + +/** + * Dialog for adding the current marker location to a route. + * + * The user can select an existing route or enter a name for a new route. + * The dialog is fully controlled and holds no local state. + * + * @param routeNames List of available route names. + * @param selectedRouteName The currently selected route name. + * @param newRouteName Input for a new route name. + * @param onRouteSelectionChange Called when a route is selected. + * @param onNewRouteNameChange Called when the new route name input changes. + * @param onConfirm Called on confirmation. + * @param onDismissRequest Called when the dialog is dismissed. + */ +@Composable +fun AddToRouteDialog( + routeNames: List, + selectedRouteName: String, + newRouteName: String, + onRouteSelectionChange: (String) -> Unit, + onNewRouteNameChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.map_add_to_route)) }, + text = { + Column { + if (routeNames.isNotEmpty()) { + Text( + text = stringResource(R.string.route_select_existing), + style = MaterialTheme.typography.bodyMedium, + ) + // Radio-button-style selection for existing routes + routeNames.forEach { name -> + Surface( + onClick = { onRouteSelectionChange(name) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + shape = MaterialTheme.shapes.small, + color = if (name == selectedRouteName) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Text( + text = name, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.route_or_create_new), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = newRouteName, + onValueChange = onNewRouteNameChange, + label = { Text(stringResource(R.string.route_new_route_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt index 589a4cb..9f2b44e 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt @@ -2,6 +2,7 @@ package com.noobexon.xposedfakelocation.manager.ui.map import androidx.annotation.StringRes import androidx.compose.runtime.Immutable +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint import org.osmdroid.util.GeoPoint /** @@ -48,6 +49,11 @@ data class FavoritesInputState( * @property hasResolvedInitialLocation Whether the map has completed its one-time initial camera * positioning. Survives navigation so that re-entering the screen restores the last camera * position instead of re-running location detection. + * @property isAddToRouteDialogVisible Whether the "Add to route" dialog is shown. + * @property availableRouteNames List of names of all saved routes for selection. + * @property selectedRouteName The currently selected route name in the dialog. + * @property newRouteNameInput Input for creating a new route from the add-to-route dialog. + * @property activeRouteWaypoints Waypoints of the currently playing route, empty when none. */ @Immutable data class MapUiState( @@ -61,6 +67,11 @@ data class MapUiState( val isAddToFavoritesDialogVisible: Boolean = false, val goToPointState: GoToPointInputState = GoToPointInputState(), val hasResolvedInitialLocation: Boolean = false, + val isAddToRouteDialogVisible: Boolean = false, + val availableRouteNames: List = emptyList(), + val selectedRouteName: String = "", + val newRouteNameInput: String = "", + val activeRouteWaypoints: List = emptyList(), ) { /** `true` when the FAB should be interactive, i.e. a spoof target has been placed on the map. */ val isFabClickable: Boolean diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt index a5e9eaa..236d8a9 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt @@ -45,6 +45,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.noobexon.xposedfakelocation.R import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen +import compose.icons.LineAwesomeIcons +import compose.icons.lineawesomeicons.RouteSolid import kotlinx.coroutines.launch /** @@ -78,6 +80,7 @@ fun MapScreen( val isFabClickable = uiState.isFabClickable val showGoToPointDialog = uiState.isGoToPointDialogVisible val showAddToFavoritesDialog = uiState.isAddToFavoritesDialogVisible + val showAddToRouteDialog = uiState.isAddToRouteDialogVisible val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() var showOptionsMenu by remember { mutableStateOf(false) } @@ -184,6 +187,20 @@ fun MapScreen( }, enabled = isFabClickable ) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = LineAwesomeIcons.RouteSolid, + contentDescription = stringResource(R.string.map_add_to_route) + ) + }, + text = { Text(stringResource(R.string.map_add_to_route)) }, + onClick = { + showOptionsMenu = false + mapViewModel.showAddToRouteDialog() + }, + enabled = isFabClickable + ) } } ) @@ -242,6 +259,7 @@ fun MapScreen( isPlaying = uiState.isPlaying, mapZoom = uiState.mapZoom, hasResolvedInitialLocation = uiState.hasResolvedInitialLocation, + activeRouteWaypoints = uiState.activeRouteWaypoints, goToPointEvent = mapViewModel.goToPointEvent, centerMapEvent = mapViewModel.centerMapEvent, onClickedLocationChange = mapViewModel::updateClickedLocation, @@ -285,5 +303,17 @@ fun MapScreen( onDismissRequest = mapViewModel::hideAddToFavoritesDialog, ) } + + if (showAddToRouteDialog) { + AddToRouteDialog( + routeNames = uiState.availableRouteNames, + selectedRouteName = uiState.selectedRouteName, + newRouteName = uiState.newRouteNameInput, + onRouteSelectionChange = mapViewModel::onRouteSelectionChange, + onNewRouteNameChange = mapViewModel::onNewRouteNameChange, + onConfirm = mapViewModel::confirmAddToRoute, + onDismissRequest = mapViewModel::hideAddToRouteDialog, + ) + } } } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt index 182158f..d4c4f40 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.noobexon.xposedfakelocation.R import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint import kotlinx.coroutines.flow.Flow import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint @@ -56,6 +57,8 @@ import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay * coordinate and places the marker there. * @param centerMapEvent One-shot [Flow] from [MapViewModel]; animates the camera to the user's * real device location. + * @param activeRouteWaypoints Waypoints of the currently playing route — drawn as a Polyline with + * Markers on the map. Empty when no route is active. * @param onClickedLocationChange Callback to update [MapViewModel] when the user taps the map or * the "Go to point" event resolves. * @param onUserLocationChange Callback to update [MapViewModel] when a real device location is @@ -72,6 +75,7 @@ fun MapViewContainer( isPlaying: Boolean, mapZoom: Double?, hasResolvedInitialLocation: Boolean, + activeRouteWaypoints: List, goToPointEvent: Flow, centerMapEvent: Flow, onClickedLocationChange: (GeoPoint?) -> Unit, @@ -107,6 +111,7 @@ fun MapViewContainer( onLoadingFinished = onLoadingFinished, onInitialLocationResolved = onInitialLocationResolved, ) + HandleActiveRouteOverlay(mapView, activeRouteWaypoints) ManageMapViewLifecycle(mapView, locationOverlay, onMapZoomChange) // Display loading spinner or MapView diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt index f5607aa..ac4dc57 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt @@ -24,6 +24,7 @@ import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_DELAY_MS import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_MAX_ATTEMPTS import com.noobexon.xposedfakelocation.data.WORLD_MAP_ZOOM +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -33,6 +34,7 @@ import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay /** @@ -461,3 +463,70 @@ internal fun ManageMapViewLifecycle( } } } + +/** + * Draws or removes the active route overlay (Polyline + waypoint Markers) on the map. + * + * When [waypoints] is non-empty, a blue Polyline connecting all waypoints is drawn and a Marker + * is placed at each waypoint. The camera is zoomed to fit the entire route bounding box. + * When [waypoints] is empty, the route overlay is removed. + * + * The overlay is keyed on the [waypoints] list so it is redrawn whenever the route changes. + * + * @param mapView The osmdroid map to draw on. + * @param waypoints The waypoints of the currently playing route, or empty to clear. + */ +@Composable +internal fun HandleActiveRouteOverlay( + mapView: MapView, + waypoints: List, +) { + LaunchedEffect(waypoints) { + // Remove old route overlays (Polyline and route Markers only) + val toRemove = mutableListOf() + for (overlay in mapView.overlays) { + if (overlay is Polyline || (overlay is Marker && overlay.relatedObject == "route_waypoint")) { + toRemove.add(overlay) + } + } + toRemove.forEach { mapView.overlays.remove(it) } + + if (waypoints.isEmpty()) { + mapView.invalidate() + return@LaunchedEffect + } + + val geoPoints = waypoints.map { GeoPoint(it.latitude, it.longitude) } + + val polyline = Polyline().apply { + setPoints(geoPoints) + outlinePaint.apply { + color = android.graphics.Color.rgb(33, 150, 243) + strokeWidth = 6f + isAntiAlias = true + } + } + mapView.overlays.add(polyline) + + waypoints.forEachIndexed { index, wp -> + val marker = Marker(mapView).apply { + position = GeoPoint(wp.latitude, wp.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = "${index + 1}: ${wp.name}" + snippet = "%.5f, %.5f".format(wp.latitude, wp.longitude) + relatedObject = "route_waypoint" + } + mapView.overlays.add(marker) + } + + if (geoPoints.size >= 2) { + val box = org.osmdroid.util.BoundingBox.fromGeoPoints(geoPoints) + mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) + } else if (geoPoints.size == 1) { + mapView.controller.setZoom(15.0) + mapView.controller.setCenter(geoPoints[0]) + } + + mapView.invalidate() + } +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt index 0c8cfda..68da40a 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.noobexon.xposedfakelocation.R import com.noobexon.xposedfakelocation.data.model.FavoriteLocation +import com.noobexon.xposedfakelocation.data.model.Route +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -77,7 +79,12 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { init { viewModelScope.launch { preferencesRepository.getIsPlayingFlow().collect { isPlaying -> - _uiState.update { it.copy(isPlaying = isPlaying) } + val waypoints = if (isPlaying) { + preferencesRepository.getActiveRouteWaypoints() + } else { + emptyList() + } + _uiState.update { it.copy(isPlaying = isPlaying, activeRouteWaypoints = waypoints) } } } @@ -391,6 +398,89 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { } } + // ---- Add to route dialog ---- + + /** Makes the "Add to route" dialog visible and loads the list of available route names. */ + fun showAddToRouteDialog() { + val routeNames = preferencesRepository.getRoutes().map { it.name } + _uiState.update { + it.copy( + isAddToRouteDialogVisible = true, + availableRouteNames = routeNames, + selectedRouteName = routeNames.firstOrNull() ?: "", + newRouteNameInput = "", + ) + } + } + + /** Dismisses the "Add to route" dialog. */ + fun hideAddToRouteDialog() { + _uiState.update { it.copy(isAddToRouteDialogVisible = false) } + } + + /** + * Updates the selected route name in the dialog. + * + * @param name The selected route name. + */ + fun onRouteSelectionChange(name: String) { + _uiState.update { it.copy(selectedRouteName = name) } + } + + /** + * Updates the new route name input field. + * + * @param value The raw string typed by the user. + */ + fun onNewRouteNameChange(value: String) { + _uiState.update { it.copy(newRouteNameInput = value) } + } + + /** + * Adds the current marker to an existing route or creates a new route. + * On success the dialog is dismissed. + */ + fun confirmAddToRoute() { + val state = _uiState.value + val location = state.lastClickedLocation ?: return + val routeName = state.selectedRouteName.ifBlank { state.newRouteNameInput.ifBlank { return } } + + viewModelScope.launch { + val routes = preferencesRepository.getRoutes().toMutableList() + val existingRoute = routes.find { it.name == routeName } + + if (existingRoute != null) { + // Add to existing route + val newWaypoint = RouteWaypoint( + name = "Waypoint ${existingRoute.waypoints.size + 1}", + latitude = location.latitude, + longitude = location.longitude, + order = existingRoute.waypoints.size, + ) + val updatedRoute = existingRoute.copy( + waypoints = existingRoute.waypoints + newWaypoint, + ) + preferencesRepository.updateRoute(existingRoute, updatedRoute) + } else { + // Create new route + val newRoute = Route( + name = routeName, + waypoints = listOf( + RouteWaypoint( + name = "Waypoint 1", + latitude = location.latitude, + longitude = location.longitude, + order = 0, + ), + ), + ) + preferencesRepository.addRoute(newRoute) + } + + hideAddToRouteDialog() + } + } + /** * Parses [input] as a `Double` and checks whether it falls within [range]. * diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/NavGraph.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/NavGraph.kt index 1c6802a..4505f1b 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/NavGraph.kt @@ -3,13 +3,17 @@ package com.noobexon.xposedfakelocation.manager.ui.navigation import androidx.compose.runtime.Composable import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import com.noobexon.xposedfakelocation.manager.ui.about.AboutScreen import com.noobexon.xposedfakelocation.manager.ui.favorites.FavoritesScreen import com.noobexon.xposedfakelocation.manager.ui.map.MapScreen import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel import com.noobexon.xposedfakelocation.manager.ui.permissions.PermissionsScreen +import com.noobexon.xposedfakelocation.manager.ui.routes.RouteDetailScreen +import com.noobexon.xposedfakelocation.manager.ui.routes.RoutesScreen import com.noobexon.xposedfakelocation.manager.ui.settings.SettingsScreen import com.noobexon.xposedfakelocation.manager.ui.targetapps.TargetAppsScreen import org.osmdroid.util.GeoPoint @@ -41,6 +45,19 @@ fun AppNavGraph( composable(route = Screen.Permissions.route) { PermissionsScreen(navController = navController) } + composable(route = Screen.Routes.route) { + RoutesScreen(navController = navController) + } + composable( + route = Screen.RouteDetail.route, + arguments = listOf(navArgument("routeName") { type = NavType.StringType }), + ) { backStackEntry -> + val routeName = java.net.URLDecoder.decode( + backStackEntry.arguments?.getString("routeName") ?: "", + "UTF-8", + ) + RouteDetailScreen(navController = navController, routeName = routeName) + } composable(route = Screen.Settings.route) { SettingsScreen(navController = navController) } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/Screen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/Screen.kt index 6114774..d8063f2 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/Screen.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/Screen.kt @@ -5,6 +5,10 @@ sealed class Screen(val route: String) { object Favorites : Screen("favorites") object Map : Screen("map") object Permissions : Screen("permissions") + object Routes : Screen("routes") + object RouteDetail : Screen("route_detail/{routeName}") { + fun createRoute(routeName: String): String = "route_detail/${java.net.URLEncoder.encode(routeName, "UTF-8")}" + } object Settings : Screen("settings") object TargetApps : Screen("target_apps") } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt index f98e4b9..ff6094c 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt @@ -189,7 +189,7 @@ private fun RouteDetailContent( .padding(innerPadding) .padding(16.dp), ) { - // Steuerungsbereich + // Control section ControlSection( isPlaying = uiState.isPlaying, playbackSpeed = uiState.playbackSpeed, @@ -202,7 +202,17 @@ private fun RouteDetailContent( Spacer(Modifier.height(16.dp)) - // Wegpunkt-Liste + // Route map preview + RouteMapView( + waypoints = uiState.waypoints, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Waypoint list if (uiState.waypoints.isEmpty()) { Text( text = stringResource(R.string.route_no_waypoints), diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt new file mode 100644 index 0000000..e1bd504 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt @@ -0,0 +1,92 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import android.content.Context +import android.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.noobexon.xposedfakelocation.data.model.RouteWaypoint +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline + +@Composable +fun RouteMapView( + waypoints: List, + modifier: Modifier = Modifier, +) { + if (waypoints.isEmpty()) return + + val context = LocalContext.current + val mapView = remember { createMapView(context) } + + DisposableEffect(Unit) { + mapView.onResume() + onDispose { + mapView.overlays.clear() + mapView.onPause() + mapView.onDetach() + } + } + + LaunchedEffect(waypoints) { + drawRoute(mapView, waypoints) + } + + AndroidView( + factory = { mapView }, + modifier = modifier, + ) +} + +private fun createMapView(context: Context): MapView { + return MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setBuiltInZoomControls(false) + setMultiTouchControls(true) + } +} + +private fun drawRoute(mapView: MapView, waypoints: List) { + mapView.overlays.clear() + + val geoPoints = waypoints.map { GeoPoint(it.latitude, it.longitude) } + + val polyline = Polyline().apply { + setPoints(geoPoints) + outlinePaint.apply { + color = Color.rgb(33, 150, 243) + strokeWidth = 6f + isAntiAlias = true + } + } + mapView.overlays.add(polyline) + + waypoints.forEachIndexed { index, wp -> + val marker = Marker(mapView).apply { + position = GeoPoint(wp.latitude, wp.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = "${index + 1}: ${wp.name}" + snippet = "%.5f, %.5f".format(wp.latitude, wp.longitude) + setInfoWindow(null) + } + mapView.overlays.add(marker) + } + + if (geoPoints.size >= 2) { + val box = BoundingBox.fromGeoPoints(geoPoints) + mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) + } else if (geoPoints.size == 1) { + mapView.controller.setZoom(15.0) + mapView.controller.setCenter(geoPoints[0]) + } + + mapView.invalidate() +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt index a3417b2..1ef1103 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -75,25 +74,18 @@ class RouteDetailViewModel(application: Application) : AndroidViewModel(applicat fun loadRoute(routeName: String) { viewModelScope.launch { val route = preferencesRepository.getRoutes().find { it.name == routeName } ?: return@launch - val playbackSpeed = preferencesRepository.getRoutePlaybackSpeedFlow() - val isLooping = preferencesRepository.getRouteLoopFlow() - val isRoutePlaying = preferencesRepository.getRoutePlayingFlow() - - combine( - preferencesRepository.getRoutePlayingFlow(), - preferencesRepository.getRoutePlaybackSpeedFlow(), - preferencesRepository.getRouteLoopFlow(), - ) { playing, speed, loop -> - _uiState.update { - it.copy( - route = route, - waypoints = route.waypoints, - isPlaying = playing, - playbackSpeed = speed, - isLooping = loop, - ) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RouteDetailUiState()) + val isPlaying = preferencesRepository.getRoutePlaying() + val playbackSpeed = preferencesRepository.getRoutePlaybackSpeed() + val isLooping = preferencesRepository.getRouteLoop() + _uiState.update { + it.copy( + route = route, + waypoints = route.waypoints, + isPlaying = isPlaying, + playbackSpeed = playbackSpeed, + isLooping = isLooping, + ) + } } } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/LocationApiHooks.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/LocationApiHooks.kt index db2bf17..260ddd3 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/LocationApiHooks.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/LocationApiHooks.kt @@ -168,8 +168,59 @@ class LocationApiHooks(private val module: XposedInterface, private val classLoa } } + hookRequestLocationUpdates(locationManagerClass) + } catch (e: Exception) { module.log(Log.ERROR, tag, "Error hooking LocationManager - ${e.message}") } } + + private fun hookRequestLocationUpdates(locationManagerClass: Class<*>) { + try { + val listenerClass = Class.forName("android.location.LocationListener", false, classLoader) + val method1 = locationManagerClass.getDeclaredMethod( + "requestLocationUpdates", String::class.java, Long::class.java, + Float::class.java, listenerClass + ) + module.log(Log.INFO, tag, "Hooking requestLocationUpdates(String, long, float, LocationListener)") + module.hook(method1).intercept { chain -> + val minTime = chain.getArg(1) as Long + val provider = chain.getArg(0) as String + module.log(Log.INFO, tag, "requestLocationUpdates called: provider=$provider minTime=${minTime}ms") + if (minTime > 1000) { + module.log(Log.INFO, tag, "Reducing minTime: ${minTime}ms -> 1000ms") + chain.proceed(arrayOf( + chain.getArg(0), 1000L, chain.getArg(2), chain.getArg(3) + )) + } else { + chain.proceed() + } + } + + val methodWithLooper = try { + locationManagerClass.getDeclaredMethod( + "requestLocationUpdates", String::class.java, Long::class.java, + Float::class.java, listenerClass, android.os.Looper::class.java + ) + } catch (e: NoSuchMethodException) { null } + if (methodWithLooper != null) { + module.log(Log.INFO, tag, "Hooking requestLocationUpdates(String, long, float, LocationListener, Looper)") + module.hook(methodWithLooper).intercept { chain -> + val minTime = chain.getArg(1) as Long + val provider = chain.getArg(0) as String + module.log(Log.INFO, tag, "requestLocationUpdates(looper): provider=$provider minTime=${minTime}ms") + if (minTime > 1000) { + module.log(Log.INFO, tag, "Reducing minTime(looper): ${minTime}ms -> 1000ms") + chain.proceed(arrayOf( + chain.getArg(0), 1000L, chain.getArg(2), chain.getArg(3), chain.getArg(4) + )) + } else { + chain.proceed() + } + } + } + } catch (e: Exception) { + module.log(Log.WARN, tag, "requestLocationUpdates hooks not available: ${e.message}") + } + } } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt index fd0c3e4..b2c7424 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt @@ -124,7 +124,6 @@ object LocationUtil { // If a route is playing, let RoutePlayer control the position RoutePlayer.loadActiveRoute() if (RoutePlayer.isRouteActive()) { - RoutePlayer.advance() return } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt index e53d8f2..821b546 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt @@ -11,209 +11,180 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -import androidx.core.content.edit - -/** - * Singleton that plays back a route inside the Xposed module process. - * - * [RoutePlayer] is called by [LocationUtil.updateLocation] when - * [PreferencesUtil.getIsPlaying] and an active route are detected. It - * interpolates the position between two waypoints based on elapsed time - * and the configured speed. - * - * The waypoint list and current progress are read from the remote preferences - * written by the manager app. - */ +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + object RoutePlayer { private const val TAG = "[RoutePlayer]" - /** - * Minimum interval between [advance] calls in nanoseconds. - * Location getters (getLatitude, getLongitude, etc.) are hooked individually - * and called in rapid succession by the target app (microseconds apart). - * This threshold ensures only one advance per ~100ms window, giving a - * meaningful time delta for interpolation. - */ - private const val MIN_ADVANCE_INTERVAL_NANOS = 100_000_000L // 100ms - @Volatile var logger: ((Int, String, String) -> Unit)? = null private fun log(msg: String, priority: Int = Log.INFO) = logger?.invoke(priority, TAG, msg) private var waypoints: List = emptyList() - private var currentIndex: Int = 0 - private var progress: Double = 0.0 - private var playbackSpeed: Double = 10.0 // m/s + private var playbackSpeed: Double = 10.0 private var isLooping: Boolean = false - private var isActive: Boolean = false + @Volatile private var isActive: Boolean = false + private var isFinished: Boolean = false - /** Timestamp of the last [advance] call, used for delta calculation. */ - private var lastUpdateTime: Long = 0L + private var routeStartTime: Long = 0L + private var segmentDistances: DoubleArray = DoubleArray(0) + private var totalRouteDistance: Double = 0.0 private val gson = Gson() - /** - * Loads active route data from the remote preferences. - * Should be called on every [LocationUtil.updateLocation] invocation - * to ensure up-to-date data is used. - * - * Does NOT reset [lastUpdateTime] – that is handled by [advance] so - * that wall-clock delta between consecutive calls is preserved. - */ + private val executor = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "RoutePlayer").also { it.isDaemon = true } + } + @Volatile private var timerHandle: java.util.concurrent.ScheduledFuture<*>? = null + fun loadActiveRoute() { try { val prefs = PreferencesUtil.getPreferences() ?: return - isActive = prefs.getBoolean("route_playing", false) - if (!isActive) return - - playbackSpeed = java.lang.Double.longBitsToDouble( - prefs.getLong("route_playback_speed", java.lang.Double.doubleToRawLongBits(10.0)) - ) - isLooping = prefs.getBoolean("route_loop", false) - currentIndex = prefs.getInt("active_route_waypoint_index", 0) - progress = java.lang.Double.longBitsToDouble( - prefs.getLong("active_route_progress", java.lang.Double.doubleToRawLongBits(0.0)) - ) + val nowPlaying = prefs.getBoolean("route_playing", false) - val waypointsJson = prefs.getString("active_route_waypoints", null) - if (!waypointsJson.isNullOrBlank()) { - val type = object : TypeToken>() {}.type - waypoints = gson.fromJson(waypointsJson, type) ?: emptyList() - } else { - waypoints = emptyList() + if (!nowPlaying) { + stopTimer() + isActive = false + isFinished = false + log("Route stopped (nowPlaying=false)") + return } - if (waypoints.isEmpty()) { - isActive = false + if (!isActive) { + if (isFinished) isFinished = false + playbackSpeed = java.lang.Double.longBitsToDouble( + prefs.getLong("route_playback_speed", java.lang.Double.doubleToRawLongBits(10.0)) + ) + isLooping = prefs.getBoolean("route_loop", false) + val waypointsJson = prefs.getString("active_route_waypoints", null) + waypoints = if (!waypointsJson.isNullOrBlank()) { + val type = object : TypeToken>() {}.type + gson.fromJson(waypointsJson, type) ?: emptyList() + } else { + emptyList() + } + if (waypoints.isEmpty()) { + log("Route not started: no waypoints") + return + } + precomputeSegmentDistances() + routeStartTime = System.nanoTime() + isActive = true + computeAndSetPosition() + startTimer() + log("Route started: ${waypoints.size} waypoints, speed=${playbackSpeed}m/s, segments=${segmentDistances.joinToString()}") } - log("Route loaded: ${waypoints.size} waypoints, index=$currentIndex, progress=$progress") + computeAndSetPosition() } catch (e: Exception) { - log("Error loading route: ${e.message}", Log.ERROR) + log("Error: ${e.message}", Log.ERROR) + stopTimer() isActive = false } } - /** - * Returns whether the RoutePlayer is active (a route is being played). - */ - fun isRouteActive(): Boolean = isActive - - /** - * Moves the position along the route based on time elapsed since the - * last call. Updates [LocationUtil.latitude] and [LocationUtil.longitude] - * directly. - * - * Should be called by [LocationUtil.updateLocation] when the RoutePlayer - * is active. - */ - fun advance() { - if (!isActive || waypoints.isEmpty()) return + private fun startTimer() { + stopTimer() + timerHandle = executor.scheduleWithFixedDelay( + { timerTick() }, + 500, 500, TimeUnit.MILLISECONDS + ) + log("Timer started (500ms)") + } - val now = System.nanoTime() + private fun stopTimer() { + timerHandle?.cancel(false) + timerHandle = null + } - if (lastUpdateTime == 0L) { - lastUpdateTime = now - setPosition() - return + private fun timerTick() { + if (isActive) { + computeAndSetPosition() } + } - val deltaNanos = now - lastUpdateTime + fun isRouteActive(): Boolean = isActive - // Skip if called too frequently (microseconds between Location getters). - // Only advance once per ~100ms to get a meaningful time delta. - if (deltaNanos < MIN_ADVANCE_INTERVAL_NANOS) return + fun computeAndSetPosition() { + if (!isActive || waypoints.isEmpty()) return - // If more than 2 seconds elapsed (stop->restart), reinitialize - // without jumping past waypoints. - if (deltaNanos > 2_000_000_000L) { - lastUpdateTime = now - setPosition() - return - } + val elapsedNanos = System.nanoTime() - routeStartTime + val elapsedSeconds = elapsedNanos / 1_000_000_000.0 + val totalDistance = playbackSpeed * elapsedSeconds + val pos = computePosition(totalDistance) - lastUpdateTime = now - val deltaSeconds = deltaNanos / 1_000_000_000.0 - - // Calculate distance between current and next waypoint in meters - if (currentIndex >= waypoints.size - 1) { - if (isLooping) { - currentIndex = 0 - progress = 0.0 - } else { - if (waypoints.isNotEmpty()) { - setPositionAt(waypoints.last()) - } - isActive = false - return - } + if (pos != null) { + LocationUtil.latitude = pos.first + LocationUtil.longitude = pos.second + val walkedWaypoints = computeWalkedWaypoints(totalDistance) + log("hook: elapsed=${"%.1f".format(elapsedSeconds)}s dist=${"%.1f".format(totalDistance)}m wp_idx=$walkedWaypoints lat=${"%.6f".format(pos.first)} lon=${"%.6f".format(pos.second)}") } + } - val currentWp = waypoints[currentIndex] - val nextWp = waypoints[currentIndex + 1] - - val distance = calculateDistance( - currentWp.latitude, currentWp.longitude, - nextWp.latitude, nextWp.longitude, - ) - - if (distance <= 0) { - currentIndex++ - progress = 0.0 - setPosition() - persistState() - return + private fun computeWalkedWaypoints(totalDistance: Double): Int { + var remaining = totalDistance + for (i in segmentDistances.indices) { + if (remaining <= segmentDistances[i]) return i + remaining -= segmentDistances[i] } + return waypoints.size - 1 + } - val timeForSegment = distance / playbackSpeed + private fun computePosition(totalDistance: Double): Pair? { + if (waypoints.isEmpty()) return null - val deltaProgress = deltaSeconds / timeForSegment - progress += deltaProgress + if (totalRouteDistance <= 0.0) { + return Pair(waypoints[0].latitude, waypoints[0].longitude) + } - if (progress >= 1.0) { - currentIndex++ - progress = 0.0 - setPosition() - persistState() + val effectiveDistance = if (isLooping) { + totalDistance % totalRouteDistance } else { - val lat = interpolate(currentWp.latitude, nextWp.latitude, progress) - val lon = interpolate(currentWp.longitude, nextWp.longitude, progress) - LocationUtil.latitude = lat - LocationUtil.longitude = lon + totalDistance } - } - /** Sets position to the current waypoint. */ - private fun setPosition() { - if (waypoints.isEmpty()) return - val wp = waypoints[currentIndex.coerceAtMost(waypoints.size - 1)] - setPositionAt(wp) - } + if (effectiveDistance < 0) { + routeStartTime = System.nanoTime() + return Pair(waypoints[0].latitude, waypoints[0].longitude) + } - private fun setPositionAt(wp: RouteWaypoint) { - LocationUtil.latitude = wp.latitude - LocationUtil.longitude = wp.longitude + var remaining = effectiveDistance + for (i in segmentDistances.indices) { + if (remaining <= segmentDistances[i]) { + val progress = if (segmentDistances[i] > 0) remaining / segmentDistances[i] else 0.0 + val lat = interpolate(waypoints[i].latitude, waypoints[i + 1].latitude, progress) + val lon = interpolate(waypoints[i].longitude, waypoints[i + 1].longitude, progress) + return Pair(lat, lon) + } + remaining -= segmentDistances[i] + } + + if (!isLooping) { + isActive = false + isFinished = true + stopTimer() + log("Route finished") + } + return Pair(waypoints.last().latitude, waypoints.last().longitude) } - /** Persists current index and progress to remote preferences. */ - private fun persistState() { - try { - val prefs = PreferencesUtil.getPreferences() ?: return - prefs.edit { - putInt("active_route_waypoint_index", currentIndex) - .putLong( - "active_route_progress", - java.lang.Double.doubleToRawLongBits(progress) - ) - } - } catch (e: Exception) { - log("Error persisting route state: ${e.message}", Log.ERROR) + private fun precomputeSegmentDistances() { + val n = waypoints.size - 1 + if (n <= 0) { + segmentDistances = DoubleArray(0) + totalRouteDistance = 0.0 + return + } + segmentDistances = DoubleArray(n) + for (i in 0 until n) { + segmentDistances[i] = calculateDistance( + waypoints[i].latitude, waypoints[i].longitude, + waypoints[i + 1].latitude, waypoints[i + 1].longitude, + ) } + totalRouteDistance = segmentDistances.sum() } - /** - * Calculates the distance in meters between two coordinates - * using the Haversine formula. - */ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val dLat = Math.toRadians(lat2 - lat1) val dLon = Math.toRadians(lon2 - lon1) @@ -224,6 +195,5 @@ object RoutePlayer { return RADIUS_EARTH * c } - /** Linear interpolation between two values. */ private fun interpolate(a: Double, b: Double, t: Double): Double = a + (b - a) * t } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 27694a2..7918132 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -37,9 +37,12 @@ %1$s löschen %1$s-Profil bearbeiten %1$s-Symbol + Nach oben + Nach unten Einstellungen Favoriten + Routen Ziel-Apps Vorlagen Über @@ -168,6 +171,23 @@ Favorit löschen? \"%1$s\" löschen? Diese Aktion kann nicht rückgängig gemacht werden. Löschen + + Noch keine Routen. + Lege Wegpunkte auf der Karte fest, um eine Route zu erstellen. + Route löschen? + \"%1$s\" löschen? Diese Aktion kann nicht rückgängig gemacht werden. + Route: %1$s + %1$d Wegpunkte + Route abspielen + Route stoppen + Geschwindigkeit: %1$d m/s + Wiederholen + Noch keine Wegpunkte. + Diesen Wegpunkt löschen? + Vorhandene Route auswählen: + Oder eine neue erstellen: + Neuer Routenname + Zu Route hinzufügen Apps oder Pakete suchen Keine Apps entsprechen \"%1$s\" Benutzer-Apps diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 00a6987..80bff5f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -35,9 +35,12 @@ 删除 %1$s 编辑 %1$s 配置 %1$s 图标 + 上移 + 下移 设置 收藏 + 路线 目标应用 模板 关于 @@ -166,6 +169,23 @@ 删除收藏? 删除"%1$s"?此操作无法撤销。 删除 + + 还没有路线。 + 在地图上保存路径点来创建路线。 + 删除路线? + 删除「%1$s」?此操作无法撤销。 + 路线:%1$s + %1$d 个路径点 + 播放路线 + 停止路线 + 速度:%1$d 米/秒 + 循环 + 还没有路径点。 + 删除此路径点? + 选择现有路线: + 或创建新路线: + 新路线名称 + 添加到路线 搜索应用或包名 没有与「%1$s」匹配的应用 用户应用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 183a7e7..2d1c3e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,9 +37,12 @@ Delete %1$s Edit %1$s profile %1$s icon + Move up + Move down Settings Favorites + Routes Target Apps Templates About @@ -168,6 +171,23 @@ Delete favorite? Delete \"%1$s\"? This action cannot be undone. Delete + + No routes yet. + Save waypoints on the map to create a route. + Delete route? + Delete \"%1$s\"? This action cannot be undone. + Route: %1$s + %1$d waypoints + Play Route + Stop Route + Speed: %1$d m/s + Loop + No waypoints yet. + Delete this waypoint? + Select an existing route: + Or create a new one: + New route name + Add to Route Search apps or packages No apps match \"%1$s\" User apps From 4183b7019df79439f3f0059bf6176e0bc2c2557e Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 16:14:18 +0200 Subject: [PATCH 06/11] fix(routes): Resolve flickering, add numbered markers, implement real-time position tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flickering fix: - Remove early return in RouteMapView when waypoints empty — always render the map - MapView now stays stable across recompositions, no layout shifts on data load Numbered markers: - Add RouteMapUtils.kt with createNumberedMarkerDrawable() — generates bitmap markers with a blue circle and white number overlay - Update RouteMapView.kt and HandleActiveRouteOverlay to use numbered markers instead of default Marker icons - Add createCurrentPositionDrawable() for the live position dot Real-time position tracking: - Add KEY_CURRENT_ROUTE_LAT/KEY_CURRENT_ROUTE_LON constants in Constants.kt - Add getCurrentRouteLat()/getCurrentRouteLon() to PrefrencesRepository (reads from remote prefs written by RoutePlayer) - RoutePlayer.persistCurrentPosition() now writes computed lat/lon to remote prefs every timer tick (every 500ms); clearCurrentPosition() resets on stop - MapViewModel init now polls every 1500ms for current route position when isPlaying - Add HandleCurrentRoutePosition composable in MapViewEffects.kt — green animated dot at the current route playback position on the main map - Wire currentRoutePosition through MapUiState, MapViewContainer, and MapScreen All code and comments are in English. --- .../xposedfakelocation/data/Constants.kt | 2 + .../data/repository/PrefrencesRepository.kt | 13 ++++ .../manager/ui/map/MapModel.kt | 2 + .../manager/ui/map/MapScreen.kt | 1 + .../manager/ui/map/MapViewContainer.kt | 3 + .../manager/ui/map/MapViewEffects.kt | 46 +++++++++++++ .../manager/ui/map/MapViewModel.kt | 16 ++++- .../manager/ui/routes/RouteMapUtils.kt | 69 +++++++++++++++++++ .../manager/ui/routes/RouteMapView.kt | 56 +++++++-------- .../xposed/utils/RoutePlayer.kt | 26 +++++++ 10 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapUtils.kt diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt index b41fefc..3f1d4bb 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt @@ -57,6 +57,8 @@ const val KEY_ACTIVE_ROUTE_WAYPOINT_INDEX = "active_route_waypoint_index" const val KEY_ACTIVE_ROUTE_PROGRESS = "active_route_progress" const val KEY_ROUTE_PLAYBACK_SPEED = "route_playback_speed" const val KEY_ROUTE_LOOP = "route_loop" +const val KEY_CURRENT_ROUTE_LAT = "current_route_lat" +const val KEY_CURRENT_ROUTE_LON = "current_route_lon" // Packages added/removed from module scope when system-level hooks are toggled. val SYSTEM_HOOK_PACKAGES = listOf("android", "com.android.phone") diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt index 5b5ac2a..7dd4db6 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt @@ -36,6 +36,8 @@ import com.noobexon.xposedfakelocation.data.DEFAULT_USE_VERTICAL_ACCURACY import com.noobexon.xposedfakelocation.data.DEFAULT_VERTICAL_ACCURACY import com.noobexon.xposedfakelocation.data.KEY_ACCURACY import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_NAME +import com.noobexon.xposedfakelocation.data.KEY_CURRENT_ROUTE_LAT +import com.noobexon.xposedfakelocation.data.KEY_CURRENT_ROUTE_LON import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_PROGRESS import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_WAYPOINT_INDEX import com.noobexon.xposedfakelocation.data.KEY_ACTIVE_ROUTE_WAYPOINTS @@ -454,6 +456,17 @@ class PreferencesRepository(context: Context) { fun getRouteLoopFlow(): Flow = remoteFlow(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) { it.getBoolean(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) } suspend fun saveRouteLoop(loop: Boolean) = editRemote { putBoolean(KEY_ROUTE_LOOP, loop) } fun getRouteLoop(): Boolean = remotePrefs()?.getBoolean(KEY_ROUTE_LOOP, DEFAULT_ROUTE_LOOP) ?: DEFAULT_ROUTE_LOOP + + // region Current Route Position (remote, written by RoutePlayer in target process) + fun getCurrentRouteLat(): Double { + val bits = remotePrefs()?.getLong(KEY_CURRENT_ROUTE_LAT, java.lang.Double.doubleToRawLongBits(0.0)) ?: java.lang.Double.doubleToRawLongBits(0.0) + return java.lang.Double.longBitsToDouble(bits) + } + fun getCurrentRouteLon(): Double { + val bits = remotePrefs()?.getLong(KEY_CURRENT_ROUTE_LON, java.lang.Double.doubleToRawLongBits(0.0)) ?: java.lang.Double.doubleToRawLongBits(0.0) + return java.lang.Double.longBitsToDouble(bits) + } + // endregion // endregion // region Map Zoom (local) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt index 9f2b44e..9ef3b8b 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapModel.kt @@ -54,6 +54,7 @@ data class FavoritesInputState( * @property selectedRouteName The currently selected route name in the dialog. * @property newRouteNameInput Input for creating a new route from the add-to-route dialog. * @property activeRouteWaypoints Waypoints of the currently playing route, empty when none. + * @property currentRoutePosition Current route playback position on the map, or null when not playing. */ @Immutable data class MapUiState( @@ -72,6 +73,7 @@ data class MapUiState( val selectedRouteName: String = "", val newRouteNameInput: String = "", val activeRouteWaypoints: List = emptyList(), + val currentRoutePosition: GeoPoint? = null, ) { /** `true` when the FAB should be interactive, i.e. a spoof target has been placed on the map. */ val isFabClickable: Boolean diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt index 236d8a9..909f664 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt @@ -260,6 +260,7 @@ fun MapScreen( mapZoom = uiState.mapZoom, hasResolvedInitialLocation = uiState.hasResolvedInitialLocation, activeRouteWaypoints = uiState.activeRouteWaypoints, + currentRoutePosition = uiState.currentRoutePosition, goToPointEvent = mapViewModel.goToPointEvent, centerMapEvent = mapViewModel.centerMapEvent, onClickedLocationChange = mapViewModel::updateClickedLocation, diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt index d4c4f40..a12fe04 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewContainer.kt @@ -59,6 +59,7 @@ import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay * real device location. * @param activeRouteWaypoints Waypoints of the currently playing route — drawn as a Polyline with * Markers on the map. Empty when no route is active. + * @param currentRoutePosition Current route playback position on the map, or null when not playing. * @param onClickedLocationChange Callback to update [MapViewModel] when the user taps the map or * the "Go to point" event resolves. * @param onUserLocationChange Callback to update [MapViewModel] when a real device location is @@ -76,6 +77,7 @@ fun MapViewContainer( mapZoom: Double?, hasResolvedInitialLocation: Boolean, activeRouteWaypoints: List, + currentRoutePosition: GeoPoint?, goToPointEvent: Flow, centerMapEvent: Flow, onClickedLocationChange: (GeoPoint?) -> Unit, @@ -112,6 +114,7 @@ fun MapViewContainer( onInitialLocationResolved = onInitialLocationResolved, ) HandleActiveRouteOverlay(mapView, activeRouteWaypoints) + HandleCurrentRoutePosition(mapView, currentRoutePosition) ManageMapViewLifecycle(mapView, locationOverlay, onMapZoomChange) // Display loading spinner or MapView diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt index ac4dc57..ff1a0f6 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt @@ -25,6 +25,8 @@ import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_DELAY_MS import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_MAX_ATTEMPTS import com.noobexon.xposedfakelocation.data.WORLD_MAP_ZOOM import com.noobexon.xposedfakelocation.data.model.RouteWaypoint +import com.noobexon.xposedfakelocation.manager.ui.routes.createCurrentPositionDrawable +import com.noobexon.xposedfakelocation.manager.ui.routes.createNumberedMarkerDrawable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -481,6 +483,7 @@ internal fun HandleActiveRouteOverlay( mapView: MapView, waypoints: List, ) { + val context = LocalContext.current LaunchedEffect(waypoints) { // Remove old route overlays (Polyline and route Markers only) val toRemove = mutableListOf() @@ -509,11 +512,13 @@ internal fun HandleActiveRouteOverlay( mapView.overlays.add(polyline) waypoints.forEachIndexed { index, wp -> + val icon = createNumberedMarkerDrawable(context, index + 1) val marker = Marker(mapView).apply { position = GeoPoint(wp.latitude, wp.longitude) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) title = "${index + 1}: ${wp.name}" snippet = "%.5f, %.5f".format(wp.latitude, wp.longitude) + setIcon(icon) relatedObject = "route_waypoint" } mapView.overlays.add(marker) @@ -530,3 +535,44 @@ internal fun HandleActiveRouteOverlay( mapView.invalidate() } } + +/** + * Draws or removes a moving marker showing the current route playback position on the map. + * + * When [position] is non-null, a green dot marker is placed at that coordinate. + * When null (route not playing), the marker is removed. + * + * @param mapView The osmdroid map to draw on. + * @param position The current route position, or null to clear. + */ +@Composable +internal fun HandleCurrentRoutePosition( + mapView: MapView, + position: GeoPoint?, +) { + val context = LocalContext.current + LaunchedEffect(position) { + val toRemove = mutableListOf() + for (overlay in mapView.overlays) { + if (overlay is Marker && overlay.relatedObject == "current_route_position") { + toRemove.add(overlay) + } + } + toRemove.forEach { mapView.overlays.remove(it) } + + if (position != null) { + val icon = createCurrentPositionDrawable(context) + val marker = Marker(mapView).apply { + this.position = position + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + setIcon(icon) + setInfoWindow(null) + title = "Current route position" + relatedObject = "current_route_position" + } + mapView.overlays.add(marker) + } + + mapView.invalidate() + } +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt index 68da40a..11e6128 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.osmdroid.util.GeoPoint @@ -84,7 +85,20 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { } else { emptyList() } - _uiState.update { it.copy(isPlaying = isPlaying, activeRouteWaypoints = waypoints) } + _uiState.update { it.copy(isPlaying = isPlaying, activeRouteWaypoints = waypoints, currentRoutePosition = null) } + } + } + + viewModelScope.launch { + while (true) { + delay(1500L) + if (_uiState.value.isPlaying) { + val lat = preferencesRepository.getCurrentRouteLat() + val lon = preferencesRepository.getCurrentRouteLon() + if (lat != 0.0 || lon != 0.0) { + _uiState.update { it.copy(currentRoutePosition = GeoPoint(lat, lon)) } + } + } } } diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapUtils.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapUtils.kt new file mode 100644 index 0000000..5ee40b8 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapUtils.kt @@ -0,0 +1,69 @@ +package com.noobexon.xposedfakelocation.manager.ui.routes + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.BitmapDrawable + +private const val MARKER_SIZE_DP = 40 +private const val MARKER_SIZE_SMALL_DP = 28 + +fun createNumberedMarkerDrawable(context: Context, number: Int, small: Boolean = false): BitmapDrawable { + val sizeDp = if (small) MARKER_SIZE_SMALL_DP else MARKER_SIZE_DP + val sizePx = (sizeDp * context.resources.displayMetrics.density).toInt() + return createNumberedBitmap(context, number, sizePx) +} + +fun createCurrentPositionDrawable(context: Context): BitmapDrawable { + val sizePx = (24 * context.resources.displayMetrics.density).toInt() + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val cx = sizePx / 2f + val cy = sizePx / 2f + + paint.color = android.graphics.Color.rgb(76, 175, 80) + paint.style = Paint.Style.FILL + canvas.drawCircle(cx, cy, sizePx / 2f - 1, paint) + + paint.color = android.graphics.Color.WHITE + paint.style = Paint.Style.STROKE + paint.strokeWidth = 2f + canvas.drawCircle(cx, cy, sizePx / 2f - 2, paint) + + paint.style = Paint.Style.FILL + canvas.drawCircle(cx, cy, sizePx / 4f, paint) + + return BitmapDrawable(context.resources, bitmap) +} + +private fun createNumberedBitmap(context: Context, number: Int, sizePx: Int): BitmapDrawable { + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val cx = sizePx / 2f + val cy = sizePx / 2f + val radius = sizePx / 2f - 2 + + paint.color = android.graphics.Color.rgb(33, 150, 243) + paint.style = Paint.Style.FILL + canvas.drawCircle(cx, cy, radius, paint) + + paint.color = android.graphics.Color.WHITE + paint.style = Paint.Style.STROKE + paint.strokeWidth = 2f + canvas.drawCircle(cx, cy, radius - 1, paint) + + paint.color = android.graphics.Color.WHITE + paint.style = Paint.Style.FILL + paint.isAntiAlias = true + paint.textSize = sizePx * 0.5f + paint.textAlign = Paint.Align.CENTER + val yOffset = -(paint.descent() + paint.ascent()) / 2f + canvas.drawText(number.toString(), cx, cy + yOffset, paint) + + return BitmapDrawable(context.resources, bitmap) +} diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt index e1bd504..a5761bd 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt @@ -22,8 +22,6 @@ fun RouteMapView( waypoints: List, modifier: Modifier = Modifier, ) { - if (waypoints.isEmpty()) return - val context = LocalContext.current val mapView = remember { createMapView(context) } @@ -37,7 +35,7 @@ fun RouteMapView( } LaunchedEffect(waypoints) { - drawRoute(mapView, waypoints) + drawRoute(context, mapView, waypoints) } AndroidView( @@ -54,38 +52,42 @@ private fun createMapView(context: Context): MapView { } } -private fun drawRoute(mapView: MapView, waypoints: List) { +private fun drawRoute(context: Context, mapView: MapView, waypoints: List) { mapView.overlays.clear() val geoPoints = waypoints.map { GeoPoint(it.latitude, it.longitude) } - val polyline = Polyline().apply { - setPoints(geoPoints) - outlinePaint.apply { - color = Color.rgb(33, 150, 243) - strokeWidth = 6f - isAntiAlias = true + if (geoPoints.isNotEmpty()) { + val polyline = Polyline().apply { + setPoints(geoPoints) + outlinePaint.apply { + color = Color.rgb(33, 150, 243) + strokeWidth = 6f + isAntiAlias = true + } } - } - mapView.overlays.add(polyline) + mapView.overlays.add(polyline) - waypoints.forEachIndexed { index, wp -> - val marker = Marker(mapView).apply { - position = GeoPoint(wp.latitude, wp.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - title = "${index + 1}: ${wp.name}" - snippet = "%.5f, %.5f".format(wp.latitude, wp.longitude) - setInfoWindow(null) + waypoints.forEachIndexed { index, wp -> + val icon = createNumberedMarkerDrawable(context, index + 1) + val marker = Marker(mapView).apply { + position = GeoPoint(wp.latitude, wp.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = "${index + 1}: ${wp.name}" + snippet = "%.5f, %.5f".format(wp.latitude, wp.longitude) + setIcon(icon) + setInfoWindow(null) + } + mapView.overlays.add(marker) } - mapView.overlays.add(marker) - } - if (geoPoints.size >= 2) { - val box = BoundingBox.fromGeoPoints(geoPoints) - mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) - } else if (geoPoints.size == 1) { - mapView.controller.setZoom(15.0) - mapView.controller.setCenter(geoPoints[0]) + if (geoPoints.size >= 2) { + val box = BoundingBox.fromGeoPoints(geoPoints) + mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) + } else { + mapView.controller.setZoom(15.0) + mapView.controller.setCenter(geoPoints[0]) + } } mapView.invalidate() diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt index 821b546..563a1b9 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt @@ -46,6 +46,7 @@ object RoutePlayer { stopTimer() isActive = false isFinished = false + clearCurrentPosition() log("Route stopped (nowPlaying=false)") return } @@ -116,6 +117,7 @@ object RoutePlayer { if (pos != null) { LocationUtil.latitude = pos.first LocationUtil.longitude = pos.second + persistCurrentPosition(pos.first, pos.second) val walkedWaypoints = computeWalkedWaypoints(totalDistance) log("hook: elapsed=${"%.1f".format(elapsedSeconds)}s dist=${"%.1f".format(totalDistance)}m wp_idx=$walkedWaypoints lat=${"%.6f".format(pos.first)} lon=${"%.6f".format(pos.second)}") } @@ -196,4 +198,28 @@ object RoutePlayer { } private fun interpolate(a: Double, b: Double, t: Double): Double = a + (b - a) * t + + private fun clearCurrentPosition() { + try { + val prefs = PreferencesUtil.getPreferences() ?: return + prefs.edit() + .putLong("current_route_lat", java.lang.Double.doubleToRawLongBits(0.0)) + .putLong("current_route_lon", java.lang.Double.doubleToRawLongBits(0.0)) + .apply() + } catch (_: Exception) { + // silent + } + } + + private fun persistCurrentPosition(lat: Double, lon: Double) { + try { + val prefs = PreferencesUtil.getPreferences() ?: return + prefs.edit() + .putLong("current_route_lat", java.lang.Double.doubleToRawLongBits(lat)) + .putLong("current_route_lon", java.lang.Double.doubleToRawLongBits(lon)) + .apply() + } catch (_: Exception) { + // silent — remote prefs may be read-only from target process + } + } } From 89318fd320b0a91905d67b7a1c89e61ea8f63d68 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 16:33:49 +0200 Subject: [PATCH 07/11] fix(anr): prevent zoomToBoundingBox on unlaid-out MapView, move IPC off main thread - Guard HandleActiveRouteOverlay zoomToBoundingBox with view-size check to avoid infinite loop in Projection.getCloserPixel() on 0x0 layout - Add flowOn(Dispatchers.IO) to getIsPlayingFlow and getLastClickedLocationFlow collections so LSPosed IPC runs off main - Wrap getActiveRouteWaypoints, getCurrentRouteLat/Lon reads in withContext(IO) to prevent main-thread blocking --- .../manager/ui/map/MapViewEffects.kt | 14 ++++--- .../manager/ui/map/MapViewModel.kt | 39 ++++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt index ff1a0f6..d212cd5 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewEffects.kt @@ -524,12 +524,14 @@ internal fun HandleActiveRouteOverlay( mapView.overlays.add(marker) } - if (geoPoints.size >= 2) { - val box = org.osmdroid.util.BoundingBox.fromGeoPoints(geoPoints) - mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) - } else if (geoPoints.size == 1) { - mapView.controller.setZoom(15.0) - mapView.controller.setCenter(geoPoints[0]) + if (mapView.width > 0 && mapView.height > 0) { + if (geoPoints.size >= 2) { + val box = org.osmdroid.util.BoundingBox.fromGeoPoints(geoPoints) + mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) + } else if (geoPoints.size == 1) { + mapView.controller.setZoom(15.0) + mapView.controller.setCenter(geoPoints[0]) + } } mapView.invalidate() diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt index 11e6128..718400c 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt @@ -15,9 +15,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.osmdroid.util.GeoPoint /** Valid latitude values accepted by the "Go to point" and "Add to favorites" dialogs. */ @@ -79,22 +82,28 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { init { viewModelScope.launch { - preferencesRepository.getIsPlayingFlow().collect { isPlaying -> - val waypoints = if (isPlaying) { - preferencesRepository.getActiveRouteWaypoints() - } else { - emptyList() + preferencesRepository.getIsPlayingFlow() + .flowOn(Dispatchers.IO) + .collect { isPlaying -> + val waypoints = withContext(Dispatchers.IO) { + preferencesRepository.getActiveRouteWaypoints() + } + _uiState.update { + it.copy( + isPlaying = isPlaying, + activeRouteWaypoints = waypoints, + currentRoutePosition = null + ) + } } - _uiState.update { it.copy(isPlaying = isPlaying, activeRouteWaypoints = waypoints, currentRoutePosition = null) } - } } viewModelScope.launch { while (true) { - delay(1500L) + delay(2000L) if (_uiState.value.isPlaying) { - val lat = preferencesRepository.getCurrentRouteLat() - val lon = preferencesRepository.getCurrentRouteLon() + val lat = withContext(Dispatchers.IO) { preferencesRepository.getCurrentRouteLat() } + val lon = withContext(Dispatchers.IO) { preferencesRepository.getCurrentRouteLon() } if (lat != 0.0 || lon != 0.0) { _uiState.update { it.copy(currentRoutePosition = GeoPoint(lat, lon)) } } @@ -103,10 +112,12 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { } viewModelScope.launch { - preferencesRepository.getLastClickedLocationFlow().collect { location -> - val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) } - _uiState.update { it.copy(lastClickedLocation = geoPoint) } - } + preferencesRepository.getLastClickedLocationFlow() + .flowOn(Dispatchers.IO) + .collect { location -> + val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) } + _uiState.update { it.copy(lastClickedLocation = geoPoint) } + } } } From 8ff2dc9850ef6de396eab0bf2abb36d95f85c01b Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 16:56:26 +0200 Subject: [PATCH 08/11] fix(routes): resolve AddToRouteDialog selection and route playback position advancement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — AddToRouteDialog: when user types a new route name, selectedRouteName was not cleared, causing confirmAddToRoute() to use the previously selected route instead of the typed name. Fix: onNewRouteNameChange now clears selectedRouteName, onRouteSelectionChange clears newRouteNameInput. Bug 2 — Route playback position never changed in target apps because system-level hooks (SystemServicesHooks in system_server) and the per-app getLastKnownLocation() hook called LocationUtil.createFakeLocation() without first calling updateLocation(). They read LocationUtil.latitude from their own process memory, which RoutePlayer never updates (it runs only in the target app's process). Fix: updateLocation() is now called inside createFakeLocation() so every fake location creation uses the up-to-date position regardless of which process calls it. --- .../xposedfakelocation/manager/ui/map/MapViewModel.kt | 4 ++-- .../noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt index 718400c..341853e 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt @@ -449,7 +449,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { * @param name The selected route name. */ fun onRouteSelectionChange(name: String) { - _uiState.update { it.copy(selectedRouteName = name) } + _uiState.update { it.copy(selectedRouteName = name, newRouteNameInput = "") } } /** @@ -458,7 +458,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { * @param value The raw string typed by the user. */ fun onNewRouteNameChange(value: String) { - _uiState.update { it.copy(newRouteNameInput = value) } + _uiState.update { it.copy(newRouteNameInput = value, selectedRouteName = "") } } /** diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt index b2c7424..9aea27e 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt @@ -49,6 +49,7 @@ object LocationUtil { @Synchronized fun createFakeLocation(originalLocation: Location? = null, provider: String = LocationManager.GPS_PROVIDER): Location { + updateLocation() val fakeLocation = if (originalLocation == null) { Location(provider).apply { time = System.currentTimeMillis() - 300 From cfafdf1ee39761e9cc4ab35527c5d750d923a5b8 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 17:12:05 +0200 Subject: [PATCH 09/11] Fix RouteMapView flickering: wait for layout before drawing, disable zoom animation --- .../xposedfakelocation/manager/ui/routes/RouteMapView.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt index a5761bd..ed4dfa1 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView @@ -35,6 +36,10 @@ fun RouteMapView( } LaunchedEffect(waypoints) { + if (waypoints.isEmpty()) return@LaunchedEffect + while (mapView.width <= 0 || mapView.height <= 0) { + withFrameNanos { } + } drawRoute(context, mapView, waypoints) } @@ -83,7 +88,7 @@ private fun drawRoute(context: Context, mapView: MapView, waypoints: List= 2) { val box = BoundingBox.fromGeoPoints(geoPoints) - mapView.zoomToBoundingBox(box.increaseByScale(1.2f), true, 48) + mapView.post { mapView.zoomToBoundingBox(box.increaseByScale(1.2f), false, 0) } } else { mapView.controller.setZoom(15.0) mapView.controller.setCenter(geoPoints[0]) From a039fe53a3653244debc7a66bf63fe5e671b8acb Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 17:45:22 +0200 Subject: [PATCH 10/11] docs: translate German inline comments to English (RouteDetailScreen.kt) --- .../manager/ui/routes/RouteDetailScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt index ff6094c..821a6b8 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt @@ -89,7 +89,7 @@ fun RouteDetailScreen( onStopPlaying = routeDetailViewModel::stopPlaying, onPlaybackSpeedChange = routeDetailViewModel::updatePlaybackSpeed, onLoopingChange = routeDetailViewModel::updateLooping, - onAddWaypoint = { /* TODO: Dialog vom MapScreen aus */ }, + onAddWaypoint = { /* TODO: trigger dialog from MapScreen */ }, onRemoveWaypoint = routeDetailViewModel::removeWaypoint, onMoveWaypointUp = { index -> if (index > 0) routeDetailViewModel.reorderWaypoint(index, index - 1) @@ -288,7 +288,7 @@ private fun ControlSection( ) } - // Geschwindigkeit + // Playback speed Text( text = stringResource(R.string.route_speed, playbackSpeed.toInt()), style = MaterialTheme.typography.bodyMedium, @@ -301,7 +301,7 @@ private fun ControlSection( modifier = Modifier.fillMaxWidth(), ) - // Schleifen-Modus + // Loop mode Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), From f1e43b6d4d4eaaf18fc819345301f7eb7edcb463 Mon Sep 17 00:00:00 2001 From: ro011110ot Date: Tue, 16 Jun 2026 19:53:30 +0200 Subject: [PATCH 11/11] fix: pre-select last used route in AddToRouteDialog; translate remaining German comments - Add lastUsedRouteName field to MapViewModel that tracks the most recently created or selected route name - showAddToRouteDialog() now pre-selects lastUsedRouteName instead of always picking the first route in the list - onRouteSelectionChange() records the user's choice as lastUsedRouteName - confirmAddToRoute() records the target route after a successful save - Remove debug Log statements added during diagnosis - Translate remaining German inline comments in RouteDetailScreen.kt (Geschwindigkeit -> Playback speed, Schleifen-Modus -> Loop mode, Dialog vom MapScreen aus -> trigger dialog from MapScreen) --- .../manager/ui/map/MapViewModel.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt index 341853e..f8d86a7 100644 --- a/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt @@ -425,14 +425,25 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { // ---- Add to route dialog ---- + /** + * Remembers the name of the route that was last created or selected, so that + * [showAddToRouteDialog] can pre-select it when the user adds another waypoint. + */ + private var lastUsedRouteName: String? = null + /** Makes the "Add to route" dialog visible and loads the list of available route names. */ fun showAddToRouteDialog() { val routeNames = preferencesRepository.getRoutes().map { it.name } + val preselected = if (lastUsedRouteName in routeNames) { + lastUsedRouteName!! + } else { + routeNames.firstOrNull() ?: "" + } _uiState.update { it.copy( isAddToRouteDialogVisible = true, availableRouteNames = routeNames, - selectedRouteName = routeNames.firstOrNull() ?: "", + selectedRouteName = preselected, newRouteNameInput = "", ) } @@ -449,6 +460,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { * @param name The selected route name. */ fun onRouteSelectionChange(name: String) { + lastUsedRouteName = name _uiState.update { it.copy(selectedRouteName = name, newRouteNameInput = "") } } @@ -475,7 +487,6 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { val existingRoute = routes.find { it.name == routeName } if (existingRoute != null) { - // Add to existing route val newWaypoint = RouteWaypoint( name = "Waypoint ${existingRoute.waypoints.size + 1}", latitude = location.latitude, @@ -487,7 +498,6 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { ) preferencesRepository.updateRoute(existingRoute, updatedRoute) } else { - // Create new route val newRoute = Route( name = routeName, waypoints = listOf( @@ -502,6 +512,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { preferencesRepository.addRoute(newRoute) } + lastUsedRouteName = routeName hideAddToRouteDialog() } }