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..3f1d4bb 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,18 @@ 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" +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") @@ -85,6 +97,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 new file mode 100644 index 0000000..c6759e4 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/data/model/Route.kt @@ -0,0 +1,11 @@ +package com.noobexon.xposedfakelocation.data.model + +/** + * 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, + 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/data/repository/PrefrencesRepository.kt b/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt index d71f1f5..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 @@ -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,12 @@ 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_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 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 +53,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 +74,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 +358,117 @@ 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 + + // 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) 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/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/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..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 @@ -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,12 @@ 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. + * @property currentRoutePosition Current route playback position on the map, or null when not playing. */ @Immutable data class MapUiState( @@ -61,6 +68,12 @@ 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(), + 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 a5e9eaa..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 @@ -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,8 @@ fun MapScreen( isPlaying = uiState.isPlaying, mapZoom = uiState.mapZoom, hasResolvedInitialLocation = uiState.hasResolvedInitialLocation, + activeRouteWaypoints = uiState.activeRouteWaypoints, + currentRoutePosition = uiState.currentRoutePosition, goToPointEvent = mapViewModel.goToPointEvent, centerMapEvent = mapViewModel.centerMapEvent, onClickedLocationChange = mapViewModel::updateClickedLocation, @@ -285,5 +304,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..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 @@ -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,9 @@ 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 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 @@ -72,6 +76,8 @@ fun MapViewContainer( isPlaying: Boolean, mapZoom: Double?, hasResolvedInitialLocation: Boolean, + activeRouteWaypoints: List, + currentRoutePosition: GeoPoint?, goToPointEvent: Flow, centerMapEvent: Flow, onClickedLocationChange: (GeoPoint?) -> Unit, @@ -107,6 +113,8 @@ fun MapViewContainer( onLoadingFinished = onLoadingFinished, 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 f5607aa..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 @@ -24,6 +24,9 @@ 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 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 @@ -33,6 +36,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 +465,116 @@ 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, +) { + val context = LocalContext.current + 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 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) + } + + 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() + } +} + +/** + * 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 0c8cfda..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 @@ -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 @@ -13,8 +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. */ @@ -76,17 +82,43 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { init { viewModelScope.launch { - preferencesRepository.getIsPlayingFlow().collect { isPlaying -> - _uiState.update { it.copy(isPlaying = isPlaying) } - } + preferencesRepository.getIsPlayingFlow() + .flowOn(Dispatchers.IO) + .collect { isPlaying -> + val waypoints = withContext(Dispatchers.IO) { + preferencesRepository.getActiveRouteWaypoints() + } + _uiState.update { + it.copy( + isPlaying = isPlaying, + activeRouteWaypoints = waypoints, + currentRoutePosition = null + ) + } + } } viewModelScope.launch { - preferencesRepository.getLastClickedLocationFlow().collect { location -> - val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) } - _uiState.update { it.copy(lastClickedLocation = geoPoint) } + while (true) { + delay(2000L) + if (_uiState.value.isPlaying) { + 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)) } + } + } } } + + viewModelScope.launch { + preferencesRepository.getLastClickedLocationFlow() + .flowOn(Dispatchers.IO) + .collect { location -> + val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) } + _uiState.update { it.copy(lastClickedLocation = geoPoint) } + } + } } /** @@ -391,6 +423,100 @@ 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 = preselected, + 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) { + lastUsedRouteName = name + _uiState.update { it.copy(selectedRouteName = name, newRouteNameInput = "") } + } + + /** + * 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, selectedRouteName = "") } + } + + /** + * 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) { + 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 { + val newRoute = Route( + name = routeName, + waypoints = listOf( + RouteWaypoint( + name = "Waypoint 1", + latitude = location.latitude, + longitude = location.longitude, + order = 0, + ), + ), + ) + preferencesRepository.addRoute(newRoute) + } + + lastUsedRouteName = routeName + 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 new file mode 100644 index 0000000..821a6b8 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteDetailScreen.kt @@ -0,0 +1,408 @@ +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: trigger dialog from MapScreen */ }, + 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), + ) { + // Control section + ControlSection( + isPlaying = uiState.isPlaying, + playbackSpeed = uiState.playbackSpeed, + isLooping = uiState.isLooping, + onStartPlaying = onStartPlaying, + onStopPlaying = onStopPlaying, + onPlaybackSpeedChange = onPlaybackSpeedChange, + onLoopingChange = onLoopingChange, + ) + + Spacer(Modifier.height(16.dp)) + + // 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), + 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 + ), + ) + } + + // Playback speed + 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(), + ) + + // Loop mode + 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/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 new file mode 100644 index 0000000..ed4dfa1 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RouteMapView.kt @@ -0,0 +1,99 @@ +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.runtime.withFrameNanos +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, +) { + val context = LocalContext.current + val mapView = remember { createMapView(context) } + + DisposableEffect(Unit) { + mapView.onResume() + onDispose { + mapView.overlays.clear() + mapView.onPause() + mapView.onDetach() + } + } + + LaunchedEffect(waypoints) { + if (waypoints.isEmpty()) return@LaunchedEffect + while (mapView.width <= 0 || mapView.height <= 0) { + withFrameNanos { } + } + drawRoute(context, 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(context: Context, mapView: MapView, waypoints: List) { + mapView.overlays.clear() + + val geoPoints = waypoints.map { GeoPoint(it.latitude, it.longitude) } + + 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) + + 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) + } + + if (geoPoints.size >= 2) { + val box = BoundingBox.fromGeoPoints(geoPoints) + mapView.post { mapView.zoomToBoundingBox(box.increaseByScale(1.2f), false, 0) } + } else { + 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/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..1ef1103 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/routes/RoutesViewModel.kt @@ -0,0 +1,197 @@ +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.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 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, + ) + } + } + } + + /** + * 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/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) { 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 9f2131c..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 @@ -121,6 +122,12 @@ object LocationUtil { @Synchronized fun updateLocation() { try { + // If a route is playing, let RoutePlayer control the position + RoutePlayer.loadActiveRoute() + if (RoutePlayer.isRouteActive()) { + 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..563a1b9 --- /dev/null +++ b/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/RoutePlayer.kt @@ -0,0 +1,225 @@ +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 java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +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 playbackSpeed: Double = 10.0 + private var isLooping: Boolean = false + @Volatile private var isActive: Boolean = false + private var isFinished: Boolean = false + + private var routeStartTime: Long = 0L + private var segmentDistances: DoubleArray = DoubleArray(0) + private var totalRouteDistance: Double = 0.0 + + private val gson = Gson() + + 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 + val nowPlaying = prefs.getBoolean("route_playing", false) + + if (!nowPlaying) { + stopTimer() + isActive = false + isFinished = false + clearCurrentPosition() + log("Route stopped (nowPlaying=false)") + return + } + + 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()}") + } + + computeAndSetPosition() + } catch (e: Exception) { + log("Error: ${e.message}", Log.ERROR) + stopTimer() + isActive = false + } + } + + private fun startTimer() { + stopTimer() + timerHandle = executor.scheduleWithFixedDelay( + { timerTick() }, + 500, 500, TimeUnit.MILLISECONDS + ) + log("Timer started (500ms)") + } + + private fun stopTimer() { + timerHandle?.cancel(false) + timerHandle = null + } + + private fun timerTick() { + if (isActive) { + computeAndSetPosition() + } + } + + fun isRouteActive(): Boolean = isActive + + fun computeAndSetPosition() { + if (!isActive || waypoints.isEmpty()) return + + val elapsedNanos = System.nanoTime() - routeStartTime + val elapsedSeconds = elapsedNanos / 1_000_000_000.0 + val totalDistance = playbackSpeed * elapsedSeconds + val pos = computePosition(totalDistance) + + 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)}") + } + } + + 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 + } + + private fun computePosition(totalDistance: Double): Pair? { + if (waypoints.isEmpty()) return null + + if (totalRouteDistance <= 0.0) { + return Pair(waypoints[0].latitude, waypoints[0].longitude) + } + + val effectiveDistance = if (isLooping) { + totalDistance % totalRouteDistance + } else { + totalDistance + } + + if (effectiveDistance < 0) { + routeStartTime = System.nanoTime() + return Pair(waypoints[0].latitude, waypoints[0].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) + } + + 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() + } + + 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 + } + + 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 + } + } +} 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..7918132 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,219 @@ + + 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 + Nach oben + Nach unten + + Einstellungen + Favoriten + Routen + 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 + + 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 + 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..80bff5f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -6,6 +6,7 @@ 跟随系统 英语 中文 + 德语 确定 取消 @@ -34,9 +35,12 @@ 删除 %1$s 编辑 %1$s 配置 %1$s 图标 + 上移 + 下移 设置 收藏 + 路线 目标应用 模板 关于 @@ -165,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 a8f562d..2d1c3e6 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 @@ -36,9 +37,12 @@ Delete %1$s Edit %1$s profile %1$s icon + Move up + Move down Settings Favorites + Routes Target Apps Templates About @@ -167,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