├── app
├── src
│ ├── main
│ │ ├── assets
│ │ │ └── xposed_init
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── arrays.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-anydpi
│ │ │ │ └── ic_launcher.xml
│ │ │ └── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── noobexon
│ │ │ │ └── xposedfakelocation
│ │ │ │ ├── data
│ │ │ │ ├── model
│ │ │ │ │ ├── LastClickedLocation.kt
│ │ │ │ │ └── FavoriteLocation.kt
│ │ │ │ ├── Constants.kt
│ │ │ │ └── repository
│ │ │ │ │ └── PrefrencesRepository.kt
│ │ │ │ ├── manager
│ │ │ │ ├── ui
│ │ │ │ │ ├── navigation
│ │ │ │ │ │ ├── Screen.kt
│ │ │ │ │ │ └── NavGraph.kt
│ │ │ │ │ ├── theme
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Type.kt
│ │ │ │ │ │ └── Theme.kt
│ │ │ │ │ ├── permissions
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── PermissionsRequestContainer.kt
│ │ │ │ │ │ │ └── PermanentlyDeniedContainer.kt
│ │ │ │ │ │ ├── PermissionsViewModel.kt
│ │ │ │ │ │ └── PermissionsScreen.kt
│ │ │ │ │ ├── components
│ │ │ │ │ │ └── ErrorScreen.kt
│ │ │ │ │ ├── favorites
│ │ │ │ │ │ ├── FavoritesViewModel.kt
│ │ │ │ │ │ └── FavoritesScreen.kt
│ │ │ │ │ ├── map
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── GoToPointDialog.kt
│ │ │ │ │ │ │ ├── AddToFavoritesDialog.kt
│ │ │ │ │ │ │ └── MapViewContainer.kt
│ │ │ │ │ │ ├── MapScreen.kt
│ │ │ │ │ │ └── MapViewModel.kt
│ │ │ │ │ ├── about
│ │ │ │ │ │ └── AboutScreen.kt
│ │ │ │ │ ├── drawer
│ │ │ │ │ │ └── DrawerContent.kt
│ │ │ │ │ └── settings
│ │ │ │ │ │ ├── SettingsViewModel.kt
│ │ │ │ │ │ └── SettingsScreen.kt
│ │ │ │ └── MainActivity.kt
│ │ │ │ └── xposed
│ │ │ │ ├── MainHook.kt
│ │ │ │ ├── hooks
│ │ │ │ ├── SystemServicesHooks.kt
│ │ │ │ └── LocationApiHooks.kt
│ │ │ │ └── utils
│ │ │ │ ├── PreferencesUtil.kt
│ │ │ │ └── LocationUtil.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── noobexon
│ │ │ └── xposedfakelocation
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── noobexon
│ │ └── xposedfakelocation
│ │ └── ExampleInstrumentedTest.kt
├── .gitignore
├── proguard-rules.pro
└── build.gradle.kts
├── images
└── xposedfakelocation.webp
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .gitignore
├── settings.gradle.kts
├── LICENSE
├── .gitattributes
├── gradle.properties
├── gradlew.bat
├── gradlew
└── README.md
/app/src/main/assets/xposed_init:
--------------------------------------------------------------------------------
1 | com.noobexon.xposedfakelocation.xposed.MainHook
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /release
3 | *.aab
4 | *.apk
5 | /debug
6 | /outputs
7 | sentry.properties
8 | keystore.properties
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | XposedFakeLocation
3 |
--------------------------------------------------------------------------------
/images/xposedfakelocation.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/images/xposedfakelocation.webp
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noobexon1/XposedFakeLocation/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | - com.noobexon.xposedfakelocation
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/data/model/LastClickedLocation.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.data.model
2 |
3 | data class LastClickedLocation(
4 | val latitude: Double,
5 | val longitude: Double
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/data/model/FavoriteLocation.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.data.model
2 |
3 | data class FavoriteLocation(
4 | val name: String,
5 | val latitude: Double,
6 | val longitude: Double
7 | )
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Oct 05 18:00:11 IDT 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/Screen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.navigation
2 |
3 | sealed class Screen(val route: String) {
4 | object About : Screen("about")
5 | object Favorites : Screen("favorites")
6 | object Map : Screen("map")
7 | object Permissions : Screen("permissions")
8 | object Settings : Screen("settings")
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/test/java/com/noobexon/xposedfakelocation/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # === Android build artifacts ===
2 | /build/
3 | /app/build/
4 | /*.apk
5 | /*.aab
6 | *.ap_
7 | *.dex
8 |
9 | # === Gradle ===
10 | .gradle/
11 | /local.properties
12 | /keystore.properties
13 |
14 | # === Android Studio ===
15 | .idea/
16 | /*.iml
17 | *.ipr
18 | *.iws
19 |
20 | # === OS-generated cruft ===
21 | .DS_Store
22 | Thumbs.db
23 | ehthumbs.db
24 |
25 | # === Logs & temporary files ===
26 | *.log
27 | *.tmp
28 | *.bak
29 | *~
30 |
31 | # === Captures / profiling / other ===
32 | captures/
33 | *.hprof
34 |
35 | # === Google services (API keys etc) ===
36 | google-services.json
37 | app/google-services.json
38 |
39 | # === Kotlin build artifacts ===
40 | .kotlin/
41 | .cursor/
42 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | maven {
20 | url = uri("https://api.xposed.info/")
21 | }
22 | }
23 | }
24 |
25 | rootProject.name = "XposedFakeLocation"
26 | include(":app")
27 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/noobexon/xposedfakelocation/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.noobexon.xposedfakelocation", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/permissions/components/PermissionsRequestContainer.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.permissions.components
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material3.*
5 | import androidx.compose.runtime.*
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.text.style.TextAlign
8 | import androidx.compose.ui.unit.dp
9 |
10 | @Composable
11 | fun PermissionRequestScreen(onGrantPermission: () -> Unit) {
12 | Text(
13 | text = "Permissions are required to use this app",
14 | style = MaterialTheme.typography.bodyLarge,
15 | textAlign = TextAlign.Center
16 | )
17 | Spacer(modifier = Modifier.height(16.dp))
18 | Button(onClick = onGrantPermission) {
19 | Text("Grant Permissions")
20 | }
21 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 noobexon1
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Set default behavior to automatically normalize line endings
2 | * text=auto
3 |
4 | # Java sources
5 | *.java text
6 | *.kt text
7 | *.kts text
8 | *.groovy text
9 |
10 | # Android files
11 | *.xml text
12 | *.gradle text
13 | *.properties text
14 |
15 | # Explicitly declare text files you want to always be normalized and converted
16 | # to native line endings on checkout.
17 | *.c text
18 | *.h text
19 | *.cpp text
20 | *.hpp text
21 |
22 | # Declare files that will always have CRLF line endings on checkout.
23 | *.bat text eol=crlf
24 |
25 | # Declare files that will always have LF line endings on checkout.
26 | *.sh text eol=lf
27 | gradlew text eol=lf
28 |
29 | # Denote all files that are truly binary and should not be modified.
30 | *.png binary
31 | *.jpg binary
32 | *.jpeg binary
33 | *.gif binary
34 | *.ico binary
35 | *.webp binary
36 | *.jar binary
37 | *.war binary
38 | *.ear binary
39 | *.zip binary
40 | *.tar binary
41 | *.gzip binary
42 | *.ttf binary
43 | *.otf binary
44 |
45 | # Documents
46 | *.md text
47 | *.txt text
48 | *.doc diff=astextplain
49 | *.DOC diff=astextplain
50 | *.docx diff=astextplain
51 | *.DOCX diff=astextplain
52 | *.pdf diff=astextplain
53 | *.PDF diff=astextplain
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/permissions/components/PermanentlyDeniedContainer.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.permissions.components
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.provider.Settings
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.*
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.text.style.TextAlign
12 | import androidx.compose.ui.unit.dp
13 |
14 | @Composable
15 | fun PermanentlyDeniedScreen(context: Context) {
16 | Text(
17 | text = "You have permanently denied location permissions. Please enable them from settings and restart the app.",
18 | style = MaterialTheme.typography.bodyLarge,
19 | textAlign = TextAlign.Center
20 | )
21 | Spacer(modifier = Modifier.height(16.dp))
22 | Button(onClick = {
23 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
24 | data = Uri.fromParts("package", context.packageName, null)
25 | }
26 | context.startActivity(intent)
27 | }) {
28 | Text("Open Settings")
29 | }
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/components/ErrorScreen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.components
2 |
3 | import androidx.compose.material3.AlertDialog
4 | import androidx.compose.material3.Button
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 |
8 | /**
9 | * Displays an error dialog when the Xposed module is not active.
10 | *
11 | * @param onDismiss Callback to be invoked when the user dismisses the dialog.
12 | * @param onConfirm Callback to be invoked when the user confirms the dialog.
13 | */
14 | @Composable
15 | fun ErrorScreen(
16 | onDismiss: () -> Unit,
17 | onConfirm: () -> Unit
18 | ) {
19 | AlertDialog(
20 | onDismissRequest = onDismiss,
21 | title = { Text("Module Not Active") },
22 | text = {
23 | Text("XposedFakeLocation module is not active in your Xposed manager app. Please enable it and restart the app to continue.")
24 | },
25 | confirmButton = {
26 | Button(onClick = onConfirm) {
27 | Text("OK")
28 | }
29 | },
30 | dismissButton = {
31 | Button(onClick = onDismiss) {
32 | Text("Cancel")
33 | }
34 | }
35 | )
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/favorites/FavoritesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.favorites
2 |
3 | import android.app.Application
4 | import androidx.lifecycle.AndroidViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
7 | import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.collectLatest
11 | import kotlinx.coroutines.launch
12 |
13 | class FavoritesViewModel(application: Application) : AndroidViewModel(application) {
14 |
15 | private val preferencesRepository = PreferencesRepository(application)
16 |
17 | private val _favorites = MutableStateFlow>(emptyList())
18 | val favorites: StateFlow> get() = _favorites
19 |
20 | init {
21 | viewModelScope.launch {
22 | preferencesRepository.getFavoritesFlow().collectLatest { favorites ->
23 | _favorites.value = favorites
24 | }
25 | }
26 | }
27 |
28 | fun removeFavorite(favorite: FavoriteLocation) {
29 | viewModelScope.launch {
30 | preferencesRepository.removeFavorite(favorite)
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/permissions/PermissionsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.permissions
2 |
3 | import android.app.Activity
4 | import android.Manifest
5 | import android.content.Context
6 | import android.content.pm.PackageManager
7 | import androidx.lifecycle.ViewModel
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.State
10 | import androidx.core.content.ContextCompat
11 |
12 | class PermissionsViewModel : ViewModel() {
13 |
14 | private val _hasPermissions = mutableStateOf(false)
15 | val hasPermissions: State get() = _hasPermissions
16 |
17 | private val _permanentlyDenied = mutableStateOf(false)
18 | val permanentlyDenied: State get() = _permanentlyDenied
19 |
20 | private val _permissionsChecked = mutableStateOf(false)
21 | val permissionsChecked: State get() = _permissionsChecked
22 |
23 | fun checkPermissions(context: Context) {
24 | val fineLocationGranted = ContextCompat.checkSelfPermission(
25 | context,
26 | Manifest.permission.ACCESS_FINE_LOCATION
27 | ) == PackageManager.PERMISSION_GRANTED
28 |
29 | _hasPermissions.value = fineLocationGranted
30 | _permissionsChecked.value = true
31 | }
32 |
33 | fun updatePermissionsStatus(granted: Boolean) {
34 | _hasPermissions.value = granted
35 | }
36 |
37 | fun checkIfPermanentlyDenied(activity: Activity) {
38 | val shouldShowRationale = activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
39 | _permanentlyDenied.value = !shouldShowRationale
40 | }
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/navigation/NavGraph.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.navigation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import androidx.navigation.NavHostController
6 | import androidx.navigation.compose.*
7 | import com.noobexon.xposedfakelocation.manager.ui.about.AboutScreen
8 | import com.noobexon.xposedfakelocation.manager.ui.favorites.FavoritesScreen
9 | import com.noobexon.xposedfakelocation.manager.ui.map.MapScreen
10 | import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
11 | import com.noobexon.xposedfakelocation.manager.ui.permissions.PermissionsScreen
12 | import com.noobexon.xposedfakelocation.manager.ui.settings.SettingsScreen
13 |
14 | @Composable
15 | fun AppNavGraph(
16 | navController: NavHostController,
17 | ) {
18 | val mapViewModel: MapViewModel = viewModel()
19 |
20 | NavHost(
21 | navController = navController,
22 | startDestination = Screen.Permissions.route,
23 | ) {
24 | composable(route = Screen.About.route) {
25 | AboutScreen(navController = navController)
26 | }
27 | composable(route = Screen.Favorites.route) {
28 | FavoritesScreen(navController = navController, mapViewModel)
29 | }
30 | composable(route = Screen.Map.route) {
31 | MapScreen(navController = navController, mapViewModel)
32 | }
33 | composable(route = Screen.Permissions.route) {
34 | PermissionsScreen(navController = navController)
35 | }
36 | composable(route = Screen.Settings.route) {
37 | SettingsScreen(navController = navController)
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.theme
2 |
3 | import android.os.Build
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.material3.dynamicDarkColorScheme
8 | import androidx.compose.material3.dynamicLightColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | private val DarkColorScheme = darkColorScheme(
14 | primary = Purple80,
15 | secondary = PurpleGrey80,
16 | tertiary = Pink80
17 | )
18 |
19 | private val LightColorScheme = lightColorScheme(
20 | primary = Purple40,
21 | secondary = PurpleGrey40,
22 | tertiary = Pink40
23 |
24 | /* Other default colors to override
25 | background = Color(0xFFFFFBFE),
26 | surface = Color(0xFFFFFBFE),
27 | onPrimary = Color.White,
28 | onSecondary = Color.White,
29 | onTertiary = Color.White,
30 | onBackground = Color(0xFF1C1B1F),
31 | onSurface = Color(0xFF1C1B1F),
32 | */
33 | )
34 |
35 | @Composable
36 | fun XposedFakeLocationTheme(
37 | darkTheme: Boolean = isSystemInDarkTheme(),
38 | // Dynamic color is available on Android 12+
39 | dynamicColor: Boolean = true,
40 | content: @Composable () -> Unit
41 | ) {
42 | val colorScheme = when {
43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
44 | val context = LocalContext.current
45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
46 | }
47 |
48 | darkTheme -> DarkColorScheme
49 | else -> LightColorScheme
50 | }
51 |
52 | MaterialTheme(
53 | colorScheme = colorScheme,
54 | typography = Typography,
55 | content = content
56 | )
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager
2 |
3 | import android.annotation.SuppressLint
4 | import android.os.Bundle
5 | import android.util.Log
6 | import androidx.activity.ComponentActivity
7 | import androidx.activity.compose.setContent
8 | import androidx.activity.enableEdgeToEdge
9 | import androidx.compose.runtime.*
10 | import androidx.navigation.compose.rememberNavController
11 | import com.noobexon.xposedfakelocation.manager.ui.components.ErrorScreen
12 | import com.noobexon.xposedfakelocation.manager.ui.navigation.AppNavGraph
13 | import com.noobexon.xposedfakelocation.manager.ui.theme.XposedFakeLocationTheme
14 | import org.osmdroid.config.Configuration
15 |
16 | class MainActivity : ComponentActivity() {
17 | companion object {
18 | private const val TAG = "MainActivity"
19 | }
20 |
21 | @SuppressLint("WorldReadableFiles")
22 | override fun onCreate(savedInstanceState: Bundle?) {
23 | super.onCreate(savedInstanceState)
24 |
25 | var isXposedModuleEnabled = true
26 |
27 | // If the module is not enabled then the app won't have permission to use MODE_WORLD_READABLE.
28 | try {
29 | Configuration.getInstance().load(this, getPreferences(MODE_WORLD_READABLE))
30 | } catch (e: SecurityException) {
31 | isXposedModuleEnabled = false
32 | Log.e(TAG, "SecurityException: ${e.message}", e)
33 | } catch (e: Exception) {
34 | isXposedModuleEnabled = false
35 | Log.e(TAG, "Exception: ${e.message}", e)
36 | }
37 |
38 | enableEdgeToEdge()
39 |
40 | setContent {
41 | XposedFakeLocationTheme {
42 | if (isXposedModuleEnabled) {
43 | val navController = rememberNavController()
44 | AppNavGraph(navController = navController)
45 | } else {
46 | ErrorScreen(
47 | onDismiss = { finish() },
48 | onConfirm = { finish() }
49 | )
50 | }
51 | }
52 | }
53 | }
54 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
34 |
35 |
38 |
39 |
42 |
43 |
46 |
47 |
48 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/data/Constants.kt:
--------------------------------------------------------------------------------
1 | //Constants.kt
2 | package com.noobexon.xposedfakelocation.data
3 |
4 | // APP
5 | const val MANAGER_APP_PACKAGE_NAME = "com.noobexon.xposedfakelocation"
6 | const val SHARED_PREFS_FILE = "xposed_shared_prefs"
7 |
8 | // KEYS
9 | const val KEY_IS_PLAYING = "is_playing"
10 |
11 | const val KEY_LAST_CLICKED_LOCATION = "last_clicked_location"
12 |
13 | const val KEY_USE_ACCURACY = "use_accuracy"
14 | const val KEY_ACCURACY = "accuracy"
15 |
16 | const val KEY_USE_ALTITUDE = "use_altitude"
17 | const val KEY_ALTITUDE = "altitude"
18 |
19 | const val KEY_USE_RANDOMIZE = "use_randomize"
20 | const val KEY_RANDOMIZE_RADIUS = "randomize_radius"
21 |
22 | const val KEY_USE_VERTICAL_ACCURACY = "use_vertical_accuracy"
23 | const val KEY_VERTICAL_ACCURACY = "vertical_accuracy"
24 |
25 | const val KEY_USE_MEAN_SEA_LEVEL = "use_mean_sea_level"
26 | const val KEY_MEAN_SEA_LEVEL = "mean_sea_level"
27 |
28 | const val KEY_USE_MEAN_SEA_LEVEL_ACCURACY = "use_mean_sea_level_accuracy"
29 | const val KEY_MEAN_SEA_LEVEL_ACCURACY = "mean_sea_level_accuracy"
30 |
31 | const val KEY_USE_SPEED = "use_speed"
32 | const val KEY_SPEED = "speed"
33 |
34 | const val KEY_USE_SPEED_ACCURACY = "use_speed_accuracy"
35 | const val KEY_SPEED_ACCURACY = "speed_accuracy"
36 |
37 | const val KEY_FAVORITES = "favorites"
38 |
39 | // DEFAULT VALUES
40 | const val DEFAULT_USE_ACCURACY = false
41 | const val DEFAULT_ACCURACY = 0.0
42 |
43 | const val DEFAULT_USE_ALTITUDE = false
44 | const val DEFAULT_ALTITUDE = 0.0
45 |
46 | const val DEFAULT_USE_RANDOMIZE = false
47 | const val DEFAULT_RANDOMIZE_RADIUS = 0.0
48 |
49 | const val DEFAULT_USE_VERTICAL_ACCURACY = false
50 | const val DEFAULT_VERTICAL_ACCURACY = 0.0f
51 |
52 | const val DEFAULT_USE_MEAN_SEA_LEVEL = false
53 | const val DEFAULT_MEAN_SEA_LEVEL = 0.0
54 |
55 | const val DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY = false
56 | const val DEFAULT_MEAN_SEA_LEVEL_ACCURACY = 0.0f
57 |
58 | const val DEFAULT_USE_SPEED = false
59 | const val DEFAULT_SPEED = 0.0f
60 |
61 | const val DEFAULT_USE_SPEED_ACCURACY = false
62 | const val DEFAULT_SPEED_ACCURACY = 0.0f
63 |
64 | // MATH & PHYS
65 | const val PI = 3.14159265359
66 | const val RADIUS_EARTH = 6378137.0 // Approximately Earth's radius in meters
67 |
68 | // MAP SETTINGS
69 | const val DEFAULT_MAP_ZOOM = 18.0
70 | const val WORLD_MAP_ZOOM = 2.0
71 | const val LOCATION_DETECTION_MAX_ATTEMPTS = 80
72 | const val LOCATION_DETECTION_DELAY_MS = 100L
73 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/xposed/MainHook.kt:
--------------------------------------------------------------------------------
1 | // MainHook.kt
2 | package com.noobexon.xposedfakelocation.xposed
3 |
4 | import android.app.Application
5 | import android.content.Context
6 | import android.widget.Toast
7 | import com.noobexon.xposedfakelocation.data.MANAGER_APP_PACKAGE_NAME
8 | import com.noobexon.xposedfakelocation.xposed.hooks.LocationApiHooks
9 | import com.noobexon.xposedfakelocation.xposed.hooks.SystemServicesHooks
10 | import com.noobexon.xposedfakelocation.xposed.utils.PreferencesUtil
11 | import de.robv.android.xposed.IXposedHookLoadPackage
12 | import de.robv.android.xposed.XC_MethodHook
13 | import de.robv.android.xposed.XposedBridge
14 | import de.robv.android.xposed.XposedHelpers
15 | import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
16 |
17 | class MainHook : IXposedHookLoadPackage {
18 | val tag = "[MainHook]"
19 |
20 | lateinit var context: Context
21 |
22 | private var locationApiHooks: LocationApiHooks? = null
23 | private var systemServicesHooks: SystemServicesHooks? = null
24 |
25 | override fun handleLoadPackage(lpparam: LoadPackageParam) {
26 | // Avoid hooking own app to prevent recursion
27 | if (lpparam.packageName == MANAGER_APP_PACKAGE_NAME) return
28 |
29 | // If not playing or null, do not proceed with hooking
30 | if (PreferencesUtil.getIsPlaying() != true) return
31 |
32 | // Hook system services if user asked for system wide hooks
33 | if (lpparam.packageName == "android") {
34 | systemServicesHooks = SystemServicesHooks(lpparam).also { it.initHooks() }
35 | }
36 |
37 | initHookingLogic(lpparam)
38 | }
39 |
40 | private fun initHookingLogic(lpparam: LoadPackageParam) {
41 | XposedHelpers.findAndHookMethod(
42 | "android.app.Instrumentation",
43 | lpparam.classLoader,
44 | "callApplicationOnCreate",
45 | Application::class.java,
46 | object : XC_MethodHook() {
47 | override fun afterHookedMethod(param: MethodHookParam) {
48 | context = (param.args[0] as Application).applicationContext.also {
49 | XposedBridge.log("$tag Target App's context has been acquired successfully.")
50 | Toast.makeText(it, "Fake Location Is Active!", Toast.LENGTH_SHORT).show()
51 | }
52 | locationApiHooks = LocationApiHooks(lpparam).also { it.initHooks() }
53 | }
54 | }
55 | )
56 | }
57 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | }
6 |
7 | android {
8 | namespace = "com.noobexon.xposedfakelocation"
9 | compileSdk = 34
10 |
11 | defaultConfig {
12 | applicationId = "com.noobexon.xposedfakelocation"
13 | minSdk = 30
14 | targetSdk = 34
15 | versionCode = 1
16 | versionName = "0.0.3"
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_1_8
32 | targetCompatibility = JavaVersion.VERSION_1_8
33 | }
34 | kotlinOptions {
35 | jvmTarget = "1.8"
36 | }
37 | buildFeatures {
38 | compose = true
39 | buildConfig = true
40 | }
41 | }
42 |
43 | dependencies {
44 |
45 | implementation(libs.androidx.core.ktx)
46 | implementation(libs.androidx.lifecycle.runtime.ktx)
47 | implementation(libs.androidx.activity.compose)
48 | implementation(platform(libs.androidx.compose.bom))
49 | implementation(libs.androidx.ui)
50 | implementation(libs.androidx.ui.graphics)
51 | implementation(libs.androidx.ui.tooling.preview)
52 | implementation(libs.androidx.material3)
53 | implementation(libs.osmdroid.android)
54 | implementation(libs.androidx.material.icons.extended)
55 | implementation(libs.androidx.lifecycle.viewmodel.compose)
56 | implementation(libs.line.awesome.android)
57 | implementation(libs.font.awesome)
58 | implementation(libs.androidx.navigation.compose)
59 | implementation(libs.gson)
60 | implementation(libs.hiddenapibypass)
61 |
62 | // DataStore
63 | implementation(libs.androidx.datastore.preferences)
64 | implementation(libs.androidx.datastore.preferences.core)
65 |
66 | testImplementation(libs.junit)
67 | androidTestImplementation(libs.androidx.junit)
68 | androidTestImplementation(libs.androidx.espresso.core)
69 | androidTestImplementation(platform(libs.androidx.compose.bom))
70 | androidTestImplementation(libs.androidx.ui.test.junit4)
71 | debugImplementation(libs.androidx.ui.tooling)
72 | debugImplementation(libs.androidx.ui.test.manifest)
73 |
74 | compileOnly("de.robv.android.xposed:api:82:sources")
75 | compileOnly("de.robv.android.xposed:api:82")
76 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/permissions/PermissionsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.permissions
2 |
3 | import android.Manifest
4 | import android.app.Activity
5 | import androidx.activity.compose.rememberLauncherForActivityResult
6 | import androidx.activity.result.contract.ActivityResultContracts
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.material3.CircularProgressIndicator
9 | import androidx.compose.material3.Text
10 | import androidx.compose.runtime.*
11 | import androidx.compose.ui.Alignment
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.lifecycle.viewmodel.compose.viewModel
15 | import androidx.navigation.NavController
16 | import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
17 | import com.noobexon.xposedfakelocation.manager.ui.permissions.components.PermanentlyDeniedScreen
18 | import com.noobexon.xposedfakelocation.manager.ui.permissions.components.PermissionRequestScreen
19 |
20 | @Composable
21 | fun PermissionsScreen(navController: NavController, permissionsViewModel: PermissionsViewModel = viewModel()) {
22 | val context = LocalContext.current
23 | val activity = context as? Activity
24 |
25 | if (activity == null) {
26 | Text("Error: Unable to access activity.")
27 | return
28 | }
29 |
30 | val hasPermissions by permissionsViewModel.hasPermissions
31 | val permanentlyDenied by permissionsViewModel.permanentlyDenied
32 | val permissionsChecked by permissionsViewModel.permissionsChecked
33 |
34 | val permissionLauncher = rememberLauncherForActivityResult(
35 | contract = ActivityResultContracts.RequestPermission(),
36 | onResult = { granted ->
37 | permissionsViewModel.updatePermissionsStatus(granted)
38 | if (granted) {
39 | navController.navigate(Screen.Map.route) {
40 | popUpTo(Screen.Permissions.route) { inclusive = true }
41 | }
42 | } else {
43 | permissionsViewModel.checkIfPermanentlyDenied(activity)
44 | }
45 | }
46 | )
47 |
48 | LaunchedEffect(Unit) {
49 | permissionsViewModel.checkPermissions(context)
50 | if (hasPermissions) {
51 | navController.navigate(Screen.Map.route) {
52 | popUpTo(Screen.Permissions.route) { inclusive = true }
53 | }
54 | }
55 | }
56 |
57 | if (!permissionsChecked) {
58 | Box(
59 | modifier = Modifier.fillMaxSize(),
60 | contentAlignment = Alignment.Center
61 | ) {
62 | CircularProgressIndicator()
63 | }
64 | } else if (!hasPermissions) {
65 | Box(
66 | modifier = Modifier.fillMaxSize(),
67 | contentAlignment = Alignment.Center
68 | ) {
69 | Column(
70 | horizontalAlignment = Alignment.CenterHorizontally
71 | ) {
72 | if (permanentlyDenied) {
73 | PermanentlyDeniedScreen(context)
74 | } else {
75 | PermissionRequestScreen {
76 | permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
77 | }
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.0"
3 | kotlin = "2.0.0"
4 | coreKtx = "1.10.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.1.5"
7 | espressoCore = "3.5.1"
8 | lifecycleRuntimeKtx = "2.6.1"
9 | activityCompose = "1.8.0"
10 | composeBom = "2024.04.01"
11 | osmdroidAndroid = "6.1.20"
12 | materialIconsExtended = "1.7.3"
13 | lifecycleViewmodelCompose = "2.8.6"
14 | lineAwesomeAndroid = "1.1.1"
15 | fontAwesome = "1.0.0"
16 | navigationCompose = "2.8.2"
17 | gson = "2.11.0"
18 | hiddenapibypass = "4.3"
19 | datastore = "1.0.0"
20 |
21 | [libraries]
22 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
23 | junit = { group = "junit", name = "junit", version.ref = "junit" }
24 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
25 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
26 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
27 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
28 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
29 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
30 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
31 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
32 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
33 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
34 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
35 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
36 | osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroidAndroid" }
37 | androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
38 | androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
39 | line-awesome-android = { group = "br.com.devsrsouza.compose.icons", name = "line-awesome-android", version.ref = "lineAwesomeAndroid" }
40 | font-awesome = { group = "br.com.devsrsouza.compose.icons.android", name = "font-awesome", version.ref = "fontAwesome" }
41 | androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
42 | gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
43 | hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" }
44 | androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
45 | androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "datastore" }
46 |
47 | [plugins]
48 | android-application = { id = "com.android.application", version.ref = "agp" }
49 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
50 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/components/GoToPointDialog.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.map.components
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material3.*
5 | import androidx.compose.runtime.*
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
9 | import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
10 |
11 | @Composable
12 | fun GoToPointDialog(
13 | mapViewModel: MapViewModel,
14 | onDismissRequest: () -> Unit,
15 | onGoToPoint: (latitude: Double, longitude: Double) -> Unit
16 | ) {
17 | // Access the UI state through StateFlow
18 | val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
19 | val goToPointState = uiState.goToPointState
20 |
21 | val latitudeInput = goToPointState.first.value
22 | val longitudeInput = goToPointState.second.value
23 | val latitudeError = goToPointState.first.errorMessage
24 | val longitudeError = goToPointState.second.errorMessage
25 |
26 | AlertDialog(
27 | onDismissRequest = {
28 | mapViewModel.clearGoToPointInputs()
29 | onDismissRequest()
30 | },
31 | title = { Text("Go to Point") },
32 | text = {
33 | Column {
34 | OutlinedTextField(
35 | value = latitudeInput,
36 | onValueChange = { mapViewModel.updateGoToPointField("latitude", it) },
37 | label = { Text("Latitude") },
38 | isError = latitudeError != null,
39 | modifier = Modifier.fillMaxWidth()
40 | )
41 | if (latitudeError != null) {
42 | Text(
43 | text = latitudeError,
44 | color = MaterialTheme.colorScheme.error,
45 | style = MaterialTheme.typography.bodySmall,
46 | modifier = Modifier.padding(start = 16.dp)
47 | )
48 | }
49 | Spacer(modifier = Modifier.height(8.dp))
50 | OutlinedTextField(
51 | value = longitudeInput,
52 | onValueChange = { mapViewModel.updateGoToPointField("longitude", it) },
53 | label = { Text("Longitude") },
54 | isError = longitudeError != null,
55 | modifier = Modifier.fillMaxWidth()
56 | )
57 | if (longitudeError != null) {
58 | Text(
59 | text = longitudeError,
60 | color = MaterialTheme.colorScheme.error,
61 | style = MaterialTheme.typography.bodySmall,
62 | modifier = Modifier.padding(start = 16.dp)
63 | )
64 | }
65 | }
66 | },
67 | confirmButton = {
68 | TextButton(
69 | onClick = {
70 | mapViewModel.validateAndGo { latitude, longitude ->
71 | onGoToPoint(latitude, longitude)
72 | }
73 | }
74 | ) {
75 | Text("Go")
76 | }
77 | },
78 | dismissButton = {
79 | TextButton(
80 | onClick = {
81 | mapViewModel.clearGoToPointInputs()
82 | onDismissRequest()
83 | }
84 | ) {
85 | Text("Cancel")
86 | }
87 | }
88 | )
89 | }
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/SystemServicesHooks.kt:
--------------------------------------------------------------------------------
1 | // SystemServicesHooks.kt
2 | package com.noobexon.xposedfakelocation.xposed.hooks
3 |
4 | import android.location.Location
5 | import android.location.LocationRequest
6 | import android.os.Build
7 | import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil
8 | import de.robv.android.xposed.XC_MethodHook
9 | import de.robv.android.xposed.XC_MethodReplacement
10 | import de.robv.android.xposed.XposedBridge
11 | import de.robv.android.xposed.XposedHelpers
12 | import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
13 |
14 | class SystemServicesHooks(val appLpparam: LoadPackageParam) {
15 | private val tag = "[SystemServicesHooks]"
16 |
17 | fun initHooks() {
18 | hookSystemServices(appLpparam.classLoader)
19 | XposedBridge.log("$tag Instantiated hooks successfully")
20 | }
21 |
22 | private fun hookSystemServices(classLoader: ClassLoader) {
23 | try {
24 | val locationManagerServiceClass = XposedHelpers.findClass("com.android.server.LocationManagerService", classLoader)
25 |
26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
27 | XposedHelpers.findAndHookMethod(
28 | locationManagerServiceClass,
29 | "getLastLocation",
30 | LocationRequest::class.java,
31 | String::class.java,
32 | object : XC_MethodHook() {
33 | override fun beforeHookedMethod(param: MethodHookParam) {
34 | XposedBridge.log("$tag [SystemHook] Entered method getLastLocation(locationRequest, packageName)")
35 | XposedBridge.log("\t Request comes from: ${param.args[1] as String}")
36 | val fakeLocation = LocationUtil.createFakeLocation()
37 | param.result = fakeLocation
38 | XposedBridge.log("\t Modified to: $fakeLocation (original method not executed)")
39 | }
40 | })
41 | } else {
42 | XposedBridge.log("$tag API level too low. System services hooks are not available.")
43 | }
44 |
45 | val methodsToReplace = arrayOf(
46 | "addGnssBatchingCallback",
47 | "addGnssMeasurementsListener",
48 | "addGnssNavigationMessageListener"
49 | )
50 |
51 | for (methodName in methodsToReplace) {
52 | XposedHelpers.findAndHookMethod(
53 | locationManagerServiceClass,
54 | methodName,
55 | XC_MethodReplacement.returnConstant(false)
56 | )
57 | }
58 |
59 |
60 | XposedHelpers.findAndHookMethod(
61 | XposedHelpers.findClass("com.android.server.LocationManagerService\$Receiver", classLoader),
62 | "callLocationChangedLocked",
63 | Location::class.java,
64 | object : XC_MethodHook() {
65 | override fun beforeHookedMethod(param: MethodHookParam) {
66 | XposedBridge.log("$tag [SystemHook] Entered method callLocationChangedLocked(location)")
67 | val fakeLocation = LocationUtil.createFakeLocation(param.args[0] as? Location)
68 | param.args[0] = fakeLocation
69 | XposedBridge.log("\t Modified to: $fakeLocation")
70 | }
71 | })
72 |
73 | } catch (e: Exception) {
74 | XposedBridge.log("$tag Error hooking system services")
75 | XposedBridge.log(e)
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/favorites/FavoritesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.favorites
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.LazyColumn
6 | import androidx.compose.foundation.lazy.items
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.ArrowBack
9 | import androidx.compose.material.icons.filled.Delete
10 | import androidx.compose.material3.*
11 | import androidx.compose.runtime.*
12 | import androidx.compose.ui.*
13 | import androidx.compose.ui.text.style.TextOverflow
14 | import androidx.lifecycle.viewmodel.compose.viewModel
15 | import androidx.navigation.NavController
16 | import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
17 | import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
18 | import org.osmdroid.util.GeoPoint
19 |
20 | @OptIn(ExperimentalMaterial3Api::class)
21 | @Composable
22 | fun FavoritesScreen(
23 | navController: NavController,
24 | mapViewModel: MapViewModel,
25 | favoritesViewModel: FavoritesViewModel = viewModel()
26 | ) {
27 | val favorites by favoritesViewModel.favorites.collectAsState()
28 |
29 | Scaffold(
30 | topBar = {
31 | TopAppBar(
32 | title = { Text("Favorites") },
33 | colors = TopAppBarDefaults.topAppBarColors(
34 | containerColor = MaterialTheme.colorScheme.primary,
35 | titleContentColor = MaterialTheme.colorScheme.onPrimary,
36 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
37 | actionIconContentColor = MaterialTheme.colorScheme.onPrimary
38 | ),
39 | navigationIcon = {
40 | IconButton(onClick = { navController.navigateUp() }) {
41 | Icon(Icons.Default.ArrowBack, contentDescription = "Back")
42 | }
43 | }
44 | )
45 | }
46 | ) { innerPadding ->
47 | if (favorites.isEmpty()) {
48 | Box(
49 | modifier = Modifier
50 | .fillMaxSize()
51 | .padding(innerPadding),
52 | contentAlignment = Alignment.Center
53 | ) {
54 | Text("No favorites added.")
55 | }
56 | } else {
57 | LazyColumn(
58 | modifier = Modifier
59 | .fillMaxSize()
60 | .padding(innerPadding)
61 | ) {
62 | items(favorites) { favorite ->
63 | FavoriteItem(
64 | favorite = favorite,
65 | onClick = {
66 | mapViewModel.updateClickedLocation(GeoPoint(favorite.latitude, favorite.longitude))
67 | navController.navigateUp()
68 | },
69 | onDelete = {
70 | favoritesViewModel.removeFavorite(favorite)
71 | }
72 | )
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | @Composable
80 | fun FavoriteItem(
81 | favorite: FavoriteLocation,
82 | onClick: () -> Unit,
83 | onDelete: () -> Unit
84 | ) {
85 | Surface(
86 | modifier = Modifier
87 | .fillMaxWidth()
88 | .clickable(onClick = onClick)
89 | ) {
90 | ListItem(
91 | headlineContent = { Text(favorite.name) },
92 | supportingContent = {
93 | Text(
94 | text = "Lat: ${favorite.latitude}, Lon: ${favorite.longitude}",
95 | maxLines = 1,
96 | overflow = TextOverflow.Ellipsis
97 | )
98 | },
99 | trailingContent = {
100 | IconButton(onClick = onDelete) {
101 | Icon(Icons.Default.Delete, contentDescription = "Delete")
102 | }
103 | }
104 | )
105 | }
106 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/PreferencesUtil.kt:
--------------------------------------------------------------------------------
1 | // PreferencesUtil.kt
2 | package com.noobexon.xposedfakelocation.xposed.utils
3 |
4 | import com.google.gson.Gson
5 | import com.noobexon.xposedfakelocation.data.*
6 | import com.noobexon.xposedfakelocation.data.model.LastClickedLocation
7 | import de.robv.android.xposed.XSharedPreferences
8 | import de.robv.android.xposed.XposedBridge
9 |
10 | object PreferencesUtil {
11 | private const val TAG = "[PreferencesUtil]"
12 |
13 | private val preferences: XSharedPreferences = XSharedPreferences(MANAGER_APP_PACKAGE_NAME, SHARED_PREFS_FILE).apply {
14 | makeWorldReadable()
15 | reload()
16 | }
17 |
18 | fun getIsPlaying(): Boolean? {
19 | return getPreference(KEY_IS_PLAYING)
20 | }
21 |
22 | fun getLastClickedLocation(): LastClickedLocation? {
23 | return getPreference(KEY_LAST_CLICKED_LOCATION)
24 | }
25 |
26 | fun getUseAccuracy(): Boolean? {
27 | return getPreference(KEY_USE_ACCURACY)
28 | }
29 |
30 | fun getAccuracy(): Double? {
31 | return getPreference(KEY_ACCURACY)
32 | }
33 |
34 | fun getUseAltitude(): Boolean? {
35 | return getPreference(KEY_USE_ALTITUDE)
36 | }
37 |
38 | fun getAltitude(): Double? {
39 | return getPreference(KEY_ALTITUDE)
40 | }
41 |
42 | fun getUseRandomize(): Boolean? {
43 | return getPreference(KEY_USE_RANDOMIZE)
44 | }
45 |
46 | fun getRandomizeRadius(): Double? {
47 | return getPreference(KEY_RANDOMIZE_RADIUS)
48 | }
49 |
50 | fun getUseVerticalAccuracy(): Boolean? {
51 | return getPreference(KEY_USE_VERTICAL_ACCURACY)
52 | }
53 |
54 | fun getVerticalAccuracy(): Float? {
55 | return getPreference(KEY_VERTICAL_ACCURACY)
56 | }
57 |
58 | fun getUseMeanSeaLevel(): Boolean? {
59 | return getPreference(KEY_USE_MEAN_SEA_LEVEL)
60 | }
61 |
62 | fun getMeanSeaLevel(): Double? {
63 | return getPreference(KEY_MEAN_SEA_LEVEL)
64 | }
65 |
66 | fun getUseMeanSeaLevelAccuracy(): Boolean? {
67 | return getPreference(KEY_USE_MEAN_SEA_LEVEL_ACCURACY)
68 | }
69 |
70 | fun getMeanSeaLevelAccuracy(): Float? {
71 | return getPreference(KEY_MEAN_SEA_LEVEL_ACCURACY)
72 | }
73 |
74 | fun getUseSpeed(): Boolean? {
75 | return getPreference(KEY_USE_SPEED)
76 | }
77 |
78 | fun getSpeed(): Float? {
79 | return getPreference(KEY_SPEED)
80 | }
81 |
82 | fun getUseSpeedAccuracy(): Boolean? {
83 | return getPreference(KEY_USE_SPEED_ACCURACY)
84 | }
85 |
86 | fun getSpeedAccuracy(): Float? {
87 | return getPreference(KEY_SPEED_ACCURACY)
88 | }
89 |
90 | private inline fun getPreference(key: String): T? {
91 | preferences.reload()
92 | return when (T::class) {
93 | Double::class -> {
94 | val defaultValue = when (key) {
95 | KEY_ACCURACY -> java.lang.Double.doubleToRawLongBits(DEFAULT_ACCURACY)
96 | KEY_ALTITUDE -> java.lang.Double.doubleToRawLongBits(DEFAULT_ALTITUDE)
97 | KEY_RANDOMIZE_RADIUS -> java.lang.Double.doubleToRawLongBits(DEFAULT_RANDOMIZE_RADIUS)
98 | KEY_MEAN_SEA_LEVEL -> java.lang.Double.doubleToRawLongBits(DEFAULT_MEAN_SEA_LEVEL)
99 | else -> -1L
100 | }
101 | val bits = preferences.getLong(key, defaultValue)
102 | java.lang.Double.longBitsToDouble(bits) as? T
103 | }
104 | Float::class -> {
105 | val defaultValue = when (key) {
106 | KEY_VERTICAL_ACCURACY -> DEFAULT_VERTICAL_ACCURACY
107 | KEY_MEAN_SEA_LEVEL_ACCURACY -> DEFAULT_MEAN_SEA_LEVEL_ACCURACY
108 | KEY_SPEED -> DEFAULT_SPEED
109 | KEY_SPEED_ACCURACY -> DEFAULT_SPEED_ACCURACY
110 | else -> -1f
111 | }
112 | preferences.getFloat(key, defaultValue) as? T
113 | }
114 | Boolean::class -> preferences.getBoolean(key, false) as? T
115 | else -> {
116 | val json = preferences.getString(key, null)
117 | if (json != null) {
118 | try {
119 | Gson().fromJson(json, T::class.java).also {
120 | XposedBridge.log("$TAG Retrieved $key: $it")
121 | }
122 | } catch (e: Exception) {
123 | XposedBridge.log("$TAG Error parsing $key JSON: ${e.message}")
124 | null
125 | }
126 | } else {
127 | XposedBridge.log("$TAG $key not found in preferences.")
128 | null
129 | }
130 | }
131 | }
132 | }
133 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/components/AddToFavoritesDialog.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.map.components
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.text.KeyboardOptions
5 | import androidx.compose.material3.*
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.text.input.KeyboardType
9 | import androidx.compose.ui.unit.dp
10 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
11 | import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
12 |
13 | @Composable
14 | fun AddToFavoritesDialog(
15 | mapViewModel: MapViewModel,
16 | onDismissRequest: () -> Unit,
17 | onAddFavorite: (name: String, latitude: Double, longitude: Double) -> Unit
18 | ) {
19 | // Access UI state through StateFlow
20 | val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
21 | val addToFavoritesState = uiState.addToFavoritesState
22 |
23 | val favoriteNameInput = addToFavoritesState.name.value
24 | val favoriteLatitudeInput = addToFavoritesState.latitude.value
25 | val favoriteLongitudeInput = addToFavoritesState.longitude.value
26 | val favoriteNameError = addToFavoritesState.name.errorMessage
27 | val favoriteLatitudeError = addToFavoritesState.latitude.errorMessage
28 | val favoriteLongitudeError = addToFavoritesState.longitude.errorMessage
29 |
30 | AlertDialog(
31 | onDismissRequest = {
32 | mapViewModel.clearAddToFavoritesInputs()
33 | onDismissRequest()
34 | },
35 | title = { Text("Add to Favorites") },
36 | text = {
37 | Column {
38 | OutlinedTextField(
39 | value = favoriteNameInput,
40 | onValueChange = { mapViewModel.updateAddToFavoritesField("name", it) },
41 | label = { Text("Name") },
42 | modifier = Modifier.fillMaxWidth(),
43 | isError = favoriteNameError != null
44 | )
45 | if (favoriteNameError != null) {
46 | Text(
47 | text = favoriteNameError,
48 | color = MaterialTheme.colorScheme.error,
49 | style = MaterialTheme.typography.bodySmall,
50 | modifier = Modifier.padding(start = 16.dp)
51 | )
52 | }
53 | Spacer(modifier = Modifier.height(8.dp))
54 | OutlinedTextField(
55 | value = favoriteLatitudeInput,
56 | onValueChange = { mapViewModel.updateAddToFavoritesField("latitude", it) },
57 | label = { Text("Latitude") },
58 | modifier = Modifier.fillMaxWidth(),
59 | keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
60 | isError = favoriteLatitudeError != null
61 | )
62 | if (favoriteLatitudeError != null) {
63 | Text(
64 | text = favoriteLatitudeError,
65 | color = MaterialTheme.colorScheme.error,
66 | style = MaterialTheme.typography.bodySmall,
67 | modifier = Modifier.padding(start = 16.dp)
68 | )
69 | }
70 | Spacer(modifier = Modifier.height(8.dp))
71 | OutlinedTextField(
72 | value = favoriteLongitudeInput,
73 | onValueChange = { mapViewModel.updateAddToFavoritesField("longitude", it) },
74 | label = { Text("Longitude") },
75 | modifier = Modifier.fillMaxWidth(),
76 | keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
77 | isError = favoriteLongitudeError != null
78 | )
79 | if (favoriteLongitudeError != null) {
80 | Text(
81 | text = favoriteLongitudeError,
82 | color = MaterialTheme.colorScheme.error,
83 | style = MaterialTheme.typography.bodySmall,
84 | modifier = Modifier.padding(start = 16.dp)
85 | )
86 | }
87 | }
88 | },
89 | confirmButton = {
90 | TextButton(
91 | onClick = {
92 | mapViewModel.validateAndAddFavorite { name, latitude, longitude ->
93 | onAddFavorite(name, latitude, longitude)
94 | }
95 | }
96 | ) {
97 | Text("Add")
98 | }
99 | },
100 | dismissButton = {
101 | TextButton(
102 | onClick = {
103 | mapViewModel.clearAddToFavoritesInputs()
104 | onDismissRequest()
105 | }
106 | ) {
107 | Text("Cancel")
108 | }
109 | }
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/about/AboutScreen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.about
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material.icons.Icons
13 | import androidx.compose.material.icons.filled.ArrowBack
14 | import androidx.compose.material3.ExperimentalMaterial3Api
15 | import androidx.compose.material3.Icon
16 | import androidx.compose.material3.IconButton
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.material3.Scaffold
19 | import androidx.compose.material3.Text
20 | import androidx.compose.material3.TopAppBar
21 | import androidx.compose.material3.TopAppBarDefaults
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.ui.Alignment
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.text.font.FontWeight
26 | import androidx.compose.ui.text.style.TextAlign
27 | import androidx.compose.ui.unit.dp
28 | import androidx.navigation.NavController
29 | import androidx.compose.ui.platform.LocalContext
30 | import com.noobexon.xposedfakelocation.BuildConfig
31 |
32 |
33 | @OptIn(ExperimentalMaterial3Api::class)
34 | @Composable
35 | fun AboutScreen(
36 | navController: NavController
37 | ) {
38 | Scaffold(
39 | topBar = { AboutTopAppBar(navController) }
40 | ) { innerPadding ->
41 | Box(
42 | modifier = Modifier
43 | .fillMaxSize()
44 | .padding(innerPadding),
45 | contentAlignment = Alignment.Center
46 | ) {
47 | AboutContent()
48 | }
49 | }
50 | }
51 |
52 | @OptIn(ExperimentalMaterial3Api::class)
53 | @Composable
54 | fun AboutTopAppBar(navController: NavController) {
55 | TopAppBar(
56 | title = { Text("About") },
57 | colors = TopAppBarDefaults.topAppBarColors(
58 | containerColor = MaterialTheme.colorScheme.primary,
59 | titleContentColor = MaterialTheme.colorScheme.onPrimary,
60 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
61 | actionIconContentColor = MaterialTheme.colorScheme.onPrimary
62 | ),
63 | navigationIcon = {
64 | IconButton(onClick = { navController.navigateUp() }) {
65 | Icon(Icons.Default.ArrowBack, contentDescription = "Back")
66 | }
67 | }
68 | )
69 | }
70 |
71 | @Composable
72 | fun AboutContent() {
73 | Column(
74 | modifier = Modifier.padding(16.dp),
75 | horizontalAlignment = Alignment.CenterHorizontally
76 | ) {
77 | AppTitle()
78 | Spacer(modifier = Modifier.height(16.dp))
79 | AppDescription()
80 | Spacer(modifier = Modifier.height(32.dp))
81 | AppVersionSection()
82 | Spacer(modifier = Modifier.height(16.dp))
83 | AppDeveloperSection()
84 | }
85 | }
86 |
87 | @Composable
88 | fun AppTitle() {
89 | Text(
90 | text = "XposedFakeLocation",
91 | style = MaterialTheme.typography.headlineSmall.copy(
92 | fontWeight = FontWeight.Bold
93 | ),
94 | textAlign = TextAlign.Center
95 | )
96 | }
97 |
98 | @Composable
99 | fun AppDescription() {
100 | Text(
101 | text = """
102 | XposedFakeLocation is an app designed to allow users to mock their location for testing or entertainment purposes.
103 |
104 | Use it responsibly, and make sure to comply with all applicable local regulations when using location services.
105 |
106 | You are fully responsible for the use of this app.
107 | """.trimIndent(),
108 | style = MaterialTheme.typography.bodyLarge,
109 | textAlign = TextAlign.Center
110 | )
111 | }
112 |
113 | @Composable
114 | fun AppVersionSection() {
115 | AppVersionTitle()
116 | Spacer(modifier = Modifier.height(16.dp))
117 | AppVersionValue()
118 | }
119 |
120 | @Composable
121 | fun AppVersionTitle() {
122 | Text(
123 | text = "Version:",
124 | style = MaterialTheme.typography.bodyMedium.copy(
125 | fontWeight = FontWeight.SemiBold
126 | ),
127 | textAlign = TextAlign.Center,
128 | modifier = Modifier.padding(top = 24.dp)
129 | )
130 | }
131 |
132 | @Composable
133 | fun AppVersionValue() {
134 | Text(
135 | text = BuildConfig.VERSION_NAME,
136 | style = MaterialTheme.typography.bodyMedium.copy(
137 | fontWeight = FontWeight.SemiBold
138 | ),
139 | textAlign = TextAlign.Center,
140 | modifier = Modifier.padding(top = 4.dp)
141 | )
142 | }
143 |
144 | @Composable
145 | fun AppDeveloperSection() {
146 | AppDeveloperTitle()
147 | Spacer(modifier = Modifier.height(16.dp))
148 | AppDeveloperValue()
149 | }
150 |
151 | @Composable
152 | fun AppDeveloperTitle() {
153 | Text(
154 | text = "Developed and maintained by:",
155 | style = MaterialTheme.typography.bodyMedium.copy(
156 | fontWeight = FontWeight.SemiBold
157 | ),
158 | textAlign = TextAlign.Center,
159 | modifier = Modifier.padding(top = 8.dp)
160 | )
161 | }
162 |
163 | @Composable
164 | fun AppDeveloperValue() {
165 | val context = LocalContext.current
166 | Text(
167 | text = "noobexon",
168 | style = MaterialTheme.typography.bodyMedium.copy(
169 | fontWeight = FontWeight.SemiBold,
170 | color = MaterialTheme.colorScheme.primary
171 | ),
172 | textAlign = TextAlign.Center,
173 | modifier = Modifier
174 | .padding(top = 4.dp)
175 | .clickable {
176 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/noobexon1"))
177 | context.startActivity(intent)
178 | }
179 | )
180 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # **XposedFakeLocation**
2 |
3 | 
4 | 
5 | 
6 | 
7 | 
8 | 
9 | []()
10 |
11 | **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.
12 |
13 |
14 |
15 |

16 |
17 |
18 |
19 | ---
20 |
21 | ## **Table of Contents**
22 |
23 | - [Features](#features)
24 | - [Prerequisites](#prerequisites)
25 | - [Installation](#installation)
26 | - [Usage](#usage)
27 | - [Development](#development)
28 | - [License](#license)
29 | - [Disclaimer](#disclaimer)
30 | - [Acknowledgements](#acknowledgements)
31 |
32 | ---
33 |
34 | ## **Features**
35 |
36 | - **Global Location Spoofing**: Override your device's location data system-wide (Unstable for now).
37 | - **Per-App Location Control**: Apply location spoofing to specific applications.
38 | - **Custom Coordinates**: Set precise latitude and longitude.
39 | - **Altitude and Accuracy Settings**: Customize altitude, accuracy and other custom sensor values.
40 | - **Randomization**: Add random offsets within a specified radius for enhanced privacy.
41 | - **User-Friendly Interface**: Modern Material Design 3 UI built with Jetpack Compose.
42 | - **Intuitive Navigation**: Easy access to maps, favorite locations, and settings.
43 | - **Community Integration**: Direct links to Telegram, Discord, and GitHub communities.
44 |
45 | ---
46 |
47 | ## **Prerequisites**
48 |
49 | - **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.
50 | - **LSPosed**: Install the Xposed Framework compatible with your Android version.
51 | - [LSPosed](https://github.com/LSPosed/LSPosed)
52 |
53 | ---
54 |
55 | ## **Installation**
56 |
57 | You can always install the latest stable version from the releases page. If you want to build by yourself:
58 |
59 | 1. **Clone or Download the Repository**
60 |
61 | ```bash
62 | git clone https://github.com/noobexon1/XposedFakeLocation.git
63 | ```
64 |
65 | 2. **Build the Application**
66 |
67 | - Open the project in **Android Studio**.
68 | - Build the APK using **Build > Build Bundle(s) / APK(s) > Build APK(s)**.
69 | - Alternatively, use Gradle:
70 |
71 | ```bash
72 | ./gradlew assembleDebug
73 | ```
74 |
75 | 3. **Install the APK on Your Device**
76 |
77 | - Transfer the APK to your device.
78 | - Install the APK using a file manager or via ADB:
79 |
80 | ```bash
81 | adb install app/build/outputs/apk/debug/app-debug.apk
82 | ```
83 |
84 | 4. **Activate the Xposed Module**
85 |
86 | - Open **Xposed Installer** or **LSPosed Manager**.
87 | - Enable the **XposedFakeLocation** module.
88 | - If you decide to apply the module system wide, Reboot your device to apply changes.
89 |
90 | ---
91 |
92 | ## **Usage**
93 |
94 | 1. **Launch the App**
95 |
96 | - Open **XposedFakeLocation** from your app drawer.
97 |
98 | 2. **Navigate the Interface**
99 |
100 | - Use the navigation drawer to access different sections:
101 | - **Map**: Primary interface for location selection
102 | - **Favorites**: Saved locations for quick access
103 | - **Settings**: Configure application behavior
104 | - **About**: View application information
105 |
106 | 3. **Select a Location**
107 |
108 | - Use the integrated map to select your desired location.
109 | - Tap on the map to set the fake location.
110 |
111 | 4. **Configure Settings**
112 |
113 | - Access the **Settings** screen to customize:
114 |
115 | - **Accuracy**: Enable and set a custom horizontal and/or vertical accuracy value.
116 | - **Altitude**: Enable and set a custom altitude.
117 | - **Other Sensor Data**: New spoofable sensors data added in new versions.
118 | - **Randomization Radius**: Set the radius in meters for location randomization.
119 |
120 | 5. **Start Spoofing**
121 |
122 | - Toggle the **Start** button to begin location spoofing.
123 | - The app will now override your device's location data based on the target(s) specified in the Xposed manager app.
124 |
125 | 6. **Stop Spoofing**
126 |
127 | - Toggle the **Stop** button to cease location spoofing.
128 |
129 | ---
130 |
131 | ### **Favorites**
132 |
133 | - Save frequently used locations for quick access.
134 | - 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.
135 | - Manage your favorites by adding or removing locations.
136 | - Access your favorites through the navigation drawer for easy selection.
137 |
138 | ---
139 |
140 | ## **Development**
141 |
142 | ### **Built With**
143 |
144 | - **Kotlin**: Programming language for Android development.
145 | - **Jetpack Compose**: Modern toolkit for building native Android UI with Material Design 3.
146 | - **Material 3 Design**: Latest design system from Google for an enhanced user experience.
147 | - **Xposed API**: Framework for runtime modification of system and app behavior.
148 | - **OSMDroid**: Open-source map rendering engine for Android.
149 |
150 | ### **User Interface**
151 |
152 | - **Navigation Drawer**: Easy access to all major app features
153 | - **Material Design Components**: Consistent design language throughout the app
154 | - **Adaptive Layouts**: Compatible with various screen sizes and orientations
155 |
156 | ### **Prerequisites**
157 |
158 | - **Android Studio Flamingo** or newer.
159 | - **Android SDK** with API level 31 or above.
160 | - **Kotlin** version 1.8.0 or above.
161 |
162 | ### **Building from Source**
163 |
164 | 1. **Clone the Repository**
165 |
166 | ```bash
167 | git clone https://github.com/noobexon1/XposedFakeLocation.git
168 | ```
169 |
170 | 2. **Open in Android Studio**
171 |
172 | - Navigate to the project directory.
173 | - Open the project with **Android Studio**.
174 |
175 | 3. **Sync Gradle**
176 |
177 | - Allow Gradle to download all dependencies.
178 |
179 | 4. **Build and Run**
180 |
181 | - Connect your rooted device or start an emulator with root capabilities.
182 | - Run the app from **Android Studio**.
183 |
184 | ---
185 |
186 | ## **License**
187 |
188 | Distributed under the **MIT License**. See [LICENSE](LICENSE) for more information.
189 |
190 | ---
191 |
192 | ## **Disclaimer**
193 |
194 | 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.
195 |
196 | ---
197 |
198 | ## **Acknowledgements**
199 |
200 | - [GpsSetter](https://github.com/Android1500/GpsSetter) - Highly inspired by this amazing project!
201 | - [Xposed Framework](https://repo.xposed.info/) - Java hooks
202 | - [LSPosed](https://github.com/LSPosed/LSPosed) - The go-to Xposed framework manager app.
203 | - [OSMDroid](https://github.com/osmdroid/osmdroid) - Open-source offline map interface.
204 | - [Jetpack Compose](https://developer.android.com/jetpack/compose) - Modern UI toolkit for Android.
205 | - [Material Design 3](https://m3.material.io/) - Latest design system from Google.
206 | - [Line Awesome Icons](https://icons8.com/line-awesome) - Beautiful icon set used in the app.
207 |
208 |
209 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/xposed/utils/LocationUtil.kt:
--------------------------------------------------------------------------------
1 | // LocationUtil.kt
2 | package com.noobexon.xposedfakelocation.xposed.utils
3 |
4 | import android.location.Location
5 | import android.location.LocationManager
6 | import android.os.Build
7 | import com.noobexon.xposedfakelocation.data.DEFAULT_ACCURACY
8 | import com.noobexon.xposedfakelocation.data.DEFAULT_ALTITUDE
9 | import com.noobexon.xposedfakelocation.data.DEFAULT_MEAN_SEA_LEVEL
10 | import com.noobexon.xposedfakelocation.data.DEFAULT_MEAN_SEA_LEVEL_ACCURACY
11 | import com.noobexon.xposedfakelocation.data.DEFAULT_RANDOMIZE_RADIUS
12 | import com.noobexon.xposedfakelocation.data.DEFAULT_SPEED
13 | import com.noobexon.xposedfakelocation.data.DEFAULT_SPEED_ACCURACY
14 | import com.noobexon.xposedfakelocation.data.DEFAULT_VERTICAL_ACCURACY
15 | import com.noobexon.xposedfakelocation.data.PI
16 | import com.noobexon.xposedfakelocation.data.RADIUS_EARTH
17 | import de.robv.android.xposed.XposedBridge
18 | import org.lsposed.hiddenapibypass.HiddenApiBypass
19 | import java.util.Random
20 | import kotlin.math.asin
21 | import kotlin.math.atan2
22 | import kotlin.math.cos
23 | import kotlin.math.sin
24 | import kotlin.math.sqrt
25 |
26 | object LocationUtil {
27 | private const val TAG = "[LocationUtil]"
28 |
29 | private const val DEBUG: Boolean = true
30 |
31 | private val random: Random = Random()
32 |
33 | var latitude: Double = 0.0
34 | var longitude: Double = 0.0
35 | var accuracy: Float = 0F
36 | var altitude: Double = 0.0
37 | var verticalAccuracy: Float = 0F
38 | var meanSeaLevel: Double = 0.0
39 | var meanSeaLevelAccuracy: Float = 0F
40 | var speed: Float = 0F
41 | var speedAccuracy: Float = 0F
42 |
43 | @Synchronized
44 | fun createFakeLocation(originalLocation: Location? = null, provider: String = LocationManager.GPS_PROVIDER): Location {
45 | val fakeLocation = if (originalLocation == null) {
46 | Location(provider).apply {
47 | time = System.currentTimeMillis() - 300
48 | }
49 | } else {
50 | Location(originalLocation.provider).apply {
51 | time = originalLocation.time
52 | accuracy = originalLocation.accuracy
53 | bearing = originalLocation.bearing
54 | bearingAccuracyDegrees = originalLocation.bearingAccuracyDegrees
55 | elapsedRealtimeNanos = originalLocation.elapsedRealtimeNanos
56 | verticalAccuracyMeters = originalLocation.verticalAccuracyMeters
57 | }
58 | }
59 |
60 | fakeLocation.latitude = latitude
61 | fakeLocation.longitude = longitude
62 |
63 | if (accuracy != 0F) {
64 | fakeLocation.accuracy = accuracy
65 | }
66 |
67 | if (altitude != 0.0) {
68 | fakeLocation.altitude = altitude
69 | }
70 |
71 | if (verticalAccuracy != 0F) {
72 | fakeLocation.verticalAccuracyMeters = verticalAccuracy
73 | }
74 |
75 | if (speed != 0F) {
76 | fakeLocation.speed = speed
77 | }
78 |
79 | if (speedAccuracy != 0F) {
80 | fakeLocation.speedAccuracyMetersPerSecond = speedAccuracy
81 | }
82 |
83 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
84 | if (meanSeaLevel != 0.0) {
85 | fakeLocation.mslAltitudeMeters = meanSeaLevel
86 | }
87 |
88 | if (meanSeaLevelAccuracy != 0F) {
89 | fakeLocation.mslAltitudeAccuracyMeters = meanSeaLevelAccuracy
90 | }
91 | }
92 |
93 | attemptHideMockProvider(fakeLocation)
94 |
95 | return fakeLocation
96 | }
97 |
98 | private fun attemptHideMockProvider(fakeLocation: Location) {
99 | try {
100 | HiddenApiBypass.invoke(fakeLocation.javaClass, fakeLocation, "setIsFromMockProvider", false)
101 | XposedBridge.log("$TAG invoked hidden API - setIsFromMockProvider: false)")
102 | } catch (e: Exception) {
103 | XposedBridge.log("$TAG Not possible to mock - ${e.message}")
104 | }
105 | }
106 |
107 | @Synchronized
108 | fun updateLocation() {
109 | try {
110 | PreferencesUtil.getLastClickedLocation()?.let {
111 | if (PreferencesUtil.getUseRandomize() == true) {
112 | val randomizationRadius = PreferencesUtil.getRandomizeRadius() ?: DEFAULT_RANDOMIZE_RADIUS
113 | val randomLocation = getRandomLocation(it.latitude, it.longitude, randomizationRadius)
114 | latitude = randomLocation.first
115 | longitude = randomLocation.second
116 | } else {
117 | latitude = it.latitude
118 | longitude = it.longitude
119 | }
120 |
121 | if (PreferencesUtil.getUseAccuracy() == true) {
122 | accuracy = (PreferencesUtil.getAccuracy() ?: DEFAULT_ACCURACY).toFloat()
123 | }
124 |
125 | if (PreferencesUtil.getUseAltitude() == true) {
126 | altitude = PreferencesUtil.getAltitude() ?: DEFAULT_ALTITUDE
127 | }
128 |
129 | if (PreferencesUtil.getUseVerticalAccuracy() == true) {
130 | verticalAccuracy = PreferencesUtil.getVerticalAccuracy()?.toFloat() ?: DEFAULT_VERTICAL_ACCURACY
131 | }
132 |
133 | if (PreferencesUtil.getUseMeanSeaLevel() == true) {
134 | meanSeaLevel = PreferencesUtil.getMeanSeaLevel() ?: DEFAULT_MEAN_SEA_LEVEL
135 | }
136 |
137 | if (PreferencesUtil.getUseMeanSeaLevelAccuracy() == true) {
138 | meanSeaLevelAccuracy = PreferencesUtil.getMeanSeaLevelAccuracy()?.toFloat() ?: DEFAULT_MEAN_SEA_LEVEL_ACCURACY
139 | }
140 |
141 | if (PreferencesUtil.getUseSpeed() == true) {
142 | speed = PreferencesUtil.getSpeed()?.toFloat() ?: DEFAULT_SPEED
143 | }
144 |
145 | if (PreferencesUtil.getUseSpeedAccuracy() == true) {
146 | speedAccuracy = PreferencesUtil.getSpeedAccuracy()?.toFloat() ?: DEFAULT_SPEED_ACCURACY
147 | }
148 |
149 | if (DEBUG) {
150 | XposedBridge.log("$TAG Updated fake location values to:")
151 | XposedBridge.log("\tCoordinates: (latitude = $latitude, longitude = $longitude)")
152 | XposedBridge.log("\tAccuracy: $accuracy")
153 | XposedBridge.log("\tAltitude: $altitude")
154 | XposedBridge.log("\tVertical Accuracy: $verticalAccuracy")
155 | XposedBridge.log("\tMean Sea Level: $meanSeaLevel")
156 | XposedBridge.log("\tMean Sea Level Accuracy: $meanSeaLevelAccuracy")
157 | XposedBridge.log("\tSpeed: $speed")
158 | XposedBridge.log("\tSpeed Accuracy: $speedAccuracy")
159 | }
160 | } ?: XposedBridge.log("$TAG Last clicked location is null")
161 | } catch (e: Exception) {
162 | XposedBridge.log("$TAG Error - ${e.message}")
163 | }
164 | }
165 |
166 | // Calculates a random point within a circle around the fake location that has the radius set by by the user. Uses Haversine's formula.
167 | private fun getRandomLocation(lat: Double, lon: Double, radiusInMeters: Double): Pair {
168 | val radiusInRadians = radiusInMeters / RADIUS_EARTH
169 |
170 | val latRad = Math.toRadians(lat)
171 | val lonRad = Math.toRadians(lon)
172 |
173 | val sinLat = sin(latRad)
174 | val cosLat = cos(latRad)
175 |
176 | // Generate two random numbers
177 | val rand1 = random.nextDouble()
178 | val rand2 = random.nextDouble()
179 |
180 | // Random distance and bearing
181 | val distance = radiusInRadians * sqrt(rand1)
182 | val bearing = 2 * PI * rand2
183 |
184 | val sinDistance = sin(distance)
185 | val cosDistance = cos(distance)
186 |
187 | val newLatRad = asin(sinLat * cosDistance + cosLat * sinDistance * cos(bearing))
188 | val newLonRad = lonRad + atan2(
189 | sin(bearing) * sinDistance * cosLat,
190 | cosDistance - sinLat * sin(newLatRad)
191 | )
192 |
193 | // Convert back to degrees
194 | val newLat = Math.toDegrees(newLatRad)
195 | var newLon = Math.toDegrees(newLonRad)
196 |
197 | // Normalize longitude to be between -180 and 180 degrees
198 | newLon = ((newLon + 180) % 360 + 360) % 360 - 180
199 |
200 | // Clamp latitude to -90 to 90 degrees
201 | val finalLat = newLat.coerceIn(-90.0, 90.0)
202 |
203 | return Pair(finalLat, newLon)
204 | }
205 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/drawer/DrawerContent.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.drawer
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import android.widget.Toast
6 | import androidx.compose.foundation.background
7 | import androidx.compose.foundation.clickable
8 | import androidx.compose.foundation.layout.*
9 | import androidx.compose.foundation.shape.CircleShape
10 | import androidx.compose.foundation.shape.RoundedCornerShape
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.Settings
13 | import androidx.compose.material.icons.outlined.Info
14 | import androidx.compose.material3.*
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.graphics.Color
20 | import androidx.compose.ui.graphics.vector.ImageVector
21 | import androidx.compose.ui.platform.LocalContext
22 | import androidx.compose.ui.text.font.FontWeight
23 | import androidx.compose.ui.text.style.TextOverflow
24 | import androidx.compose.ui.unit.dp
25 | import androidx.navigation.NavController
26 | import compose.icons.LineAwesomeIcons
27 | import compose.icons.lineawesomeicons.*
28 | import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
29 |
30 | // Constants for drawer dimensions and styling
31 | private object DrawerDimensions {
32 | val SECTION_SPACING = 24.dp
33 | val ITEM_SPACING = 4.dp
34 | val ICON_SIZE = 24.dp
35 | val SECTION_PADDING = 8.dp
36 | val HEADER_PADDING = 16.dp
37 | val DRAWER_PADDING = 16.dp
38 | val ITEM_PADDING = 12.dp
39 | val ITEM_CORNER_RADIUS = 12.dp
40 | val BADGE_SIZE = 8.dp
41 | }
42 |
43 | @Composable
44 | fun DrawerContent(
45 | navController: NavController,
46 | onCloseDrawer: () -> Unit = {}
47 | ) {
48 | val context = LocalContext.current
49 |
50 | ModalDrawerSheet(
51 | drawerContainerColor = MaterialTheme.colorScheme.surface,
52 | drawerContentColor = MaterialTheme.colorScheme.onSurface
53 | ) {
54 | Column(
55 | modifier = Modifier
56 | .fillMaxHeight()
57 | .padding(DrawerDimensions.DRAWER_PADDING)
58 | ) {
59 | // App Header
60 | DrawerHeader()
61 |
62 | Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
63 |
64 | // Navigation Section
65 | DrawerSectionHeader("Navigation")
66 |
67 | DrawerItem(
68 | icon = LineAwesomeIcons.MapSolid,
69 | label = "Map",
70 | onClick = {
71 | navController.navigate(Screen.Map.route)
72 | onCloseDrawer()
73 | },
74 | isSelected = navController.currentDestination?.route == Screen.Map.route
75 | )
76 |
77 | DrawerItem(
78 | icon = LineAwesomeIcons.HeartSolid,
79 | label = "Favorites",
80 | onClick = {
81 | navController.navigate(Screen.Favorites.route)
82 | onCloseDrawer()
83 | },
84 | isSelected = navController.currentDestination?.route == Screen.Favorites.route
85 | )
86 |
87 | DrawerItem(
88 | icon = Icons.Default.Settings,
89 | label = "Settings",
90 | onClick = {
91 | navController.navigate(Screen.Settings.route)
92 | onCloseDrawer()
93 | },
94 | isSelected = navController.currentDestination?.route == Screen.Settings.route
95 | )
96 |
97 | Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
98 |
99 | // Community Section
100 | DrawerSectionHeader("Community")
101 |
102 | DrawerItem(
103 | icon = LineAwesomeIcons.Telegram,
104 | label = "Telegram",
105 | onClick = { Toast.makeText(context, "Coming soon!", Toast.LENGTH_SHORT).show() },
106 | trailingIcon = {
107 | Box(
108 | modifier = Modifier
109 | .size(DrawerDimensions.BADGE_SIZE)
110 | .clip(CircleShape)
111 | .background(MaterialTheme.colorScheme.primary)
112 | )
113 | }
114 | )
115 |
116 | DrawerItem(
117 | icon = LineAwesomeIcons.Discord,
118 | label = "Discord",
119 | onClick = {
120 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://discord.gg/8eCRU3KzVS"))
121 | context.startActivity(intent)
122 | onCloseDrawer()
123 | }
124 | )
125 |
126 | DrawerItem(
127 | icon = LineAwesomeIcons.Github,
128 | label = "GitHub",
129 | onClick = {
130 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/noobexon1/XposedFakeLocation"))
131 | context.startActivity(intent)
132 | onCloseDrawer()
133 | }
134 | )
135 |
136 | Spacer(modifier = Modifier.height(DrawerDimensions.SECTION_SPACING))
137 |
138 | // About Section
139 | DrawerSectionHeader("App Info")
140 |
141 | DrawerItem(
142 | icon = LineAwesomeIcons.InfoCircleSolid,
143 | label = "About",
144 | onClick = {
145 | navController.navigate(Screen.About.route)
146 | onCloseDrawer()
147 | },
148 | isSelected = navController.currentDestination?.route == Screen.About.route
149 | )
150 |
151 | // Add version info at the bottom
152 | Spacer(modifier = Modifier.weight(1f))
153 |
154 | Text(
155 | text = "Version 1.0",
156 | style = MaterialTheme.typography.bodySmall,
157 | color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
158 | modifier = Modifier
159 | .padding(DrawerDimensions.SECTION_PADDING)
160 | .align(Alignment.CenterHorizontally)
161 | )
162 | }
163 | }
164 | }
165 |
166 | @Composable
167 | fun DrawerHeader() {
168 | Column(
169 | modifier = Modifier
170 | .fillMaxWidth()
171 | .padding(DrawerDimensions.HEADER_PADDING)
172 | ) {
173 | Text(
174 | text = "XposedFakeLocation",
175 | style = MaterialTheme.typography.headlineMedium,
176 | fontWeight = FontWeight.Bold,
177 | color = MaterialTheme.colorScheme.primary
178 | )
179 |
180 | Text(
181 | text = "Spoof your location easily",
182 | style = MaterialTheme.typography.bodyMedium,
183 | color = MaterialTheme.colorScheme.onSurfaceVariant
184 | )
185 | }
186 | }
187 |
188 | @Composable
189 | fun DrawerSectionHeader(title: String) {
190 | Text(
191 | text = title,
192 | style = MaterialTheme.typography.titleSmall,
193 | fontWeight = FontWeight.Medium,
194 | color = MaterialTheme.colorScheme.primary,
195 | modifier = Modifier.padding(
196 | start = DrawerDimensions.SECTION_PADDING,
197 | bottom = DrawerDimensions.SECTION_PADDING
198 | )
199 | )
200 | }
201 |
202 | @Composable
203 | fun DrawerItem(
204 | icon: ImageVector,
205 | label: String,
206 | onClick: () -> Unit,
207 | isSelected: Boolean = false,
208 | trailingIcon: @Composable (() -> Unit)? = null
209 | ) {
210 | val backgroundColor = if (isSelected) {
211 | MaterialTheme.colorScheme.primaryContainer
212 | } else {
213 | Color.Transparent
214 | }
215 |
216 | val contentColor = if (isSelected) {
217 | MaterialTheme.colorScheme.onPrimaryContainer
218 | } else {
219 | MaterialTheme.colorScheme.onSurface
220 | }
221 |
222 | Surface(
223 | modifier = Modifier
224 | .fillMaxWidth()
225 | .padding(vertical = DrawerDimensions.ITEM_SPACING)
226 | .clip(RoundedCornerShape(DrawerDimensions.ITEM_CORNER_RADIUS))
227 | .clickable(onClick = onClick),
228 | color = backgroundColor
229 | ) {
230 | Row(
231 | modifier = Modifier
232 | .fillMaxWidth()
233 | .padding(DrawerDimensions.ITEM_PADDING),
234 | verticalAlignment = Alignment.CenterVertically,
235 | horizontalArrangement = Arrangement.spacedBy(16.dp)
236 | ) {
237 | Icon(
238 | imageVector = icon,
239 | contentDescription = null,
240 | modifier = Modifier.size(DrawerDimensions.ICON_SIZE),
241 | tint = contentColor
242 | )
243 |
244 | Text(
245 | text = label,
246 | style = MaterialTheme.typography.bodyLarge,
247 | color = contentColor,
248 | overflow = TextOverflow.Ellipsis,
249 | modifier = Modifier.weight(1f)
250 | )
251 |
252 | trailingIcon?.invoke()
253 | }
254 | }
255 | }
256 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/xposed/hooks/LocationApiHooks.kt:
--------------------------------------------------------------------------------
1 | // LocationApiHooks.kt
2 | package com.noobexon.xposedfakelocation.xposed.hooks
3 |
4 | import android.location.Location
5 | import com.noobexon.xposedfakelocation.xposed.utils.LocationUtil
6 | import com.noobexon.xposedfakelocation.xposed.utils.PreferencesUtil
7 | import de.robv.android.xposed.XC_MethodHook
8 | import de.robv.android.xposed.XposedBridge
9 | import de.robv.android.xposed.XposedHelpers
10 | import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
11 |
12 | class LocationApiHooks(val appLpparam: LoadPackageParam) {
13 | private val tag = "[LocationApiHooks]"
14 |
15 | fun initHooks() {
16 | hookLocationAPI()
17 | XposedBridge.log("$tag Instantiated hooks successfully")
18 | }
19 |
20 | private fun hookLocationAPI() {
21 | hookLocation(appLpparam.classLoader)
22 | hookLocationManager(appLpparam.classLoader)
23 | }
24 |
25 | private fun hookLocation(classLoader: ClassLoader) {
26 | try {
27 | val locationClass = XposedHelpers.findClass("android.location.Location", classLoader)
28 |
29 | XposedHelpers.findAndHookMethod(
30 | locationClass,
31 | "getLatitude",
32 | object : XC_MethodHook() {
33 | override fun afterHookedMethod(param: MethodHookParam) {
34 | LocationUtil.updateLocation()
35 | XposedBridge.log("$tag Leaving method getLatitude()")
36 | XposedBridge.log("\t Original latitude: ${param.result as Double}")
37 | param.result = LocationUtil.latitude
38 | XposedBridge.log("\t Modified to: ${LocationUtil.latitude}")
39 | }
40 | })
41 |
42 | XposedHelpers.findAndHookMethod(
43 | locationClass,
44 | "getLongitude",
45 | object : XC_MethodHook() {
46 | override fun afterHookedMethod(param: MethodHookParam) {
47 | LocationUtil.updateLocation()
48 | XposedBridge.log("$tag Leaving method getLongitude()")
49 | XposedBridge.log("\t Original longitude: ${param.result as Double}")
50 | param.result = LocationUtil.longitude
51 | XposedBridge.log("\t Modified to: ${LocationUtil.longitude}")
52 | }
53 | })
54 |
55 | XposedHelpers.findAndHookMethod(
56 | locationClass,
57 | "getAccuracy",
58 | object : XC_MethodHook() {
59 | override fun afterHookedMethod(param: MethodHookParam) {
60 | LocationUtil.updateLocation()
61 | XposedBridge.log("$tag Leaving method getAccuracy()")
62 | XposedBridge.log("\t Original accuracy: ${param.result as Float}")
63 | if (PreferencesUtil.getUseAccuracy() == true) {
64 | param.result = LocationUtil.accuracy
65 | XposedBridge.log("\t Modified to: ${LocationUtil.accuracy}")
66 | }
67 | }
68 |
69 | })
70 |
71 | XposedHelpers.findAndHookMethod(
72 | locationClass,
73 | "getAltitude",
74 | object : XC_MethodHook() {
75 | override fun afterHookedMethod(param: MethodHookParam) {
76 | LocationUtil.updateLocation()
77 | XposedBridge.log("$tag Leaving method getAltitude()")
78 | XposedBridge.log("\t Original altitude: ${param.result as Double}")
79 | if (PreferencesUtil.getUseAltitude() == true) {
80 | param.result = LocationUtil.altitude
81 | XposedBridge.log("\t Modified to: ${LocationUtil.altitude}")
82 | }
83 | }
84 |
85 | })
86 |
87 | XposedHelpers.findAndHookMethod(
88 | locationClass,
89 | "getVerticalAccuracyMeters",
90 | object : XC_MethodHook() {
91 | override fun afterHookedMethod(param: MethodHookParam) {
92 | LocationUtil.updateLocation()
93 | XposedBridge.log("$tag Leaving method getVerticalAccuracyMeters()")
94 | XposedBridge.log("\tOriginal vertical accuracy: ${param.result as Float}")
95 | if (PreferencesUtil.getUseVerticalAccuracy() == true) {
96 | param.result = LocationUtil.verticalAccuracy
97 | XposedBridge.log("\tModified to: ${LocationUtil.verticalAccuracy}")
98 | }
99 | }
100 | })
101 |
102 | XposedHelpers.findAndHookMethod(
103 | locationClass,
104 | "getSpeed",
105 | object : XC_MethodHook() {
106 | override fun afterHookedMethod(param: MethodHookParam) {
107 | LocationUtil.updateLocation()
108 | XposedBridge.log("$tag Leaving method getSpeed()")
109 | XposedBridge.log("\tOriginal speed: ${param.result as Float}")
110 | if (PreferencesUtil.getUseSpeed() == true) {
111 | param.result = LocationUtil.speed
112 | XposedBridge.log("\tModified to: ${LocationUtil.speed}")
113 | }
114 | }
115 | })
116 |
117 | XposedHelpers.findAndHookMethod(
118 | locationClass,
119 | "getSpeedAccuracyMetersPerSecond",
120 | object : XC_MethodHook() {
121 | override fun afterHookedMethod(param: MethodHookParam) {
122 | LocationUtil.updateLocation()
123 | XposedBridge.log("$tag Leaving method getSpeedAccuracyMetersPerSecond()")
124 | XposedBridge.log("\tOriginal speed accuracy: ${param.result as Float}")
125 | if (PreferencesUtil.getUseSpeedAccuracy() == true) {
126 | param.result = LocationUtil.speedAccuracy
127 | XposedBridge.log("\tModified to: ${LocationUtil.speedAccuracy}")
128 | }
129 | }
130 | })
131 |
132 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
133 | XposedHelpers.findAndHookMethod(
134 | locationClass,
135 | "getMslAltitudeMeters",
136 | object : XC_MethodHook() {
137 | override fun afterHookedMethod(param: MethodHookParam) {
138 | LocationUtil.updateLocation()
139 | XposedBridge.log("$tag Leaving method getMslAltitudeMeters()")
140 | val originalMslAltitude = param.result as? Double
141 | XposedBridge.log("\tOriginal MSL altitude: $originalMslAltitude")
142 | if (PreferencesUtil.getUseMeanSeaLevel() == true) {
143 | param.result = LocationUtil.meanSeaLevel
144 | XposedBridge.log("\tModified to: ${LocationUtil.meanSeaLevel}")
145 | }
146 | }
147 | })
148 |
149 | // Hook getMslAltitudeAccuracyMeters()
150 | XposedHelpers.findAndHookMethod(
151 | locationClass,
152 | "getMslAltitudeAccuracyMeters",
153 | object : XC_MethodHook() {
154 | override fun afterHookedMethod(param: MethodHookParam) {
155 | LocationUtil.updateLocation()
156 | XposedBridge.log("$tag Leaving method getMslAltitudeAccuracyMeters()")
157 | val originalMslAltitudeAccuracy = param.result as? Float
158 | XposedBridge.log("\tOriginal MSL altitude accuracy: $originalMslAltitudeAccuracy")
159 | if (PreferencesUtil.getUseMeanSeaLevelAccuracy() == true) {
160 | param.result = LocationUtil.meanSeaLevelAccuracy
161 | XposedBridge.log("\tModified to: ${LocationUtil.meanSeaLevelAccuracy}")
162 | }
163 | }
164 | })
165 | } else {
166 | XposedBridge.log("$tag getMslAltitudeMeters() and getMslAltitudeAccuracyMeters() not available on this API level")
167 | }
168 |
169 | } catch (e: Exception) {
170 | XposedBridge.log("$tag Error hooking Location class - ${e.message}")
171 | }
172 | }
173 |
174 | private fun hookLocationManager(classLoader: ClassLoader) {
175 | try {
176 | val locationManagerClass = XposedHelpers.findClass("android.location.LocationManager", classLoader)
177 |
178 | XposedHelpers.findAndHookMethod(
179 | locationManagerClass,
180 | "getLastKnownLocation",
181 | String::class.java,
182 | object : XC_MethodHook() {
183 | override fun afterHookedMethod(param: MethodHookParam) {
184 | XposedBridge.log("$tag Leaving method getLastKnownLocation(provider)")
185 | XposedBridge.log("\t Original location: ${param.result as? Location}")
186 | val provider = param.args[0] as String
187 | XposedBridge.log("\t Requested data from: $provider")
188 | val fakeLocation = LocationUtil.createFakeLocation(provider = provider)
189 | param.result = fakeLocation
190 | XposedBridge.log("\t Modified location: $fakeLocation")
191 | }
192 | })
193 |
194 | } catch (e: Exception) {
195 | XposedBridge.log("$tag Error hooking LocationManager - ${e.message}")
196 | }
197 | }
198 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/settings/SettingsViewModel.kt:
--------------------------------------------------------------------------------
1 | //SettingsViewModel.kt
2 | package com.noobexon.xposedfakelocation.manager.ui.settings
3 |
4 | import android.app.Application
5 | import androidx.lifecycle.AndroidViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import com.noobexon.xposedfakelocation.data.*
8 | import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
9 | import kotlinx.coroutines.flow.*
10 | import kotlinx.coroutines.launch
11 |
12 | class SettingsViewModel(application: Application) : AndroidViewModel(application) {
13 | private val preferencesRepository = PreferencesRepository(application)
14 |
15 | // Generic state holders for different types of preferences
16 | private class BooleanPreference(
17 | initialValue: Boolean,
18 | private val flow: Flow,
19 | private val saveOperation: suspend (Boolean) -> Unit,
20 | private val viewModelScope: kotlinx.coroutines.CoroutineScope
21 | ) {
22 | private val _state = MutableStateFlow(initialValue)
23 | val state: StateFlow = _state.asStateFlow()
24 |
25 | init {
26 | viewModelScope.launch {
27 | flow.collect { _state.value = it }
28 | }
29 | }
30 |
31 | fun setValue(value: Boolean) {
32 | _state.value = value
33 | viewModelScope.launch {
34 | try {
35 | saveOperation(value)
36 | } catch (e: Exception) {
37 | // Add error handling if needed
38 | }
39 | }
40 | }
41 | }
42 |
43 | private class DoublePreference(
44 | initialValue: Double,
45 | private val flow: Flow,
46 | private val saveOperation: suspend (Double) -> Unit,
47 | private val viewModelScope: kotlinx.coroutines.CoroutineScope
48 | ) {
49 | private val _state = MutableStateFlow(initialValue)
50 | val state: StateFlow = _state.asStateFlow()
51 |
52 | init {
53 | viewModelScope.launch {
54 | flow.collect { _state.value = it }
55 | }
56 | }
57 |
58 | fun setValue(value: Double) {
59 | _state.value = value
60 | viewModelScope.launch {
61 | try {
62 | saveOperation(value)
63 | } catch (e: Exception) {
64 | // Add error handling if needed
65 | }
66 | }
67 | }
68 | }
69 |
70 | private class FloatPreference(
71 | initialValue: Float,
72 | private val flow: Flow,
73 | private val saveOperation: suspend (Float) -> Unit,
74 | private val viewModelScope: kotlinx.coroutines.CoroutineScope
75 | ) {
76 | private val _state = MutableStateFlow(initialValue)
77 | val state: StateFlow = _state.asStateFlow()
78 |
79 | init {
80 | viewModelScope.launch {
81 | flow.collect { _state.value = it }
82 | }
83 | }
84 |
85 | fun setValue(value: Float) {
86 | _state.value = value
87 | viewModelScope.launch {
88 | try {
89 | saveOperation(value)
90 | } catch (e: Exception) {
91 | // Add error handling if needed
92 | }
93 | }
94 | }
95 | }
96 |
97 | // Preferences for Accuracy
98 | private val _useAccuracyPreference = BooleanPreference(
99 | DEFAULT_USE_ACCURACY,
100 | preferencesRepository.getUseAccuracyFlow(),
101 | preferencesRepository::saveUseAccuracy,
102 | viewModelScope
103 | )
104 | val useAccuracy: StateFlow = _useAccuracyPreference.state
105 |
106 | private val _accuracyPreference = DoublePreference(
107 | DEFAULT_ACCURACY,
108 | preferencesRepository.getAccuracyFlow(),
109 | preferencesRepository::saveAccuracy,
110 | viewModelScope
111 | )
112 | val accuracy: StateFlow = _accuracyPreference.state
113 |
114 | // Preferences for Altitude
115 | private val _useAltitudePreference = BooleanPreference(
116 | DEFAULT_USE_ALTITUDE,
117 | preferencesRepository.getUseAltitudeFlow(),
118 | preferencesRepository::saveUseAltitude,
119 | viewModelScope
120 | )
121 | val useAltitude: StateFlow = _useAltitudePreference.state
122 |
123 | private val _altitudePreference = DoublePreference(
124 | DEFAULT_ALTITUDE,
125 | preferencesRepository.getAltitudeFlow(),
126 | preferencesRepository::saveAltitude,
127 | viewModelScope
128 | )
129 | val altitude: StateFlow = _altitudePreference.state
130 |
131 | // Preferences for Randomize
132 | private val _useRandomizePreference = BooleanPreference(
133 | DEFAULT_USE_RANDOMIZE,
134 | preferencesRepository.getUseRandomizeFlow(),
135 | preferencesRepository::saveUseRandomize,
136 | viewModelScope
137 | )
138 | val useRandomize: StateFlow = _useRandomizePreference.state
139 |
140 | private val _randomizeRadiusPreference = DoublePreference(
141 | DEFAULT_RANDOMIZE_RADIUS,
142 | preferencesRepository.getRandomizeRadiusFlow(),
143 | preferencesRepository::saveRandomizeRadius,
144 | viewModelScope
145 | )
146 | val randomizeRadius: StateFlow = _randomizeRadiusPreference.state
147 |
148 | // Preferences for Vertical Accuracy
149 | private val _useVerticalAccuracyPreference = BooleanPreference(
150 | DEFAULT_USE_VERTICAL_ACCURACY,
151 | preferencesRepository.getUseVerticalAccuracyFlow(),
152 | preferencesRepository::saveUseVerticalAccuracy,
153 | viewModelScope
154 | )
155 | val useVerticalAccuracy: StateFlow = _useVerticalAccuracyPreference.state
156 |
157 | private val _verticalAccuracyPreference = FloatPreference(
158 | DEFAULT_VERTICAL_ACCURACY,
159 | preferencesRepository.getVerticalAccuracyFlow(),
160 | preferencesRepository::saveVerticalAccuracy,
161 | viewModelScope
162 | )
163 | val verticalAccuracy: StateFlow = _verticalAccuracyPreference.state
164 |
165 | // Preferences for Mean Sea Level
166 | private val _useMeanSeaLevelPreference = BooleanPreference(
167 | DEFAULT_USE_MEAN_SEA_LEVEL,
168 | preferencesRepository.getUseMeanSeaLevelFlow(),
169 | preferencesRepository::saveUseMeanSeaLevel,
170 | viewModelScope
171 | )
172 | val useMeanSeaLevel: StateFlow = _useMeanSeaLevelPreference.state
173 |
174 | private val _meanSeaLevelPreference = DoublePreference(
175 | DEFAULT_MEAN_SEA_LEVEL,
176 | preferencesRepository.getMeanSeaLevelFlow(),
177 | preferencesRepository::saveMeanSeaLevel,
178 | viewModelScope
179 | )
180 | val meanSeaLevel: StateFlow = _meanSeaLevelPreference.state
181 |
182 | // Preferences for Mean Sea Level Accuracy
183 | private val _useMeanSeaLevelAccuracyPreference = BooleanPreference(
184 | DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY,
185 | preferencesRepository.getUseMeanSeaLevelAccuracyFlow(),
186 | preferencesRepository::saveUseMeanSeaLevelAccuracy,
187 | viewModelScope
188 | )
189 | val useMeanSeaLevelAccuracy: StateFlow = _useMeanSeaLevelAccuracyPreference.state
190 |
191 | private val _meanSeaLevelAccuracyPreference = FloatPreference(
192 | DEFAULT_MEAN_SEA_LEVEL_ACCURACY,
193 | preferencesRepository.getMeanSeaLevelAccuracyFlow(),
194 | preferencesRepository::saveMeanSeaLevelAccuracy,
195 | viewModelScope
196 | )
197 | val meanSeaLevelAccuracy: StateFlow = _meanSeaLevelAccuracyPreference.state
198 |
199 | // Preferences for Speed
200 | private val _useSpeedPreference = BooleanPreference(
201 | DEFAULT_USE_SPEED,
202 | preferencesRepository.getUseSpeedFlow(),
203 | preferencesRepository::saveUseSpeed,
204 | viewModelScope
205 | )
206 | val useSpeed: StateFlow = _useSpeedPreference.state
207 |
208 | private val _speedPreference = FloatPreference(
209 | DEFAULT_SPEED,
210 | preferencesRepository.getSpeedFlow(),
211 | preferencesRepository::saveSpeed,
212 | viewModelScope
213 | )
214 | val speed: StateFlow = _speedPreference.state
215 |
216 | // Preferences for Speed Accuracy
217 | private val _useSpeedAccuracyPreference = BooleanPreference(
218 | DEFAULT_USE_SPEED_ACCURACY,
219 | preferencesRepository.getUseSpeedAccuracyFlow(),
220 | preferencesRepository::saveUseSpeedAccuracy,
221 | viewModelScope
222 | )
223 | val useSpeedAccuracy: StateFlow = _useSpeedAccuracyPreference.state
224 |
225 | private val _speedAccuracyPreference = FloatPreference(
226 | DEFAULT_SPEED_ACCURACY,
227 | preferencesRepository.getSpeedAccuracyFlow(),
228 | preferencesRepository::saveSpeedAccuracy,
229 | viewModelScope
230 | )
231 | val speedAccuracy: StateFlow = _speedAccuracyPreference.state
232 |
233 | // Setter methods for all preferences
234 | fun setUseAccuracy(value: Boolean) = _useAccuracyPreference.setValue(value)
235 | fun setAccuracy(value: Double) = _accuracyPreference.setValue(value)
236 | fun setUseAltitude(value: Boolean) = _useAltitudePreference.setValue(value)
237 | fun setAltitude(value: Double) = _altitudePreference.setValue(value)
238 | fun setUseRandomize(value: Boolean) = _useRandomizePreference.setValue(value)
239 | fun setRandomizeRadius(value: Double) = _randomizeRadiusPreference.setValue(value)
240 | fun setUseVerticalAccuracy(value: Boolean) = _useVerticalAccuracyPreference.setValue(value)
241 | fun setVerticalAccuracy(value: Float) = _verticalAccuracyPreference.setValue(value)
242 | fun setUseMeanSeaLevel(value: Boolean) = _useMeanSeaLevelPreference.setValue(value)
243 | fun setMeanSeaLevel(value: Double) = _meanSeaLevelPreference.setValue(value)
244 | fun setUseMeanSeaLevelAccuracy(value: Boolean) = _useMeanSeaLevelAccuracyPreference.setValue(value)
245 | fun setMeanSeaLevelAccuracy(value: Float) = _meanSeaLevelAccuracyPreference.setValue(value)
246 | fun setUseSpeed(value: Boolean) = _useSpeedPreference.setValue(value)
247 | fun setSpeed(value: Float) = _speedPreference.setValue(value)
248 | fun setUseSpeedAccuracy(value: Boolean) = _useSpeedAccuracyPreference.setValue(value)
249 | fun setSpeedAccuracy(value: Float) = _speedAccuracyPreference.setValue(value)
250 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapScreen.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.map
2 |
3 | import android.widget.Toast
4 | import androidx.activity.compose.BackHandler
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.*
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.*
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.platform.LocalContext
12 | import androidx.compose.ui.unit.dp
13 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
14 | import androidx.navigation.NavController
15 | import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
16 | import com.noobexon.xposedfakelocation.manager.ui.drawer.DrawerContent
17 | import com.noobexon.xposedfakelocation.manager.ui.map.components.AddToFavoritesDialog
18 | import com.noobexon.xposedfakelocation.manager.ui.map.components.GoToPointDialog
19 | import com.noobexon.xposedfakelocation.manager.ui.map.components.MapViewContainer
20 | import com.noobexon.xposedfakelocation.manager.ui.navigation.Screen
21 | import kotlinx.coroutines.launch
22 |
23 | @OptIn(ExperimentalMaterial3Api::class)
24 | @Composable
25 | fun MapScreen(
26 | navController: NavController,
27 | mapViewModel: MapViewModel
28 | ) {
29 | val context = LocalContext.current
30 | val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
31 |
32 | // Extract values from UI state
33 | val isPlaying = uiState.isPlaying
34 | val isFabClickable = uiState.isFabClickable
35 | val isLoading = uiState.loadingState == LoadingState.Loading
36 |
37 | // Dialog states
38 | val showGoToPointDialog = uiState.goToPointDialogState == DialogState.Visible
39 | val showAddToFavoritesDialog = uiState.addToFavoritesDialogState == DialogState.Visible
40 |
41 | val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
42 | val scope = rememberCoroutineScope()
43 |
44 | var showOptionsMenu by remember { mutableStateOf(false) }
45 |
46 | // BackHandler to close the drawer when open
47 | BackHandler(enabled = drawerState.isOpen) {
48 | scope.launch { drawerState.close() }
49 | }
50 |
51 | // Scaffold with drawer
52 | ModalNavigationDrawer(
53 | drawerContent = {
54 | DrawerContent(
55 | onCloseDrawer = {
56 | scope.launch { drawerState.close() }
57 | },
58 | navController = navController
59 | )
60 | },
61 | scrimColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f), // Custom scrim color
62 | drawerState = drawerState,
63 | gesturesEnabled = false,
64 | modifier = Modifier.fillMaxSize()
65 | ) {
66 | Scaffold(
67 | modifier = Modifier.fillMaxSize(),
68 | topBar = {
69 | TopAppBar(
70 | title = { Text("XposedFakeLocation") },
71 | colors = TopAppBarDefaults.topAppBarColors(
72 | containerColor = MaterialTheme.colorScheme.primary,
73 | titleContentColor = MaterialTheme.colorScheme.onPrimary,
74 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
75 | actionIconContentColor = MaterialTheme.colorScheme.onPrimary
76 | ),
77 | navigationIcon = {
78 | IconButton(
79 | onClick = { scope.launch { drawerState.open() } }
80 | ) {
81 | Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu")
82 | }
83 | },
84 | actions = {
85 | IconButton(
86 | onClick = {
87 | mapViewModel.triggerCenterMapEvent()
88 | }
89 | ) {
90 | Icon(imageVector = Icons.Default.MyLocation, contentDescription = "Center")
91 | }
92 | IconButton(
93 | onClick = {
94 | showOptionsMenu = true
95 | }
96 | ) {
97 | Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Options")
98 | }
99 | DropdownMenu(
100 | expanded = showOptionsMenu,
101 | onDismissRequest = { showOptionsMenu = false }
102 | ) {
103 | DropdownMenuItem(
104 | leadingIcon = { Icon(imageVector = Icons.Default.LocationSearching, contentDescription = "Go to Point") },
105 | text = { Text("Go to Point") },
106 | onClick = {
107 | showOptionsMenu = false
108 | mapViewModel.showGoToPointDialog()
109 | }
110 | )
111 | DropdownMenuItem(
112 | leadingIcon = { Icon(imageVector = Icons.Default.FavoriteBorder, contentDescription = "Add to Favorites") },
113 | text = { Text("Add to Favorites") },
114 | onClick = {
115 | showOptionsMenu = false
116 | mapViewModel.showAddToFavoritesDialog()
117 | }
118 | )
119 | DropdownMenuItem(
120 | leadingIcon = { Icon(imageVector = Icons.Default.Star, contentDescription = "Favorites") },
121 | text = { Text("Favorites") },
122 | onClick = {
123 | showOptionsMenu = false
124 | navController.navigate(Screen.Favorites.route)
125 | }
126 | )
127 | // add clear location feature
128 | DropdownMenuItem(
129 | leadingIcon = { Icon(imageVector = Icons.Default.Clear, contentDescription = "Clear Location") },
130 | text = { Text("Clear Location") },
131 | onClick = {
132 | showOptionsMenu = false
133 | mapViewModel.updateClickedLocation(null)
134 | },
135 | enabled = isFabClickable // allow clearing only when a location is marked.
136 | )
137 | }
138 | }
139 | )
140 | },
141 | floatingActionButton = {
142 | FloatingActionButton(
143 | onClick = {
144 | if (isFabClickable) {
145 | val wasPlaying = uiState.isPlaying
146 | mapViewModel.togglePlaying()
147 | if (!wasPlaying) {
148 | Toast.makeText(context, "Fake Location Set", Toast.LENGTH_SHORT).show()
149 | } else {
150 | Toast.makeText(context, "Unset Fake Location", Toast.LENGTH_SHORT).show()
151 | }
152 | }
153 | },
154 | modifier = Modifier
155 | .navigationBarsPadding()
156 | .padding(16.dp),
157 | containerColor = if (isFabClickable) {
158 | MaterialTheme.colorScheme.primary
159 | } else {
160 | MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
161 | },
162 | contentColor = if (isFabClickable) {
163 | contentColorFor(MaterialTheme.colorScheme.primary)
164 | } else {
165 | MaterialTheme.colorScheme.onSurfaceVariant
166 | },
167 | elevation = FloatingActionButtonDefaults.elevation(
168 | defaultElevation = if (isFabClickable) 6.dp else 0.dp,
169 | pressedElevation = if (isFabClickable) 12.dp else 0.dp
170 | )
171 | ) {
172 | Icon(
173 | imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
174 | contentDescription = if (isPlaying) "Stop" else "Play"
175 | )
176 | }
177 | }
178 | ) { innerPadding ->
179 | Box(
180 | modifier = Modifier
181 | .fillMaxSize()
182 | .padding(innerPadding)
183 | ) {
184 | MapViewContainer(mapViewModel)
185 | }
186 | }
187 |
188 | if (showGoToPointDialog) {
189 | GoToPointDialog(
190 | onDismissRequest = { mapViewModel.hideGoToPointDialog() },
191 | onGoToPoint = { latitude, longitude ->
192 | mapViewModel.goToPoint(latitude, longitude)
193 | mapViewModel.hideGoToPointDialog()
194 | },
195 | mapViewModel = mapViewModel
196 | )
197 | }
198 |
199 | if (showAddToFavoritesDialog) {
200 | // Prefill coordinates from the last clicked location (marker)
201 | val lastClickedLocation = uiState.lastClickedLocation
202 |
203 | LaunchedEffect(lastClickedLocation) {
204 | mapViewModel.prefillCoordinatesFromMarker(
205 | lastClickedLocation?.latitude,
206 | lastClickedLocation?.longitude
207 | )
208 | }
209 |
210 | AddToFavoritesDialog(
211 | mapViewModel = mapViewModel,
212 | onDismissRequest = { mapViewModel.hideAddToFavoritesDialog() },
213 | onAddFavorite = { name, latitude, longitude ->
214 | val favorite = FavoriteLocation(name, latitude, longitude)
215 | mapViewModel.addFavoriteLocation(favorite)
216 | mapViewModel.hideAddToFavoritesDialog()
217 | }
218 | )
219 | }
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/MapViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.map
2 |
3 | import android.app.Application
4 | import androidx.lifecycle.AndroidViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM
7 | import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
8 | import com.noobexon.xposedfakelocation.data.repository.PreferencesRepository
9 | import kotlinx.coroutines.flow.*
10 | import kotlinx.coroutines.launch
11 | import org.osmdroid.util.GeoPoint
12 |
13 | /**
14 | * Sealed classes to represent different dialog states
15 | */
16 | sealed class DialogState {
17 | object Hidden : DialogState()
18 | object Visible : DialogState()
19 | }
20 |
21 | /**
22 | * Sealed class to represent different loading states
23 | */
24 | sealed class LoadingState {
25 | object Loading : LoadingState()
26 | object Loaded : LoadingState()
27 | }
28 |
29 | /**
30 | * ViewModel for the Map screen that manages map-related state and operations.
31 | */
32 | class MapViewModel(application: Application) : AndroidViewModel(application) {
33 | private val preferencesRepository = PreferencesRepository(application)
34 |
35 | /**
36 | * Represents field input state with value and validation error message
37 | */
38 | data class InputFieldState(val value: String = "", val errorMessage: String? = null)
39 |
40 | /**
41 | * Represents the UI state for the favorites input dialog
42 | */
43 | data class FavoritesInputState(
44 | val name: InputFieldState = InputFieldState(),
45 | val latitude: InputFieldState = InputFieldState(),
46 | val longitude: InputFieldState = InputFieldState()
47 | )
48 |
49 | /**
50 | * Represents the complete UI state for the Map screen
51 | */
52 | data class MapUiState(
53 | val isPlaying: Boolean = false,
54 | val lastClickedLocation: GeoPoint? = null,
55 | val userLocation: GeoPoint? = null,
56 | val loadingState: LoadingState = LoadingState.Loading,
57 | val mapZoom: Double? = null,
58 | val goToPointDialogState: DialogState = DialogState.Hidden,
59 | val addToFavoritesDialogState: DialogState = DialogState.Hidden,
60 | val goToPointState: Pair = InputFieldState() to InputFieldState(),
61 | val addToFavoritesState: FavoritesInputState = FavoritesInputState()
62 | ) {
63 | val isFabClickable: Boolean
64 | get() = lastClickedLocation != null
65 | }
66 |
67 | // Private mutable state
68 | private val _uiState = MutableStateFlow(MapUiState())
69 |
70 | // Public immutable state
71 | val uiState: StateFlow = _uiState.asStateFlow()
72 |
73 | // Events
74 | private val _goToPointEvent = MutableSharedFlow()
75 | val goToPointEvent: SharedFlow = _goToPointEvent.asSharedFlow()
76 |
77 | private val _centerMapEvent = MutableSharedFlow()
78 | val centerMapEvent: SharedFlow = _centerMapEvent.asSharedFlow()
79 |
80 | init {
81 | viewModelScope.launch {
82 | // Load initial isPlaying state
83 | preferencesRepository.getIsPlayingFlow().collectLatest { isPlaying ->
84 | _uiState.update { it.copy(isPlaying = isPlaying) }
85 | }
86 | }
87 |
88 | viewModelScope.launch {
89 | // Load initial lastClickedLocation
90 | preferencesRepository.getLastClickedLocationFlow().collectLatest { location ->
91 | val geoPoint = location?.let { GeoPoint(it.latitude, it.longitude) }
92 | _uiState.update { it.copy(lastClickedLocation = geoPoint) }
93 | }
94 | }
95 | }
96 |
97 | fun togglePlaying() {
98 | val currentIsPlaying = !_uiState.value.isPlaying
99 | _uiState.update { it.copy(isPlaying = currentIsPlaying) }
100 |
101 | viewModelScope.launch {
102 | preferencesRepository.saveIsPlaying(currentIsPlaying)
103 | }
104 | }
105 |
106 | fun updateUserLocation(location: GeoPoint) {
107 | _uiState.update { it.copy(userLocation = location) }
108 | }
109 |
110 | fun updateClickedLocation(geoPoint: GeoPoint?) {
111 | _uiState.update { it.copy(lastClickedLocation = geoPoint) }
112 |
113 | viewModelScope.launch {
114 | geoPoint?.let {
115 | preferencesRepository.saveLastClickedLocation(
116 | it.latitude,
117 | it.longitude
118 | )
119 | } ?: preferencesRepository.clearLastClickedLocation()
120 | }
121 | }
122 |
123 | fun addFavoriteLocation(favoriteLocation: FavoriteLocation) {
124 | viewModelScope.launch {
125 | preferencesRepository.addFavorite(favoriteLocation)
126 | }
127 | }
128 |
129 | // Update specific fields in the FavoritesInputState
130 | fun updateAddToFavoritesField(fieldName: String, newValue: String) {
131 | val currentState = _uiState.value.addToFavoritesState
132 | val errorMessage = when (fieldName) {
133 | "name" -> if (newValue.isBlank()) "Please provide a name" else null
134 | "latitude" -> validateInput(newValue, -90.0..90.0, "Latitude must be between -90 and 90")
135 | "longitude" -> validateInput(newValue, -180.0..180.0, "Longitude must be between -180 and 180")
136 | else -> null
137 | }
138 |
139 | val updatedState = when (fieldName) {
140 | "name" -> currentState.copy(name = currentState.name.copy(value = newValue, errorMessage = errorMessage))
141 | "latitude" -> currentState.copy(latitude = currentState.latitude.copy(value = newValue, errorMessage = errorMessage))
142 | "longitude" -> currentState.copy(longitude = currentState.longitude.copy(value = newValue, errorMessage = errorMessage))
143 | else -> currentState
144 | }
145 |
146 | _uiState.update { it.copy(addToFavoritesState = updatedState) }
147 | }
148 |
149 | // Go to point logic
150 | fun goToPoint(latitude: Double, longitude: Double) {
151 | viewModelScope.launch {
152 | _goToPointEvent.emit(GeoPoint(latitude, longitude))
153 | }
154 | }
155 |
156 | // Update specific fields in the GoToPointDialog state
157 | fun updateGoToPointField(fieldName: String, newValue: String) {
158 | val (latitudeField, longitudeField) = _uiState.value.goToPointState
159 | val updatedGoToPointState = when (fieldName) {
160 | "latitude" -> latitudeField.copy(value = newValue) to longitudeField
161 | "longitude" -> latitudeField to longitudeField.copy(value = newValue)
162 | else -> latitudeField to longitudeField
163 | }
164 |
165 | _uiState.update { it.copy(goToPointState = updatedGoToPointState) }
166 | }
167 |
168 | // Center map
169 | fun triggerCenterMapEvent() {
170 | viewModelScope.launch {
171 | _centerMapEvent.emit(Unit)
172 | }
173 | }
174 |
175 | fun setLoadingStarted() {
176 | _uiState.update { it.copy(loadingState = LoadingState.Loading) }
177 | }
178 |
179 | // Set loading finished
180 | fun setLoadingFinished() {
181 | _uiState.update { it.copy(loadingState = LoadingState.Loaded) }
182 | }
183 |
184 | // Dialog show/hide logic
185 | fun showGoToPointDialog() {
186 | _uiState.update { it.copy(goToPointDialogState = DialogState.Visible) }
187 | }
188 |
189 | fun hideGoToPointDialog() {
190 | _uiState.update { it.copy(goToPointDialogState = DialogState.Hidden) }
191 | clearGoToPointInputs()
192 | }
193 |
194 | fun showAddToFavoritesDialog() {
195 | _uiState.update { it.copy(addToFavoritesDialogState = DialogState.Visible) }
196 | }
197 |
198 | fun hideAddToFavoritesDialog() {
199 | _uiState.update { it.copy(addToFavoritesDialogState = DialogState.Hidden) }
200 | clearAddToFavoritesInputs()
201 | }
202 |
203 | // Helper for input validation
204 | private fun validateInput(
205 | input: String, range: ClosedRange, errorMessage: String
206 | ): String? {
207 | val value = input.toDoubleOrNull()
208 | return if (value == null || value !in range) errorMessage else null
209 | }
210 |
211 | // Validate GoToPoint inputs
212 | fun validateAndGo(onSuccess: (latitude: Double, longitude: Double) -> Unit) {
213 | val (latField, lonField) = _uiState.value.goToPointState
214 | val latitudeError = validateInput(latField.value, -90.0..90.0, "Latitude must be between -90 and 90")
215 | val longitudeError = validateInput(lonField.value, -180.0..180.0, "Longitude must be between -180 and 180")
216 |
217 | val updatedGoToPointState = latField.copy(errorMessage = latitudeError) to lonField.copy(errorMessage = longitudeError)
218 | _uiState.update { it.copy(goToPointState = updatedGoToPointState) }
219 |
220 | if (latitudeError == null && longitudeError == null) {
221 | onSuccess(latField.value.toDouble(), lonField.value.toDouble())
222 | }
223 | }
224 |
225 | // Clear GoToPoint inputs
226 | fun clearGoToPointInputs() {
227 | _uiState.update {
228 | it.copy(goToPointState = InputFieldState() to InputFieldState())
229 | }
230 | }
231 |
232 | // Prefill AddToFavorites latitude/longitude with marker values (if available)
233 | fun prefillCoordinatesFromMarker(latitude: Double?, longitude: Double?) {
234 | if (latitude != null && longitude != null) {
235 | val latField = InputFieldState(value = latitude.toString())
236 | val lngField = InputFieldState(value = longitude.toString())
237 |
238 | _uiState.update { currentState ->
239 | val favState = currentState.addToFavoritesState
240 | currentState.copy(
241 | addToFavoritesState = favState.copy(
242 | latitude = latField,
243 | longitude = lngField
244 | )
245 | )
246 | }
247 | }
248 | }
249 |
250 | // Validate and add favorite location
251 | fun validateAndAddFavorite(onSuccess: (name: String, latitude: Double, longitude: Double) -> Unit) {
252 | val currentState = _uiState.value.addToFavoritesState
253 |
254 | val latitudeError = validateInput(currentState.latitude.value, -90.0..90.0, "Latitude must be between -90 and 90")
255 | val longitudeError = validateInput(currentState.longitude.value, -180.0..180.0, "Longitude must be between -180 and 180")
256 | val nameError = if (currentState.name.value.isBlank()) "Please provide a name" else null
257 |
258 | val updatedState = currentState.copy(
259 | name = currentState.name.copy(errorMessage = nameError),
260 | latitude = currentState.latitude.copy(errorMessage = latitudeError),
261 | longitude = currentState.longitude.copy(errorMessage = longitudeError)
262 | )
263 |
264 | _uiState.update { it.copy(addToFavoritesState = updatedState) }
265 |
266 | if (nameError == null && latitudeError == null && longitudeError == null) {
267 | onSuccess(currentState.name.value, currentState.latitude.value.toDouble(), currentState.longitude.value.toDouble())
268 | }
269 | }
270 |
271 | // Clear AddToFavorites inputs
272 | fun clearAddToFavoritesInputs() {
273 | _uiState.update { it.copy(addToFavoritesState = FavoritesInputState()) }
274 | }
275 |
276 | // Update map zoom level
277 | fun updateMapZoom(zoom: Double) {
278 | _uiState.update { it.copy(mapZoom = zoom) }
279 | }
280 | }
281 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/map/components/MapViewContainer.kt:
--------------------------------------------------------------------------------
1 | package com.noobexon.xposedfakelocation.manager.ui.map.components
2 |
3 | import android.content.Context
4 | import android.widget.Toast
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.material3.CircularProgressIndicator
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.*
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.platform.LocalContext
17 | import androidx.compose.ui.text.style.TextAlign
18 | import androidx.compose.ui.unit.dp
19 | import androidx.compose.ui.viewinterop.AndroidView
20 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
21 | import com.noobexon.xposedfakelocation.data.DEFAULT_MAP_ZOOM
22 | import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_DELAY_MS
23 | import com.noobexon.xposedfakelocation.data.LOCATION_DETECTION_MAX_ATTEMPTS
24 | import com.noobexon.xposedfakelocation.data.WORLD_MAP_ZOOM
25 | import com.noobexon.xposedfakelocation.manager.ui.map.DialogState
26 | import com.noobexon.xposedfakelocation.manager.ui.map.LoadingState
27 | import com.noobexon.xposedfakelocation.manager.ui.map.MapViewModel
28 | import kotlinx.coroutines.delay
29 | import kotlinx.coroutines.flow.collect
30 | import org.osmdroid.events.MapEventsReceiver
31 | import org.osmdroid.events.MapListener
32 | import org.osmdroid.events.ScrollEvent
33 | import org.osmdroid.events.ZoomEvent
34 | import org.osmdroid.tileprovider.tilesource.TileSourceFactory
35 | import org.osmdroid.util.GeoPoint
36 | import org.osmdroid.views.MapView
37 | import org.osmdroid.views.overlay.MapEventsOverlay
38 | import org.osmdroid.views.overlay.Marker
39 | import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
40 | import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
41 |
42 | @Composable
43 | fun MapViewContainer(
44 | mapViewModel: MapViewModel
45 | ) {
46 | val context = LocalContext.current
47 | val uiState by mapViewModel.uiState.collectAsStateWithLifecycle()
48 |
49 | // Extract state from uiState
50 | val loadingState = uiState.loadingState
51 | val lastClickedLocation = uiState.lastClickedLocation
52 | val isPlaying = uiState.isPlaying
53 | val mapZoom = uiState.mapZoom
54 |
55 | // Remember MapView and overlays
56 | val mapView = rememberMapView(context)
57 | val userMarker = rememberUserMarker(mapView)
58 | val locationOverlay = rememberLocationOverlay(context, mapView)
59 |
60 | // Add the location overlay to the map
61 | AddLocationOverlayToMap(mapView, locationOverlay)
62 |
63 | // Handle map events and updates
64 | HandleCenterMapEvent(mapView, locationOverlay, mapViewModel)
65 | HandleGoToPointEvent(mapView, mapViewModel)
66 | HandleMarkerUpdates(mapView, userMarker, lastClickedLocation)
67 | SetupMapClickListener(mapView, mapViewModel, isPlaying)
68 | CenterMapOnUserLocation(mapView, locationOverlay, mapViewModel, lastClickedLocation, mapZoom)
69 | ManageMapViewLifecycle(mapView, mapViewModel, locationOverlay)
70 |
71 | // Add MapListener to update zoom level
72 | DisposableEffect(mapView) {
73 | val mapListener = object : MapListener {
74 | override fun onScroll(event: ScrollEvent?): Boolean {
75 | // Optional: update map center if needed
76 | return false
77 | }
78 |
79 | override fun onZoom(event: ZoomEvent?): Boolean {
80 | // Update zoom state through proper ViewModel methods
81 | // This will be handled by the ViewModel's state update logic
82 | mapViewModel.updateMapZoom(mapView.zoomLevelDouble)
83 | return true
84 | }
85 | }
86 | mapView.addMapListener(mapListener)
87 |
88 | onDispose {
89 | mapView.removeMapListener(mapListener)
90 | }
91 | }
92 |
93 | // Display loading spinner or MapView
94 | if (loadingState == LoadingState.Loading) {
95 | LoadingSpinner()
96 | } else {
97 | DisplayMapView(mapView)
98 | }
99 | }
100 |
101 | @Composable
102 | private fun rememberMapView(context: Context): MapView {
103 | return remember {
104 | MapView(context).apply {
105 | setTileSource(TileSourceFactory.MAPNIK)
106 | setBuiltInZoomControls(false)
107 | setMultiTouchControls(true)
108 | }
109 | }
110 | }
111 |
112 | @Composable
113 | private fun rememberUserMarker(mapView: MapView): Marker {
114 | return remember {
115 | Marker(mapView).apply {
116 | setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
117 | }
118 | }
119 | }
120 |
121 | @Composable
122 | private fun rememberLocationOverlay(context: Context, mapView: MapView): MyLocationNewOverlay {
123 | return remember {
124 | MyLocationNewOverlay(GpsMyLocationProvider(context), mapView).apply {
125 | enableMyLocation()
126 | }
127 | }
128 | }
129 |
130 | @Composable
131 | private fun AddLocationOverlayToMap(
132 | mapView: MapView,
133 | locationOverlay: MyLocationNewOverlay
134 | ) {
135 | LaunchedEffect(Unit) {
136 | if (!mapView.overlays.contains(locationOverlay)) {
137 | mapView.overlays.add(locationOverlay)
138 | }
139 | }
140 | }
141 |
142 | @Composable
143 | private fun HandleCenterMapEvent(
144 | mapView: MapView,
145 | locationOverlay: MyLocationNewOverlay,
146 | mapViewModel: MapViewModel
147 | ) {
148 | val context = LocalContext.current
149 | LaunchedEffect(Unit) {
150 | mapViewModel.centerMapEvent.collect {
151 | val userLocation = locationOverlay.myLocation
152 | if (userLocation != null) {
153 | mapView.controller.animateTo(userLocation)
154 | } else {
155 | Toast.makeText(context, "User location not available", Toast.LENGTH_SHORT).show()
156 | }
157 | }
158 | }
159 | }
160 |
161 | @Composable
162 | private fun HandleGoToPointEvent(
163 | mapView: MapView,
164 | mapViewModel: MapViewModel
165 | ) {
166 | LaunchedEffect(Unit) {
167 | mapViewModel.goToPointEvent.collect { geoPoint ->
168 | mapView.controller.animateTo(geoPoint)
169 | mapViewModel.updateClickedLocation(geoPoint)
170 | }
171 | }
172 | }
173 |
174 | @Composable
175 | private fun HandleMarkerUpdates(
176 | mapView: MapView,
177 | userMarker: Marker,
178 | lastClickedLocation: GeoPoint?,
179 | ) {
180 | LaunchedEffect(lastClickedLocation) {
181 | if (lastClickedLocation != null) {
182 | // Add the marker to the map if not already added
183 | if (!mapView.overlays.contains(userMarker)) {
184 | mapView.overlays.add(userMarker)
185 | }
186 | userMarker.position = lastClickedLocation
187 | mapView.controller.animateTo(lastClickedLocation)
188 | mapView.invalidate()
189 | } else {
190 | // Remove the marker from the map if it exists
191 | if (mapView.overlays.contains(userMarker)) {
192 | mapView.overlays.remove(userMarker)
193 | mapView.invalidate()
194 | }
195 | }
196 | }
197 | }
198 |
199 | @Composable
200 | private fun SetupMapClickListener(
201 | mapView: MapView,
202 | mapViewModel: MapViewModel,
203 | isPlaying: Boolean
204 | ) {
205 | DisposableEffect(mapView, isPlaying) {
206 | val mapEventsReceiver = object : MapEventsReceiver {
207 | override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
208 | if (!isPlaying) {
209 | mapViewModel.updateClickedLocation(p)
210 | }
211 | return true
212 | }
213 |
214 | override fun longPressHelper(p: GeoPoint): Boolean {
215 | return false
216 | }
217 | }
218 |
219 | val mapEventsOverlay = MapEventsOverlay(mapEventsReceiver)
220 | mapView.overlays.add(mapEventsOverlay)
221 |
222 | onDispose {
223 | mapView.overlays.remove(mapEventsOverlay)
224 | }
225 | }
226 | }
227 |
228 | @Composable
229 | private fun CenterMapOnUserLocation(
230 | mapView: MapView,
231 | locationOverlay: MyLocationNewOverlay,
232 | mapViewModel: MapViewModel,
233 | lastClickedLocation: GeoPoint?,
234 | mapZoom: Double?
235 | ) {
236 | LaunchedEffect(mapView, lastClickedLocation) {
237 | if (lastClickedLocation != null) {
238 | centerOnMarkerLocation(mapView, lastClickedLocation, mapZoom, mapViewModel)
239 | } else {
240 | if (!tryToFindAndCenterUserLocation(mapView, locationOverlay, mapViewModel)) {
241 | centerOnDefaultLocation(mapView, mapViewModel)
242 | }
243 | }
244 | }
245 | }
246 |
247 | /**
248 | * Centers the map on a specific marker location
249 | */
250 | private suspend fun centerOnMarkerLocation(
251 | mapView: MapView,
252 | markerLocation: GeoPoint,
253 | mapZoom: Double?,
254 | mapViewModel: MapViewModel
255 | ) {
256 | // If marker exists, center on it using stored zoom level
257 | val zoom = mapZoom ?: mapView.zoomLevelDouble
258 | mapView.controller.setZoom(zoom)
259 | mapView.controller.animateTo(markerLocation)
260 | mapViewModel.setLoadingFinished()
261 | }
262 |
263 | /**
264 | * Attempts to find and center on the user's current location
265 | * @return true if user location was found, false otherwise
266 | */
267 | private suspend fun tryToFindAndCenterUserLocation(
268 | mapView: MapView,
269 | locationOverlay: MyLocationNewOverlay,
270 | mapViewModel: MapViewModel
271 | ): Boolean {
272 | // Attempt to find user location within a timeout period
273 | repeat(LOCATION_DETECTION_MAX_ATTEMPTS) {
274 | val userLocation = locationOverlay.myLocation
275 | if (userLocation != null) {
276 | mapViewModel.updateUserLocation(userLocation)
277 | mapView.controller.setZoom(DEFAULT_MAP_ZOOM)
278 | mapView.controller.animateTo(userLocation)
279 | mapViewModel.updateMapZoom(DEFAULT_MAP_ZOOM)
280 | mapViewModel.setLoadingFinished()
281 | return true
282 | }
283 | delay(LOCATION_DETECTION_DELAY_MS)
284 | }
285 | return false
286 | }
287 |
288 | /**
289 | * Centers the map on a default world location when user location can't be found
290 | */
291 | private fun centerOnDefaultLocation(
292 | mapView: MapView,
293 | mapViewModel: MapViewModel
294 | ) {
295 | // If location is not available after timeout, set default location
296 | mapView.controller.setZoom(WORLD_MAP_ZOOM)
297 | mapView.controller.setCenter(GeoPoint(0.0, 0.0))
298 | mapViewModel.updateMapZoom(WORLD_MAP_ZOOM)
299 | mapViewModel.setLoadingFinished()
300 | }
301 |
302 | @Composable
303 | private fun ManageMapViewLifecycle(
304 | mapView: MapView,
305 | mapViewModel: MapViewModel,
306 | locationOverlay: MyLocationNewOverlay
307 | ) {
308 | DisposableEffect(Unit) {
309 | mapView.onResume()
310 | locationOverlay.enableMyLocation()
311 | onDispose {
312 | locationOverlay.disableMyLocation()
313 | mapView.overlays.clear()
314 | mapView.onPause()
315 | mapView.onDetach()
316 | mapViewModel.setLoadingStarted()
317 | }
318 | }
319 | }
320 |
321 | @Composable
322 | private fun LoadingSpinner() {
323 | Box(
324 | modifier = Modifier.fillMaxSize(),
325 | contentAlignment = Alignment.Center
326 | ) {
327 | Column(
328 | horizontalAlignment = Alignment.CenterHorizontally
329 | ) {
330 | CircularProgressIndicator()
331 | Spacer(modifier = Modifier.height(16.dp))
332 | Text(
333 | text = "Updating Map...",
334 | style = MaterialTheme.typography.bodyLarge,
335 | textAlign = TextAlign.Center
336 | )
337 | }
338 | }
339 | }
340 |
341 | @Composable
342 | private fun DisplayMapView(mapView: MapView) {
343 | AndroidView(
344 | factory = { mapView },
345 | modifier = Modifier.fillMaxSize()
346 | )
347 | }
348 |
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/data/repository/PrefrencesRepository.kt:
--------------------------------------------------------------------------------
1 | // PreferencesRepository.kt
2 | package com.noobexon.xposedfakelocation.data.repository
3 |
4 | import android.annotation.SuppressLint
5 | import android.content.Context
6 | import android.util.Log
7 | import androidx.datastore.core.DataStore
8 | import androidx.datastore.preferences.core.*
9 | import androidx.datastore.preferences.preferencesDataStore
10 | import com.google.gson.Gson
11 | import com.google.gson.JsonSyntaxException
12 | import com.google.gson.reflect.TypeToken
13 | import com.noobexon.xposedfakelocation.data.*
14 | import com.noobexon.xposedfakelocation.data.model.FavoriteLocation
15 | import com.noobexon.xposedfakelocation.data.model.LastClickedLocation
16 | import kotlinx.coroutines.flow.Flow
17 | import kotlinx.coroutines.flow.catch
18 | import kotlinx.coroutines.flow.firstOrNull
19 | import kotlinx.coroutines.flow.map
20 | import java.io.IOException
21 |
22 | private val Context.dataStore: DataStore by preferencesDataStore(name = SHARED_PREFS_FILE)
23 |
24 | class PreferencesRepository(private val context: Context) {
25 | private val tag = "PreferencesRepository"
26 |
27 | // Legacy SharedPreferences for Xposed Module compatibility
28 | @SuppressLint("WorldReadableFiles")
29 | private val sharedPrefs = try {
30 | context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_WORLD_READABLE)
31 | } catch (e: SecurityException) {
32 | Log.w(tag, "MODE_WORLD_READABLE not available: ${e.message}")
33 | context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE)
34 | }
35 |
36 | private val gson = Gson()
37 |
38 | // DataStore preference keys
39 | private object PreferenceKeys {
40 | val IS_PLAYING = booleanPreferencesKey(KEY_IS_PLAYING)
41 | val LAST_CLICKED_LOCATION = stringPreferencesKey(KEY_LAST_CLICKED_LOCATION)
42 | val USE_ACCURACY = booleanPreferencesKey(KEY_USE_ACCURACY)
43 | val ACCURACY = doublePreferencesKey(KEY_ACCURACY)
44 | val USE_ALTITUDE = booleanPreferencesKey(KEY_USE_ALTITUDE)
45 | val ALTITUDE = doublePreferencesKey(KEY_ALTITUDE)
46 | val USE_RANDOMIZE = booleanPreferencesKey(KEY_USE_RANDOMIZE)
47 | val RANDOMIZE_RADIUS = doublePreferencesKey(KEY_RANDOMIZE_RADIUS)
48 | val USE_VERTICAL_ACCURACY = booleanPreferencesKey(KEY_USE_VERTICAL_ACCURACY)
49 | val VERTICAL_ACCURACY = floatPreferencesKey(KEY_VERTICAL_ACCURACY)
50 | val USE_MEAN_SEA_LEVEL = booleanPreferencesKey(KEY_USE_MEAN_SEA_LEVEL)
51 | val MEAN_SEA_LEVEL = doublePreferencesKey(KEY_MEAN_SEA_LEVEL)
52 | val USE_MEAN_SEA_LEVEL_ACCURACY = booleanPreferencesKey(KEY_USE_MEAN_SEA_LEVEL_ACCURACY)
53 | val MEAN_SEA_LEVEL_ACCURACY = floatPreferencesKey(KEY_MEAN_SEA_LEVEL_ACCURACY)
54 | val USE_SPEED = booleanPreferencesKey(KEY_USE_SPEED)
55 | val SPEED = floatPreferencesKey(KEY_SPEED)
56 | val USE_SPEED_ACCURACY = booleanPreferencesKey(KEY_USE_SPEED_ACCURACY)
57 | val SPEED_ACCURACY = floatPreferencesKey(KEY_SPEED_ACCURACY)
58 | val FAVORITES = stringPreferencesKey(KEY_FAVORITES)
59 | }
60 |
61 | // Generic helper for DataStore flows with error handling
62 | private fun getPreferenceFlow(key: Preferences.Key, defaultValue: T): Flow {
63 | return context.dataStore.data
64 | .catch { exception ->
65 | if (exception is IOException) {
66 | Log.e(tag, "Error reading preferences: ${exception.message}")
67 | emit(emptyPreferences())
68 | } else {
69 | throw exception
70 | }
71 | }
72 | .map { preferences ->
73 | preferences[key] ?: defaultValue
74 | }
75 | }
76 |
77 | // Helper to write both to DataStore and legacy SharedPreferences
78 | private suspend inline fun savePreference(
79 | key: Preferences.Key,
80 | value: T,
81 | sharedPrefsKey: String,
82 | sharedPrefsValue: Any
83 | ) {
84 | try {
85 | // Save to DataStore
86 | context.dataStore.edit { preferences ->
87 | preferences[key] = value
88 | }
89 |
90 | // Save to legacy SharedPreferences for Xposed Module
91 | when (value) {
92 | is Boolean -> sharedPrefs.edit().putBoolean(sharedPrefsKey, value).apply()
93 | is String -> sharedPrefs.edit().putString(sharedPrefsKey, value).apply()
94 | is Float -> sharedPrefs.edit().putFloat(sharedPrefsKey, value).apply()
95 | is Double -> {
96 | val bits = java.lang.Double.doubleToRawLongBits(value)
97 | sharedPrefs.edit().putLong(sharedPrefsKey, bits).apply()
98 | }
99 | is Long -> sharedPrefs.edit().putLong(sharedPrefsKey, value).apply()
100 | is Int -> sharedPrefs.edit().putInt(sharedPrefsKey, value).apply()
101 | }
102 |
103 | Log.d(tag, "Saved $sharedPrefsKey: $value")
104 | } catch (e: Exception) {
105 | Log.e(tag, "Error saving preference $sharedPrefsKey: ${e.message}")
106 | }
107 | }
108 |
109 | // Is Playing
110 | fun getIsPlayingFlow(): Flow {
111 | return getPreferenceFlow(PreferenceKeys.IS_PLAYING, DEFAULT_USE_ACCURACY)
112 | }
113 |
114 | suspend fun saveIsPlaying(isPlaying: Boolean) {
115 | savePreference(PreferenceKeys.IS_PLAYING, isPlaying, KEY_IS_PLAYING, isPlaying)
116 | }
117 |
118 | // For backward compatibility
119 | fun getIsPlaying(): Boolean {
120 | return sharedPrefs.getBoolean(KEY_IS_PLAYING, false)
121 | }
122 |
123 | // Last Clicked Location
124 | fun getLastClickedLocationFlow(): Flow {
125 | return context.dataStore.data
126 | .catch { exception ->
127 | if (exception is IOException) {
128 | Log.e(tag, "Error reading preferences: ${exception.message}")
129 | emit(emptyPreferences())
130 | } else {
131 | throw exception
132 | }
133 | }
134 | .map { preferences ->
135 | val json = preferences[PreferenceKeys.LAST_CLICKED_LOCATION]
136 | if (json != null) {
137 | try {
138 | gson.fromJson(json, LastClickedLocation::class.java)
139 | } catch (e: JsonSyntaxException) {
140 | Log.e(tag, "Error parsing LastClickedLocation: ${e.message}")
141 | null
142 | }
143 | } else {
144 | null
145 | }
146 | }
147 | }
148 |
149 | suspend fun saveLastClickedLocation(latitude: Double, longitude: Double) {
150 | try {
151 | val location = LastClickedLocation(latitude, longitude)
152 | val json = gson.toJson(location)
153 | savePreference(PreferenceKeys.LAST_CLICKED_LOCATION, json, KEY_LAST_CLICKED_LOCATION, json)
154 | } catch (e: Exception) {
155 | Log.e(tag, "Error saving LastClickedLocation: ${e.message}")
156 | }
157 | }
158 |
159 | // For backward compatibility
160 | fun getLastClickedLocation(): LastClickedLocation? {
161 | val json = sharedPrefs.getString(KEY_LAST_CLICKED_LOCATION, null)
162 | return if (json != null) {
163 | try {
164 | gson.fromJson(json, LastClickedLocation::class.java)
165 | } catch (e: JsonSyntaxException) {
166 | Log.e(tag, "Error parsing LastClickedLocation: ${e.message}")
167 | null
168 | }
169 | } else {
170 | null
171 | }
172 | }
173 |
174 | suspend fun clearLastClickedLocation() {
175 | try {
176 | context.dataStore.edit { preferences ->
177 | preferences.remove(PreferenceKeys.LAST_CLICKED_LOCATION)
178 | }
179 |
180 | sharedPrefs.edit()
181 | .remove(KEY_LAST_CLICKED_LOCATION)
182 | .apply()
183 |
184 | saveIsPlaying(false)
185 | Log.d(tag, "Cleared 'LastClickedLocation' from preferences and set 'IsPlaying' to false")
186 | } catch (e: Exception) {
187 | Log.e(tag, "Error clearing LastClickedLocation: ${e.message}")
188 | }
189 | }
190 |
191 | // Use Accuracy
192 | fun getUseAccuracyFlow(): Flow {
193 | return getPreferenceFlow(PreferenceKeys.USE_ACCURACY, DEFAULT_USE_ACCURACY)
194 | }
195 |
196 | suspend fun saveUseAccuracy(useAccuracy: Boolean) {
197 | savePreference(PreferenceKeys.USE_ACCURACY, useAccuracy, KEY_USE_ACCURACY, useAccuracy)
198 | }
199 |
200 | // For backward compatibility
201 | fun getUseAccuracy(): Boolean {
202 | return sharedPrefs.getBoolean(KEY_USE_ACCURACY, DEFAULT_USE_ACCURACY)
203 | }
204 |
205 | // Accuracy
206 | fun getAccuracyFlow(): Flow {
207 | return getPreferenceFlow(PreferenceKeys.ACCURACY, DEFAULT_ACCURACY)
208 | }
209 |
210 | suspend fun saveAccuracy(accuracy: Double) {
211 | savePreference(PreferenceKeys.ACCURACY, accuracy, KEY_ACCURACY, accuracy)
212 | }
213 |
214 | // For backward compatibility
215 | fun getAccuracy(): Double {
216 | val bits = sharedPrefs.getLong(KEY_ACCURACY, java.lang.Double.doubleToRawLongBits(DEFAULT_ACCURACY))
217 | return java.lang.Double.longBitsToDouble(bits)
218 | }
219 |
220 | // Use Altitude
221 | fun getUseAltitudeFlow(): Flow {
222 | return getPreferenceFlow(PreferenceKeys.USE_ALTITUDE, DEFAULT_USE_ALTITUDE)
223 | }
224 |
225 | suspend fun saveUseAltitude(useAltitude: Boolean) {
226 | savePreference(PreferenceKeys.USE_ALTITUDE, useAltitude, KEY_USE_ALTITUDE, useAltitude)
227 | }
228 |
229 | // For backward compatibility
230 | fun getUseAltitude(): Boolean {
231 | return sharedPrefs.getBoolean(KEY_USE_ALTITUDE, DEFAULT_USE_ALTITUDE)
232 | }
233 |
234 | // Altitude
235 | fun getAltitudeFlow(): Flow {
236 | return getPreferenceFlow(PreferenceKeys.ALTITUDE, DEFAULT_ALTITUDE)
237 | }
238 |
239 | suspend fun saveAltitude(altitude: Double) {
240 | savePreference(PreferenceKeys.ALTITUDE, altitude, KEY_ALTITUDE, altitude)
241 | }
242 |
243 | // For backward compatibility
244 | fun getAltitude(): Double {
245 | val bits = sharedPrefs.getLong(KEY_ALTITUDE, java.lang.Double.doubleToRawLongBits(DEFAULT_ALTITUDE))
246 | return java.lang.Double.longBitsToDouble(bits)
247 | }
248 |
249 | // Use Randomize
250 | fun getUseRandomizeFlow(): Flow {
251 | return getPreferenceFlow(PreferenceKeys.USE_RANDOMIZE, DEFAULT_USE_RANDOMIZE)
252 | }
253 |
254 | suspend fun saveUseRandomize(randomize: Boolean) {
255 | savePreference(PreferenceKeys.USE_RANDOMIZE, randomize, KEY_USE_RANDOMIZE, randomize)
256 | }
257 |
258 | // For backward compatibility
259 | fun getUseRandomize(): Boolean {
260 | return sharedPrefs.getBoolean(KEY_USE_RANDOMIZE, DEFAULT_USE_RANDOMIZE)
261 | }
262 |
263 | // Randomize Radius
264 | fun getRandomizeRadiusFlow(): Flow {
265 | return getPreferenceFlow(PreferenceKeys.RANDOMIZE_RADIUS, DEFAULT_RANDOMIZE_RADIUS)
266 | }
267 |
268 | suspend fun saveRandomizeRadius(radius: Double) {
269 | savePreference(PreferenceKeys.RANDOMIZE_RADIUS, radius, KEY_RANDOMIZE_RADIUS, radius)
270 | }
271 |
272 | // For backward compatibility
273 | fun getRandomizeRadius(): Double {
274 | val bits = sharedPrefs.getLong(
275 | KEY_RANDOMIZE_RADIUS,
276 | java.lang.Double.doubleToRawLongBits(DEFAULT_RANDOMIZE_RADIUS)
277 | )
278 | return java.lang.Double.longBitsToDouble(bits)
279 | }
280 |
281 | // Favorites
282 | fun getFavoritesFlow(): Flow> {
283 | return context.dataStore.data
284 | .catch { exception ->
285 | if (exception is IOException) {
286 | Log.e(tag, "Error reading preferences: ${exception.message}")
287 | emit(emptyPreferences())
288 | } else {
289 | throw exception
290 | }
291 | }
292 | .map { preferences ->
293 | val json = preferences[PreferenceKeys.FAVORITES]
294 | if (json != null) {
295 | try {
296 | val type = object : TypeToken>() {}.type
297 | gson.fromJson(json, type)
298 | } catch (e: JsonSyntaxException) {
299 | Log.e(tag, "Error parsing Favorites: ${e.message}")
300 | emptyList()
301 | }
302 | } else {
303 | emptyList()
304 | }
305 | }
306 | }
307 |
308 | suspend fun addFavorite(favorite: FavoriteLocation) {
309 | try {
310 | val favorites = getFavoritesFlow().firstOrNull() ?: emptyList()
311 | val updatedFavorites = favorites.toMutableList().apply { add(favorite) }
312 | saveFavorites(updatedFavorites)
313 | Log.d(tag, "Added Favorite: $favorite")
314 | } catch (e: Exception) {
315 | Log.e(tag, "Error adding favorite: ${e.message}")
316 | }
317 | }
318 |
319 | private suspend fun saveFavorites(favorites: List) {
320 | try {
321 | val json = gson.toJson(favorites)
322 | savePreference(PreferenceKeys.FAVORITES, json, KEY_FAVORITES, json)
323 | } catch (e: Exception) {
324 | Log.e(tag, "Error saving favorites: ${e.message}")
325 | }
326 | }
327 |
328 | suspend fun removeFavorite(favorite: FavoriteLocation) {
329 | try {
330 | val favorites = getFavoritesFlow().firstOrNull() ?: emptyList()
331 | val updatedFavorites = favorites.toMutableList().apply { remove(favorite) }
332 | saveFavorites(updatedFavorites)
333 | Log.d(tag, "Removed Favorite: $favorite from preferences")
334 | } catch (e: Exception) {
335 | Log.e(tag, "Error removing favorite: ${e.message}")
336 | }
337 | }
338 |
339 | // For backward compatibility
340 | fun getFavorites(): List {
341 | val json = sharedPrefs.getString(KEY_FAVORITES, null)
342 | return if (json != null) {
343 | try {
344 | val type = object : TypeToken>() {}.type
345 | gson.fromJson(json, type)
346 | } catch (e: JsonSyntaxException) {
347 | Log.e(tag, "Error parsing Favorites: ${e.message}")
348 | emptyList()
349 | }
350 | } else {
351 | emptyList()
352 | }
353 | }
354 |
355 | // Vertical Accuracy
356 | fun getUseVerticalAccuracyFlow(): Flow {
357 | return getPreferenceFlow(PreferenceKeys.USE_VERTICAL_ACCURACY, DEFAULT_USE_VERTICAL_ACCURACY)
358 | }
359 |
360 | suspend fun saveUseVerticalAccuracy(useVerticalAccuracy: Boolean) {
361 | savePreference(PreferenceKeys.USE_VERTICAL_ACCURACY, useVerticalAccuracy, KEY_USE_VERTICAL_ACCURACY, useVerticalAccuracy)
362 | }
363 |
364 | // For backward compatibility
365 | fun getUseVerticalAccuracy(): Boolean {
366 | return sharedPrefs.getBoolean(KEY_USE_VERTICAL_ACCURACY, DEFAULT_USE_VERTICAL_ACCURACY)
367 | }
368 |
369 | // Vertical Accuracy Value
370 | fun getVerticalAccuracyFlow(): Flow {
371 | return getPreferenceFlow(PreferenceKeys.VERTICAL_ACCURACY, DEFAULT_VERTICAL_ACCURACY)
372 | }
373 |
374 | suspend fun saveVerticalAccuracy(verticalAccuracy: Float) {
375 | savePreference(PreferenceKeys.VERTICAL_ACCURACY, verticalAccuracy, KEY_VERTICAL_ACCURACY, verticalAccuracy)
376 | }
377 |
378 | // For backward compatibility
379 | fun getVerticalAccuracy(): Float {
380 | return sharedPrefs.getFloat(KEY_VERTICAL_ACCURACY, DEFAULT_VERTICAL_ACCURACY)
381 | }
382 |
383 | // Use Mean Sea Level
384 | fun getUseMeanSeaLevelFlow(): Flow {
385 | return getPreferenceFlow(PreferenceKeys.USE_MEAN_SEA_LEVEL, DEFAULT_USE_MEAN_SEA_LEVEL)
386 | }
387 |
388 | suspend fun saveUseMeanSeaLevel(useMeanSeaLevel: Boolean) {
389 | savePreference(PreferenceKeys.USE_MEAN_SEA_LEVEL, useMeanSeaLevel, KEY_USE_MEAN_SEA_LEVEL, useMeanSeaLevel)
390 | }
391 |
392 | // For backward compatibility
393 | fun getUseMeanSeaLevel(): Boolean {
394 | return sharedPrefs.getBoolean(KEY_USE_MEAN_SEA_LEVEL, DEFAULT_USE_MEAN_SEA_LEVEL)
395 | }
396 |
397 | // Mean Sea Level
398 | fun getMeanSeaLevelFlow(): Flow {
399 | return getPreferenceFlow(PreferenceKeys.MEAN_SEA_LEVEL, DEFAULT_MEAN_SEA_LEVEL)
400 | }
401 |
402 | suspend fun saveMeanSeaLevel(meanSeaLevel: Double) {
403 | savePreference(PreferenceKeys.MEAN_SEA_LEVEL, meanSeaLevel, KEY_MEAN_SEA_LEVEL, meanSeaLevel)
404 | }
405 |
406 | // For backward compatibility
407 | fun getMeanSeaLevel(): Double {
408 | val bits = sharedPrefs.getLong(KEY_MEAN_SEA_LEVEL, java.lang.Double.doubleToRawLongBits(DEFAULT_MEAN_SEA_LEVEL))
409 | return java.lang.Double.longBitsToDouble(bits)
410 | }
411 |
412 | // Use Mean Sea Level Accuracy
413 | fun getUseMeanSeaLevelAccuracyFlow(): Flow {
414 | return getPreferenceFlow(PreferenceKeys.USE_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY)
415 | }
416 |
417 | suspend fun saveUseMeanSeaLevelAccuracy(useMeanSeaLevelAccuracy: Boolean) {
418 | savePreference(PreferenceKeys.USE_MEAN_SEA_LEVEL_ACCURACY, useMeanSeaLevelAccuracy, KEY_USE_MEAN_SEA_LEVEL_ACCURACY, useMeanSeaLevelAccuracy)
419 | }
420 |
421 | // For backward compatibility
422 | fun getUseMeanSeaLevelAccuracy(): Boolean {
423 | return sharedPrefs.getBoolean(KEY_USE_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_USE_MEAN_SEA_LEVEL_ACCURACY)
424 | }
425 |
426 | // Mean Sea Level Accuracy
427 | fun getMeanSeaLevelAccuracyFlow(): Flow {
428 | return getPreferenceFlow(PreferenceKeys.MEAN_SEA_LEVEL_ACCURACY, DEFAULT_MEAN_SEA_LEVEL_ACCURACY)
429 | }
430 |
431 | suspend fun saveMeanSeaLevelAccuracy(meanSeaLevelAccuracy: Float) {
432 | savePreference(PreferenceKeys.MEAN_SEA_LEVEL_ACCURACY, meanSeaLevelAccuracy, KEY_MEAN_SEA_LEVEL_ACCURACY, meanSeaLevelAccuracy)
433 | }
434 |
435 | // For backward compatibility
436 | fun getMeanSeaLevelAccuracy(): Float {
437 | return sharedPrefs.getFloat(KEY_MEAN_SEA_LEVEL_ACCURACY, DEFAULT_MEAN_SEA_LEVEL_ACCURACY)
438 | }
439 |
440 | // Use Speed
441 | fun getUseSpeedFlow(): Flow {
442 | return getPreferenceFlow(PreferenceKeys.USE_SPEED, DEFAULT_USE_SPEED)
443 | }
444 |
445 | suspend fun saveUseSpeed(useSpeed: Boolean) {
446 | savePreference(PreferenceKeys.USE_SPEED, useSpeed, KEY_USE_SPEED, useSpeed)
447 | }
448 |
449 | // For backward compatibility
450 | fun getUseSpeed(): Boolean {
451 | return sharedPrefs.getBoolean(KEY_USE_SPEED, DEFAULT_USE_SPEED)
452 | }
453 |
454 | // Speed
455 | fun getSpeedFlow(): Flow {
456 | return getPreferenceFlow(PreferenceKeys.SPEED, DEFAULT_SPEED)
457 | }
458 |
459 | suspend fun saveSpeed(speed: Float) {
460 | savePreference(PreferenceKeys.SPEED, speed, KEY_SPEED, speed)
461 | }
462 |
463 | // For backward compatibility
464 | fun getSpeed(): Float {
465 | return sharedPrefs.getFloat(KEY_SPEED, DEFAULT_SPEED)
466 | }
467 |
468 | // Use Speed Accuracy
469 | fun getUseSpeedAccuracyFlow(): Flow {
470 | return getPreferenceFlow(PreferenceKeys.USE_SPEED_ACCURACY, DEFAULT_USE_SPEED_ACCURACY)
471 | }
472 |
473 | suspend fun saveUseSpeedAccuracy(useSpeedAccuracy: Boolean) {
474 | savePreference(PreferenceKeys.USE_SPEED_ACCURACY, useSpeedAccuracy, KEY_USE_SPEED_ACCURACY, useSpeedAccuracy)
475 | }
476 |
477 | // For backward compatibility
478 | fun getUseSpeedAccuracy(): Boolean {
479 | return sharedPrefs.getBoolean(KEY_USE_SPEED_ACCURACY, DEFAULT_USE_SPEED_ACCURACY)
480 | }
481 |
482 | // Speed Accuracy
483 | fun getSpeedAccuracyFlow(): Flow {
484 | return getPreferenceFlow(PreferenceKeys.SPEED_ACCURACY, DEFAULT_SPEED_ACCURACY)
485 | }
486 |
487 | suspend fun saveSpeedAccuracy(speedAccuracy: Float) {
488 | savePreference(PreferenceKeys.SPEED_ACCURACY, speedAccuracy, KEY_SPEED_ACCURACY, speedAccuracy)
489 | }
490 |
491 | // For backward compatibility
492 | fun getSpeedAccuracy(): Float {
493 | return sharedPrefs.getFloat(KEY_SPEED_ACCURACY, DEFAULT_SPEED_ACCURACY)
494 | }
495 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/noobexon/xposedfakelocation/manager/ui/settings/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | //SettingsScreen.kt
2 | package com.noobexon.xposedfakelocation.manager.ui.settings
3 |
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.interaction.MutableInteractionSource
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.foundation.shape.RoundedCornerShape
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
11 | import androidx.compose.material.icons.filled.Info
12 | import androidx.compose.material3.*
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.lifecycle.viewmodel.compose.viewModel
17 | import androidx.navigation.NavController
18 | import androidx.compose.runtime.*
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.platform.LocalFocusManager
21 | import androidx.compose.ui.text.font.FontWeight
22 | import androidx.compose.ui.unit.dp
23 | import androidx.compose.foundation.rememberScrollState
24 | import androidx.compose.foundation.verticalScroll
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.semantics.contentDescription
27 | import androidx.compose.ui.semantics.semantics
28 | import androidx.compose.ui.text.style.TextAlign
29 |
30 | // Dimension constants
31 | private object Dimensions {
32 | val SPACING_EXTRA_SMALL = 4.dp
33 | val SPACING_SMALL = 8.dp
34 | val SPACING_MEDIUM = 16.dp
35 | val SPACING_LARGE = 24.dp
36 | val CARD_CORNER_RADIUS = 12.dp
37 | val CARD_ELEVATION = 2.dp
38 | val CATEGORY_SPACING = 32.dp
39 | }
40 |
41 | // Setting definitions to reduce duplication
42 | private object SettingDefinitions {
43 | // Define setting categories
44 | val CATEGORIES = mapOf(
45 | "Location" to listOf("Randomize Nearby Location", "Custom Horizontal Accuracy", "Custom Vertical Accuracy"),
46 | "Altitude" to listOf("Custom Altitude", "Custom MSL", "Custom MSL Accuracy"),
47 | "Movement" to listOf("Custom Speed", "Custom Speed Accuracy")
48 | )
49 |
50 | // Define all settings with their parameters
51 | @Composable
52 | fun getSettings(viewModel: SettingsViewModel): List = listOf(
53 | // Randomize Nearby Location
54 | DoubleSettingData(
55 | title = "Randomize Nearby Location",
56 | description = "Randomly places your location within the specified radius",
57 | useValueState = viewModel.useRandomize.collectAsState(),
58 | valueState = viewModel.randomizeRadius.collectAsState(),
59 | setUseValue = viewModel::setUseRandomize,
60 | setValue = viewModel::setRandomizeRadius,
61 | label = "Randomization Radius",
62 | unit = "m",
63 | minValue = 0f,
64 | maxValue = 2000f,
65 | step = 0.1f
66 | ),
67 | // Custom Horizontal Accuracy
68 | DoubleSettingData(
69 | title = "Custom Horizontal Accuracy",
70 | description = "Sets the horizontal accuracy of your location",
71 | useValueState = viewModel.useAccuracy.collectAsState(),
72 | valueState = viewModel.accuracy.collectAsState(),
73 | setUseValue = viewModel::setUseAccuracy,
74 | setValue = viewModel::setAccuracy,
75 | label = "Horizontal Accuracy",
76 | unit = "m",
77 | minValue = 0f,
78 | maxValue = 100f,
79 | step = 1f
80 | ),
81 | // Custom Vertical Accuracy
82 | FloatSettingData(
83 | title = "Custom Vertical Accuracy",
84 | description = "Sets the vertical accuracy of your location",
85 | useValueState = viewModel.useVerticalAccuracy.collectAsState(),
86 | valueState = viewModel.verticalAccuracy.collectAsState(),
87 | setUseValue = viewModel::setUseVerticalAccuracy,
88 | setValue = viewModel::setVerticalAccuracy,
89 | label = "Vertical Accuracy",
90 | unit = "m",
91 | minValue = 0f,
92 | maxValue = 100f,
93 | step = 1f
94 | ),
95 | // Custom Altitude
96 | DoubleSettingData(
97 | title = "Custom Altitude",
98 | description = "Sets a custom altitude for your location",
99 | useValueState = viewModel.useAltitude.collectAsState(),
100 | valueState = viewModel.altitude.collectAsState(),
101 | setUseValue = viewModel::setUseAltitude,
102 | setValue = viewModel::setAltitude,
103 | label = "Altitude",
104 | unit = "m",
105 | minValue = 0f,
106 | maxValue = 2000f,
107 | step = 0.5f
108 | ),
109 | // Custom MSL
110 | DoubleSettingData(
111 | title = "Custom MSL",
112 | description = "Sets a custom mean sea level value",
113 | useValueState = viewModel.useMeanSeaLevel.collectAsState(),
114 | valueState = viewModel.meanSeaLevel.collectAsState(),
115 | setUseValue = viewModel::setUseMeanSeaLevel,
116 | setValue = viewModel::setMeanSeaLevel,
117 | label = "MSL",
118 | unit = "m",
119 | minValue = -400f,
120 | maxValue = 2000f,
121 | step = 0.5f
122 | ),
123 | // Custom MSL Accuracy
124 | FloatSettingData(
125 | title = "Custom MSL Accuracy",
126 | description = "Sets the accuracy of the mean sea level value",
127 | useValueState = viewModel.useMeanSeaLevelAccuracy.collectAsState(),
128 | valueState = viewModel.meanSeaLevelAccuracy.collectAsState(),
129 | setUseValue = viewModel::setUseMeanSeaLevelAccuracy,
130 | setValue = viewModel::setMeanSeaLevelAccuracy,
131 | label = "MSL Accuracy",
132 | unit = "m",
133 | minValue = 0f,
134 | maxValue = 100f,
135 | step = 1f
136 | ),
137 | // Custom Speed
138 | FloatSettingData(
139 | title = "Custom Speed",
140 | description = "Sets a custom speed for your location",
141 | useValueState = viewModel.useSpeed.collectAsState(),
142 | valueState = viewModel.speed.collectAsState(),
143 | setUseValue = viewModel::setUseSpeed,
144 | setValue = viewModel::setSpeed,
145 | label = "Speed",
146 | unit = "m/s",
147 | minValue = 0f,
148 | maxValue = 30f,
149 | step = 0.1f
150 | ),
151 | // Custom Speed Accuracy
152 | FloatSettingData(
153 | title = "Custom Speed Accuracy",
154 | description = "Sets the accuracy of your speed value",
155 | useValueState = viewModel.useSpeedAccuracy.collectAsState(),
156 | valueState = viewModel.speedAccuracy.collectAsState(),
157 | setUseValue = viewModel::setUseSpeedAccuracy,
158 | setValue = viewModel::setSpeedAccuracy,
159 | label = "Speed Accuracy",
160 | unit = "m/s",
161 | minValue = 0f,
162 | maxValue = 100f,
163 | step = 1f
164 | )
165 | )
166 | }
167 |
168 | @OptIn(ExperimentalMaterial3Api::class)
169 | @Composable
170 | fun SettingsScreen(
171 | navController: NavController,
172 | settingsViewModel: SettingsViewModel = viewModel ()
173 | ) {
174 | val focusManager = LocalFocusManager.current
175 | val scrollState = rememberScrollState()
176 |
177 | // Get settings from the definition object
178 | val allSettings = SettingDefinitions.getSettings(settingsViewModel)
179 |
180 | Scaffold(
181 | topBar = {
182 | TopAppBar(
183 | title = { Text("Settings") },
184 | colors = TopAppBarDefaults.topAppBarColors(
185 | containerColor = MaterialTheme.colorScheme.primary,
186 | titleContentColor = MaterialTheme.colorScheme.onPrimary,
187 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
188 | actionIconContentColor = MaterialTheme.colorScheme.onPrimary
189 | ),
190 | navigationIcon = {
191 | IconButton(onClick = { navController.navigateUp() }) {
192 | Icon(
193 | Icons.AutoMirrored.Filled.ArrowBack,
194 | contentDescription = "Navigate back"
195 | )
196 | }
197 | }
198 | )
199 | }
200 | ) { innerPadding ->
201 | Box(
202 | modifier = Modifier
203 | .fillMaxSize()
204 | .padding(innerPadding)
205 | .clickable(
206 | indication = null,
207 | interactionSource = remember { MutableInteractionSource() }
208 | ) { focusManager.clearFocus() }
209 | ) {
210 | Column(
211 | modifier = Modifier
212 | .fillMaxSize()
213 | .padding(horizontal = Dimensions.SPACING_MEDIUM)
214 | .verticalScroll(scrollState)
215 | ) {
216 | Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
217 |
218 | // Display settings by category
219 | SettingDefinitions.CATEGORIES.forEach { (category, settingsInCategory) ->
220 | CategoryHeader(category)
221 |
222 | Card(
223 | modifier = Modifier
224 | .fillMaxWidth()
225 | .padding(vertical = Dimensions.SPACING_SMALL),
226 | shape = RoundedCornerShape(Dimensions.CARD_CORNER_RADIUS),
227 | elevation = CardDefaults.cardElevation(defaultElevation = Dimensions.CARD_ELEVATION)
228 | ) {
229 | Column(modifier = Modifier.padding(Dimensions.SPACING_SMALL)) {
230 | settingsInCategory.forEach { settingTitle ->
231 | val setting = allSettings.find { it.title == settingTitle }
232 | setting?.let {
233 | when (setting) {
234 | is DoubleSettingData -> {
235 | DoubleSettingComposable(setting)
236 | }
237 | is FloatSettingData -> {
238 | FloatSettingComposable(setting)
239 | }
240 | }
241 | if (settingTitle != settingsInCategory.last()) {
242 | HorizontalDivider(
243 | modifier = Modifier.padding(vertical = Dimensions.SPACING_SMALL),
244 | color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
245 | )
246 | }
247 | }
248 | }
249 | }
250 | }
251 |
252 | Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
253 | }
254 |
255 | // Add space at the bottom of the list
256 | Spacer(modifier = Modifier.height(Dimensions.SPACING_LARGE))
257 | }
258 | }
259 | }
260 | }
261 |
262 | @Composable
263 | fun CategoryHeader(title: String) {
264 | Row(
265 | verticalAlignment = Alignment.CenterVertically,
266 | modifier = Modifier
267 | .fillMaxWidth()
268 | .padding(bottom = Dimensions.SPACING_SMALL)
269 | ) {
270 | Text(
271 | text = title,
272 | style = MaterialTheme.typography.titleLarge.copy(
273 | fontWeight = FontWeight.Bold,
274 | color = MaterialTheme.colorScheme.primary
275 | )
276 | )
277 | Spacer(modifier = Modifier.weight(1f))
278 | HorizontalDivider(
279 | modifier = Modifier
280 | .weight(2f)
281 | .padding(start = Dimensions.SPACING_MEDIUM),
282 | color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
283 | )
284 | }
285 | }
286 |
287 | @Composable
288 | fun DoubleSettingItem(
289 | title: String,
290 | description: String,
291 | useValue: Boolean,
292 | onUseValueChange: (Boolean) -> Unit,
293 | value: Double,
294 | onValueChange: (Double) -> Unit,
295 | label: String,
296 | unit: String,
297 | minValue: Float,
298 | maxValue: Float,
299 | step: Float
300 | ) {
301 | SettingItem(
302 | title = title,
303 | description = description,
304 | useValue = useValue,
305 | onUseValueChange = onUseValueChange,
306 | value = value,
307 | onValueChange = onValueChange,
308 | label = label,
309 | unit = unit,
310 | minValue = minValue,
311 | maxValue = maxValue,
312 | step = step,
313 | valueFormatter = { "%.2f".format(it) },
314 | parseValue = { it.toDouble() }
315 | )
316 | }
317 |
318 | @Composable
319 | fun FloatSettingItem(
320 | title: String,
321 | description: String,
322 | useValue: Boolean,
323 | onUseValueChange: (Boolean) -> Unit,
324 | value: Float,
325 | onValueChange: (Float) -> Unit,
326 | label: String,
327 | unit: String,
328 | minValue: Float,
329 | maxValue: Float,
330 | step: Float
331 | ) {
332 | SettingItem(
333 | title = title,
334 | description = description,
335 | useValue = useValue,
336 | onUseValueChange = onUseValueChange,
337 | value = value,
338 | onValueChange = onValueChange,
339 | label = label,
340 | unit = unit,
341 | minValue = minValue,
342 | maxValue = maxValue,
343 | step = step,
344 | valueFormatter = { "%.2f".format(it) },
345 | parseValue = { it }
346 | )
347 | }
348 |
349 | @OptIn(ExperimentalMaterial3Api::class)
350 | @Composable
351 | private fun SettingItem(
352 | title: String,
353 | description: String,
354 | useValue: Boolean,
355 | onUseValueChange: (Boolean) -> Unit,
356 | value: T,
357 | onValueChange: (T) -> Unit,
358 | label: String,
359 | unit: String,
360 | minValue: Float,
361 | maxValue: Float,
362 | step: Float,
363 | valueFormatter: (T) -> String,
364 | parseValue: (Float) -> T
365 | ) {
366 | var showTooltip by remember { mutableStateOf(false) }
367 |
368 | Column(modifier = Modifier
369 | .fillMaxWidth()
370 | .padding(Dimensions.SPACING_SMALL)
371 | ) {
372 | Row(
373 | verticalAlignment = Alignment.CenterVertically,
374 | modifier = Modifier.fillMaxWidth()
375 | ) {
376 | Column(modifier = Modifier.weight(1f)) {
377 | Row(
378 | verticalAlignment = Alignment.CenterVertically
379 | ) {
380 | Text(
381 | text = title,
382 | style = MaterialTheme.typography.titleMedium,
383 | fontWeight = FontWeight.Medium
384 | )
385 |
386 | IconButton(
387 | onClick = { showTooltip = !showTooltip },
388 | modifier = Modifier.size(24.dp)
389 | ) {
390 | Icon(
391 | Icons.Default.Info,
392 | contentDescription = "More information about $title",
393 | tint = MaterialTheme.colorScheme.primary,
394 | modifier = Modifier.size(16.dp)
395 | )
396 | }
397 | }
398 |
399 | if (showTooltip) {
400 | Text(
401 | text = description,
402 | style = MaterialTheme.typography.bodySmall,
403 | color = MaterialTheme.colorScheme.onSurfaceVariant,
404 | modifier = Modifier.padding(top = Dimensions.SPACING_EXTRA_SMALL)
405 | )
406 | }
407 | }
408 |
409 | Switch(
410 | checked = useValue,
411 | onCheckedChange = onUseValueChange,
412 | colors = SwitchDefaults.colors(
413 | checkedThumbColor = MaterialTheme.colorScheme.primary,
414 | checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
415 | uncheckedThumbColor = MaterialTheme.colorScheme.outline,
416 | uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant
417 | ),
418 | modifier = Modifier.semantics {
419 | contentDescription = if (useValue) "Disable $title" else "Enable $title"
420 | }
421 | )
422 | }
423 |
424 | if (useValue) {
425 | Spacer(modifier = Modifier.height(Dimensions.SPACING_MEDIUM))
426 |
427 | var sliderValue by remember { mutableFloatStateOf(value.toFloat()) }
428 | var showExactValue by remember { mutableStateOf(false) }
429 |
430 | LaunchedEffect(value) {
431 | if (sliderValue != value.toFloat()) {
432 | sliderValue = value.toFloat()
433 | }
434 | }
435 |
436 | Row(
437 | verticalAlignment = Alignment.CenterVertically,
438 | horizontalArrangement = Arrangement.spacedBy(Dimensions.SPACING_SMALL),
439 | modifier = Modifier.fillMaxWidth()
440 | ) {
441 | val displayText = "$label: ${valueFormatter(parseValue(sliderValue))} $unit"
442 | Text(
443 | text = displayText,
444 | style = MaterialTheme.typography.bodyMedium,
445 | modifier = Modifier
446 | .weight(1f)
447 | .clickable { showExactValue = !showExactValue }
448 | )
449 |
450 | // Add +/- buttons for precise adjustment
451 | OutlinedIconButton(
452 | onClick = {
453 | val newValue = (sliderValue - step).coerceAtLeast(minValue)
454 | sliderValue = newValue
455 | onValueChange(parseValue(newValue))
456 | },
457 | enabled = sliderValue > minValue,
458 | modifier = Modifier.size(32.dp),
459 | shape = RoundedCornerShape(4.dp)
460 | ) {
461 | Text(
462 | text = "−",
463 | textAlign = TextAlign.Center,
464 | style = MaterialTheme.typography.titleSmall
465 | )
466 | }
467 |
468 | OutlinedIconButton(
469 | onClick = {
470 | val newValue = (sliderValue + step).coerceAtMost(maxValue)
471 | sliderValue = newValue
472 | onValueChange(parseValue(newValue))
473 | },
474 | enabled = sliderValue < maxValue,
475 | modifier = Modifier.size(32.dp),
476 | shape = RoundedCornerShape(4.dp)
477 | ) {
478 | Text(
479 | text = "+",
480 | textAlign = TextAlign.Center,
481 | style = MaterialTheme.typography.titleSmall
482 | )
483 | }
484 | }
485 |
486 | // Min and max value labels
487 | Row(
488 | horizontalArrangement = Arrangement.SpaceBetween,
489 | modifier = Modifier
490 | .fillMaxWidth()
491 | .padding(horizontal = Dimensions.SPACING_SMALL)
492 | ) {
493 | Text(
494 | text = "${minValue.toInt()}",
495 | style = MaterialTheme.typography.bodySmall,
496 | color = MaterialTheme.colorScheme.onSurfaceVariant
497 | )
498 | Text(
499 | text = "${maxValue.toInt()}",
500 | style = MaterialTheme.typography.bodySmall,
501 | color = MaterialTheme.colorScheme.onSurfaceVariant
502 | )
503 | }
504 |
505 | Slider(
506 | value = sliderValue,
507 | onValueChange = { newValue ->
508 | sliderValue = newValue
509 | },
510 | onValueChangeFinished = {
511 | onValueChange(parseValue(sliderValue))
512 | },
513 | valueRange = minValue..maxValue,
514 | steps = ((maxValue - minValue) / step).toInt() - 1,
515 | colors = SliderDefaults.colors(
516 | thumbColor = MaterialTheme.colorScheme.primary,
517 | activeTrackColor = MaterialTheme.colorScheme.primary,
518 | inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
519 | ),
520 | modifier = Modifier
521 | .fillMaxWidth()
522 | .semantics {
523 | contentDescription = "Adjust $title value"
524 | }
525 | )
526 | }
527 | }
528 | }
529 |
530 | sealed class SettingData {
531 | abstract val title: String
532 | abstract val description: String
533 | abstract val useValueState: State
534 | abstract val setUseValue: (Boolean) -> Unit
535 | abstract val label: String
536 | abstract val unit: String
537 | abstract val minValue: Float
538 | abstract val maxValue: Float
539 | abstract val step: Float
540 | }
541 |
542 | data class DoubleSettingData(
543 | override val title: String,
544 | override val description: String,
545 | override val useValueState: State,
546 | val valueState: State,
547 | override val setUseValue: (Boolean) -> Unit,
548 | val setValue: (Double) -> Unit,
549 | override val label: String,
550 | override val unit: String,
551 | override val minValue: Float,
552 | override val maxValue: Float,
553 | override val step: Float
554 | ) : SettingData()
555 |
556 | data class FloatSettingData(
557 | override val title: String,
558 | override val description: String,
559 | override val useValueState: State,
560 | val valueState: State,
561 | override val setUseValue: (Boolean) -> Unit,
562 | val setValue: (Float) -> Unit,
563 | override val label: String,
564 | override val unit: String,
565 | override val minValue: Float,
566 | override val maxValue: Float,
567 | override val step: Float
568 | ) : SettingData()
569 |
570 | @Composable
571 | fun DoubleSettingComposable(
572 | setting: DoubleSettingData
573 | ) {
574 | DoubleSettingItem(
575 | title = setting.title,
576 | description = setting.description,
577 | useValue = setting.useValueState.value,
578 | onUseValueChange = setting.setUseValue,
579 | value = setting.valueState.value,
580 | onValueChange = setting.setValue,
581 | label = setting.label,
582 | unit = setting.unit,
583 | minValue = setting.minValue,
584 | maxValue = setting.maxValue,
585 | step = setting.step
586 | )
587 | }
588 |
589 | @Composable
590 | fun FloatSettingComposable(
591 | setting: FloatSettingData
592 | ) {
593 | FloatSettingItem(
594 | title = setting.title,
595 | description = setting.description,
596 | useValue = setting.useValueState.value,
597 | onUseValueChange = setting.setUseValue,
598 | value = setting.valueState.value,
599 | onValueChange = setting.setValue,
600 | label = setting.label,
601 | unit = setting.unit,
602 | minValue = setting.minValue,
603 | maxValue = setting.maxValue,
604 | step = setting.step
605 | )
606 | }
--------------------------------------------------------------------------------