feat: XposedFakeLocation 增强版
🎯 位置Hook模块: - LocationApiHooks: 核心位置API拦截 - GooglePlayServicesHooks: FusedLocationProviderClient - WifiHooks: WiFi扫描定位拦截 - TelephonyHooks: 基站定位拦截 - SensorHooks: 传感器监控 - GnssHooks: GPS卫星数据伪造 - SystemServicesHooks: 系统服务Hook 🔧 功能特性: - 完整位置伪造支持 - Mock位置检测隐藏 - 多种定位方式全覆盖 - 中文界面支持 📦 包含预编译APK
@@ -0,0 +1,24 @@
|
||||
package com.noobexon.xposedfakelocation
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.noobexon.xposedfakelocation", appContext.packageName)
|
||||
}
|
||||
}
|
||||
54
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.XposedFakeLocation"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".manager.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.XposedFakeLocation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Whether to be an Xposed module (specify true) -->
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true"/>
|
||||
<!-- Introduction to the module (shown in the framework) -->
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="XposedFakeLocation" />
|
||||
<!-- The minimum supported Api version of the module is usually 54. -->
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="93"/>
|
||||
<!-- Module Scopes -->
|
||||
<meta-data
|
||||
android:name="xposedscope"
|
||||
android:resource="@array/xposedscope"/>
|
||||
|
||||
<!-- Indicate usage of new XSharedPreferences -->
|
||||
<meta-data
|
||||
android:name="xposedsharedprefs"
|
||||
android:value="true" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
1
app/src/main/assets/xposed_init
Normal file
@@ -0,0 +1 @@
|
||||
com.noobexon.xposedfakelocation.xposed.MainHook
|
||||
@@ -0,0 +1,72 @@
|
||||
//Constants.kt
|
||||
package com.noobexon.xposedfakelocation.data
|
||||
|
||||
// APP
|
||||
const val MANAGER_APP_PACKAGE_NAME = "com.noobexon.xposedfakelocation"
|
||||
const val SHARED_PREFS_FILE = "xposed_shared_prefs"
|
||||
|
||||
// KEYS
|
||||
const val KEY_IS_PLAYING = "is_playing"
|
||||
|
||||
const val KEY_LAST_CLICKED_LOCATION = "last_clicked_location"
|
||||
|
||||
const val KEY_USE_ACCURACY = "use_accuracy"
|
||||
const val KEY_ACCURACY = "accuracy"
|
||||
|
||||
const val KEY_USE_ALTITUDE = "use_altitude"
|
||||
const val KEY_ALTITUDE = "altitude"
|
||||
|
||||
const val KEY_USE_RANDOMIZE = "use_randomize"
|
||||
const val KEY_RANDOMIZE_RADIUS = "randomize_radius"
|
||||
|
||||
const val KEY_USE_VERTICAL_ACCURACY = "use_vertical_accuracy"
|
||||
const val KEY_VERTICAL_ACCURACY = "vertical_accuracy"
|
||||
|
||||
const val KEY_USE_MEAN_SEA_LEVEL = "use_mean_sea_level"
|
||||
const val KEY_MEAN_SEA_LEVEL = "mean_sea_level"
|
||||
|
||||
const val KEY_USE_MEAN_SEA_LEVEL_ACCURACY = "use_mean_sea_level_accuracy"
|
||||
const val KEY_MEAN_SEA_LEVEL_ACCURACY = "mean_sea_level_accuracy"
|
||||
|
||||
const val KEY_USE_SPEED = "use_speed"
|
||||
const val KEY_SPEED = "speed"
|
||||
|
||||
const val KEY_USE_SPEED_ACCURACY = "use_speed_accuracy"
|
||||
const val KEY_SPEED_ACCURACY = "speed_accuracy"
|
||||
|
||||
const val KEY_FAVORITES = "favorites"
|
||||
|
||||
// DEFAULT VALUES
|
||||
const val DEFAULT_USE_ACCURACY = false
|
||||
const val DEFAULT_ACCURACY = 0.0
|
||||
|
||||
const val DEFAULT_USE_ALTITUDE = false
|
||||
const val DEFAULT_ALTITUDE = 0.0
|
||||
|
||||
const val DEFAULT_USE_RANDOMIZE = false
|
||||
const val DEFAULT_RANDOMIZE_RADIUS = 0.0
|
||||
|
||||
const val DEFAULT_USE_VERTICAL_ACCURACY = false
|
||||
const val DEFAULT_VERTICAL_ACCURACY = 0.0f
|
||||
|
||||
const val DEFAULT_USE_MEAN_SEA_LEVEL = false
|
||||
const val DEFAULT_MEAN_SEA_LEVEL = 0.0
|
||||
|
||||
const val DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY = false
|
||||
const val DEFAULT_MEAN_SEA_LEVEL_ACCURACY = 0.0f
|
||||
|
||||
const val DEFAULT_USE_SPEED = false
|
||||
const val DEFAULT_SPEED = 0.0f
|
||||
|
||||
const val DEFAULT_USE_SPEED_ACCURACY = false
|
||||
const val DEFAULT_SPEED_ACCURACY = 0.0f
|
||||
|
||||
// MATH & PHYS
|
||||
const val PI = 3.14159265359
|
||||
const val RADIUS_EARTH = 6378137.0 // Approximately Earth's radius in meters
|
||||
|
||||
// MAP SETTINGS
|
||||
const val DEFAULT_MAP_ZOOM = 18.0
|
||||
const val WORLD_MAP_ZOOM = 2.0
|
||||
const val LOCATION_DETECTION_MAX_ATTEMPTS = 80
|
||||
const val LOCATION_DETECTION_DELAY_MS = 100L
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.noobexon.xposedfakelocation.data.model
|
||||
|
||||
data class FavoriteLocation(
|
||||
val name: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.noobexon.xposedfakelocation.data.model
|
||||
|
||||
data class LastClickedLocation(
|
||||
val latitude: Double,
|
||||
val longitude: Double
|
||||
)
|
||||
@@ -0,0 +1,495 @@
|
||||
// PreferencesRepository.kt
|
||||
package com.noobexon.xposedfakelocation.data.repository
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.noobexon.xposedfakelocation.data.*
|
||||
import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
|
||||
import com.noobexon.xposedfakelocation.data.model.LastClickedLocation
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.IOException
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = SHARED_PREFS_FILE)
|
||||
|
||||
class PreferencesRepository(private val context: Context) {
|
||||
private val tag = "PreferencesRepository"
|
||||
|
||||
// Legacy SharedPreferences for Xposed Module compatibility
|
||||
@SuppressLint("WorldReadableFiles")
|
||||
private val sharedPrefs = try {
|
||||
context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_WORLD_READABLE)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(tag, "MODE_WORLD_READABLE not available: ${e.message}")
|
||||
context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
// DataStore preference keys
|
||||
private object PreferenceKeys {
|
||||
val IS_PLAYING = booleanPreferencesKey(KEY_IS_PLAYING)
|
||||
val LAST_CLICKED_LOCATION = stringPreferencesKey(KEY_LAST_CLICKED_LOCATION)
|
||||
val USE_ACCURACY = booleanPreferencesKey(KEY_USE_ACCURACY)
|
||||
val ACCURACY = doublePreferencesKey(KEY_ACCURACY)
|
||||
val USE_ALTITUDE = booleanPreferencesKey(KEY_USE_ALTITUDE)
|
||||
val ALTITUDE = doublePreferencesKey(KEY_ALTITUDE)
|
||||
val USE_RANDOMIZE = booleanPreferencesKey(KEY_USE_RANDOMIZE)
|
||||
val RANDOMIZE_RADIUS = doublePreferencesKey(KEY_RANDOMIZE_RADIUS)
|
||||
val USE_VERTICAL_ACCURACY = booleanPreferencesKey(KEY_USE_VERTICAL_ACCURACY)
|
||||
val VERTICAL_ACCURACY = floatPreferencesKey(KEY_VERTICAL_ACCURACY)
|
||||
val USE_MEAN_SEA_LEVEL = booleanPreferencesKey(KEY_USE_MEAN_SEA_LEVEL)
|
||||
val MEAN_SEA_LEVEL = doublePreferencesKey(KEY_MEAN_SEA_LEVEL)
|
||||
val USE_MEAN_SEA_LEVEL_ACCURACY = booleanPreferencesKey(KEY_USE_MEAN_SEA_LEVEL_ACCURACY)
|
||||
val MEAN_SEA_LEVEL_ACCURACY = floatPreferencesKey(KEY_MEAN_SEA_LEVEL_ACCURACY)
|
||||
val USE_SPEED = booleanPreferencesKey(KEY_USE_SPEED)
|
||||
val SPEED = floatPreferencesKey(KEY_SPEED)
|
||||
val USE_SPEED_ACCURACY = booleanPreferencesKey(KEY_USE_SPEED_ACCURACY)
|
||||
val SPEED_ACCURACY = floatPreferencesKey(KEY_SPEED_ACCURACY)
|
||||
val FAVORITES = stringPreferencesKey(KEY_FAVORITES)
|
||||
}
|
||||
|
||||
// Generic helper for DataStore flows with error handling
|
||||
private fun <T> getPreferenceFlow(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
|
||||
return context.dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e(tag, "Error reading preferences: ${exception.message}")
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
preferences[key] ?: defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to write both to DataStore and legacy SharedPreferences
|
||||
private suspend inline fun <reified T> savePreference(
|
||||
key: Preferences.Key<T>,
|
||||
value: T,
|
||||
sharedPrefsKey: String,
|
||||
sharedPrefsValue: Any
|
||||
) {
|
||||
try {
|
||||
// Save to DataStore
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[key] = value
|
||||
}
|
||||
|
||||
// Save to legacy SharedPreferences for Xposed Module
|
||||
when (value) {
|
||||
is Boolean -> sharedPrefs.edit().putBoolean(sharedPrefsKey, value).apply()
|
||||
is String -> sharedPrefs.edit().putString(sharedPrefsKey, value).apply()
|
||||
is Float -> sharedPrefs.edit().putFloat(sharedPrefsKey, value).apply()
|
||||
is Double -> {
|
||||
val bits = java.lang.Double.doubleToRawLongBits(value)
|
||||
sharedPrefs.edit().putLong(sharedPrefsKey, bits).apply()
|
||||
}
|
||||
is Long -> sharedPrefs.edit().putLong(sharedPrefsKey, value).apply()
|
||||
is Int -> sharedPrefs.edit().putInt(sharedPrefsKey, value).apply()
|
||||
}
|
||||
|
||||
Log.d(tag, "Saved $sharedPrefsKey: $value")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error saving preference $sharedPrefsKey: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Is Playing
|
||||
fun getIsPlayingFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.IS_PLAYING, DEFAULT_USE_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveIsPlaying(isPlaying: Boolean) {
|
||||
savePreference(PreferenceKeys.IS_PLAYING, isPlaying, KEY_IS_PLAYING, isPlaying)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getIsPlaying(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_IS_PLAYING, false)
|
||||
}
|
||||
|
||||
// Last Clicked Location
|
||||
fun getLastClickedLocationFlow(): Flow<LastClickedLocation?> {
|
||||
return context.dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e(tag, "Error reading preferences: ${exception.message}")
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
val json = preferences[PreferenceKeys.LAST_CLICKED_LOCATION]
|
||||
if (json != null) {
|
||||
try {
|
||||
gson.fromJson(json, LastClickedLocation::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(tag, "Error parsing LastClickedLocation: ${e.message}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveLastClickedLocation(latitude: Double, longitude: Double) {
|
||||
try {
|
||||
val location = LastClickedLocation(latitude, longitude)
|
||||
val json = gson.toJson(location)
|
||||
savePreference(PreferenceKeys.LAST_CLICKED_LOCATION, json, KEY_LAST_CLICKED_LOCATION, json)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error saving LastClickedLocation: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getLastClickedLocation(): LastClickedLocation? {
|
||||
val json = sharedPrefs.getString(KEY_LAST_CLICKED_LOCATION, null)
|
||||
return if (json != null) {
|
||||
try {
|
||||
gson.fromJson(json, LastClickedLocation::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(tag, "Error parsing LastClickedLocation: ${e.message}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearLastClickedLocation() {
|
||||
try {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.remove(PreferenceKeys.LAST_CLICKED_LOCATION)
|
||||
}
|
||||
|
||||
sharedPrefs.edit()
|
||||
.remove(KEY_LAST_CLICKED_LOCATION)
|
||||
.apply()
|
||||
|
||||
saveIsPlaying(false)
|
||||
Log.d(tag, "Cleared 'LastClickedLocation' from preferences and set 'IsPlaying' to false")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error clearing LastClickedLocation: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Use Accuracy
|
||||
fun getUseAccuracyFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_ACCURACY, DEFAULT_USE_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveUseAccuracy(useAccuracy: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_ACCURACY, useAccuracy, KEY_USE_ACCURACY, useAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseAccuracy(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_ACCURACY, DEFAULT_USE_ACCURACY)
|
||||
}
|
||||
|
||||
// Accuracy
|
||||
fun getAccuracyFlow(): Flow<Double> {
|
||||
return getPreferenceFlow(PreferenceKeys.ACCURACY, DEFAULT_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveAccuracy(accuracy: Double) {
|
||||
savePreference(PreferenceKeys.ACCURACY, accuracy, KEY_ACCURACY, accuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getAccuracy(): Double {
|
||||
val bits = sharedPrefs.getLong(KEY_ACCURACY, java.lang.Double.doubleToRawLongBits(DEFAULT_ACCURACY))
|
||||
return java.lang.Double.longBitsToDouble(bits)
|
||||
}
|
||||
|
||||
// Use Altitude
|
||||
fun getUseAltitudeFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_ALTITUDE, DEFAULT_USE_ALTITUDE)
|
||||
}
|
||||
|
||||
suspend fun saveUseAltitude(useAltitude: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_ALTITUDE, useAltitude, KEY_USE_ALTITUDE, useAltitude)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseAltitude(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_ALTITUDE, DEFAULT_USE_ALTITUDE)
|
||||
}
|
||||
|
||||
// Altitude
|
||||
fun getAltitudeFlow(): Flow<Double> {
|
||||
return getPreferenceFlow(PreferenceKeys.ALTITUDE, DEFAULT_ALTITUDE)
|
||||
}
|
||||
|
||||
suspend fun saveAltitude(altitude: Double) {
|
||||
savePreference(PreferenceKeys.ALTITUDE, altitude, KEY_ALTITUDE, altitude)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getAltitude(): Double {
|
||||
val bits = sharedPrefs.getLong(KEY_ALTITUDE, java.lang.Double.doubleToRawLongBits(DEFAULT_ALTITUDE))
|
||||
return java.lang.Double.longBitsToDouble(bits)
|
||||
}
|
||||
|
||||
// Use Randomize
|
||||
fun getUseRandomizeFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_RANDOMIZE, DEFAULT_USE_RANDOMIZE)
|
||||
}
|
||||
|
||||
suspend fun saveUseRandomize(randomize: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_RANDOMIZE, randomize, KEY_USE_RANDOMIZE, randomize)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseRandomize(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_RANDOMIZE, DEFAULT_USE_RANDOMIZE)
|
||||
}
|
||||
|
||||
// Randomize Radius
|
||||
fun getRandomizeRadiusFlow(): Flow<Double> {
|
||||
return getPreferenceFlow(PreferenceKeys.RANDOMIZE_RADIUS, DEFAULT_RANDOMIZE_RADIUS)
|
||||
}
|
||||
|
||||
suspend fun saveRandomizeRadius(radius: Double) {
|
||||
savePreference(PreferenceKeys.RANDOMIZE_RADIUS, radius, KEY_RANDOMIZE_RADIUS, radius)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getRandomizeRadius(): Double {
|
||||
val bits = sharedPrefs.getLong(
|
||||
KEY_RANDOMIZE_RADIUS,
|
||||
java.lang.Double.doubleToRawLongBits(DEFAULT_RANDOMIZE_RADIUS)
|
||||
)
|
||||
return java.lang.Double.longBitsToDouble(bits)
|
||||
}
|
||||
|
||||
// Favorites
|
||||
fun getFavoritesFlow(): Flow<List<FavoriteLocation>> {
|
||||
return context.dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e(tag, "Error reading preferences: ${exception.message}")
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
val json = preferences[PreferenceKeys.FAVORITES]
|
||||
if (json != null) {
|
||||
try {
|
||||
val type = object : TypeToken<List<FavoriteLocation>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(tag, "Error parsing Favorites: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addFavorite(favorite: FavoriteLocation) {
|
||||
try {
|
||||
val favorites = getFavoritesFlow().firstOrNull() ?: emptyList()
|
||||
val updatedFavorites = favorites.toMutableList().apply { add(favorite) }
|
||||
saveFavorites(updatedFavorites)
|
||||
Log.d(tag, "Added Favorite: $favorite")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error adding favorite: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveFavorites(favorites: List<FavoriteLocation>) {
|
||||
try {
|
||||
val json = gson.toJson(favorites)
|
||||
savePreference(PreferenceKeys.FAVORITES, json, KEY_FAVORITES, json)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error saving favorites: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFavorite(favorite: FavoriteLocation) {
|
||||
try {
|
||||
val favorites = getFavoritesFlow().firstOrNull() ?: emptyList()
|
||||
val updatedFavorites = favorites.toMutableList().apply { remove(favorite) }
|
||||
saveFavorites(updatedFavorites)
|
||||
Log.d(tag, "Removed Favorite: $favorite from preferences")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error removing favorite: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getFavorites(): List<FavoriteLocation> {
|
||||
val json = sharedPrefs.getString(KEY_FAVORITES, null)
|
||||
return if (json != null) {
|
||||
try {
|
||||
val type = object : TypeToken<List<FavoriteLocation>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(tag, "Error parsing Favorites: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical Accuracy
|
||||
fun getUseVerticalAccuracyFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_VERTICAL_ACCURACY, DEFAULT_USE_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveUseVerticalAccuracy(useVerticalAccuracy: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_VERTICAL_ACCURACY, useVerticalAccuracy, KEY_USE_VERTICAL_ACCURACY, useVerticalAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseVerticalAccuracy(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_VERTICAL_ACCURACY, DEFAULT_USE_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
// Vertical Accuracy Value
|
||||
fun getVerticalAccuracyFlow(): Flow<Float> {
|
||||
return getPreferenceFlow(PreferenceKeys.VERTICAL_ACCURACY, DEFAULT_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveVerticalAccuracy(verticalAccuracy: Float) {
|
||||
savePreference(PreferenceKeys.VERTICAL_ACCURACY, verticalAccuracy, KEY_VERTICAL_ACCURACY, verticalAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getVerticalAccuracy(): Float {
|
||||
return sharedPrefs.getFloat(KEY_VERTICAL_ACCURACY, DEFAULT_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
// Use Mean Sea Level
|
||||
fun getUseMeanSeaLevelFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_MEAN_SEA_LEVEL, DEFAULT_USE_MEAN_SEA_LEVEL)
|
||||
}
|
||||
|
||||
suspend fun saveUseMeanSeaLevel(useMeanSeaLevel: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_MEAN_SEA_LEVEL, useMeanSeaLevel, KEY_USE_MEAN_SEA_LEVEL, useMeanSeaLevel)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseMeanSeaLevel(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_MEAN_SEA_LEVEL, DEFAULT_USE_MEAN_SEA_LEVEL)
|
||||
}
|
||||
|
||||
// Mean Sea Level
|
||||
fun getMeanSeaLevelFlow(): Flow<Double> {
|
||||
return getPreferenceFlow(PreferenceKeys.MEAN_SEA_LEVEL, DEFAULT_MEAN_SEA_LEVEL)
|
||||
}
|
||||
|
||||
suspend fun saveMeanSeaLevel(meanSeaLevel: Double) {
|
||||
savePreference(PreferenceKeys.MEAN_SEA_LEVEL, meanSeaLevel, KEY_MEAN_SEA_LEVEL, meanSeaLevel)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getMeanSeaLevel(): Double {
|
||||
val bits = sharedPrefs.getLong(KEY_MEAN_SEA_LEVEL, java.lang.Double.doubleToRawLongBits(DEFAULT_MEAN_SEA_LEVEL))
|
||||
return java.lang.Double.longBitsToDouble(bits)
|
||||
}
|
||||
|
||||
// Use Mean Sea Level Accuracy
|
||||
fun getUseMeanSeaLevelAccuracyFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveUseMeanSeaLevelAccuracy(useMeanSeaLevelAccuracy: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_MEAN_SEA_LEVEL_ACCURACY, useMeanSeaLevelAccuracy, KEY_USE_MEAN_SEA_LEVEL_ACCURACY, useMeanSeaLevelAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseMeanSeaLevelAccuracy(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
// Mean Sea Level Accuracy
|
||||
fun getMeanSeaLevelAccuracyFlow(): Flow<Float> {
|
||||
return getPreferenceFlow(PreferenceKeys.MEAN_SEA_LEVEL_ACCURACY, DEFAULT_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveMeanSeaLevelAccuracy(meanSeaLevelAccuracy: Float) {
|
||||
savePreference(PreferenceKeys.MEAN_SEA_LEVEL_ACCURACY, meanSeaLevelAccuracy, KEY_MEAN_SEA_LEVEL_ACCURACY, meanSeaLevelAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getMeanSeaLevelAccuracy(): Float {
|
||||
return sharedPrefs.getFloat(KEY_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
// Use Speed
|
||||
fun getUseSpeedFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_SPEED, DEFAULT_USE_SPEED)
|
||||
}
|
||||
|
||||
suspend fun saveUseSpeed(useSpeed: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_SPEED, useSpeed, KEY_USE_SPEED, useSpeed)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseSpeed(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_SPEED, DEFAULT_USE_SPEED)
|
||||
}
|
||||
|
||||
// Speed
|
||||
fun getSpeedFlow(): Flow<Float> {
|
||||
return getPreferenceFlow(PreferenceKeys.SPEED, DEFAULT_SPEED)
|
||||
}
|
||||
|
||||
suspend fun saveSpeed(speed: Float) {
|
||||
savePreference(PreferenceKeys.SPEED, speed, KEY_SPEED, speed)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getSpeed(): Float {
|
||||
return sharedPrefs.getFloat(KEY_SPEED, DEFAULT_SPEED)
|
||||
}
|
||||
|
||||
// Use Speed Accuracy
|
||||
fun getUseSpeedAccuracyFlow(): Flow<Boolean> {
|
||||
return getPreferenceFlow(PreferenceKeys.USE_SPEED_ACCURACY, DEFAULT_USE_SPEED_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveUseSpeedAccuracy(useSpeedAccuracy: Boolean) {
|
||||
savePreference(PreferenceKeys.USE_SPEED_ACCURACY, useSpeedAccuracy, KEY_USE_SPEED_ACCURACY, useSpeedAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getUseSpeedAccuracy(): Boolean {
|
||||
return sharedPrefs.getBoolean(KEY_USE_SPEED_ACCURACY, DEFAULT_USE_SPEED_ACCURACY)
|
||||
}
|
||||
|
||||
// Speed Accuracy
|
||||
fun getSpeedAccuracyFlow(): Flow<Float> {
|
||||
return getPreferenceFlow(PreferenceKeys.SPEED_ACCURACY, DEFAULT_SPEED_ACCURACY)
|
||||
}
|
||||
|
||||
suspend fun saveSpeedAccuracy(speedAccuracy: Float) {
|
||||
savePreference(PreferenceKeys.SPEED_ACCURACY, speedAccuracy, KEY_SPEED_ACCURACY, speedAccuracy)
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
fun getSpeedAccuracy(): Float {
|
||||
return sharedPrefs.getFloat(KEY_SPEED_ACCURACY, DEFAULT_SPEED_ACCURACY)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.noobexon.xposedfakelocation.manager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.noobexon.xposedfakelocation.manager.ui.components.ErrorScreen
|
||||
import com.noobexon.xposedfakelocation.manager.ui.navigation.AppNavGraph
|
||||
import com.noobexon.xposedfakelocation.manager.ui.theme.XposedFakeLocationTheme
|
||||
import org.osmdroid.config.Configuration
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
|
||||
@SuppressLint("WorldReadableFiles")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
var isXposedModuleEnabled = true
|
||||
|
||||
// If the module is not enabled then the app won't have permission to use MODE_WORLD_READABLE.
|
||||
try {
|
||||
Configuration.getInstance().load(this, getPreferences(MODE_WORLD_READABLE))
|
||||
} catch (e: SecurityException) {
|
||||
isXposedModuleEnabled = false
|
||||
Log.e(TAG, "SecurityException: ${e.message}", e)
|
||||
} catch (e: Exception) {
|
||||
isXposedModuleEnabled = false
|
||||
Log.e(TAG, "Exception: ${e.message}", e)
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
XposedFakeLocationTheme {
|
||||
if (isXposedModuleEnabled) {
|
||||
val navController = rememberNavController()
|
||||
AppNavGraph(navController = navController)
|
||||
} else {
|
||||
ErrorScreen(
|
||||
onDismiss = { finish() },
|
||||
onConfirm = { finish() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
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.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.noobexon.xposedfakelocation.BuildConfig
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
navController: NavController
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { AboutTopAppBar(navController) }
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AboutContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutTopAppBar(navController: NavController) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.about_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.about_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutContent() {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AppTitle()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppDescription()
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
AppVersionSection()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppDeveloperSection()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppTitle() {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppDescription() {
|
||||
Text(
|
||||
text = stringResource(R.string.about_app_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppVersionSection() {
|
||||
AppVersionTitle()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppVersionValue()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppVersionTitle() {
|
||||
Text(
|
||||
text = stringResource(R.string.about_version_label),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppVersionValue() {
|
||||
Text(
|
||||
text = BuildConfig.VERSION_NAME,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppDeveloperSection() {
|
||||
AppDeveloperTitle()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppDeveloperValue()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppDeveloperTitle() {
|
||||
Text(
|
||||
text = stringResource(R.string.about_developer_label),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppDeveloperValue() {
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
text = stringResource(R.string.about_developer_name),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/noobexon1"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
|
||||
/**
|
||||
* Displays an error dialog when the Xposed module is not active.
|
||||
*
|
||||
* @param onDismiss Callback to be invoked when the user dismisses the dialog.
|
||||
* @param onConfirm Callback to be invoked when the user confirms the dialog.
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorScreen(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.error_module_not_active_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.error_module_not_active_message))
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.drawer
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.navigation.NavController
|
||||
import compose.icons.LineAwesomeIcons
|
||||
import compose.icons.lineawesomeicons.*
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
|
||||
|
||||
// Constants for drawer dimensions and styling
|
||||
private object DrawerDimensions {
|
||||
val SECTION_SPACING = 24.dp
|
||||
val ITEM_SPACING = 4.dp
|
||||
val ICON_SIZE = 24.dp
|
||||
val SECTION_PADDING = 8.dp
|
||||
val HEADER_PADDING = 16.dp
|
||||
val DRAWER_PADDING = 16.dp
|
||||
val ITEM_PADDING = 12.dp
|
||||
val ITEM_CORNER_RADIUS = 12.dp
|
||||
val BADGE_SIZE = 8.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
navController: NavController,
|
||||
onCloseDrawer: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
||||
drawerContentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(DrawerDimensions.DRAWER_PADDING)
|
||||
) {
|
||||
// App Header
|
||||
DrawerHeader()
|
||||
|
||||
Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
|
||||
|
||||
// Navigation Section
|
||||
DrawerSectionHeader(stringResource(R.string.nav_section_navigation))
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.MapSolid,
|
||||
label = stringResource(R.string.nav_map),
|
||||
onClick = {
|
||||
navController.navigate(Screen.Map.route)
|
||||
onCloseDrawer()
|
||||
},
|
||||
isSelected = navController.currentDestination?.route == Screen.Map.route
|
||||
)
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.HeartSolid,
|
||||
label = stringResource(R.string.nav_favorites),
|
||||
onClick = {
|
||||
navController.navigate(Screen.Favorites.route)
|
||||
onCloseDrawer()
|
||||
},
|
||||
isSelected = navController.currentDestination?.route == Screen.Favorites.route
|
||||
)
|
||||
|
||||
DrawerItem(
|
||||
icon = Icons.Default.Settings,
|
||||
label = stringResource(R.string.nav_settings),
|
||||
onClick = {
|
||||
navController.navigate(Screen.Settings.route)
|
||||
onCloseDrawer()
|
||||
},
|
||||
isSelected = navController.currentDestination?.route == Screen.Settings.route
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
|
||||
|
||||
// Community Section
|
||||
DrawerSectionHeader(stringResource(R.string.nav_section_community))
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.Telegram,
|
||||
label = stringResource(R.string.nav_telegram),
|
||||
onClick = { Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show() },
|
||||
trailingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(DrawerDimensions.BADGE_SIZE)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.Discord,
|
||||
label = stringResource(R.string.nav_discord),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://discord.gg/8eCRU3KzVS"))
|
||||
context.startActivity(intent)
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.Github,
|
||||
label = stringResource(R.string.nav_github),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/noobexon1/XposedFakeLocation"))
|
||||
context.startActivity(intent)
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
|
||||
|
||||
// About Section
|
||||
DrawerSectionHeader(stringResource(R.string.nav_section_app_info))
|
||||
|
||||
DrawerItem(
|
||||
icon = LineAwesomeIcons.InfoCircleSolid,
|
||||
label = stringResource(R.string.nav_about),
|
||||
onClick = {
|
||||
navController.navigate(Screen.About.route)
|
||||
onCloseDrawer()
|
||||
},
|
||||
isSelected = navController.currentDestination?.route == Screen.About.route
|
||||
)
|
||||
|
||||
// Add version info at the bottom
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.version_format, "1.0"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.padding(DrawerDimensions.SECTION_PADDING)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerHeader() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DrawerDimensions.HEADER_PADDING)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = DrawerDimensions.SECTION_PADDING,
|
||||
bottom = DrawerDimensions.SECTION_PADDING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
isSelected: Boolean = false,
|
||||
trailingIcon: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = DrawerDimensions.ITEM_SPACING)
|
||||
.clip(RoundedCornerShape(DrawerDimensions.ITEM_CORNER_RADIUS))
|
||||
.clickable(onClick = onClick),
|
||||
color = backgroundColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DrawerDimensions.ITEM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(DrawerDimensions.ICON_SIZE),
|
||||
tint = contentColor
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
trailingIcon?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.favorites
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FavoritesScreen(
|
||||
navController: NavController,
|
||||
mapViewModel: MapViewModel,
|
||||
favoritesViewModel: FavoritesViewModel = viewModel()
|
||||
) {
|
||||
val favorites by favoritesViewModel.favorites.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.favorites_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (favorites.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.favorites_empty))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
items(favorites) { favorite ->
|
||||
FavoriteItem(
|
||||
favorite = favorite,
|
||||
onClick = {
|
||||
mapViewModel.updateClickedLocation(GeoPoint(favorite.latitude, favorite.longitude))
|
||||
navController.navigateUp()
|
||||
},
|
||||
onDelete = {
|
||||
favoritesViewModel.removeFavorite(favorite)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FavoriteItem(
|
||||
favorite: FavoriteLocation,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(favorite.name) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.favorites_location_format, favorite.latitude, favorite.longitude),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.favorites
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
|
||||
import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoritesViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val preferencesRepository = PreferencesRepository(application)
|
||||
|
||||
private val _favorites = MutableStateFlow<List<FavoriteLocation>>(emptyList())
|
||||
val favorites: StateFlow<List<FavoriteLocation>> get() = _favorites
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.getFavoritesFlow().collectLatest { favorites ->
|
||||
_favorites.value = favorites
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFavorite(favorite: FavoriteLocation) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.removeFavorite(favorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.map
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
|
||||
import com.noobexon.xposedfakelocation.manager.ui.drawer.DrawerContent
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.components.AddToFavoritesDialog
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.components.GoToPointDialog
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.components.MapViewContainer
|
||||
import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MapScreen(
|
||||
navController: NavController,
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Extract values from UI state
|
||||
val isPlaying = uiState.isPlaying
|
||||
val isFabClickable = uiState.isFabClickable
|
||||
val isLoading = uiState.loadingState == LoadingState.Loading
|
||||
|
||||
// Dialog states
|
||||
val showGoToPointDialog = uiState.goToPointDialogState == DialogState.Visible
|
||||
val showAddToFavoritesDialog = uiState.addToFavoritesDialogState == DialogState.Visible
|
||||
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// BackHandler to close the drawer when open
|
||||
BackHandler(enabled = drawerState.isOpen) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
|
||||
// Scaffold with drawer
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
DrawerContent(
|
||||
onCloseDrawer = {
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
navController = navController
|
||||
)
|
||||
},
|
||||
scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f), // Custom scrim color
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = false,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { scope.launch { drawerState.open() } }
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.menu))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
mapViewModel.triggerCenterMapEvent()
|
||||
}
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MyLocation, contentDescription = stringResource(R.string.center))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
showOptionsMenu = true
|
||||
}
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.options))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showOptionsMenu,
|
||||
onDismissRequest = { showOptionsMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(imageVector = Icons.Default.LocationSearching, contentDescription = stringResource(R.string.go_to_point)) },
|
||||
text = { Text(stringResource(R.string.go_to_point)) },
|
||||
onClick = {
|
||||
showOptionsMenu = false
|
||||
mapViewModel.showGoToPointDialog()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(imageVector = Icons.Default.FavoriteBorder, contentDescription = stringResource(R.string.add_to_favorites)) },
|
||||
text = { Text(stringResource(R.string.add_to_favorites)) },
|
||||
onClick = {
|
||||
showOptionsMenu = false
|
||||
mapViewModel.showAddToFavoritesDialog()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(imageVector = Icons.Default.Star, contentDescription = stringResource(R.string.nav_favorites)) },
|
||||
text = { Text(stringResource(R.string.nav_favorites)) },
|
||||
onClick = {
|
||||
showOptionsMenu = false
|
||||
navController.navigate(Screen.Favorites.route)
|
||||
}
|
||||
)
|
||||
// add clear location feature
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(imageVector = Icons.Default.Clear, contentDescription = stringResource(R.string.clear_location)) },
|
||||
text = { Text(stringResource(R.string.clear_location)) },
|
||||
onClick = {
|
||||
showOptionsMenu = false
|
||||
mapViewModel.updateClickedLocation(null)
|
||||
},
|
||||
enabled = isFabClickable // allow clearing only when a location is marked.
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val fakeLocationSetText = stringResource(R.string.fake_location_set)
|
||||
val fakeLocationUnsetText = stringResource(R.string.fake_location_unset)
|
||||
val stopText = stringResource(R.string.stop)
|
||||
val playText = stringResource(R.string.play)
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (isFabClickable) {
|
||||
val wasPlaying = uiState.isPlaying
|
||||
mapViewModel.togglePlaying()
|
||||
if (!wasPlaying) {
|
||||
Toast.makeText(context, fakeLocationSetText, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, fakeLocationUnsetText, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
containerColor = if (isFabClickable) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
},
|
||||
contentColor = if (isFabClickable) {
|
||||
contentColorFor(MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = if (isFabClickable) 6.dp else 0.dp,
|
||||
pressedElevation = if (isFabClickable) 12.dp else 0.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) stopText else playText
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
MapViewContainer(mapViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (showGoToPointDialog) {
|
||||
GoToPointDialog(
|
||||
onDismissRequest = { mapViewModel.hideGoToPointDialog() },
|
||||
onGoToPoint = { latitude, longitude ->
|
||||
mapViewModel.goToPoint(latitude, longitude)
|
||||
mapViewModel.hideGoToPointDialog()
|
||||
},
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddToFavoritesDialog) {
|
||||
// Prefill coordinates from the last clicked location (marker)
|
||||
val lastClickedLocation = uiState.lastClickedLocation
|
||||
|
||||
LaunchedEffect(lastClickedLocation) {
|
||||
mapViewModel.prefillCoordinatesFromMarker(
|
||||
lastClickedLocation?.latitude,
|
||||
lastClickedLocation?.longitude
|
||||
)
|
||||
}
|
||||
|
||||
AddToFavoritesDialog(
|
||||
mapViewModel = mapViewModel,
|
||||
onDismissRequest = { mapViewModel.hideAddToFavoritesDialog() },
|
||||
onAddFavorite = { name, latitude, longitude ->
|
||||
val favorite = FavoriteLocation(name, latitude, longitude)
|
||||
mapViewModel.addFavoriteLocation(favorite)
|
||||
mapViewModel.hideAddToFavoritesDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.map
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM
|
||||
import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
|
||||
import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
/**
|
||||
* Sealed classes to represent different dialog states
|
||||
*/
|
||||
sealed class DialogState {
|
||||
object Hidden : DialogState()
|
||||
object Visible : DialogState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class to represent different loading states
|
||||
*/
|
||||
sealed class LoadingState {
|
||||
object Loading : LoadingState()
|
||||
object Loaded : LoadingState()
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for the Map screen that manages map-related state and operations.
|
||||
*/
|
||||
class MapViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val preferencesRepository = PreferencesRepository(application)
|
||||
|
||||
/**
|
||||
* Represents field input state with value and validation error message
|
||||
*/
|
||||
data class InputFieldState(val value: String = "", val errorMessage: String? = null)
|
||||
|
||||
/**
|
||||
* Represents the UI state for the favorites input dialog
|
||||
*/
|
||||
data class FavoritesInputState(
|
||||
val name: InputFieldState = InputFieldState(),
|
||||
val latitude: InputFieldState = InputFieldState(),
|
||||
val longitude: InputFieldState = InputFieldState()
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the complete UI state for the Map screen
|
||||
*/
|
||||
data class MapUiState(
|
||||
val isPlaying: Boolean = false,
|
||||
val lastClickedLocation: GeoPoint? = null,
|
||||
val userLocation: GeoPoint? = null,
|
||||
val loadingState: LoadingState = LoadingState.Loading,
|
||||
val mapZoom: Double? = null,
|
||||
val goToPointDialogState: DialogState = DialogState.Hidden,
|
||||
val addToFavoritesDialogState: DialogState = DialogState.Hidden,
|
||||
val goToPointState: Pair<InputFieldState, InputFieldState> = InputFieldState() to InputFieldState(),
|
||||
val addToFavoritesState: FavoritesInputState = FavoritesInputState()
|
||||
) {
|
||||
val isFabClickable: Boolean
|
||||
get() = lastClickedLocation != null
|
||||
}
|
||||
|
||||
// Private mutable state
|
||||
private val _uiState = MutableStateFlow(MapUiState())
|
||||
|
||||
// Public immutable state
|
||||
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Events
|
||||
private val _goToPointEvent = MutableSharedFlow<GeoPoint>()
|
||||
val goToPointEvent: SharedFlow<GeoPoint> = _goToPointEvent.asSharedFlow()
|
||||
|
||||
private val _centerMapEvent = MutableSharedFlow<Unit>()
|
||||
val centerMapEvent: SharedFlow<Unit> = _centerMapEvent.asSharedFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
// Load initial isPlaying state
|
||||
preferencesRepository.getIsPlayingFlow().collectLatest { isPlaying ->
|
||||
_uiState.update { it.copy(isPlaying = isPlaying) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Load initial lastClickedLocation
|
||||
preferencesRepository.getLastClickedLocationFlow().collectLatest { location ->
|
||||
val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) }
|
||||
_uiState.update { it.copy(lastClickedLocation = geoPoint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlaying() {
|
||||
val currentIsPlaying = !_uiState.value.isPlaying
|
||||
_uiState.update { it.copy(isPlaying = currentIsPlaying) }
|
||||
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.saveIsPlaying(currentIsPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserLocation(location: GeoPoint) {
|
||||
_uiState.update { it.copy(userLocation = location) }
|
||||
}
|
||||
|
||||
fun updateClickedLocation(geoPoint: GeoPoint?) {
|
||||
_uiState.update { it.copy(lastClickedLocation = geoPoint) }
|
||||
|
||||
viewModelScope.launch {
|
||||
geoPoint?.let {
|
||||
preferencesRepository.saveLastClickedLocation(
|
||||
it.latitude,
|
||||
it.longitude
|
||||
)
|
||||
} ?: preferencesRepository.clearLastClickedLocation()
|
||||
}
|
||||
}
|
||||
|
||||
fun addFavoriteLocation(favoriteLocation: FavoriteLocation) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.addFavorite(favoriteLocation)
|
||||
}
|
||||
}
|
||||
|
||||
// Update specific fields in the FavoritesInputState
|
||||
fun updateAddToFavoritesField(fieldName: String, newValue: String) {
|
||||
val currentState = _uiState.value.addToFavoritesState
|
||||
val errorMessage = when (fieldName) {
|
||||
"name" -> if (newValue.isBlank()) "Please provide a name" else null
|
||||
"latitude" -> validateInput(newValue, -90.0..90.0, "Latitude must be between -90 and 90")
|
||||
"longitude" -> validateInput(newValue, -180.0..180.0, "Longitude must be between -180 and 180")
|
||||
else -> null
|
||||
}
|
||||
|
||||
val updatedState = when (fieldName) {
|
||||
"name" -> currentState.copy(name = currentState.name.copy(value = newValue, errorMessage = errorMessage))
|
||||
"latitude" -> currentState.copy(latitude = currentState.latitude.copy(value = newValue, errorMessage = errorMessage))
|
||||
"longitude" -> currentState.copy(longitude = currentState.longitude.copy(value = newValue, errorMessage = errorMessage))
|
||||
else -> currentState
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(addToFavoritesState = updatedState) }
|
||||
}
|
||||
|
||||
// Go to point logic
|
||||
fun goToPoint(latitude: Double, longitude: Double) {
|
||||
viewModelScope.launch {
|
||||
_goToPointEvent.emit(GeoPoint(latitude, longitude))
|
||||
}
|
||||
}
|
||||
|
||||
// Update specific fields in the GoToPointDialog state
|
||||
fun updateGoToPointField(fieldName: String, newValue: String) {
|
||||
val (latitudeField, longitudeField) = _uiState.value.goToPointState
|
||||
val updatedGoToPointState = when (fieldName) {
|
||||
"latitude" -> latitudeField.copy(value = newValue) to longitudeField
|
||||
"longitude" -> latitudeField to longitudeField.copy(value = newValue)
|
||||
else -> latitudeField to longitudeField
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(goToPointState = updatedGoToPointState) }
|
||||
}
|
||||
|
||||
// Center map
|
||||
fun triggerCenterMapEvent() {
|
||||
viewModelScope.launch {
|
||||
_centerMapEvent.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLoadingStarted() {
|
||||
_uiState.update { it.copy(loadingState = LoadingState.Loading) }
|
||||
}
|
||||
|
||||
// Set loading finished
|
||||
fun setLoadingFinished() {
|
||||
_uiState.update { it.copy(loadingState = LoadingState.Loaded) }
|
||||
}
|
||||
|
||||
// Dialog show/hide logic
|
||||
fun showGoToPointDialog() {
|
||||
_uiState.update { it.copy(goToPointDialogState = DialogState.Visible) }
|
||||
}
|
||||
|
||||
fun hideGoToPointDialog() {
|
||||
_uiState.update { it.copy(goToPointDialogState = DialogState.Hidden) }
|
||||
clearGoToPointInputs()
|
||||
}
|
||||
|
||||
fun showAddToFavoritesDialog() {
|
||||
_uiState.update { it.copy(addToFavoritesDialogState = DialogState.Visible) }
|
||||
}
|
||||
|
||||
fun hideAddToFavoritesDialog() {
|
||||
_uiState.update { it.copy(addToFavoritesDialogState = DialogState.Hidden) }
|
||||
clearAddToFavoritesInputs()
|
||||
}
|
||||
|
||||
// Helper for input validation
|
||||
private fun validateInput(
|
||||
input: String, range: ClosedRange<Double>, errorMessage: String
|
||||
): String? {
|
||||
val value = input.toDoubleOrNull()
|
||||
return if (value == null || value !in range) errorMessage else null
|
||||
}
|
||||
|
||||
// Validate GoToPoint inputs
|
||||
fun validateAndGo(onSuccess: (latitude: Double, longitude: Double) -> Unit) {
|
||||
val (latField, lonField) = _uiState.value.goToPointState
|
||||
val latitudeError = validateInput(latField.value, -90.0..90.0, "Latitude must be between -90 and 90")
|
||||
val longitudeError = validateInput(lonField.value, -180.0..180.0, "Longitude must be between -180 and 180")
|
||||
|
||||
val updatedGoToPointState = latField.copy(errorMessage = latitudeError) to lonField.copy(errorMessage = longitudeError)
|
||||
_uiState.update { it.copy(goToPointState = updatedGoToPointState) }
|
||||
|
||||
if (latitudeError == null && longitudeError == null) {
|
||||
onSuccess(latField.value.toDouble(), lonField.value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear GoToPoint inputs
|
||||
fun clearGoToPointInputs() {
|
||||
_uiState.update {
|
||||
it.copy(goToPointState = InputFieldState() to InputFieldState())
|
||||
}
|
||||
}
|
||||
|
||||
// Prefill AddToFavorites latitude/longitude with marker values (if available)
|
||||
fun prefillCoordinatesFromMarker(latitude: Double?, longitude: Double?) {
|
||||
if (latitude != null && longitude != null) {
|
||||
val latField = InputFieldState(value = latitude.toString())
|
||||
val lngField = InputFieldState(value = longitude.toString())
|
||||
|
||||
_uiState.update { currentState ->
|
||||
val favState = currentState.addToFavoritesState
|
||||
currentState.copy(
|
||||
addToFavoritesState = favState.copy(
|
||||
latitude = latField,
|
||||
longitude = lngField
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and add favorite location
|
||||
fun validateAndAddFavorite(onSuccess: (name: String, latitude: Double, longitude: Double) -> Unit) {
|
||||
val currentState = _uiState.value.addToFavoritesState
|
||||
|
||||
val latitudeError = validateInput(currentState.latitude.value, -90.0..90.0, "Latitude must be between -90 and 90")
|
||||
val longitudeError = validateInput(currentState.longitude.value, -180.0..180.0, "Longitude must be between -180 and 180")
|
||||
val nameError = if (currentState.name.value.isBlank()) "Please provide a name" else null
|
||||
|
||||
val updatedState = currentState.copy(
|
||||
name = currentState.name.copy(errorMessage = nameError),
|
||||
latitude = currentState.latitude.copy(errorMessage = latitudeError),
|
||||
longitude = currentState.longitude.copy(errorMessage = longitudeError)
|
||||
)
|
||||
|
||||
_uiState.update { it.copy(addToFavoritesState = updatedState) }
|
||||
|
||||
if (nameError == null && latitudeError == null && longitudeError == null) {
|
||||
onSuccess(currentState.name.value, currentState.latitude.value.toDouble(), currentState.longitude.value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear AddToFavorites inputs
|
||||
fun clearAddToFavoritesInputs() {
|
||||
_uiState.update { it.copy(addToFavoritesState = FavoritesInputState()) }
|
||||
}
|
||||
|
||||
// Update map zoom level
|
||||
fun updateMapZoom(zoom: Double) {
|
||||
_uiState.update { it.copy(mapZoom = zoom) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
|
||||
|
||||
@Composable
|
||||
fun AddToFavoritesDialog(
|
||||
mapViewModel: MapViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onAddFavorite: (name: String, latitude: Double, longitude: Double) -> Unit
|
||||
) {
|
||||
// Access UI state through StateFlow
|
||||
val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val addToFavoritesState = uiState.addToFavoritesState
|
||||
|
||||
val favoriteNameInput = addToFavoritesState.name.value
|
||||
val favoriteLatitudeInput = addToFavoritesState.latitude.value
|
||||
val favoriteLongitudeInput = addToFavoritesState.longitude.value
|
||||
val favoriteNameError = addToFavoritesState.name.errorMessage
|
||||
val favoriteLatitudeError = addToFavoritesState.latitude.errorMessage
|
||||
val favoriteLongitudeError = addToFavoritesState.longitude.errorMessage
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
mapViewModel.clearAddToFavoritesInputs()
|
||||
onDismissRequest()
|
||||
},
|
||||
title = { Text(stringResource(R.string.dialog_add_favorite_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = favoriteNameInput,
|
||||
onValueChange = { mapViewModel.updateAddToFavoritesField("name", it) },
|
||||
label = { Text(stringResource(R.string.dialog_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = favoriteNameError != null
|
||||
)
|
||||
if (favoriteNameError != null) {
|
||||
Text(
|
||||
text = favoriteNameError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = favoriteLatitudeInput,
|
||||
onValueChange = { mapViewModel.updateAddToFavoritesField("latitude", it) },
|
||||
label = { Text(stringResource(R.string.dialog_latitude)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||
isError = favoriteLatitudeError != null
|
||||
)
|
||||
if (favoriteLatitudeError != null) {
|
||||
Text(
|
||||
text = favoriteLatitudeError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = favoriteLongitudeInput,
|
||||
onValueChange = { mapViewModel.updateAddToFavoritesField("longitude", it) },
|
||||
label = { Text(stringResource(R.string.dialog_longitude)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||
isError = favoriteLongitudeError != null
|
||||
)
|
||||
if (favoriteLongitudeError != null) {
|
||||
Text(
|
||||
text = favoriteLongitudeError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
mapViewModel.validateAndAddFavorite { name, latitude, longitude ->
|
||||
onAddFavorite(name, latitude, longitude)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_add))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
mapViewModel.clearAddToFavoritesInputs()
|
||||
onDismissRequest()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
|
||||
|
||||
@Composable
|
||||
fun GoToPointDialog(
|
||||
mapViewModel: MapViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onGoToPoint: (latitude: Double, longitude: Double) -> Unit
|
||||
) {
|
||||
// Access the UI state through StateFlow
|
||||
val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val goToPointState = uiState.goToPointState
|
||||
|
||||
val latitudeInput = goToPointState.first.value
|
||||
val longitudeInput = goToPointState.second.value
|
||||
val latitudeError = goToPointState.first.errorMessage
|
||||
val longitudeError = goToPointState.second.errorMessage
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
mapViewModel.clearGoToPointInputs()
|
||||
onDismissRequest()
|
||||
},
|
||||
title = { Text(stringResource(R.string.dialog_go_to_point_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = latitudeInput,
|
||||
onValueChange = { mapViewModel.updateGoToPointField("latitude", it) },
|
||||
label = { Text(stringResource(R.string.dialog_latitude)) },
|
||||
isError = latitudeError != null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (latitudeError != null) {
|
||||
Text(
|
||||
text = latitudeError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = longitudeInput,
|
||||
onValueChange = { mapViewModel.updateGoToPointField("longitude", it) },
|
||||
label = { Text(stringResource(R.string.dialog_longitude)) },
|
||||
isError = longitudeError != null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (longitudeError != null) {
|
||||
Text(
|
||||
text = longitudeError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
mapViewModel.validateAndGo { latitude, longitude ->
|
||||
onGoToPoint(latitude, longitude)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_go))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
mapViewModel.clearGoToPointInputs()
|
||||
onDismissRequest()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.map.components
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.manager.ui.map.DialogState
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.LoadingState
|
||||
import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
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.mylocation.MyLocationNewOverlay
|
||||
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
|
||||
|
||||
@Composable
|
||||
fun MapViewContainer(
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Extract state from uiState
|
||||
val loadingState = uiState.loadingState
|
||||
val lastClickedLocation = uiState.lastClickedLocation
|
||||
val isPlaying = uiState.isPlaying
|
||||
val mapZoom = uiState.mapZoom
|
||||
|
||||
// Remember MapView and overlays
|
||||
val mapView = rememberMapView(context)
|
||||
val userMarker = rememberUserMarker(mapView)
|
||||
val locationOverlay = rememberLocationOverlay(context, mapView)
|
||||
|
||||
// Add the location overlay to the map
|
||||
AddLocationOverlayToMap(mapView, locationOverlay)
|
||||
|
||||
// Handle map events and updates
|
||||
HandleCenterMapEvent(mapView, locationOverlay, mapViewModel)
|
||||
HandleGoToPointEvent(mapView, mapViewModel)
|
||||
HandleMarkerUpdates(mapView, userMarker, lastClickedLocation)
|
||||
SetupMapClickListener(mapView, mapViewModel, isPlaying)
|
||||
CenterMapOnUserLocation(mapView, locationOverlay, mapViewModel, lastClickedLocation, mapZoom)
|
||||
ManageMapViewLifecycle(mapView, mapViewModel, locationOverlay)
|
||||
|
||||
// Add MapListener to update zoom level
|
||||
DisposableEffect(mapView) {
|
||||
val mapListener = object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?): Boolean {
|
||||
// Optional: update map center if needed
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent?): Boolean {
|
||||
// Update zoom state through proper ViewModel methods
|
||||
// This will be handled by the ViewModel's state update logic
|
||||
mapViewModel.updateMapZoom(mapView.zoomLevelDouble)
|
||||
return true
|
||||
}
|
||||
}
|
||||
mapView.addMapListener(mapListener)
|
||||
|
||||
onDispose {
|
||||
mapView.removeMapListener(mapListener)
|
||||
}
|
||||
}
|
||||
|
||||
// Display loading spinner or MapView
|
||||
if (loadingState == LoadingState.Loading) {
|
||||
LoadingSpinner()
|
||||
} else {
|
||||
DisplayMapView(mapView)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMapView(context: Context): MapView {
|
||||
return remember {
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setBuiltInZoomControls(false)
|
||||
setMultiTouchControls(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberUserMarker(mapView: MapView): Marker {
|
||||
return remember {
|
||||
Marker(mapView).apply {
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberLocationOverlay(context: Context, mapView: MapView): MyLocationNewOverlay {
|
||||
return remember {
|
||||
MyLocationNewOverlay(GpsMyLocationProvider(context), mapView).apply {
|
||||
enableMyLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddLocationOverlayToMap(
|
||||
mapView: MapView,
|
||||
locationOverlay: MyLocationNewOverlay
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (!mapView.overlays.contains(locationOverlay)) {
|
||||
mapView.overlays.add(locationOverlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleCenterMapEvent(
|
||||
mapView: MapView,
|
||||
locationOverlay: MyLocationNewOverlay,
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
mapViewModel.centerMapEvent.collect {
|
||||
val userLocation = locationOverlay.myLocation
|
||||
if (userLocation != null) {
|
||||
mapView.controller.animateTo(userLocation)
|
||||
} else {
|
||||
Toast.makeText(context, "User location not available", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleGoToPointEvent(
|
||||
mapView: MapView,
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
mapViewModel.goToPointEvent.collect { geoPoint ->
|
||||
mapView.controller.animateTo(geoPoint)
|
||||
mapViewModel.updateClickedLocation(geoPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleMarkerUpdates(
|
||||
mapView: MapView,
|
||||
userMarker: Marker,
|
||||
lastClickedLocation: GeoPoint?,
|
||||
) {
|
||||
LaunchedEffect(lastClickedLocation) {
|
||||
if (lastClickedLocation != null) {
|
||||
// Add the marker to the map if not already added
|
||||
if (!mapView.overlays.contains(userMarker)) {
|
||||
mapView.overlays.add(userMarker)
|
||||
}
|
||||
userMarker.position = lastClickedLocation
|
||||
mapView.controller.animateTo(lastClickedLocation)
|
||||
mapView.invalidate()
|
||||
} else {
|
||||
// Remove the marker from the map if it exists
|
||||
if (mapView.overlays.contains(userMarker)) {
|
||||
mapView.overlays.remove(userMarker)
|
||||
mapView.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupMapClickListener(
|
||||
mapView: MapView,
|
||||
mapViewModel: MapViewModel,
|
||||
isPlaying: Boolean
|
||||
) {
|
||||
DisposableEffect(mapView, isPlaying) {
|
||||
val mapEventsReceiver = object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
if (!isPlaying) {
|
||||
mapViewModel.updateClickedLocation(p)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val mapEventsOverlay = MapEventsOverlay(mapEventsReceiver)
|
||||
mapView.overlays.add(mapEventsOverlay)
|
||||
|
||||
onDispose {
|
||||
mapView.overlays.remove(mapEventsOverlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMapOnUserLocation(
|
||||
mapView: MapView,
|
||||
locationOverlay: MyLocationNewOverlay,
|
||||
mapViewModel: MapViewModel,
|
||||
lastClickedLocation: GeoPoint?,
|
||||
mapZoom: Double?
|
||||
) {
|
||||
LaunchedEffect(mapView, lastClickedLocation) {
|
||||
if (lastClickedLocation != null) {
|
||||
centerOnMarkerLocation(mapView, lastClickedLocation, mapZoom, mapViewModel)
|
||||
} else {
|
||||
if (!tryToFindAndCenterUserLocation(mapView, locationOverlay, mapViewModel)) {
|
||||
centerOnDefaultLocation(mapView, mapViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers the map on a specific marker location
|
||||
*/
|
||||
private suspend fun centerOnMarkerLocation(
|
||||
mapView: MapView,
|
||||
markerLocation: GeoPoint,
|
||||
mapZoom: Double?,
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
// If marker exists, center on it using stored zoom level
|
||||
val zoom = mapZoom ?: mapView.zoomLevelDouble
|
||||
mapView.controller.setZoom(zoom)
|
||||
mapView.controller.animateTo(markerLocation)
|
||||
mapViewModel.setLoadingFinished()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to find and center on the user's current location
|
||||
* @return true if user location was found, false otherwise
|
||||
*/
|
||||
private suspend fun tryToFindAndCenterUserLocation(
|
||||
mapView: MapView,
|
||||
locationOverlay: MyLocationNewOverlay,
|
||||
mapViewModel: MapViewModel
|
||||
): Boolean {
|
||||
// Attempt to find user location within a timeout period
|
||||
repeat(LOCATION_DETECTION_MAX_ATTEMPTS) {
|
||||
val userLocation = locationOverlay.myLocation
|
||||
if (userLocation != null) {
|
||||
mapViewModel.updateUserLocation(userLocation)
|
||||
mapView.controller.setZoom(DEFAULT_MAP_ZOOM)
|
||||
mapView.controller.animateTo(userLocation)
|
||||
mapViewModel.updateMapZoom(DEFAULT_MAP_ZOOM)
|
||||
mapViewModel.setLoadingFinished()
|
||||
return true
|
||||
}
|
||||
delay(LOCATION_DETECTION_DELAY_MS)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers the map on a default world location when user location can't be found
|
||||
*/
|
||||
private fun centerOnDefaultLocation(
|
||||
mapView: MapView,
|
||||
mapViewModel: MapViewModel
|
||||
) {
|
||||
// If location is not available after timeout, set default location
|
||||
mapView.controller.setZoom(WORLD_MAP_ZOOM)
|
||||
mapView.controller.setCenter(GeoPoint(0.0, 0.0))
|
||||
mapViewModel.updateMapZoom(WORLD_MAP_ZOOM)
|
||||
mapViewModel.setLoadingFinished()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageMapViewLifecycle(
|
||||
mapView: MapView,
|
||||
mapViewModel: MapViewModel,
|
||||
locationOverlay: MyLocationNewOverlay
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
mapView.onResume()
|
||||
locationOverlay.enableMyLocation()
|
||||
onDispose {
|
||||
locationOverlay.disableMyLocation()
|
||||
mapView.overlays.clear()
|
||||
mapView.onPause()
|
||||
mapView.onDetach()
|
||||
mapViewModel.setLoadingStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingSpinner() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Updating Map...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayMapView(mapView: MapView) {
|
||||
AndroidView(
|
||||
factory = { mapView },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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.compose.*
|
||||
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.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavGraph(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val mapViewModel: MapViewModel = viewModel()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Permissions.route,
|
||||
) {
|
||||
composable(route = Screen.About.route) {
|
||||
AboutScreen(navController = navController)
|
||||
}
|
||||
composable(route = Screen.Favorites.route) {
|
||||
FavoritesScreen(navController = navController, mapViewModel)
|
||||
}
|
||||
composable(route = Screen.Map.route) {
|
||||
MapScreen(navController = navController, mapViewModel)
|
||||
}
|
||||
composable(route = Screen.Permissions.route) {
|
||||
PermissionsScreen(navController = navController)
|
||||
}
|
||||
composable(route = Screen.Settings.route) {
|
||||
SettingsScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object About : Screen("about")
|
||||
object Favorites : Screen("favorites")
|
||||
object Map : Screen("map")
|
||||
object Permissions : Screen("permissions")
|
||||
object Settings : Screen("settings")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.permissions
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
|
||||
import com.noobexon.xposedfakelocation.manager.ui.permissions.components.PermanentlyDeniedScreen
|
||||
import com.noobexon.xposedfakelocation.manager.ui.permissions.components.PermissionRequestScreen
|
||||
|
||||
@Composable
|
||||
fun PermissionsScreen(navController: NavController, permissionsViewModel: PermissionsViewModel = viewModel()) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
if (activity == null) {
|
||||
Text(stringResource(R.string.error_no_activity))
|
||||
return
|
||||
}
|
||||
|
||||
val hasPermissions by permissionsViewModel.hasPermissions
|
||||
val permanentlyDenied by permissionsViewModel.permanentlyDenied
|
||||
val permissionsChecked by permissionsViewModel.permissionsChecked
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { granted ->
|
||||
permissionsViewModel.updatePermissionsStatus(granted)
|
||||
if (granted) {
|
||||
navController.navigate(Screen.Map.route) {
|
||||
popUpTo(Screen.Permissions.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
permissionsViewModel.checkIfPermanentlyDenied(activity)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
permissionsViewModel.checkPermissions(context)
|
||||
if (hasPermissions) {
|
||||
navController.navigate(Screen.Map.route) {
|
||||
popUpTo(Screen.Permissions.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionsChecked) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (!hasPermissions) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (permanentlyDenied) {
|
||||
PermanentlyDeniedScreen(context)
|
||||
} else {
|
||||
PermissionRequestScreen {
|
||||
permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.permissions
|
||||
|
||||
import android.app.Activity
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class PermissionsViewModel : ViewModel() {
|
||||
|
||||
private val _hasPermissions = mutableStateOf(false)
|
||||
val hasPermissions: State<Boolean> get() = _hasPermissions
|
||||
|
||||
private val _permanentlyDenied = mutableStateOf(false)
|
||||
val permanentlyDenied: State<Boolean> get() = _permanentlyDenied
|
||||
|
||||
private val _permissionsChecked = mutableStateOf(false)
|
||||
val permissionsChecked: State<Boolean> get() = _permissionsChecked
|
||||
|
||||
fun checkPermissions(context: Context) {
|
||||
val fineLocationGranted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
_hasPermissions.value = fineLocationGranted
|
||||
_permissionsChecked.value = true
|
||||
}
|
||||
|
||||
fun updatePermissionsStatus(granted: Boolean) {
|
||||
_hasPermissions.value = granted
|
||||
}
|
||||
|
||||
fun checkIfPermanentlyDenied(activity: Activity) {
|
||||
val shouldShowRationale = activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
_permanentlyDenied.value = !shouldShowRationale
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.permissions.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
|
||||
@Composable
|
||||
fun PermanentlyDeniedScreen(context: Context) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_permanently_denied),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}) {
|
||||
Text(stringResource(R.string.open_settings))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.permissions.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
|
||||
@Composable
|
||||
fun PermissionRequestScreen(onGrantPermission: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_required),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onGrantPermission) {
|
||||
Text(stringResource(R.string.grant_permissions))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//SettingsScreen.kt
|
||||
package com.noobexon.xposedfakelocation.manager.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import com.noobexon.xposedfakelocation.R
|
||||
|
||||
// Dimension constants
|
||||
private object Dimensions {
|
||||
val SPACING_EXTRA_SMALL = 4.dp
|
||||
val SPACING_SMALL = 8.dp
|
||||
val SPACING_MEDIUM = 16.dp
|
||||
val SPACING_LARGE = 24.dp
|
||||
val CARD_CORNER_RADIUS = 12.dp
|
||||
val CARD_ELEVATION = 2.dp
|
||||
val CATEGORY_SPACING = 32.dp
|
||||
}
|
||||
|
||||
// Setting definitions to reduce duplication - uses string resource IDs
|
||||
private object SettingDefinitions {
|
||||
// Define setting categories with resource IDs
|
||||
data class CategoryDef(
|
||||
val titleResId: Int,
|
||||
val settingKeys: List<String>
|
||||
)
|
||||
|
||||
val CATEGORIES = listOf(
|
||||
CategoryDef(R.string.category_location, listOf("randomize", "horizontal_accuracy", "vertical_accuracy")),
|
||||
CategoryDef(R.string.category_altitude, listOf("altitude", "msl", "msl_accuracy")),
|
||||
CategoryDef(R.string.category_movement, listOf("speed", "speed_accuracy"))
|
||||
)
|
||||
|
||||
// Define all settings with their parameters using resource IDs
|
||||
@Composable
|
||||
fun getSettings(viewModel: SettingsViewModel): Map<String, SettingData> {
|
||||
val context = LocalContext.current
|
||||
return mapOf(
|
||||
// Randomize Nearby Location
|
||||
"randomize" to DoubleSettingData(
|
||||
title = stringResource(R.string.setting_randomize_title),
|
||||
description = stringResource(R.string.setting_randomize_desc),
|
||||
useValueState = viewModel.useRandomize.collectAsState(),
|
||||
valueState = viewModel.randomizeRadius.collectAsState(),
|
||||
setUseValue = viewModel::setUseRandomize,
|
||||
setValue = viewModel::setRandomizeRadius,
|
||||
label = stringResource(R.string.setting_randomize_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = 0f,
|
||||
maxValue = 2000f,
|
||||
step = 0.1f
|
||||
),
|
||||
// Custom Horizontal Accuracy
|
||||
"horizontal_accuracy" to DoubleSettingData(
|
||||
title = stringResource(R.string.setting_horizontal_accuracy_title),
|
||||
description = stringResource(R.string.setting_horizontal_accuracy_desc),
|
||||
useValueState = viewModel.useAccuracy.collectAsState(),
|
||||
valueState = viewModel.accuracy.collectAsState(),
|
||||
setUseValue = viewModel::setUseAccuracy,
|
||||
setValue = viewModel::setAccuracy,
|
||||
label = stringResource(R.string.setting_horizontal_accuracy_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = 0f,
|
||||
maxValue = 100f,
|
||||
step = 1f
|
||||
),
|
||||
// Custom Vertical Accuracy
|
||||
"vertical_accuracy" to FloatSettingData(
|
||||
title = stringResource(R.string.setting_vertical_accuracy_title),
|
||||
description = stringResource(R.string.setting_vertical_accuracy_desc),
|
||||
useValueState = viewModel.useVerticalAccuracy.collectAsState(),
|
||||
valueState = viewModel.verticalAccuracy.collectAsState(),
|
||||
setUseValue = viewModel::setUseVerticalAccuracy,
|
||||
setValue = viewModel::setVerticalAccuracy,
|
||||
label = stringResource(R.string.setting_vertical_accuracy_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = 0f,
|
||||
maxValue = 100f,
|
||||
step = 1f
|
||||
),
|
||||
// Custom Altitude
|
||||
"altitude" to DoubleSettingData(
|
||||
title = stringResource(R.string.setting_altitude_title),
|
||||
description = stringResource(R.string.setting_altitude_desc),
|
||||
useValueState = viewModel.useAltitude.collectAsState(),
|
||||
valueState = viewModel.altitude.collectAsState(),
|
||||
setUseValue = viewModel::setUseAltitude,
|
||||
setValue = viewModel::setAltitude,
|
||||
label = stringResource(R.string.setting_altitude_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = 0f,
|
||||
maxValue = 2000f,
|
||||
step = 0.5f
|
||||
),
|
||||
// Custom MSL
|
||||
"msl" to DoubleSettingData(
|
||||
title = stringResource(R.string.setting_msl_title),
|
||||
description = stringResource(R.string.setting_msl_desc),
|
||||
useValueState = viewModel.useMeanSeaLevel.collectAsState(),
|
||||
valueState = viewModel.meanSeaLevel.collectAsState(),
|
||||
setUseValue = viewModel::setUseMeanSeaLevel,
|
||||
setValue = viewModel::setMeanSeaLevel,
|
||||
label = stringResource(R.string.setting_msl_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = -400f,
|
||||
maxValue = 2000f,
|
||||
step = 0.5f
|
||||
),
|
||||
// Custom MSL Accuracy
|
||||
"msl_accuracy" to FloatSettingData(
|
||||
title = stringResource(R.string.setting_msl_accuracy_title),
|
||||
description = stringResource(R.string.setting_msl_accuracy_desc),
|
||||
useValueState = viewModel.useMeanSeaLevelAccuracy.collectAsState(),
|
||||
valueState = viewModel.meanSeaLevelAccuracy.collectAsState(),
|
||||
setUseValue = viewModel::setUseMeanSeaLevelAccuracy,
|
||||
setValue = viewModel::setMeanSeaLevelAccuracy,
|
||||
label = stringResource(R.string.setting_msl_accuracy_label),
|
||||
unit = stringResource(R.string.unit_meters),
|
||||
minValue = 0f,
|
||||
maxValue = 100f,
|
||||
step = 1f
|
||||
),
|
||||
// Custom Speed
|
||||
"speed" to FloatSettingData(
|
||||
title = stringResource(R.string.setting_speed_title),
|
||||
description = stringResource(R.string.setting_speed_desc),
|
||||
useValueState = viewModel.useSpeed.collectAsState(),
|
||||
valueState = viewModel.speed.collectAsState(),
|
||||
setUseValue = viewModel::setUseSpeed,
|
||||
setValue = viewModel::setSpeed,
|
||||
label = stringResource(R.string.setting_speed_label),
|
||||
unit = stringResource(R.string.unit_meters_per_second),
|
||||
minValue = 0f,
|
||||
maxValue = 30f,
|
||||
step = 0.1f
|
||||
),
|
||||
// Custom Speed Accuracy
|
||||
"speed_accuracy" to FloatSettingData(
|
||||
title = stringResource(R.string.setting_speed_accuracy_title),
|
||||
description = stringResource(R.string.setting_speed_accuracy_desc),
|
||||
useValueState = viewModel.useSpeedAccuracy.collectAsState(),
|
||||
valueState = viewModel.speedAccuracy.collectAsState(),
|
||||
setUseValue = viewModel::setUseSpeedAccuracy,
|
||||
setValue = viewModel::setSpeedAccuracy,
|
||||
label = stringResource(R.string.setting_speed_accuracy_label),
|
||||
unit = stringResource(R.string.unit_meters_per_second),
|
||||
minValue = 0f,
|
||||
maxValue = 100f,
|
||||
step = 1f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
navController: NavController,
|
||||
settingsViewModel: SettingsViewModel = viewModel ()
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Get settings from the definition object
|
||||
val allSettings = SettingDefinitions.getSettings(settingsViewModel)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.navigate_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) { focusManager.clearFocus() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = Dimensions.SPACING_MEDIUM)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
|
||||
|
||||
// Display settings by category
|
||||
SettingDefinitions.CATEGORIES.forEach { categoryDef ->
|
||||
val categoryTitle = stringResource(categoryDef.titleResId)
|
||||
CategoryHeader(categoryTitle)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Dimensions.SPACING_SMALL),
|
||||
shape = RoundedCornerShape(Dimensions.CARD_CORNER_RADIUS),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = Dimensions.CARD_ELEVATION)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(Dimensions.SPACING_SMALL)) {
|
||||
categoryDef.settingKeys.forEachIndexed { index, settingKey ->
|
||||
val setting = allSettings[settingKey]
|
||||
setting?.let {
|
||||
when (setting) {
|
||||
is DoubleSettingData -> {
|
||||
DoubleSettingComposable(setting)
|
||||
}
|
||||
is FloatSettingData -> {
|
||||
FloatSettingComposable(setting)
|
||||
}
|
||||
}
|
||||
if (index != categoryDef.settingKeys.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = Dimensions.SPACING_SMALL),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
|
||||
}
|
||||
|
||||
// Add space at the bottom of the list
|
||||
Spacer(modifier = Modifier.height(Dimensions.SPACING_LARGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryHeader(title: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = Dimensions.SPACING_SMALL)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.padding(start = Dimensions.SPACING_MEDIUM),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DoubleSettingItem(
|
||||
title: String,
|
||||
description: String,
|
||||
useValue: Boolean,
|
||||
onUseValueChange: (Boolean) -> Unit,
|
||||
value: Double,
|
||||
onValueChange: (Double) -> Unit,
|
||||
label: String,
|
||||
unit: String,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
step: Float
|
||||
) {
|
||||
SettingItem(
|
||||
title = title,
|
||||
description = description,
|
||||
useValue = useValue,
|
||||
onUseValueChange = onUseValueChange,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = label,
|
||||
unit = unit,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
step = step,
|
||||
valueFormatter = { "%.2f".format(it) },
|
||||
parseValue = { it.toDouble() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatSettingItem(
|
||||
title: String,
|
||||
description: String,
|
||||
useValue: Boolean,
|
||||
onUseValueChange: (Boolean) -> Unit,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
label: String,
|
||||
unit: String,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
step: Float
|
||||
) {
|
||||
SettingItem(
|
||||
title = title,
|
||||
description = description,
|
||||
useValue = useValue,
|
||||
onUseValueChange = onUseValueChange,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = label,
|
||||
unit = unit,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
step = step,
|
||||
valueFormatter = { "%.2f".format(it) },
|
||||
parseValue = { it }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T : Number> SettingItem(
|
||||
title: String,
|
||||
description: String,
|
||||
useValue: Boolean,
|
||||
onUseValueChange: (Boolean) -> Unit,
|
||||
value: T,
|
||||
onValueChange: (T) -> Unit,
|
||||
label: String,
|
||||
unit: String,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
step: Float,
|
||||
valueFormatter: (T) -> String,
|
||||
parseValue: (Float) -> T
|
||||
) {
|
||||
var showTooltip by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Dimensions.SPACING_SMALL)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { showTooltip = !showTooltip },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = "More information about $title",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTooltip) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = Dimensions.SPACING_EXTRA_SMALL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = useValue,
|
||||
onCheckedChange = onUseValueChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = if (useValue) "Disable $title" else "Enable $title"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (useValue) {
|
||||
Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
|
||||
|
||||
var sliderValue by remember { mutableFloatStateOf(value.toFloat()) }
|
||||
var showExactValue by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (sliderValue != value.toFloat()) {
|
||||
sliderValue = value.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimensions.SPACING_SMALL),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val displayText = "$label: ${valueFormatter(parseValue(sliderValue))} $unit"
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { showExactValue = !showExactValue }
|
||||
)
|
||||
|
||||
// Add +/- buttons for precise adjustment
|
||||
OutlinedIconButton(
|
||||
onClick = {
|
||||
val newValue = (sliderValue - step).coerceAtLeast(minValue)
|
||||
sliderValue = newValue
|
||||
onValueChange(parseValue(newValue))
|
||||
},
|
||||
enabled = sliderValue > minValue,
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "−",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedIconButton(
|
||||
onClick = {
|
||||
val newValue = (sliderValue + step).coerceAtMost(maxValue)
|
||||
sliderValue = newValue
|
||||
onValueChange(parseValue(newValue))
|
||||
},
|
||||
enabled = sliderValue < maxValue,
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "+",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Min and max value labels
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Dimensions.SPACING_SMALL)
|
||||
) {
|
||||
Text(
|
||||
text = "${minValue.toInt()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${maxValue.toInt()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { newValue ->
|
||||
sliderValue = newValue
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
onValueChange(parseValue(sliderValue))
|
||||
},
|
||||
valueRange = minValue..maxValue,
|
||||
steps = ((maxValue - minValue) / step).toInt() - 1,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Adjust $title value"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SettingData {
|
||||
abstract val title: String
|
||||
abstract val description: String
|
||||
abstract val useValueState: State<Boolean>
|
||||
abstract val setUseValue: (Boolean) -> Unit
|
||||
abstract val label: String
|
||||
abstract val unit: String
|
||||
abstract val minValue: Float
|
||||
abstract val maxValue: Float
|
||||
abstract val step: Float
|
||||
}
|
||||
|
||||
data class DoubleSettingData(
|
||||
override val title: String,
|
||||
override val description: String,
|
||||
override val useValueState: State<Boolean>,
|
||||
val valueState: State<Double>,
|
||||
override val setUseValue: (Boolean) -> Unit,
|
||||
val setValue: (Double) -> Unit,
|
||||
override val label: String,
|
||||
override val unit: String,
|
||||
override val minValue: Float,
|
||||
override val maxValue: Float,
|
||||
override val step: Float
|
||||
) : SettingData()
|
||||
|
||||
data class FloatSettingData(
|
||||
override val title: String,
|
||||
override val description: String,
|
||||
override val useValueState: State<Boolean>,
|
||||
val valueState: State<Float>,
|
||||
override val setUseValue: (Boolean) -> Unit,
|
||||
val setValue: (Float) -> Unit,
|
||||
override val label: String,
|
||||
override val unit: String,
|
||||
override val minValue: Float,
|
||||
override val maxValue: Float,
|
||||
override val step: Float
|
||||
) : SettingData()
|
||||
|
||||
@Composable
|
||||
fun DoubleSettingComposable(
|
||||
setting: DoubleSettingData
|
||||
) {
|
||||
DoubleSettingItem(
|
||||
title = setting.title,
|
||||
description = setting.description,
|
||||
useValue = setting.useValueState.value,
|
||||
onUseValueChange = setting.setUseValue,
|
||||
value = setting.valueState.value,
|
||||
onValueChange = setting.setValue,
|
||||
label = setting.label,
|
||||
unit = setting.unit,
|
||||
minValue = setting.minValue,
|
||||
maxValue = setting.maxValue,
|
||||
step = setting.step
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatSettingComposable(
|
||||
setting: FloatSettingData
|
||||
) {
|
||||
FloatSettingItem(
|
||||
title = setting.title,
|
||||
description = setting.description,
|
||||
useValue = setting.useValueState.value,
|
||||
onUseValueChange = setting.setUseValue,
|
||||
value = setting.valueState.value,
|
||||
onValueChange = setting.setValue,
|
||||
label = setting.label,
|
||||
unit = setting.unit,
|
||||
minValue = setting.minValue,
|
||||
maxValue = setting.maxValue,
|
||||
step = setting.step
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//SettingsViewModel.kt
|
||||
package com.noobexon.xposedfakelocation.manager.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.noobexon.xposedfakelocation.data.*
|
||||
import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val preferencesRepository = PreferencesRepository(application)
|
||||
|
||||
// Generic state holders for different types of preferences
|
||||
private class BooleanPreference(
|
||||
initialValue: Boolean,
|
||||
private val flow: Flow<Boolean>,
|
||||
private val saveOperation: suspend (Boolean) -> Unit,
|
||||
private val viewModelScope: kotlinx.coroutines.CoroutineScope
|
||||
) {
|
||||
private val _state = MutableStateFlow(initialValue)
|
||||
val state: StateFlow<Boolean> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
flow.collect { _state.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun setValue(value: Boolean) {
|
||||
_state.value = value
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
saveOperation(value)
|
||||
} catch (e: Exception) {
|
||||
// Add error handling if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DoublePreference(
|
||||
initialValue: Double,
|
||||
private val flow: Flow<Double>,
|
||||
private val saveOperation: suspend (Double) -> Unit,
|
||||
private val viewModelScope: kotlinx.coroutines.CoroutineScope
|
||||
) {
|
||||
private val _state = MutableStateFlow(initialValue)
|
||||
val state: StateFlow<Double> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
flow.collect { _state.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun setValue(value: Double) {
|
||||
_state.value = value
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
saveOperation(value)
|
||||
} catch (e: Exception) {
|
||||
// Add error handling if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FloatPreference(
|
||||
initialValue: Float,
|
||||
private val flow: Flow<Float>,
|
||||
private val saveOperation: suspend (Float) -> Unit,
|
||||
private val viewModelScope: kotlinx.coroutines.CoroutineScope
|
||||
) {
|
||||
private val _state = MutableStateFlow(initialValue)
|
||||
val state: StateFlow<Float> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
flow.collect { _state.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun setValue(value: Float) {
|
||||
_state.value = value
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
saveOperation(value)
|
||||
} catch (e: Exception) {
|
||||
// Add error handling if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preferences for Accuracy
|
||||
private val _useAccuracyPreference = BooleanPreference(
|
||||
DEFAULT_USE_ACCURACY,
|
||||
preferencesRepository.getUseAccuracyFlow(),
|
||||
preferencesRepository::saveUseAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val useAccuracy: StateFlow<Boolean> = _useAccuracyPreference.state
|
||||
|
||||
private val _accuracyPreference = DoublePreference(
|
||||
DEFAULT_ACCURACY,
|
||||
preferencesRepository.getAccuracyFlow(),
|
||||
preferencesRepository::saveAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val accuracy: StateFlow<Double> = _accuracyPreference.state
|
||||
|
||||
// Preferences for Altitude
|
||||
private val _useAltitudePreference = BooleanPreference(
|
||||
DEFAULT_USE_ALTITUDE,
|
||||
preferencesRepository.getUseAltitudeFlow(),
|
||||
preferencesRepository::saveUseAltitude,
|
||||
viewModelScope
|
||||
)
|
||||
val useAltitude: StateFlow<Boolean> = _useAltitudePreference.state
|
||||
|
||||
private val _altitudePreference = DoublePreference(
|
||||
DEFAULT_ALTITUDE,
|
||||
preferencesRepository.getAltitudeFlow(),
|
||||
preferencesRepository::saveAltitude,
|
||||
viewModelScope
|
||||
)
|
||||
val altitude: StateFlow<Double> = _altitudePreference.state
|
||||
|
||||
// Preferences for Randomize
|
||||
private val _useRandomizePreference = BooleanPreference(
|
||||
DEFAULT_USE_RANDOMIZE,
|
||||
preferencesRepository.getUseRandomizeFlow(),
|
||||
preferencesRepository::saveUseRandomize,
|
||||
viewModelScope
|
||||
)
|
||||
val useRandomize: StateFlow<Boolean> = _useRandomizePreference.state
|
||||
|
||||
private val _randomizeRadiusPreference = DoublePreference(
|
||||
DEFAULT_RANDOMIZE_RADIUS,
|
||||
preferencesRepository.getRandomizeRadiusFlow(),
|
||||
preferencesRepository::saveRandomizeRadius,
|
||||
viewModelScope
|
||||
)
|
||||
val randomizeRadius: StateFlow<Double> = _randomizeRadiusPreference.state
|
||||
|
||||
// Preferences for Vertical Accuracy
|
||||
private val _useVerticalAccuracyPreference = BooleanPreference(
|
||||
DEFAULT_USE_VERTICAL_ACCURACY,
|
||||
preferencesRepository.getUseVerticalAccuracyFlow(),
|
||||
preferencesRepository::saveUseVerticalAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val useVerticalAccuracy: StateFlow<Boolean> = _useVerticalAccuracyPreference.state
|
||||
|
||||
private val _verticalAccuracyPreference = FloatPreference(
|
||||
DEFAULT_VERTICAL_ACCURACY,
|
||||
preferencesRepository.getVerticalAccuracyFlow(),
|
||||
preferencesRepository::saveVerticalAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val verticalAccuracy: StateFlow<Float> = _verticalAccuracyPreference.state
|
||||
|
||||
// Preferences for Mean Sea Level
|
||||
private val _useMeanSeaLevelPreference = BooleanPreference(
|
||||
DEFAULT_USE_MEAN_SEA_LEVEL,
|
||||
preferencesRepository.getUseMeanSeaLevelFlow(),
|
||||
preferencesRepository::saveUseMeanSeaLevel,
|
||||
viewModelScope
|
||||
)
|
||||
val useMeanSeaLevel: StateFlow<Boolean> = _useMeanSeaLevelPreference.state
|
||||
|
||||
private val _meanSeaLevelPreference = DoublePreference(
|
||||
DEFAULT_MEAN_SEA_LEVEL,
|
||||
preferencesRepository.getMeanSeaLevelFlow(),
|
||||
preferencesRepository::saveMeanSeaLevel,
|
||||
viewModelScope
|
||||
)
|
||||
val meanSeaLevel: StateFlow<Double> = _meanSeaLevelPreference.state
|
||||
|
||||
// Preferences for Mean Sea Level Accuracy
|
||||
private val _useMeanSeaLevelAccuracyPreference = BooleanPreference(
|
||||
DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY,
|
||||
preferencesRepository.getUseMeanSeaLevelAccuracyFlow(),
|
||||
preferencesRepository::saveUseMeanSeaLevelAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val useMeanSeaLevelAccuracy: StateFlow<Boolean> = _useMeanSeaLevelAccuracyPreference.state
|
||||
|
||||
private val _meanSeaLevelAccuracyPreference = FloatPreference(
|
||||
DEFAULT_MEAN_SEA_LEVEL_ACCURACY,
|
||||
preferencesRepository.getMeanSeaLevelAccuracyFlow(),
|
||||
preferencesRepository::saveMeanSeaLevelAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val meanSeaLevelAccuracy: StateFlow<Float> = _meanSeaLevelAccuracyPreference.state
|
||||
|
||||
// Preferences for Speed
|
||||
private val _useSpeedPreference = BooleanPreference(
|
||||
DEFAULT_USE_SPEED,
|
||||
preferencesRepository.getUseSpeedFlow(),
|
||||
preferencesRepository::saveUseSpeed,
|
||||
viewModelScope
|
||||
)
|
||||
val useSpeed: StateFlow<Boolean> = _useSpeedPreference.state
|
||||
|
||||
private val _speedPreference = FloatPreference(
|
||||
DEFAULT_SPEED,
|
||||
preferencesRepository.getSpeedFlow(),
|
||||
preferencesRepository::saveSpeed,
|
||||
viewModelScope
|
||||
)
|
||||
val speed: StateFlow<Float> = _speedPreference.state
|
||||
|
||||
// Preferences for Speed Accuracy
|
||||
private val _useSpeedAccuracyPreference = BooleanPreference(
|
||||
DEFAULT_USE_SPEED_ACCURACY,
|
||||
preferencesRepository.getUseSpeedAccuracyFlow(),
|
||||
preferencesRepository::saveUseSpeedAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val useSpeedAccuracy: StateFlow<Boolean> = _useSpeedAccuracyPreference.state
|
||||
|
||||
private val _speedAccuracyPreference = FloatPreference(
|
||||
DEFAULT_SPEED_ACCURACY,
|
||||
preferencesRepository.getSpeedAccuracyFlow(),
|
||||
preferencesRepository::saveSpeedAccuracy,
|
||||
viewModelScope
|
||||
)
|
||||
val speedAccuracy: StateFlow<Float> = _speedAccuracyPreference.state
|
||||
|
||||
// Setter methods for all preferences
|
||||
fun setUseAccuracy(value: Boolean) = _useAccuracyPreference.setValue(value)
|
||||
fun setAccuracy(value: Double) = _accuracyPreference.setValue(value)
|
||||
fun setUseAltitude(value: Boolean) = _useAltitudePreference.setValue(value)
|
||||
fun setAltitude(value: Double) = _altitudePreference.setValue(value)
|
||||
fun setUseRandomize(value: Boolean) = _useRandomizePreference.setValue(value)
|
||||
fun setRandomizeRadius(value: Double) = _randomizeRadiusPreference.setValue(value)
|
||||
fun setUseVerticalAccuracy(value: Boolean) = _useVerticalAccuracyPreference.setValue(value)
|
||||
fun setVerticalAccuracy(value: Float) = _verticalAccuracyPreference.setValue(value)
|
||||
fun setUseMeanSeaLevel(value: Boolean) = _useMeanSeaLevelPreference.setValue(value)
|
||||
fun setMeanSeaLevel(value: Double) = _meanSeaLevelPreference.setValue(value)
|
||||
fun setUseMeanSeaLevelAccuracy(value: Boolean) = _useMeanSeaLevelAccuracyPreference.setValue(value)
|
||||
fun setMeanSeaLevelAccuracy(value: Float) = _meanSeaLevelAccuracyPreference.setValue(value)
|
||||
fun setUseSpeed(value: Boolean) = _useSpeedPreference.setValue(value)
|
||||
fun setSpeed(value: Float) = _speedPreference.setValue(value)
|
||||
fun setUseSpeedAccuracy(value: Boolean) = _useSpeedAccuracyPreference.setValue(value)
|
||||
fun setSpeedAccuracy(value: Float) = _speedAccuracyPreference.setValue(value)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun XposedFakeLocationTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.noobexon.xposedfakelocation.manager.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
// MainHook.kt
|
||||
package com.noobexon.xposedfakelocation.xposed
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.noobexon.xposedfakelocation.data.MANAGER_APP_PACKAGE_NAME
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.GnssHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.GooglePlayServicesHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.LocationApiHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.SensorHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.SystemServicesHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.TelephonyHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.hooks.WifiHooks
|
||||
import com.noobexon.xposedfakelocation.xposed.utils.PreferencesUtil
|
||||
import de.robv.android.xposed.IXposedHookLoadPackage
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
class MainHook : IXposedHookLoadPackage {
|
||||
val tag = "[MainHook]"
|
||||
|
||||
lateinit var context: Context
|
||||
|
||||
private var locationApiHooks: LocationApiHooks? = null
|
||||
private var systemServicesHooks: SystemServicesHooks? = null
|
||||
private var googlePlayServicesHooks: GooglePlayServicesHooks? = null
|
||||
private var wifiHooks: WifiHooks? = null
|
||||
private var telephonyHooks: TelephonyHooks? = null
|
||||
private var sensorHooks: SensorHooks? = null
|
||||
private var gnssHooks: GnssHooks? = null
|
||||
|
||||
override fun handleLoadPackage(lpparam: LoadPackageParam) {
|
||||
// Avoid hooking own app to prevent recursion
|
||||
if (lpparam.packageName == MANAGER_APP_PACKAGE_NAME) return
|
||||
|
||||
// If not playing or null, do not proceed with hooking
|
||||
if (PreferencesUtil.getIsPlaying() != true) return
|
||||
|
||||
XposedBridge.log("$tag Loading hooks for: ${lpparam.packageName}")
|
||||
|
||||
// Hook system services if user asked for system wide hooks
|
||||
if (lpparam.packageName == "android") {
|
||||
systemServicesHooks = SystemServicesHooks(lpparam).also { it.initHooks() }
|
||||
}
|
||||
|
||||
initHookingLogic(lpparam)
|
||||
}
|
||||
|
||||
private fun initHookingLogic(lpparam: LoadPackageParam) {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
"android.app.Instrumentation",
|
||||
lpparam.classLoader,
|
||||
"callApplicationOnCreate",
|
||||
Application::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
context = (param.args[0] as Application).applicationContext.also {
|
||||
XposedBridge.log("$tag Target App's context has been acquired successfully.")
|
||||
XposedBridge.log("$tag Package: ${lpparam.packageName}")
|
||||
Toast.makeText(it, "虚拟定位已激活!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// Initialize all hooks
|
||||
try {
|
||||
// Core Location API hooks (most important)
|
||||
locationApiHooks = LocationApiHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag LocationApiHooks initialized")
|
||||
}
|
||||
|
||||
// Google Play Services hooks (for apps using GMS)
|
||||
googlePlayServicesHooks = GooglePlayServicesHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag GooglePlayServicesHooks initialized")
|
||||
}
|
||||
|
||||
// WiFi-based location hooks
|
||||
wifiHooks = WifiHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag WifiHooks initialized")
|
||||
}
|
||||
|
||||
// Cell tower-based location hooks
|
||||
telephonyHooks = TelephonyHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag TelephonyHooks initialized")
|
||||
}
|
||||
|
||||
// Sensor hooks (for movement detection prevention)
|
||||
sensorHooks = SensorHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag SensorHooks initialized")
|
||||
}
|
||||
|
||||
// GNSS/GPS satellite data hooks
|
||||
gnssHooks = GnssHooks(lpparam).also {
|
||||
it.initHooks()
|
||||
XposedBridge.log("$tag GnssHooks initialized")
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag All hooks initialized successfully for ${lpparam.packageName}")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error initializing hooks: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// GnssHooks.kt
|
||||
// Hook for GNSS/GPS satellite data
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.location.GnssStatus
|
||||
import android.os.Build
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
/**
|
||||
* Hooks for GNSS/GPS satellite data
|
||||
* Prevents apps from using satellite info to verify location authenticity
|
||||
*/
|
||||
class GnssHooks(private val lpparam: LoadPackageParam) {
|
||||
private val tag = "[GnssHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookGnssStatus()
|
||||
hookGnssStatusCallback()
|
||||
hookGpsSatellite()
|
||||
hookGpsStatus()
|
||||
hookNmea()
|
||||
XposedBridge.log("$tag Initialized GNSS hooks")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook GnssStatus to report fake satellite data
|
||||
*/
|
||||
private fun hookGnssStatus() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
XposedBridge.log("$tag GnssStatus not available on this API level")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val gnssStatusClass = XposedHelpers.findClass(
|
||||
"android.location.GnssStatus",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getSatelliteCount() - return a realistic number
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gnssStatusClass,
|
||||
"getSatelliteCount",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Return realistic satellite count (8-12 is normal for good GPS signal)
|
||||
param.result = 10
|
||||
XposedBridge.log("$tag GnssStatus.getSatelliteCount() -> 10")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook usedInFix() - report satellites as used in fix
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gnssStatusClass,
|
||||
"usedInFix",
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val satIndex = param.args[0] as Int
|
||||
// First 6-8 satellites are typically used in fix
|
||||
param.result = satIndex < 8
|
||||
XposedBridge.log("$tag GnssStatus.usedInFix($satIndex) -> ${satIndex < 8}")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getCn0DbHz() - return good signal strength
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gnssStatusClass,
|
||||
"getCn0DbHz",
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Good signal is typically 30-50 dB-Hz
|
||||
param.result = 35.0f + (param.args[0] as Int % 15)
|
||||
XposedBridge.log("$tag GnssStatus.getCn0DbHz() -> ${param.result}")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getAzimuthDegrees()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gnssStatusClass,
|
||||
"getAzimuthDegrees",
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val satIndex = param.args[0] as Int
|
||||
// Distribute satellites around the sky
|
||||
param.result = (satIndex * 36.0f) % 360.0f
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getElevationDegrees()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gnssStatusClass,
|
||||
"getElevationDegrees",
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val satIndex = param.args[0] as Int
|
||||
// Elevation between 10-80 degrees
|
||||
param.result = 10.0f + (satIndex * 7.0f) % 70.0f
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag GnssStatus hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking GnssStatus: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook GnssStatus.Callback
|
||||
*/
|
||||
private fun hookGnssStatusCallback() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
|
||||
try {
|
||||
val callbackClass = XposedHelpers.findClass(
|
||||
"android.location.GnssStatus\$Callback",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook onSatelliteStatusChanged
|
||||
XposedHelpers.findAndHookMethod(
|
||||
callbackClass,
|
||||
"onSatelliteStatusChanged",
|
||||
GnssStatus::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// The GnssStatus object will be modified by our GnssStatus hooks
|
||||
XposedBridge.log("$tag GnssStatus.Callback.onSatelliteStatusChanged() intercepted")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook onStarted
|
||||
XposedHelpers.findAndHookMethod(
|
||||
callbackClass,
|
||||
"onStarted",
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag GnssStatus.Callback.onStarted()")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook onFirstFix - report a fast first fix
|
||||
XposedHelpers.findAndHookMethod(
|
||||
callbackClass,
|
||||
"onFirstFix",
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// Report fast time-to-first-fix (in milliseconds)
|
||||
param.args[0] = 1500 // 1.5 seconds - fast fix
|
||||
XposedBridge.log("$tag GnssStatus.Callback.onFirstFix() -> 1500ms")
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag GnssStatus.Callback hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking GnssStatus.Callback: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook legacy GpsSatellite class (deprecated but still used by some apps)
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun hookGpsSatellite() {
|
||||
try {
|
||||
val gpsSatelliteClass = XposedHelpers.findClass(
|
||||
"android.location.GpsSatellite",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook usedInFix()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gpsSatelliteClass,
|
||||
"usedInFix",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
XposedBridge.log("$tag GpsSatellite.usedInFix() -> true")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getSnr() - signal-to-noise ratio
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gpsSatelliteClass,
|
||||
"getSnr",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = 30.0f // Good signal
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag GpsSatellite hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag GpsSatellite not found (expected on newer devices)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook legacy GpsStatus (deprecated)
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun hookGpsStatus() {
|
||||
try {
|
||||
val gpsStatusClass = XposedHelpers.findClass(
|
||||
"android.location.GpsStatus",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getTimeToFirstFix()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gpsStatusClass,
|
||||
"getTimeToFirstFix",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = 1500 // Fast fix
|
||||
XposedBridge.log("$tag GpsStatus.getTimeToFirstFix() -> 1500")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getMaxSatellites()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
gpsStatusClass,
|
||||
"getMaxSatellites",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = 24
|
||||
XposedBridge.log("$tag GpsStatus.getMaxSatellites() -> 24")
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag GpsStatus hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag GpsStatus not found (expected on newer devices)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook NMEA message listener to block raw GPS data
|
||||
*/
|
||||
private fun hookNmea() {
|
||||
try {
|
||||
val locationManagerClass = XposedHelpers.findClass(
|
||||
"android.location.LocationManager",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Block addNmeaListener
|
||||
val nmeaMethods = locationManagerClass.declaredMethods.filter {
|
||||
it.name == "addNmeaListener"
|
||||
}
|
||||
|
||||
for (method in nmeaMethods) {
|
||||
XposedBridge.hookMethod(method, XC_MethodReplacement.returnConstant(false))
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag NMEA listener hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking NMEA: ${e.message}")
|
||||
}
|
||||
|
||||
// Also hook OnNmeaMessageListener callback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
val nmeaListenerClass = XposedHelpers.findClass(
|
||||
"android.location.OnNmeaMessageListener",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
nmeaListenerClass,
|
||||
"onNmeaMessage",
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// Block NMEA messages which contain raw GPS coordinates
|
||||
param.result = null
|
||||
XposedBridge.log("$tag OnNmeaMessageListener blocked")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag OnNmeaMessageListener hook failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// GooglePlayServicesHooks.kt
|
||||
// Hook for Google Play Services FusedLocationProviderClient
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.location.Location
|
||||
import android.os.Looper
|
||||
import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Hooks for Google Play Services Location APIs
|
||||
* This is crucial for apps using FusedLocationProviderClient
|
||||
*/
|
||||
class GooglePlayServicesHooks(private val lpparam: LoadPackageParam) {
|
||||
private val tag = "[GooglePlayServicesHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookFusedLocationProviderClient()
|
||||
hookLocationResult()
|
||||
hookLocationCallback()
|
||||
hookLocationAvailability()
|
||||
hookSettingsClient()
|
||||
XposedBridge.log("$tag Initialized Google Play Services hooks")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook FusedLocationProviderClient - the main Google location API
|
||||
*/
|
||||
private fun hookFusedLocationProviderClient() {
|
||||
try {
|
||||
// Hook the main FusedLocationProviderClient class
|
||||
val fusedClientClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.location.FusedLocationProviderClient",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getLastLocation() - returns Task<Location>
|
||||
hookAllMethodsWithName(fusedClientClass, "getLastLocation") { param ->
|
||||
XposedBridge.log("$tag Hooked getLastLocation()")
|
||||
// We'll modify the result in the Task
|
||||
}
|
||||
|
||||
// Hook getCurrentLocation() - returns Task<Location>
|
||||
hookAllMethodsWithName(fusedClientClass, "getCurrentLocation") { param ->
|
||||
XposedBridge.log("$tag Hooked getCurrentLocation()")
|
||||
}
|
||||
|
||||
// Hook requestLocationUpdates - multiple overloads
|
||||
hookAllMethodsWithName(fusedClientClass, "requestLocationUpdates") { param ->
|
||||
XposedBridge.log("$tag Hooked requestLocationUpdates()")
|
||||
// The callback will be handled by hookLocationCallback
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag FusedLocationProviderClient hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag FusedLocationProviderClient not found (might not use GMS): ${e.message}")
|
||||
}
|
||||
|
||||
// Also try to hook the internal implementation
|
||||
try {
|
||||
val fusedClientImplClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.internal.location.zzaz",
|
||||
lpparam.classLoader
|
||||
)
|
||||
hookAllMethods(fusedClientImplClass)
|
||||
} catch (e: Throwable) {
|
||||
// Internal class names change between versions, this is expected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook LocationResult - the container for location data from GMS
|
||||
*/
|
||||
private fun hookLocationResult() {
|
||||
try {
|
||||
val locationResultClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.location.LocationResult",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getLastLocation()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationResultClass,
|
||||
"getLastLocation",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
val originalLocation = param.result as? Location
|
||||
if (originalLocation != null) {
|
||||
val fakeLocation = LocationUtil.createFakeLocation(originalLocation)
|
||||
param.result = fakeLocation
|
||||
XposedBridge.log("$tag LocationResult.getLastLocation() -> faked")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook getLocations() - returns List<Location>
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationResultClass,
|
||||
"getLocations",
|
||||
object : XC_MethodHook() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
val originalLocations = param.result as? List<Location>
|
||||
if (originalLocations != null && originalLocations.isNotEmpty()) {
|
||||
val fakeLocations = originalLocations.map { location ->
|
||||
LocationUtil.createFakeLocation(location)
|
||||
}
|
||||
param.result = fakeLocations
|
||||
XposedBridge.log("$tag LocationResult.getLocations() -> faked ${fakeLocations.size} locations")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag LocationResult hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag LocationResult not found: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook LocationCallback - intercept location updates
|
||||
*/
|
||||
private fun hookLocationCallback() {
|
||||
try {
|
||||
val locationCallbackClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.location.LocationCallback",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook onLocationResult
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationCallbackClass,
|
||||
"onLocationResult",
|
||||
"com.google.android.gms.location.LocationResult",
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// LocationResult will be modified by our LocationResult hooks
|
||||
XposedBridge.log("$tag LocationCallback.onLocationResult() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook onLocationAvailability
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationCallbackClass,
|
||||
"onLocationAvailability",
|
||||
"com.google.android.gms.location.LocationAvailability",
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag LocationCallback.onLocationAvailability() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag LocationCallback hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag LocationCallback not found: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook LocationAvailability - always report location as available
|
||||
*/
|
||||
private fun hookLocationAvailability() {
|
||||
try {
|
||||
val locationAvailabilityClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.location.LocationAvailability",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationAvailabilityClass,
|
||||
"isLocationAvailable",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
XposedBridge.log("$tag LocationAvailability.isLocationAvailable() -> true")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag LocationAvailability hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag LocationAvailability not found: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook SettingsClient to bypass location settings checks
|
||||
*/
|
||||
private fun hookSettingsClient() {
|
||||
try {
|
||||
val settingsClientClass = XposedHelpers.findClass(
|
||||
"com.google.android.gms.location.SettingsClient",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook checkLocationSettings
|
||||
hookAllMethodsWithName(settingsClientClass, "checkLocationSettings") { param ->
|
||||
XposedBridge.log("$tag SettingsClient.checkLocationSettings() intercepted")
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag SettingsClient hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag SettingsClient not found: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to hook all methods with a specific name
|
||||
*/
|
||||
private fun hookAllMethodsWithName(clazz: Class<*>, methodName: String, callback: (XC_MethodHook.MethodHookParam) -> Unit) {
|
||||
try {
|
||||
val methods = clazz.declaredMethods.filter { it.name == methodName }
|
||||
for (method in methods) {
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
callback(param)
|
||||
}
|
||||
})
|
||||
}
|
||||
XposedBridge.log("$tag Hooked ${methods.size} overloads of $methodName")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking $methodName: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to hook all methods of a class (for debugging)
|
||||
*/
|
||||
private fun hookAllMethods(clazz: Class<*>) {
|
||||
try {
|
||||
for (method in clazz.declaredMethods) {
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag ${clazz.simpleName}.${method.name}() called")
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking all methods: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
// LocationApiHooks.kt
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.location.LocationRequest
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Looper
|
||||
import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil
|
||||
import com.noobexon.xposedfakelocation.xposed.utils.PreferencesUtil
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.function.Consumer
|
||||
|
||||
class LocationApiHooks(val appLpparam: LoadPackageParam) {
|
||||
private val tag = "[LocationApiHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookLocationAPI()
|
||||
XposedBridge.log("$tag Instantiated hooks successfully")
|
||||
}
|
||||
|
||||
private fun hookLocationAPI() {
|
||||
hookLocation(appLpparam.classLoader)
|
||||
hookLocationManager(appLpparam.classLoader)
|
||||
hookLocationListener(appLpparam.classLoader)
|
||||
hookMockLocationDetection(appLpparam.classLoader)
|
||||
hookGeocoder(appLpparam.classLoader)
|
||||
}
|
||||
|
||||
private fun hookLocation(classLoader: ClassLoader) {
|
||||
try {
|
||||
val locationClass = XposedHelpers.findClass("android.location.Location", classLoader)
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getLatitude",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getLatitude()")
|
||||
XposedBridge.log("\t Original latitude: ${param.result as Double}")
|
||||
param.result = LocationUtil.latitude
|
||||
XposedBridge.log("\t Modified to: ${LocationUtil.latitude}")
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getLongitude",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getLongitude()")
|
||||
XposedBridge.log("\t Original longitude: ${param.result as Double}")
|
||||
param.result = LocationUtil.longitude
|
||||
XposedBridge.log("\t Modified to: ${LocationUtil.longitude}")
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getAccuracy",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getAccuracy()")
|
||||
XposedBridge.log("\t Original accuracy: ${param.result as Float}")
|
||||
if (PreferencesUtil.getUseAccuracy() == true) {
|
||||
param.result = LocationUtil.accuracy
|
||||
XposedBridge.log("\t Modified to: ${LocationUtil.accuracy}")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getAltitude",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getAltitude()")
|
||||
XposedBridge.log("\t Original altitude: ${param.result as Double}")
|
||||
if (PreferencesUtil.getUseAltitude() == true) {
|
||||
param.result = LocationUtil.altitude
|
||||
XposedBridge.log("\t Modified to: ${LocationUtil.altitude}")
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getVerticalAccuracyMeters",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getVerticalAccuracyMeters()")
|
||||
XposedBridge.log("\tOriginal vertical accuracy: ${param.result as Float}")
|
||||
if (PreferencesUtil.getUseVerticalAccuracy() == true) {
|
||||
param.result = LocationUtil.verticalAccuracy
|
||||
XposedBridge.log("\tModified to: ${LocationUtil.verticalAccuracy}")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getSpeed",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getSpeed()")
|
||||
XposedBridge.log("\tOriginal speed: ${param.result as Float}")
|
||||
if (PreferencesUtil.getUseSpeed() == true) {
|
||||
param.result = LocationUtil.speed
|
||||
XposedBridge.log("\tModified to: ${LocationUtil.speed}")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getSpeedAccuracyMetersPerSecond",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getSpeedAccuracyMetersPerSecond()")
|
||||
XposedBridge.log("\tOriginal speed accuracy: ${param.result as Float}")
|
||||
if (PreferencesUtil.getUseSpeedAccuracy() == true) {
|
||||
param.result = LocationUtil.speedAccuracy
|
||||
XposedBridge.log("\tModified to: ${LocationUtil.speedAccuracy}")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getMslAltitudeMeters",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getMslAltitudeMeters()")
|
||||
val originalMslAltitude = param.result as? Double
|
||||
XposedBridge.log("\tOriginal MSL altitude: $originalMslAltitude")
|
||||
if (PreferencesUtil.getUseMeanSeaLevel() == true) {
|
||||
param.result = LocationUtil.meanSeaLevel
|
||||
XposedBridge.log("\tModified to: ${LocationUtil.meanSeaLevel}")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Hook getMslAltitudeAccuracyMeters()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getMslAltitudeAccuracyMeters",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
LocationUtil.updateLocation()
|
||||
XposedBridge.log("$tag Leaving method getMslAltitudeAccuracyMeters()")
|
||||
val originalMslAltitudeAccuracy = param.result as? Float
|
||||
XposedBridge.log("\tOriginal MSL altitude accuracy: $originalMslAltitudeAccuracy")
|
||||
if (PreferencesUtil.getUseMeanSeaLevelAccuracy() == true) {
|
||||
param.result = LocationUtil.meanSeaLevelAccuracy
|
||||
XposedBridge.log("\tModified to: ${LocationUtil.meanSeaLevelAccuracy}")
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
XposedBridge.log("$tag getMslAltitudeMeters() and getMslAltitudeAccuracyMeters() not available on this API level")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$tag Error hooking Location class - ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookLocationManager(classLoader: ClassLoader) {
|
||||
try {
|
||||
val locationManagerClass = XposedHelpers.findClass("android.location.LocationManager", classLoader)
|
||||
|
||||
// Hook getLastKnownLocation(String provider)
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"getLastKnownLocation",
|
||||
String::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag Leaving method getLastKnownLocation(provider)")
|
||||
XposedBridge.log("\t Original location: ${param.result as? Location}")
|
||||
val provider = param.args[0] as String
|
||||
XposedBridge.log("\t Requested data from: $provider")
|
||||
val fakeLocation = LocationUtil.createFakeLocation(provider = provider)
|
||||
param.result = fakeLocation
|
||||
XposedBridge.log("\t Modified location: $fakeLocation")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook requestLocationUpdates - multiple overloads
|
||||
hookRequestLocationUpdates(locationManagerClass)
|
||||
|
||||
// Hook requestSingleUpdate - multiple overloads
|
||||
hookRequestSingleUpdate(locationManagerClass)
|
||||
|
||||
// Hook getCurrentLocation (API 30+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
hookGetCurrentLocation(locationManagerClass)
|
||||
}
|
||||
|
||||
// Hook isProviderEnabled to always return true
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"isProviderEnabled",
|
||||
String::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
XposedBridge.log("$tag isProviderEnabled(${param.args[0]}) -> true")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook isLocationEnabled (API 28+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"isLocationEnabled",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
XposedBridge.log("$tag isLocationEnabled() -> true")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag isLocationEnabled hook failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Hook getProviders to include GPS
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"getProviders",
|
||||
Boolean::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val providers = param.result as? MutableList<String>
|
||||
if (providers != null) {
|
||||
if (!providers.contains(LocationManager.GPS_PROVIDER)) {
|
||||
providers.add(LocationManager.GPS_PROVIDER)
|
||||
}
|
||||
if (!providers.contains(LocationManager.NETWORK_PROVIDER)) {
|
||||
providers.add(LocationManager.NETWORK_PROVIDER)
|
||||
}
|
||||
param.result = providers
|
||||
}
|
||||
XposedBridge.log("$tag getProviders() -> $providers")
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag LocationManager hooks installed")
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$tag Error hooking LocationManager - ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookRequestLocationUpdates(locationManagerClass: Class<*>) {
|
||||
// requestLocationUpdates(String provider, long minTime, float minDistance, LocationListener listener)
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"requestLocationUpdates",
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
LocationListener::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag requestLocationUpdates(provider, minTime, minDistance, listener) hooked")
|
||||
// Immediately send a fake location to the listener
|
||||
val listener = param.args[3] as? LocationListener
|
||||
val provider = param.args[0] as String
|
||||
listener?.onLocationChanged(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestLocationUpdates variant 1 not found")
|
||||
}
|
||||
|
||||
// requestLocationUpdates(String provider, long minTime, float minDistance, LocationListener listener, Looper looper)
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"requestLocationUpdates",
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
LocationListener::class.java,
|
||||
Looper::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag requestLocationUpdates(provider, minTime, minDistance, listener, looper) hooked")
|
||||
val listener = param.args[3] as? LocationListener
|
||||
val provider = param.args[0] as String
|
||||
listener?.onLocationChanged(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestLocationUpdates variant 2 not found")
|
||||
}
|
||||
|
||||
// requestLocationUpdates(String provider, long minTimeMs, float minDistanceM, Executor executor, LocationListener listener) - API 30+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"requestLocationUpdates",
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
Executor::class.java,
|
||||
LocationListener::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag requestLocationUpdates(provider, minTime, minDistance, executor, listener) hooked")
|
||||
val listener = param.args[4] as? LocationListener
|
||||
val provider = param.args[0] as String
|
||||
listener?.onLocationChanged(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestLocationUpdates variant 3 not found")
|
||||
}
|
||||
}
|
||||
|
||||
// requestLocationUpdates with LocationRequest (API 31+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"requestLocationUpdates",
|
||||
String::class.java,
|
||||
LocationRequest::class.java,
|
||||
Executor::class.java,
|
||||
LocationListener::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag requestLocationUpdates(provider, request, executor, listener) hooked")
|
||||
val listener = param.args[3] as? LocationListener
|
||||
val provider = param.args[0] as String
|
||||
listener?.onLocationChanged(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestLocationUpdates variant 4 not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookRequestSingleUpdate(locationManagerClass: Class<*>) {
|
||||
// requestSingleUpdate(String provider, LocationListener listener, Looper looper)
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"requestSingleUpdate",
|
||||
String::class.java,
|
||||
LocationListener::class.java,
|
||||
Looper::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag requestSingleUpdate(provider, listener, looper) hooked")
|
||||
val listener = param.args[1] as? LocationListener
|
||||
val provider = param.args[0] as String
|
||||
listener?.onLocationChanged(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestSingleUpdate not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookGetCurrentLocation(locationManagerClass: Class<*>) {
|
||||
// getCurrentLocation(String provider, CancellationSignal, Executor, Consumer<Location>)
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"getCurrentLocation",
|
||||
String::class.java,
|
||||
CancellationSignal::class.java,
|
||||
Executor::class.java,
|
||||
Consumer::class.java,
|
||||
object : XC_MethodHook() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag getCurrentLocation() hooked")
|
||||
val consumer = param.args[3] as? Consumer<Location>
|
||||
val provider = param.args[0] as String
|
||||
consumer?.accept(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getCurrentLocation not found: ${e.message}")
|
||||
}
|
||||
|
||||
// getCurrentLocation with LocationRequest (API 31+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerClass,
|
||||
"getCurrentLocation",
|
||||
String::class.java,
|
||||
LocationRequest::class.java,
|
||||
CancellationSignal::class.java,
|
||||
Executor::class.java,
|
||||
Consumer::class.java,
|
||||
object : XC_MethodHook() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag getCurrentLocation(request) hooked")
|
||||
val consumer = param.args[4] as? Consumer<Location>
|
||||
val provider = param.args[0] as String
|
||||
consumer?.accept(LocationUtil.createFakeLocation(provider = provider))
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getCurrentLocation with LocationRequest not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook LocationListener callbacks to modify any location passed to them
|
||||
*/
|
||||
private fun hookLocationListener(classLoader: ClassLoader) {
|
||||
try {
|
||||
val locationListenerClass = XposedHelpers.findClass(
|
||||
"android.location.LocationListener",
|
||||
classLoader
|
||||
)
|
||||
|
||||
// Hook onLocationChanged(Location)
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationListenerClass,
|
||||
"onLocationChanged",
|
||||
Location::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val originalLocation = param.args[0] as? Location
|
||||
if (originalLocation != null) {
|
||||
LocationUtil.updateLocation()
|
||||
val fakeLocation = LocationUtil.createFakeLocation(originalLocation)
|
||||
param.args[0] = fakeLocation
|
||||
XposedBridge.log("$tag LocationListener.onLocationChanged() -> faked")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Hook onLocationChanged(List<Location>) - API 31+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationListenerClass,
|
||||
"onLocationChanged",
|
||||
List::class.java,
|
||||
object : XC_MethodHook() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val originalLocations = param.args[0] as? List<Location>
|
||||
if (originalLocations != null && originalLocations.isNotEmpty()) {
|
||||
LocationUtil.updateLocation()
|
||||
val fakeLocations = originalLocations.map { location ->
|
||||
LocationUtil.createFakeLocation(location)
|
||||
}
|
||||
param.args[0] = fakeLocations
|
||||
XposedBridge.log("$tag LocationListener.onLocationChanged(List) -> faked ${fakeLocations.size} locations")
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag onLocationChanged(List) not found")
|
||||
}
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag LocationListener hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking LocationListener: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook methods that detect mock locations
|
||||
*/
|
||||
private fun hookMockLocationDetection(classLoader: ClassLoader) {
|
||||
try {
|
||||
val locationClass = XposedHelpers.findClass("android.location.Location", classLoader)
|
||||
|
||||
// Hook isFromMockProvider() - deprecated but still used
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"isFromMockProvider",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = false
|
||||
XposedBridge.log("$tag Location.isFromMockProvider() -> false")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag isFromMockProvider not found")
|
||||
}
|
||||
|
||||
// Hook isMock() - API 31+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"isMock",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = false
|
||||
XposedBridge.log("$tag Location.isMock() -> false")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag isMock not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Hook getExtras() to remove mock location flags
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationClass,
|
||||
"getExtras",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val extras = param.result as? android.os.Bundle
|
||||
extras?.remove("mockLocation")
|
||||
extras?.remove("isFromMockProvider")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getExtras hook failed")
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag Mock location detection hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking mock location detection: ${e.message}")
|
||||
}
|
||||
|
||||
// Hook Settings.Secure to hide mock location apps setting
|
||||
try {
|
||||
val settingsSecureClass = XposedHelpers.findClass(
|
||||
"android.provider.Settings\$Secure",
|
||||
classLoader
|
||||
)
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
settingsSecureClass,
|
||||
"getString",
|
||||
android.content.ContentResolver::class.java,
|
||||
String::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val key = param.args[1] as? String
|
||||
if (key == "mock_location" || key == "enabled_mock_location_app") {
|
||||
param.result = null
|
||||
XposedBridge.log("$tag Settings.Secure.getString($key) -> null")
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Settings.Secure hook failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook Geocoder to return fake addresses for our fake location
|
||||
*/
|
||||
private fun hookGeocoder(classLoader: ClassLoader) {
|
||||
try {
|
||||
val geocoderClass = XposedHelpers.findClass("android.location.Geocoder", classLoader)
|
||||
|
||||
// Hook getFromLocation to intercept reverse geocoding
|
||||
// We don't modify this because it would need a real address database
|
||||
// But we log it for debugging
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
geocoderClass,
|
||||
"getFromLocation",
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// Replace the coordinates with our fake ones
|
||||
LocationUtil.updateLocation()
|
||||
param.args[0] = LocationUtil.latitude
|
||||
param.args[1] = LocationUtil.longitude
|
||||
XposedBridge.log("$tag Geocoder.getFromLocation() coordinates replaced")
|
||||
}
|
||||
})
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Geocoder.getFromLocation hook failed")
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag Geocoder hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking Geocoder: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// SensorHooks.kt
|
||||
// Hook for sensors that might be used to detect fake location
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
/**
|
||||
* Hooks for sensor APIs that might reveal location or movement
|
||||
* Some apps use accelerometer/gyroscope to detect if user is actually moving
|
||||
*/
|
||||
class SensorHooks(private val lpparam: LoadPackageParam) {
|
||||
private val tag = "[SensorHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookSensorManager()
|
||||
hookSensorEventListener()
|
||||
XposedBridge.log("$tag Initialized Sensor hooks")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook SensorManager to control which sensors are available
|
||||
*/
|
||||
private fun hookSensorManager() {
|
||||
try {
|
||||
val sensorManagerClass = XposedHelpers.findClass(
|
||||
"android.hardware.SensorManager",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook registerListener to intercept sensor registrations
|
||||
// We won't block sensors but we'll log which ones are being used
|
||||
val registerMethods = sensorManagerClass.declaredMethods.filter {
|
||||
it.name == "registerListener"
|
||||
}
|
||||
|
||||
for (method in registerMethods) {
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// Find the Sensor parameter
|
||||
for (arg in param.args) {
|
||||
if (arg is Sensor) {
|
||||
XposedBridge.log("$tag App registering sensor: ${arg.name} (type: ${arg.type})")
|
||||
// Types that might detect movement:
|
||||
// TYPE_ACCELEROMETER (1), TYPE_GYROSCOPE (4), TYPE_STEP_COUNTER (19)
|
||||
// TYPE_STEP_DETECTOR (18), TYPE_SIGNIFICANT_MOTION (17)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag SensorManager hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking SensorManager: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook SensorEventListener to modify sensor data
|
||||
* This makes the device appear stationary (matching a fake static location)
|
||||
*/
|
||||
private fun hookSensorEventListener() {
|
||||
try {
|
||||
val sensorEventListenerClass = XposedHelpers.findClass(
|
||||
"android.hardware.SensorEventListener",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
sensorEventListenerClass,
|
||||
"onSensorChanged",
|
||||
SensorEvent::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val event = param.args[0] as? SensorEvent ?: return
|
||||
val sensor = event.sensor ?: return
|
||||
|
||||
when (sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
// Make it look like the device is stationary (only gravity, no movement)
|
||||
// Normal gravity is approximately [0, 0, 9.8] when flat
|
||||
// Don't modify values to allow normal app function
|
||||
// But log for debugging
|
||||
XposedBridge.log("$tag Accelerometer data passed through")
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// Gyroscope measures rotation - stationary device has [0, 0, 0]
|
||||
XposedBridge.log("$tag Gyroscope data passed through")
|
||||
}
|
||||
Sensor.TYPE_STEP_COUNTER,
|
||||
Sensor.TYPE_STEP_DETECTOR -> {
|
||||
// Step sensors - could reveal that user isn't actually walking
|
||||
XposedBridge.log("$tag Step sensor data: ${event.values.contentToString()}")
|
||||
}
|
||||
Sensor.TYPE_SIGNIFICANT_MOTION -> {
|
||||
// Significant motion detector
|
||||
XposedBridge.log("$tag Significant motion detected")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
XposedBridge.log("$tag SensorEventListener hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking SensorEventListener: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SystemServicesHooks.kt
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.location.Location
|
||||
import android.location.LocationRequest
|
||||
import android.os.Build
|
||||
import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
class SystemServicesHooks(val appLpparam: LoadPackageParam) {
|
||||
private val tag = "[SystemServicesHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookSystemServices(appLpparam.classLoader)
|
||||
XposedBridge.log("$tag Instantiated hooks successfully")
|
||||
}
|
||||
|
||||
private fun hookSystemServices(classLoader: ClassLoader) {
|
||||
try {
|
||||
val locationManagerServiceClass = XposedHelpers.findClass("com.android.server.LocationManagerService", classLoader)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerServiceClass,
|
||||
"getLastLocation",
|
||||
LocationRequest::class.java,
|
||||
String::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag [SystemHook] Entered method getLastLocation(locationRequest, packageName)")
|
||||
XposedBridge.log("\t Request comes from: ${param.args[1] as String}")
|
||||
val fakeLocation = LocationUtil.createFakeLocation()
|
||||
param.result = fakeLocation
|
||||
XposedBridge.log("\t Modified to: $fakeLocation (original method not executed)")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
XposedBridge.log("$tag API level too low. System services hooks are not available.")
|
||||
}
|
||||
|
||||
val methodsToReplace = arrayOf(
|
||||
"addGnssBatchingCallback",
|
||||
"addGnssMeasurementsListener",
|
||||
"addGnssNavigationMessageListener"
|
||||
)
|
||||
|
||||
for (methodName in methodsToReplace) {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
locationManagerServiceClass,
|
||||
methodName,
|
||||
XC_MethodReplacement.returnConstant(false)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
XposedHelpers.findAndHookMethod(
|
||||
XposedHelpers.findClass("com.android.server.LocationManagerService\$Receiver", classLoader),
|
||||
"callLocationChangedLocked",
|
||||
Location::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag [SystemHook] Entered method callLocationChangedLocked(location)")
|
||||
val fakeLocation = LocationUtil.createFakeLocation(param.args[0] as? Location)
|
||||
param.args[0] = fakeLocation
|
||||
XposedBridge.log("\t Modified to: $fakeLocation")
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$tag Error hooking system services")
|
||||
XposedBridge.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// TelephonyHooks.kt
|
||||
// Hook for cell tower-based location detection
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.os.Build
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
/**
|
||||
* Hooks for telephony/cell tower-based location APIs
|
||||
* Prevents apps from using cell tower information for positioning
|
||||
*/
|
||||
class TelephonyHooks(private val lpparam: LoadPackageParam) {
|
||||
private val tag = "[TelephonyHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookTelephonyManager()
|
||||
hookCellInfo()
|
||||
XposedBridge.log("$tag Initialized Telephony hooks")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook TelephonyManager to prevent cell-based location
|
||||
*/
|
||||
private fun hookTelephonyManager() {
|
||||
try {
|
||||
val telephonyManagerClass = XposedHelpers.findClass(
|
||||
"android.telephony.TelephonyManager",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getCellLocation() - deprecated but still used
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
telephonyManagerClass,
|
||||
"getCellLocation",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = null
|
||||
XposedBridge.log("$tag TelephonyManager.getCellLocation() -> null")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getCellLocation not found (might be removed in newer APIs)")
|
||||
}
|
||||
|
||||
// Hook getAllCellInfo() - returns list of cell info
|
||||
XposedHelpers.findAndHookMethod(
|
||||
telephonyManagerClass,
|
||||
"getAllCellInfo",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = emptyList<Any>()
|
||||
XposedBridge.log("$tag TelephonyManager.getAllCellInfo() -> empty list")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook getNeighboringCellInfo() - deprecated but might still be used
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
telephonyManagerClass,
|
||||
"getNeighboringCellInfo",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = emptyList<Any>()
|
||||
XposedBridge.log("$tag TelephonyManager.getNeighboringCellInfo() -> empty list")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getNeighboringCellInfo not found")
|
||||
}
|
||||
|
||||
// Hook requestCellInfoUpdate for API 29+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
try {
|
||||
// Find all overloaded versions of requestCellInfoUpdate
|
||||
val methods = telephonyManagerClass.declaredMethods.filter {
|
||||
it.name == "requestCellInfoUpdate"
|
||||
}
|
||||
for (method in methods) {
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag TelephonyManager.requestCellInfoUpdate() intercepted")
|
||||
// Let it run but the callback will get empty data
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag requestCellInfoUpdate hook failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Hook getServiceState() which contains cell tower info
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
telephonyManagerClass,
|
||||
"getServiceState",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Don't null it completely as it might crash apps
|
||||
// But we've blocked cell info through other hooks
|
||||
XposedBridge.log("$tag TelephonyManager.getServiceState() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag getServiceState hook failed")
|
||||
}
|
||||
|
||||
XposedBridge.log("$tag TelephonyManager hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking TelephonyManager: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook individual CellInfo classes to hide location data
|
||||
*/
|
||||
private fun hookCellInfo() {
|
||||
val cellInfoClasses = listOf(
|
||||
"android.telephony.CellInfoGsm",
|
||||
"android.telephony.CellInfoCdma",
|
||||
"android.telephony.CellInfoLte",
|
||||
"android.telephony.CellInfoWcdma",
|
||||
"android.telephony.CellInfoNr", // 5G
|
||||
"android.telephony.CellInfoTdscdma"
|
||||
)
|
||||
|
||||
for (className in cellInfoClasses) {
|
||||
try {
|
||||
val cellInfoClass = XposedHelpers.findClass(className, lpparam.classLoader)
|
||||
|
||||
// Hook getCellIdentity()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
cellInfoClass,
|
||||
"getCellIdentity",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// We could return null or a fake identity
|
||||
// For now just log that we intercepted it
|
||||
XposedBridge.log("$tag $className.getCellIdentity() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook getCellSignalStrength()
|
||||
XposedHelpers.findAndHookMethod(
|
||||
cellInfoClass,
|
||||
"getCellSignalStrength",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
XposedBridge.log("$tag $className.getCellSignalStrength() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag $className hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
// Not all cell types exist on all devices
|
||||
XposedBridge.log("$tag $className not found (expected on some devices)")
|
||||
}
|
||||
}
|
||||
|
||||
// Hook CellIdentity base class methods that return location
|
||||
try {
|
||||
val cellIdentityClass = XposedHelpers.findClass(
|
||||
"android.telephony.CellIdentity",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// These methods might not exist on all API levels
|
||||
val methodsToHook = listOf("getLatitude", "getLongitude", "getLac", "getCid", "getMcc", "getMnc")
|
||||
|
||||
for (methodName in methodsToHook) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
cellIdentityClass,
|
||||
methodName,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Return invalid/unknown values
|
||||
when (param.result) {
|
||||
is Int -> param.result = Int.MAX_VALUE
|
||||
is Long -> param.result = Long.MAX_VALUE
|
||||
is Double -> param.result = Double.MAX_VALUE
|
||||
}
|
||||
XposedBridge.log("$tag CellIdentity.$methodName() -> hidden")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// Method might not exist
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag CellIdentity base class hooks failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// WifiHooks.kt
|
||||
// Hook for WiFi-based location detection
|
||||
package com.noobexon.xposedfakelocation.xposed.hooks
|
||||
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WifiInfo
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XC_MethodReplacement
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
/**
|
||||
* Hooks for WiFi-based location APIs
|
||||
* Prevents apps from using WiFi scan results for positioning
|
||||
*/
|
||||
class WifiHooks(private val lpparam: LoadPackageParam) {
|
||||
private val tag = "[WifiHooks]"
|
||||
|
||||
fun initHooks() {
|
||||
hookWifiManager()
|
||||
hookWifiInfo()
|
||||
XposedBridge.log("$tag Initialized WiFi hooks")
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook WifiManager to prevent WiFi-based location
|
||||
*/
|
||||
private fun hookWifiManager() {
|
||||
try {
|
||||
val wifiManagerClass = XposedHelpers.findClass(
|
||||
"android.net.wifi.WifiManager",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getScanResults() - returns empty list to prevent WiFi positioning
|
||||
XposedHelpers.findAndHookMethod(
|
||||
wifiManagerClass,
|
||||
"getScanResults",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Return empty list to prevent WiFi-based location
|
||||
param.result = emptyList<ScanResult>()
|
||||
XposedBridge.log("$tag WifiManager.getScanResults() -> empty list")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook startScan() - return false to indicate scan not started
|
||||
XposedHelpers.findAndHookMethod(
|
||||
wifiManagerClass,
|
||||
"startScan",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = false
|
||||
XposedBridge.log("$tag WifiManager.startScan() -> false")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook getConnectionInfo() for connected WiFi info
|
||||
XposedHelpers.findAndHookMethod(
|
||||
wifiManagerClass,
|
||||
"getConnectionInfo",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// We'll modify the WifiInfo through WifiInfo hooks
|
||||
XposedBridge.log("$tag WifiManager.getConnectionInfo() intercepted")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag WifiManager hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking WifiManager: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook WifiInfo to hide BSSID/SSID which can be used for location
|
||||
*/
|
||||
private fun hookWifiInfo() {
|
||||
try {
|
||||
val wifiInfoClass = XposedHelpers.findClass(
|
||||
"android.net.wifi.WifiInfo",
|
||||
lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook getBSSID() - return null to hide the access point identifier
|
||||
XposedHelpers.findAndHookMethod(
|
||||
wifiInfoClass,
|
||||
"getBSSID",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = "02:00:00:00:00:00" // Generic/fake BSSID
|
||||
XposedBridge.log("$tag WifiInfo.getBSSID() -> fake BSSID")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Hook getSSID() - return generic SSID
|
||||
XposedHelpers.findAndHookMethod(
|
||||
wifiInfoClass,
|
||||
"getSSID",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
param.result = "<unknown ssid>"
|
||||
XposedBridge.log("$tag WifiInfo.getSSID() -> unknown")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XposedBridge.log("$tag WifiInfo hooks installed")
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$tag Error hooking WifiInfo: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// LocationUtil.kt
|
||||
package com.noobexon.xposedfakelocation.xposed.utils
|
||||
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import com.noobexon.xposedfakelocation.data.DEFAULT_ACCURACY
|
||||
import com.noobexon.xposedfakelocation.data.DEFAULT_ALTITUDE
|
||||
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_SPEED
|
||||
import com.noobexon.xposedfakelocation.data.DEFAULT_SPEED_ACCURACY
|
||||
import com.noobexon.xposedfakelocation.data.DEFAULT_VERTICAL_ACCURACY
|
||||
import com.noobexon.xposedfakelocation.data.PI
|
||||
import com.noobexon.xposedfakelocation.data.RADIUS_EARTH
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
import java.util.Random
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
object LocationUtil {
|
||||
private const val TAG = "[LocationUtil]"
|
||||
|
||||
private const val DEBUG: Boolean = true
|
||||
|
||||
private val random: Random = Random()
|
||||
|
||||
var latitude: Double = 0.0
|
||||
var longitude: Double = 0.0
|
||||
var accuracy: Float = 0F
|
||||
var altitude: Double = 0.0
|
||||
var verticalAccuracy: Float = 0F
|
||||
var meanSeaLevel: Double = 0.0
|
||||
var meanSeaLevelAccuracy: Float = 0F
|
||||
var speed: Float = 0F
|
||||
var speedAccuracy: Float = 0F
|
||||
|
||||
@Synchronized
|
||||
fun createFakeLocation(originalLocation: Location? = null, provider: String = LocationManager.GPS_PROVIDER): Location {
|
||||
val fakeLocation = if (originalLocation == null) {
|
||||
Location(provider).apply {
|
||||
time = System.currentTimeMillis() - 300
|
||||
}
|
||||
} else {
|
||||
Location(originalLocation.provider).apply {
|
||||
time = originalLocation.time
|
||||
accuracy = originalLocation.accuracy
|
||||
bearing = originalLocation.bearing
|
||||
bearingAccuracyDegrees = originalLocation.bearingAccuracyDegrees
|
||||
elapsedRealtimeNanos = originalLocation.elapsedRealtimeNanos
|
||||
verticalAccuracyMeters = originalLocation.verticalAccuracyMeters
|
||||
}
|
||||
}
|
||||
|
||||
fakeLocation.latitude = latitude
|
||||
fakeLocation.longitude = longitude
|
||||
|
||||
if (accuracy != 0F) {
|
||||
fakeLocation.accuracy = accuracy
|
||||
}
|
||||
|
||||
if (altitude != 0.0) {
|
||||
fakeLocation.altitude = altitude
|
||||
}
|
||||
|
||||
if (verticalAccuracy != 0F) {
|
||||
fakeLocation.verticalAccuracyMeters = verticalAccuracy
|
||||
}
|
||||
|
||||
if (speed != 0F) {
|
||||
fakeLocation.speed = speed
|
||||
}
|
||||
|
||||
if (speedAccuracy != 0F) {
|
||||
fakeLocation.speedAccuracyMetersPerSecond = speedAccuracy
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
if (meanSeaLevel != 0.0) {
|
||||
fakeLocation.mslAltitudeMeters = meanSeaLevel
|
||||
}
|
||||
|
||||
if (meanSeaLevelAccuracy != 0F) {
|
||||
fakeLocation.mslAltitudeAccuracyMeters = meanSeaLevelAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
attemptHideMockProvider(fakeLocation)
|
||||
|
||||
return fakeLocation
|
||||
}
|
||||
|
||||
private fun attemptHideMockProvider(fakeLocation: Location) {
|
||||
try {
|
||||
HiddenApiBypass.invoke(fakeLocation.javaClass, fakeLocation, "setIsFromMockProvider", false)
|
||||
XposedBridge.log("$TAG invoked hidden API - setIsFromMockProvider: false)")
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG Not possible to mock - ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateLocation() {
|
||||
try {
|
||||
PreferencesUtil.getLastClickedLocation()?.let {
|
||||
if (PreferencesUtil.getUseRandomize() == true) {
|
||||
val randomizationRadius = PreferencesUtil.getRandomizeRadius() ?: DEFAULT_RANDOMIZE_RADIUS
|
||||
val randomLocation = getRandomLocation(it.latitude, it.longitude, randomizationRadius)
|
||||
latitude = randomLocation.first
|
||||
longitude = randomLocation.second
|
||||
} else {
|
||||
latitude = it.latitude
|
||||
longitude = it.longitude
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseAccuracy() == true) {
|
||||
accuracy = (PreferencesUtil.getAccuracy() ?: DEFAULT_ACCURACY).toFloat()
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseAltitude() == true) {
|
||||
altitude = PreferencesUtil.getAltitude() ?: DEFAULT_ALTITUDE
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseVerticalAccuracy() == true) {
|
||||
verticalAccuracy = PreferencesUtil.getVerticalAccuracy()?.toFloat() ?: DEFAULT_VERTICAL_ACCURACY
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseMeanSeaLevel() == true) {
|
||||
meanSeaLevel = PreferencesUtil.getMeanSeaLevel() ?: DEFAULT_MEAN_SEA_LEVEL
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseMeanSeaLevelAccuracy() == true) {
|
||||
meanSeaLevelAccuracy = PreferencesUtil.getMeanSeaLevelAccuracy()?.toFloat() ?: DEFAULT_MEAN_SEA_LEVEL_ACCURACY
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseSpeed() == true) {
|
||||
speed = PreferencesUtil.getSpeed()?.toFloat() ?: DEFAULT_SPEED
|
||||
}
|
||||
|
||||
if (PreferencesUtil.getUseSpeedAccuracy() == true) {
|
||||
speedAccuracy = PreferencesUtil.getSpeedAccuracy()?.toFloat() ?: DEFAULT_SPEED_ACCURACY
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
XposedBridge.log("$TAG Updated fake location values to:")
|
||||
XposedBridge.log("\tCoordinates: (latitude = $latitude, longitude = $longitude)")
|
||||
XposedBridge.log("\tAccuracy: $accuracy")
|
||||
XposedBridge.log("\tAltitude: $altitude")
|
||||
XposedBridge.log("\tVertical Accuracy: $verticalAccuracy")
|
||||
XposedBridge.log("\tMean Sea Level: $meanSeaLevel")
|
||||
XposedBridge.log("\tMean Sea Level Accuracy: $meanSeaLevelAccuracy")
|
||||
XposedBridge.log("\tSpeed: $speed")
|
||||
XposedBridge.log("\tSpeed Accuracy: $speedAccuracy")
|
||||
}
|
||||
} ?: XposedBridge.log("$TAG Last clicked location is null")
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG Error - ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates a random point within a circle around the fake location that has the radius set by by the user. Uses Haversine's formula.
|
||||
private fun getRandomLocation(lat: Double, lon: Double, radiusInMeters: Double): Pair<Double, Double> {
|
||||
val radiusInRadians = radiusInMeters / RADIUS_EARTH
|
||||
|
||||
val latRad = Math.toRadians(lat)
|
||||
val lonRad = Math.toRadians(lon)
|
||||
|
||||
val sinLat = sin(latRad)
|
||||
val cosLat = cos(latRad)
|
||||
|
||||
// Generate two random numbers
|
||||
val rand1 = random.nextDouble()
|
||||
val rand2 = random.nextDouble()
|
||||
|
||||
// Random distance and bearing
|
||||
val distance = radiusInRadians * sqrt(rand1)
|
||||
val bearing = 2 * PI * rand2
|
||||
|
||||
val sinDistance = sin(distance)
|
||||
val cosDistance = cos(distance)
|
||||
|
||||
val newLatRad = asin(sinLat * cosDistance + cosLat * sinDistance * cos(bearing))
|
||||
val newLonRad = lonRad + atan2(
|
||||
sin(bearing) * sinDistance * cosLat,
|
||||
cosDistance - sinLat * sin(newLatRad)
|
||||
)
|
||||
|
||||
// Convert back to degrees
|
||||
val newLat = Math.toDegrees(newLatRad)
|
||||
var newLon = Math.toDegrees(newLonRad)
|
||||
|
||||
// Normalize longitude to be between -180 and 180 degrees
|
||||
newLon = ((newLon + 180) % 360 + 360) % 360 - 180
|
||||
|
||||
// Clamp latitude to -90 to 90 degrees
|
||||
val finalLat = newLat.coerceIn(-90.0, 90.0)
|
||||
|
||||
return Pair(finalLat, newLon)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// PreferencesUtil.kt
|
||||
package com.noobexon.xposedfakelocation.xposed.utils
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.noobexon.xposedfakelocation.data.*
|
||||
import com.noobexon.xposedfakelocation.data.model.LastClickedLocation
|
||||
import de.robv.android.xposed.XSharedPreferences
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
object PreferencesUtil {
|
||||
private const val TAG = "[PreferencesUtil]"
|
||||
|
||||
private val preferences: XSharedPreferences = XSharedPreferences(MANAGER_APP_PACKAGE_NAME, SHARED_PREFS_FILE).apply {
|
||||
makeWorldReadable()
|
||||
reload()
|
||||
}
|
||||
|
||||
fun getIsPlaying(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_IS_PLAYING)
|
||||
}
|
||||
|
||||
fun getLastClickedLocation(): LastClickedLocation? {
|
||||
return getPreference<LastClickedLocation>(KEY_LAST_CLICKED_LOCATION)
|
||||
}
|
||||
|
||||
fun getUseAccuracy(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_ACCURACY)
|
||||
}
|
||||
|
||||
fun getAccuracy(): Double? {
|
||||
return getPreference<Double>(KEY_ACCURACY)
|
||||
}
|
||||
|
||||
fun getUseAltitude(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_ALTITUDE)
|
||||
}
|
||||
|
||||
fun getAltitude(): Double? {
|
||||
return getPreference<Double>(KEY_ALTITUDE)
|
||||
}
|
||||
|
||||
fun getUseRandomize(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_RANDOMIZE)
|
||||
}
|
||||
|
||||
fun getRandomizeRadius(): Double? {
|
||||
return getPreference<Double>(KEY_RANDOMIZE_RADIUS)
|
||||
}
|
||||
|
||||
fun getUseVerticalAccuracy(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
fun getVerticalAccuracy(): Float? {
|
||||
return getPreference<Float>(KEY_VERTICAL_ACCURACY)
|
||||
}
|
||||
|
||||
fun getUseMeanSeaLevel(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_MEAN_SEA_LEVEL)
|
||||
}
|
||||
|
||||
fun getMeanSeaLevel(): Double? {
|
||||
return getPreference<Double>(KEY_MEAN_SEA_LEVEL)
|
||||
}
|
||||
|
||||
fun getUseMeanSeaLevelAccuracy(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
fun getMeanSeaLevelAccuracy(): Float? {
|
||||
return getPreference<Float>(KEY_MEAN_SEA_LEVEL_ACCURACY)
|
||||
}
|
||||
|
||||
fun getUseSpeed(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_SPEED)
|
||||
}
|
||||
|
||||
fun getSpeed(): Float? {
|
||||
return getPreference<Float>(KEY_SPEED)
|
||||
}
|
||||
|
||||
fun getUseSpeedAccuracy(): Boolean? {
|
||||
return getPreference<Boolean>(KEY_USE_SPEED_ACCURACY)
|
||||
}
|
||||
|
||||
fun getSpeedAccuracy(): Float? {
|
||||
return getPreference<Float>(KEY_SPEED_ACCURACY)
|
||||
}
|
||||
|
||||
private inline fun <reified T> getPreference(key: String): T? {
|
||||
preferences.reload()
|
||||
return when (T::class) {
|
||||
Double::class -> {
|
||||
val defaultValue = when (key) {
|
||||
KEY_ACCURACY -> java.lang.Double.doubleToRawLongBits(DEFAULT_ACCURACY)
|
||||
KEY_ALTITUDE -> java.lang.Double.doubleToRawLongBits(DEFAULT_ALTITUDE)
|
||||
KEY_RANDOMIZE_RADIUS -> java.lang.Double.doubleToRawLongBits(DEFAULT_RANDOMIZE_RADIUS)
|
||||
KEY_MEAN_SEA_LEVEL -> java.lang.Double.doubleToRawLongBits(DEFAULT_MEAN_SEA_LEVEL)
|
||||
else -> -1L
|
||||
}
|
||||
val bits = preferences.getLong(key, defaultValue)
|
||||
java.lang.Double.longBitsToDouble(bits) as? T
|
||||
}
|
||||
Float::class -> {
|
||||
val defaultValue = when (key) {
|
||||
KEY_VERTICAL_ACCURACY -> DEFAULT_VERTICAL_ACCURACY
|
||||
KEY_MEAN_SEA_LEVEL_ACCURACY -> DEFAULT_MEAN_SEA_LEVEL_ACCURACY
|
||||
KEY_SPEED -> DEFAULT_SPEED
|
||||
KEY_SPEED_ACCURACY -> DEFAULT_SPEED_ACCURACY
|
||||
else -> -1f
|
||||
}
|
||||
preferences.getFloat(key, defaultValue) as? T
|
||||
}
|
||||
Boolean::class -> preferences.getBoolean(key, false) as? T
|
||||
else -> {
|
||||
val json = preferences.getString(key, null)
|
||||
if (json != null) {
|
||||
try {
|
||||
Gson().fromJson(json, T::class.java).also {
|
||||
XposedBridge.log("$TAG Retrieved $key: $it")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG Error parsing $key JSON: ${e.message}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
XposedBridge.log("$TAG $key not found in preferences.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 857 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 463 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
129
app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 应用名称 -->
|
||||
<string name="app_name">虚拟定位</string>
|
||||
|
||||
<!-- 侧边栏内容 -->
|
||||
<string name="drawer_title">虚拟定位</string>
|
||||
<string name="drawer_subtitle">轻松伪装您的位置</string>
|
||||
<string name="nav_section_navigation">导航</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_favorites">收藏</string>
|
||||
<string name="nav_settings">设置</string>
|
||||
<string name="nav_section_community">社区</string>
|
||||
<string name="nav_telegram">Telegram</string>
|
||||
<string name="nav_discord">Discord</string>
|
||||
<string name="nav_github">GitHub</string>
|
||||
<string name="nav_section_app_info">应用信息</string>
|
||||
<string name="nav_about">关于</string>
|
||||
<string name="version_format">版本 %s</string>
|
||||
<string name="coming_soon">即将推出!</string>
|
||||
|
||||
<!-- 地图界面 -->
|
||||
<string name="menu">菜单</string>
|
||||
<string name="center">居中</string>
|
||||
<string name="options">选项</string>
|
||||
<string name="go_to_point">前往坐标</string>
|
||||
<string name="add_to_favorites">添加到收藏</string>
|
||||
<string name="clear_location">清除位置</string>
|
||||
<string name="fake_location_set">已设置虚拟位置</string>
|
||||
<string name="fake_location_unset">已取消虚拟位置</string>
|
||||
<string name="stop">停止</string>
|
||||
<string name="play">启动</string>
|
||||
<string name="my_location">我的位置</string>
|
||||
<string name="navigate_back">返回</string>
|
||||
|
||||
<!-- 设置界面 -->
|
||||
<string name="settings_title">设置</string>
|
||||
<string name="category_location">位置</string>
|
||||
<string name="category_altitude">海拔</string>
|
||||
<string name="category_movement">移动</string>
|
||||
|
||||
<!-- 设置项:随机附近位置 -->
|
||||
<string name="setting_randomize_title">随机附近位置</string>
|
||||
<string name="setting_randomize_desc">在指定半径范围内随机生成您的位置</string>
|
||||
<string name="setting_randomize_label">随机半径</string>
|
||||
|
||||
<!-- 设置项:自定义水平精度 -->
|
||||
<string name="setting_horizontal_accuracy_title">自定义水平精度</string>
|
||||
<string name="setting_horizontal_accuracy_desc">设置您位置的水平精度</string>
|
||||
<string name="setting_horizontal_accuracy_label">水平精度</string>
|
||||
|
||||
<!-- 设置项:自定义垂直精度 -->
|
||||
<string name="setting_vertical_accuracy_title">自定义垂直精度</string>
|
||||
<string name="setting_vertical_accuracy_desc">设置您位置的垂直精度</string>
|
||||
<string name="setting_vertical_accuracy_label">垂直精度</string>
|
||||
|
||||
<!-- 设置项:自定义海拔 -->
|
||||
<string name="setting_altitude_title">自定义海拔</string>
|
||||
<string name="setting_altitude_desc">为您的位置设置自定义海拔高度</string>
|
||||
<string name="setting_altitude_label">海拔</string>
|
||||
|
||||
<!-- 设置项:自定义平均海平面 -->
|
||||
<string name="setting_msl_title">自定义平均海平面</string>
|
||||
<string name="setting_msl_desc">设置自定义平均海平面值</string>
|
||||
<string name="setting_msl_label">平均海平面</string>
|
||||
|
||||
<!-- 设置项:自定义平均海平面精度 -->
|
||||
<string name="setting_msl_accuracy_title">自定义平均海平面精度</string>
|
||||
<string name="setting_msl_accuracy_desc">设置平均海平面值的精度</string>
|
||||
<string name="setting_msl_accuracy_label">平均海平面精度</string>
|
||||
|
||||
<!-- 设置项:自定义速度 -->
|
||||
<string name="setting_speed_title">自定义速度</string>
|
||||
<string name="setting_speed_desc">为您的位置设置自定义移动速度</string>
|
||||
<string name="setting_speed_label">速度</string>
|
||||
|
||||
<!-- 设置项:自定义速度精度 -->
|
||||
<string name="setting_speed_accuracy_title">自定义速度精度</string>
|
||||
<string name="setting_speed_accuracy_desc">设置您速度值的精度</string>
|
||||
<string name="setting_speed_accuracy_label">速度精度</string>
|
||||
|
||||
<!-- 单位 -->
|
||||
<string name="unit_meters">米</string>
|
||||
<string name="unit_meters_per_second">米/秒</string>
|
||||
|
||||
<!-- 无障碍 -->
|
||||
<string name="more_info_about">关于%s的更多信息</string>
|
||||
<string name="enable_setting">启用%s</string>
|
||||
<string name="disable_setting">禁用%s</string>
|
||||
<string name="adjust_value">调整%s的值</string>
|
||||
|
||||
<!-- 关于界面 -->
|
||||
<string name="about_title">关于</string>
|
||||
<string name="about_back">返回</string>
|
||||
<string name="about_app_description">虚拟定位是一款允许用户模拟位置的应用,适用于测试或娱乐目的。\n\n请负责任地使用,并确保在使用位置服务时遵守所有适用的当地法规。\n\n您需对本应用的使用承担全部责任。</string>
|
||||
<string name="about_version_label">版本:</string>
|
||||
<string name="about_developer_label">开发和维护者:</string>
|
||||
<string name="about_developer_name">noobexon</string>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<string name="dialog_go_to_point_title">前往坐标</string>
|
||||
<string name="dialog_latitude">纬度</string>
|
||||
<string name="dialog_longitude">经度</string>
|
||||
<string name="dialog_cancel">取消</string>
|
||||
<string name="dialog_go">前往</string>
|
||||
<string name="dialog_add_favorite_title">添加到收藏</string>
|
||||
<string name="dialog_name">名称</string>
|
||||
<string name="dialog_add">添加</string>
|
||||
|
||||
<!-- 收藏界面 -->
|
||||
<string name="favorites_title">收藏</string>
|
||||
<string name="favorites_empty">暂无收藏</string>
|
||||
<string name="favorites_location_format">纬度: %1$.6f, 经度: %2$.6f</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="back">返回</string>
|
||||
|
||||
<!-- 错误界面 -->
|
||||
<string name="error_module_not_active_title">模块未激活</string>
|
||||
<string name="error_module_not_active_message">虚拟定位模块在您的 Xposed 管理器中未激活。请启用它并重启应用后继续使用。</string>
|
||||
<string name="ok">确定</string>
|
||||
<string name="cancel">取消</string>
|
||||
|
||||
<!-- 权限界面 -->
|
||||
<string name="error_no_activity">错误:无法访问 Activity。</string>
|
||||
<string name="permission_required">使用此应用需要授予权限</string>
|
||||
<string name="grant_permissions">授予权限</string>
|
||||
<string name="permission_permanently_denied">您已永久拒绝位置权限。请在设置中启用权限并重启应用。</string>
|
||||
<string name="open_settings">打开设置</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/arrays.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string-array name="xposedscope" >
|
||||
<item>com.noobexon.xposedfakelocation</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
128
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<resources>
|
||||
<!-- App Name -->
|
||||
<string name="app_name">XposedFakeLocation</string>
|
||||
|
||||
<!-- Drawer Content -->
|
||||
<string name="drawer_title">XposedFakeLocation</string>
|
||||
<string name="drawer_subtitle">Spoof your location easily</string>
|
||||
<string name="nav_section_navigation">Navigation</string>
|
||||
<string name="nav_map">Map</string>
|
||||
<string name="nav_favorites">Favorites</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_section_community">Community</string>
|
||||
<string name="nav_telegram">Telegram</string>
|
||||
<string name="nav_discord">Discord</string>
|
||||
<string name="nav_github">GitHub</string>
|
||||
<string name="nav_section_app_info">App Info</string>
|
||||
<string name="nav_about">About</string>
|
||||
<string name="version_format">Version %s</string>
|
||||
<string name="coming_soon">Coming soon!</string>
|
||||
|
||||
<!-- Map Screen -->
|
||||
<string name="menu">Menu</string>
|
||||
<string name="center">Center</string>
|
||||
<string name="options">Options</string>
|
||||
<string name="go_to_point">Go to Point</string>
|
||||
<string name="add_to_favorites">Add to Favorites</string>
|
||||
<string name="clear_location">Clear Location</string>
|
||||
<string name="fake_location_set">Fake Location Set</string>
|
||||
<string name="fake_location_unset">Unset Fake Location</string>
|
||||
<string name="stop">Stop</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="my_location">My Location</string>
|
||||
<string name="navigate_back">Navigate back</string>
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="category_location">Location</string>
|
||||
<string name="category_altitude">Altitude</string>
|
||||
<string name="category_movement">Movement</string>
|
||||
|
||||
<!-- Setting: Randomize Nearby Location -->
|
||||
<string name="setting_randomize_title">Randomize Nearby Location</string>
|
||||
<string name="setting_randomize_desc">Randomly places your location within the specified radius</string>
|
||||
<string name="setting_randomize_label">Randomization Radius</string>
|
||||
|
||||
<!-- Setting: Custom Horizontal Accuracy -->
|
||||
<string name="setting_horizontal_accuracy_title">Custom Horizontal Accuracy</string>
|
||||
<string name="setting_horizontal_accuracy_desc">Sets the horizontal accuracy of your location</string>
|
||||
<string name="setting_horizontal_accuracy_label">Horizontal Accuracy</string>
|
||||
|
||||
<!-- Setting: Custom Vertical Accuracy -->
|
||||
<string name="setting_vertical_accuracy_title">Custom Vertical Accuracy</string>
|
||||
<string name="setting_vertical_accuracy_desc">Sets the vertical accuracy of your location</string>
|
||||
<string name="setting_vertical_accuracy_label">Vertical Accuracy</string>
|
||||
|
||||
<!-- Setting: Custom Altitude -->
|
||||
<string name="setting_altitude_title">Custom Altitude</string>
|
||||
<string name="setting_altitude_desc">Sets a custom altitude for your location</string>
|
||||
<string name="setting_altitude_label">Altitude</string>
|
||||
|
||||
<!-- Setting: Custom MSL -->
|
||||
<string name="setting_msl_title">Custom MSL</string>
|
||||
<string name="setting_msl_desc">Sets a custom mean sea level value</string>
|
||||
<string name="setting_msl_label">MSL</string>
|
||||
|
||||
<!-- Setting: Custom MSL Accuracy -->
|
||||
<string name="setting_msl_accuracy_title">Custom MSL Accuracy</string>
|
||||
<string name="setting_msl_accuracy_desc">Sets the accuracy of the mean sea level value</string>
|
||||
<string name="setting_msl_accuracy_label">MSL Accuracy</string>
|
||||
|
||||
<!-- Setting: Custom Speed -->
|
||||
<string name="setting_speed_title">Custom Speed</string>
|
||||
<string name="setting_speed_desc">Sets a custom speed for your location</string>
|
||||
<string name="setting_speed_label">Speed</string>
|
||||
|
||||
<!-- Setting: Custom Speed Accuracy -->
|
||||
<string name="setting_speed_accuracy_title">Custom Speed Accuracy</string>
|
||||
<string name="setting_speed_accuracy_desc">Sets the accuracy of your speed value</string>
|
||||
<string name="setting_speed_accuracy_label">Speed Accuracy</string>
|
||||
|
||||
<!-- Units -->
|
||||
<string name="unit_meters">m</string>
|
||||
<string name="unit_meters_per_second">m/s</string>
|
||||
|
||||
<!-- Accessibility -->
|
||||
<string name="more_info_about">More information about %s</string>
|
||||
<string name="enable_setting">Enable %s</string>
|
||||
<string name="disable_setting">Disable %s</string>
|
||||
<string name="adjust_value">Adjust %s value</string>
|
||||
|
||||
<!-- About Screen -->
|
||||
<string name="about_title">About</string>
|
||||
<string name="about_back">Back</string>
|
||||
<string name="about_app_description">XposedFakeLocation is an app designed to allow users to mock their location for testing or entertainment purposes.\n\nUse it responsibly, and make sure to comply with all applicable local regulations when using location services.\n\nYou are fully responsible for the use of this app.</string>
|
||||
<string name="about_version_label">Version:</string>
|
||||
<string name="about_developer_label">Developed and maintained by:</string>
|
||||
<string name="about_developer_name">noobexon</string>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<string name="dialog_go_to_point_title">Go to Point</string>
|
||||
<string name="dialog_latitude">Latitude</string>
|
||||
<string name="dialog_longitude">Longitude</string>
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_go">Go</string>
|
||||
<string name="dialog_add_favorite_title">Add to Favorites</string>
|
||||
<string name="dialog_name">Name</string>
|
||||
<string name="dialog_add">Add</string>
|
||||
|
||||
<!-- Favorites Screen -->
|
||||
<string name="favorites_title">Favorites</string>
|
||||
<string name="favorites_empty">No favorites added.</string>
|
||||
<string name="favorites_location_format">Lat: %1$.6f, Lon: %2$.6f</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="back">Back</string>
|
||||
|
||||
<!-- Error Screen -->
|
||||
<string name="error_module_not_active_title">Module Not Active</string>
|
||||
<string name="error_module_not_active_message">XposedFakeLocation module is not active in your Xposed manager app. Please enable it and restart the app to continue.</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
<!-- Permissions Screen -->
|
||||
<string name="error_no_activity">Error: Unable to access activity.</string>
|
||||
<string name="permission_required">Permissions are required to use this app</string>
|
||||
<string name="grant_permissions">Grant Permissions</string>
|
||||
<string name="permission_permanently_denied">You have permanently denied location permissions. Please enable them from settings and restart the app.</string>
|
||||
<string name="open_settings">Open Settings</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.XposedFakeLocation" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.noobexon.xposedfakelocation
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||