feat: XposedFakeLocation 增强版

🎯 位置Hook模块:
- LocationApiHooks: 核心位置API拦截
- GooglePlayServicesHooks: FusedLocationProviderClient
- WifiHooks: WiFi扫描定位拦截
- TelephonyHooks: 基站定位拦截
- SensorHooks: 传感器监控
- GnssHooks: GPS卫星数据伪造
- SystemServicesHooks: 系统服务Hook

🔧 功能特性:
- 完整位置伪造支持
- Mock位置检测隐藏
- 多种定位方式全覆盖
- 中文界面支持

📦 包含预编译APK
This commit is contained in:
237899745
2026-01-27 01:04:46 +08:00
commit 4288a1b483
85 changed files with 6864 additions and 0 deletions

53
.gitattributes vendored Normal file
View File

@@ -0,0 +1,53 @@
# Set default behavior to automatically normalize line endings
* text=auto
# Java sources
*.java text
*.kt text
*.kts text
*.groovy text
# Android files
*.xml text
*.gradle text
*.properties text
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.c text
*.h text
*.cpp text
*.hpp text
# Declare files that will always have CRLF line endings on checkout.
*.bat text eol=crlf
# Declare files that will always have LF line endings on checkout.
*.sh text eol=lf
gradlew text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.jar binary
*.war binary
*.ear binary
*.zip binary
*.tar binary
*.gzip binary
*.ttf binary
*.otf binary
# Documents
*.md text
*.txt text
*.doc diff=astextplain
*.DOC diff=astextplain
*.docx diff=astextplain
*.DOCX diff=astextplain
*.pdf diff=astextplain
*.PDF diff=astextplain

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# === Android build artifacts ===
/build/
/app/build/
/*.apk
/*.aab
*.ap_
*.dex
# === Gradle ===
.gradle/
/local.properties
/keystore.properties
# === Android Studio ===
.idea/
/*.iml
*.ipr
*.iws
# === OS-generated cruft ===
.DS_Store
Thumbs.db
ehthumbs.db
# === Logs & temporary files ===
*.log
*.tmp
*.bak
*~
# === Captures / profiling / other ===
captures/
*.hprof
# === Google services (API keys etc) ===
google-services.json
app/google-services.json
# === Kotlin build artifacts ===
.kotlin/
.cursor/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 noobexon1
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

208
README.md Normal file
View File

@@ -0,0 +1,208 @@
# **XposedFakeLocation**
![GitHub License](https://img.shields.io/github/license/noobexon1/XposedFakeLocation?color=blue)
![GitHub Release Date](https://img.shields.io/github/release-date/noobexon1/XposedFakeLocation?color=violet)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/noobexon1/XposedFakeLocation/total)
![GitHub repo size](https://img.shields.io/github/repo-size/noobexon1/XposedFakeLocation)
![GitHub Repo stars](https://img.shields.io/github/stars/noobexon1/XposedFakeLocation)
![GitHub Release](https://img.shields.io/github/v/release/noobexon1/XposedFakeLocation?color=red)
[![Platform](https://img.shields.io/badge/platform-Android-green.svg)]()
**XposedFakeLocation** is an Android application and Xposed module that allows you to spoof your device's location globally or for specific apps without using "mock location" from the developer options. Customize your location with precision, including sensor data, and add randomization within a specified radius for enhanced privacy.
<div align="center">
<img src="images/xposedfakelocation.webp" alt="App Logo" width="256" />
</div>
---
## **Table of Contents**
- [Features](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Usage](#usage)
- [Development](#development)
- [License](#license)
- [Disclaimer](#disclaimer)
- [Acknowledgements](#acknowledgements)
---
## **Features**
- **Global Location Spoofing**: Override your device's location data system-wide (Unstable for now).
- **Per-App Location Control**: Apply location spoofing to specific applications.
- **Custom Coordinates**: Set precise latitude and longitude.
- **Altitude and Accuracy Settings**: Customize altitude, accuracy and other custom sensor values.
- **Randomization**: Add random offsets within a specified radius for enhanced privacy.
- **User-Friendly Interface**: Modern Material Design 3 UI built with Jetpack Compose.
- **Intuitive Navigation**: Easy access to maps, favorite locations, and settings.
- **Community Integration**: Direct links to Telegram, Discord, and GitHub communities.
---
## **Prerequisites**
- **Rooted Android Device**: The app requires root access to function properly. That being said, you can try working with Xposed virtual environement on non rooted device.
- **LSPosed**: Install the Xposed Framework compatible with your Android version.
- [LSPosed](https://github.com/LSPosed/LSPosed)
---
## **Installation**
You can always install the latest stable version from the releases page. If you want to build by yourself:
1. **Clone or Download the Repository**
```bash
git clone https://github.com/noobexon1/XposedFakeLocation.git
```
2. **Build the Application**
- Open the project in **Android Studio**.
- Build the APK using **Build > Build Bundle(s) / APK(s) > Build APK(s)**.
- Alternatively, use Gradle:
```bash
./gradlew assembleDebug
```
3. **Install the APK on Your Device**
- Transfer the APK to your device.
- Install the APK using a file manager or via ADB:
```bash
adb install app/build/outputs/apk/debug/app-debug.apk
```
4. **Activate the Xposed Module**
- Open **Xposed Installer** or **LSPosed Manager**.
- Enable the **XposedFakeLocation** module.
- If you decide to apply the module system wide, Reboot your device to apply changes.
---
## **Usage**
1. **Launch the App**
- Open **XposedFakeLocation** from your app drawer.
2. **Navigate the Interface**
- Use the navigation drawer to access different sections:
- **Map**: Primary interface for location selection
- **Favorites**: Saved locations for quick access
- **Settings**: Configure application behavior
- **About**: View application information
3. **Select a Location**
- Use the integrated map to select your desired location.
- Tap on the map to set the fake location.
4. **Configure Settings**
- Access the **Settings** screen to customize:
- **Accuracy**: Enable and set a custom horizontal and/or vertical accuracy value.
- **Altitude**: Enable and set a custom altitude.
- **Other Sensor Data**: New spoofable sensors data added in new versions.
- **Randomization Radius**: Set the radius in meters for location randomization.
5. **Start Spoofing**
- Toggle the **Start** button to begin location spoofing.
- The app will now override your device's location data based on the target(s) specified in the Xposed manager app.
6. **Stop Spoofing**
- Toggle the **Stop** button to cease location spoofing.
---
### **Favorites**
- Save frequently used locations for quick access.
- If a marker is already present on the map, the coordinates for the new favorite location will automatically be copied to the fields from it.
- Manage your favorites by adding or removing locations.
- Access your favorites through the navigation drawer for easy selection.
---
## **Development**
### **Built With**
- **Kotlin**: Programming language for Android development.
- **Jetpack Compose**: Modern toolkit for building native Android UI with Material Design 3.
- **Material 3 Design**: Latest design system from Google for an enhanced user experience.
- **Xposed API**: Framework for runtime modification of system and app behavior.
- **OSMDroid**: Open-source map rendering engine for Android.
### **User Interface**
- **Navigation Drawer**: Easy access to all major app features
- **Material Design Components**: Consistent design language throughout the app
- **Adaptive Layouts**: Compatible with various screen sizes and orientations
### **Prerequisites**
- **Android Studio Flamingo** or newer.
- **Android SDK** with API level 31 or above.
- **Kotlin** version 1.8.0 or above.
### **Building from Source**
1. **Clone the Repository**
```bash
git clone https://github.com/noobexon1/XposedFakeLocation.git
```
2. **Open in Android Studio**
- Navigate to the project directory.
- Open the project with **Android Studio**.
3. **Sync Gradle**
- Allow Gradle to download all dependencies.
4. **Build and Run**
- Connect your rooted device or start an emulator with root capabilities.
- Run the app from **Android Studio**.
---
## **License**
Distributed under the **MIT License**. See [LICENSE](LICENSE) for more information.
---
## **Disclaimer**
This application is intended for **development and testing purposes only**. Misuse of location spoofing can violate terms of service of other applications and services. Use at your own risk. There is no responsibility whatsoever for any damage to the device.
---
## **Acknowledgements**
- [GpsSetter](https://github.com/Android1500/GpsSetter) - Highly inspired by this amazing project!
- [Xposed Framework](https://repo.xposed.info/) - Java hooks
- [LSPosed](https://github.com/LSPosed/LSPosed) - The go-to Xposed framework manager app.
- [OSMDroid](https://github.com/osmdroid/osmdroid) - Open-source offline map interface.
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Modern UI toolkit for Android.
- [Material Design 3](https://m3.material.io/) - Latest design system from Google.
- [Line Awesome Icons](https://icons8.com/line-awesome) - Beautiful icon set used in the app.

8
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/build
/release
*.aab
*.apk
/debug
/outputs
sentry.properties
keystore.properties

76
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,76 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.noobexon.xposedfakelocation"
compileSdk = 34
defaultConfig {
applicationId = "com.noobexon.xposedfakelocation"
minSdk = 30
targetSdk = 34
versionCode = 1
versionName = "0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.osmdroid.android)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.line.awesome.android)
implementation(libs.font.awesome)
implementation(libs.androidx.navigation.compose)
implementation(libs.gson)
implementation(libs.hiddenapibypass)
// DataStore
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore.preferences.core)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
compileOnly("de.robv.android.xposed:api:82:sources")
compileOnly("de.robv.android.xposed:api:82")
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -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)
}
}

View 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>

View File

@@ -0,0 +1 @@
com.noobexon.xposedfakelocation.xposed.MainHook

View File

@@ -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

View File

@@ -0,0 +1,7 @@
package com.noobexon.xposedfakelocation.data.model
data class FavoriteLocation(
val name: String,
val latitude: Double,
val longitude: Double
)

View File

@@ -0,0 +1,6 @@
package com.noobexon.xposedfakelocation.data.model
data class LastClickedLocation(
val latitude: Double,
val longitude: Double
)

View File

@@ -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)
}
}

View File

@@ -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() }
)
}
}
}
}
}

View File

@@ -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)
}
)
}

View File

@@ -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))
}
}
)
}

View File

@@ -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()
}
}
}

View File

@@ -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))
}
}
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
)
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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))
}
}
)
}

View File

@@ -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))
}
}
)
}

View File

@@ -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()
)
}

View File

@@ -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)
}
}
}

View File

@@ -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")
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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
)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -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()
}
}
}
)
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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}")
}
}
}

View File

@@ -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}")
}
}
}

View File

@@ -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}")
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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}")
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}
}
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View 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>

View File

@@ -0,0 +1,5 @@
<resources>
<string-array name="xposedscope" >
<item>com.noobexon.xposedfakelocation</item>
</string-array>
</resources>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.XposedFakeLocation" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View 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>

View 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>

View File

@@ -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)
}
}

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

51
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,51 @@
[versions]
agp = "8.7.0"
kotlin = "2.0.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
osmdroidAndroid = "6.1.20"
materialIconsExtended = "1.7.3"
lifecycleViewmodelCompose = "2.8.6"
lineAwesomeAndroid = "1.1.1"
fontAwesome = "1.0.0"
navigationCompose = "2.8.2"
gson = "2.11.0"
hiddenapibypass = "4.3"
datastore = "1.0.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroidAndroid" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
line-awesome-android = { group = "br.com.devsrsouza.compose.icons", name = "line-awesome-android", version.ref = "lineAwesomeAndroid" }
font-awesome = { group = "br.com.devsrsouza.compose.icons.android", name = "font-awesome", version.ref = "fontAwesome" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "datastore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Sat Oct 05 18:00:11 IDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

26
settings.gradle.kts Normal file
View File

@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://api.xposed.info/")
}
}
}
rootProject.name = "XposedFakeLocation"
include(":app")