Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added 0
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RouteWaypoint>,
val createdAt: Long = System.currentTimeMillis(),
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -342,6 +358,117 @@ class PreferencesRepository(context: Context) {
}
// endregion

// region Routes (local)
fun getRoutesFlow(): Flow<List<Route>> =
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<Route> = parseRoutes(localPrefs.getString(KEY_ROUTES, null))

private fun saveRoutes(routes: List<Route>) {
val json = gson.toJson(routes)
editLocal { putString(KEY_ROUTES, json) }
}

private fun parseRoutes(json: String?): List<Route> {
if (json.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<Route>>() {}.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<Boolean> = 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<String> = 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<List<RouteWaypoint>> =
remoteFlow(KEY_ACTIVE_ROUTE_WAYPOINTS, emptyList<RouteWaypoint>()) {
parseRouteWaypoints(it.getString(KEY_ACTIVE_ROUTE_WAYPOINTS, null))
}

suspend fun saveActiveRouteWaypoints(waypoints: List<RouteWaypoint>) {
val json = gson.toJson(waypoints)
editRemote { putString(KEY_ACTIVE_ROUTE_WAYPOINTS, json) }
}

fun getActiveRouteWaypoints(): List<RouteWaypoint> =
parseRouteWaypoints(remotePrefs()?.getString(KEY_ACTIVE_ROUTE_WAYPOINTS, null))

private fun parseRouteWaypoints(json: String?): List<RouteWaypoint> {
if (json.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<RouteWaypoint>>() {}.type
gson.fromJson(json, type)
} catch (e: JsonSyntaxException) {
Log.e(tag, "Error parsing active route waypoints: ${e.message}")
emptyList()
}
}

fun getActiveRouteWaypointIndexFlow(): Flow<Int> = 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<Double> = 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<Double> = 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<Boolean> = 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
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))
}
}
)
}
Loading