feat: better location overlay

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk
2024-06-02 23:28:42 +02:00
parent c4b5856ba6
commit 9dd7422b9f
23 changed files with 852 additions and 142 deletions

View File

@@ -0,0 +1,15 @@
package me.rhunk.snapenhance
import me.rhunk.snapenhance.bridge.location.FriendLocation
import me.rhunk.snapenhance.bridge.location.LocationManager
class RemoteLocationManager(
private val remoteSideContext: RemoteSideContext
): LocationManager.Stub() {
var friendsLocation = listOf<FriendLocation>()
private set
override fun provideFriendsLocation(friendsLocation: List<FriendLocation>) {
this.friendsLocation = friendsLocation.sortedBy { -it.lastUpdated }
}
}

View File

@@ -39,7 +39,7 @@ import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModInfo
import me.rhunk.snapenhance.ui.manager.data.PlatformInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
import me.rhunk.snapenhance.ui.overlay.SettingsOverlay
import me.rhunk.snapenhance.ui.overlay.RemoteOverlay
import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.setup.SetupActivity
import java.io.ByteArrayInputStream
@@ -70,11 +70,12 @@ class RemoteSideContext(
val streaksReminder = StreaksReminder(this)
val log = LogManager(this)
val scriptManager = RemoteScriptManager(this)
val settingsOverlay = SettingsOverlay(this)
val remoteOverlay = RemoteOverlay(this)
val e2eeImplementation = E2EEImplementation(this)
val messageLogger by lazy { LoggerWrapper(androidContext) }
val tracker = RemoteTracker(this)
val accountStorage = RemoteAccountStorage(this)
val locationManager = RemoteLocationManager(this)
//used to load bitmoji selfies and download previews
val imageLoader by lazy {

View File

@@ -12,6 +12,7 @@ import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.logger.LogLevel
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.common.util.toParcelable
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.download.FFMpegProcessor
@@ -201,24 +202,31 @@ class BridgeService : Service() {
override fun getTracker() = remoteSideContext.tracker
override fun getAccountStorage() = remoteSideContext.accountStorage
override fun getFileHandleManager() = remoteSideContext.fileHandleManager
override fun getLocationManager() = remoteSideContext.locationManager
override fun registerMessagingBridge(bridge: MessagingBridge) {
messagingBridge = bridge
}
override fun openSettingsOverlay() {
override fun openOverlay(type: String) {
runCatching {
remoteSideContext.settingsOverlay.show()
val overlayType = OverlayType.fromKey(type) ?: throw IllegalArgumentException("Unknown overlay type: $type")
remoteSideContext.remoteOverlay.show { routes ->
when (overlayType) {
OverlayType.SETTINGS -> routes.features
OverlayType.BETTER_LOCATION -> routes.betterLocation
}
}
}.onFailure {
remoteSideContext.log.error("Failed to open settings overlay", it)
remoteSideContext.log.error("Failed to open $type overlay", it)
}
}
override fun closeSettingsOverlay() {
override fun closeOverlay() {
runCatching {
remoteSideContext.settingsOverlay.close()
remoteSideContext.remoteOverlay.close()
}.onFailure {
remoteSideContext.log.error("Failed to close settings overlay", it)
remoteSideContext.log.error("Failed to close overlay", it)
}
}

View File

@@ -81,7 +81,14 @@ class AppDatabase(
"quick_tiles" to listOf(
"key VARCHAR PRIMARY KEY",
"position INTEGER",
)
),
"location_coordinates" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"name VARCHAR",
"latitude DOUBLE",
"longitude DOUBLE",
"radius DOUBLE",
),
))
}
}

View File

@@ -0,0 +1,61 @@
package me.rhunk.snapenhance.storage
import android.content.ContentValues
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.bridge.location.LocationCoordinates
import me.rhunk.snapenhance.common.util.ktx.getDoubleOrNull
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getLocationCoordinates(): List<LocationCoordinates> {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT * FROM location_coordinates ORDER BY id DESC", null).use { cursor ->
val locationCoordinates = mutableListOf<LocationCoordinates>()
while (cursor.moveToNext()) {
locationCoordinates.add(
LocationCoordinates().run {
id = cursor.getInteger("id")
name = cursor.getStringOrNull("name") ?: return@run null
latitude = cursor.getDoubleOrNull("latitude") ?: return@run null
longitude = cursor.getDoubleOrNull("longitude") ?: return@run null
radius = cursor.getDoubleOrNull("radius") ?: return@run null
this
} ?: continue
)
}
locationCoordinates
}
}
}
fun AppDatabase.addOrUpdateLocationCoordinate(id: Int?, locationCoordinates: LocationCoordinates): Int {
return runBlocking(executor.asCoroutineDispatcher()) {
if (id == null) {
val resultId = database.insert("location_coordinates", null, ContentValues().apply {
put("name", locationCoordinates.name)
put("latitude", locationCoordinates.latitude)
put("longitude", locationCoordinates.longitude)
put("radius", locationCoordinates.radius)
})
resultId.toInt()
} else {
database.update("location_coordinates", ContentValues().apply {
put("name", locationCoordinates.name)
put("latitude", locationCoordinates.latitude)
put("longitude", locationCoordinates.longitude)
put("radius", locationCoordinates.radius)
}, "id = ?", arrayOf(id.toString()))
id
}
}
}
fun AppDatabase.removeLocationCoordinate(id: Int) {
runBlocking(executor.asCoroutineDispatcher()) {
database.delete("location_coordinates", "id = ?", arrayOf(id.toString()))
}
}

View File

@@ -77,7 +77,7 @@ class Navigation(
fun BottomBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.primary } }
val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.showInNavBar } }
NavigationBar {
primaryRoutes.forEach { route ->

View File

@@ -15,6 +15,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.BetterLocationRoot
import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
@@ -36,6 +37,7 @@ data class RouteInfo(
val key: String = id,
val icon: ImageVector = Icons.Default.Home,
val primary: Boolean = false,
val showInNavBar: Boolean = primary,
) {
var translatedKey: Lazy<String?>? = null
val childIds = mutableListOf<String>()
@@ -67,6 +69,8 @@ class Routes(
val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRoot())
val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot())
open class Route {
open val init: () -> Unit = { }
open val title: @Composable (() -> Unit)? = null

View File

@@ -0,0 +1,388 @@
package me.rhunk.snapenhance.ui.manager.pages
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.bridge.location.FriendLocation
import me.rhunk.snapenhance.bridge.location.LocationCoordinates
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.storage.addOrUpdateLocationCoordinate
import me.rhunk.snapenhance.storage.getLocationCoordinates
import me.rhunk.snapenhance.storage.removeLocationCoordinate
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.DialogProperties
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
class BetterLocationRoot : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) }
@Composable
private fun FriendLocationItem(
friendLocation: FriendLocation,
dismiss: () -> Unit
) {
ElevatedCard(onClick = {
context.config.root.global.betterLocation.coordinates.setAny(friendLocation.latitude to friendLocation.longitude)
dismiss()
}, modifier = Modifier.padding(4.dp)) {
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BitmojiImage(
context = context,
url = BitmojiSelfie.getBitmojiSelfie(
friendLocation.bitmojiSelfieId,
friendLocation.bitmojiId,
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
),
size = 48,
modifier = Modifier.padding(6.dp)
)
Column(
modifier = Modifier.weight(1f),
) {
Text(friendLocation.displayName?.let { "$it (${friendLocation.username})" }
?: friendLocation.username, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text(
text = buildString {
append(friendLocation.localityPieces.joinToString(", "))
append("\n")
append("Lat: ${friendLocation.latitude.toFloat()}, Lng: ${friendLocation.longitude.toFloat()}")
},
fontSize = 10.sp,
fontWeight = FontWeight.Light,
lineHeight = 15.sp
)
}
}
}
}
@Composable
private fun FriendLocationsDialogs(
friendsLocation: List<FriendLocation>,
dismiss: () -> Unit
) {
var search by remember { mutableStateOf("") }
val filteredFriendsLocation = rememberAsyncMutableStateList(defaultValue = friendsLocation, keys = arrayOf(search)) {
search.takeIf { it.isNotBlank() }?.let {
friendsLocation.filter {
it.displayName?.contains(search, ignoreCase = true) == true || it.username.contains(search, ignoreCase = true)
}
} ?: friendsLocation
}
ElevatedCard(
shape = MaterialTheme.shapes.large,
modifier = Modifier.padding(top = 32.dp, bottom = 32.dp)
) {
Text(
translation["teleport_to_friend_title"],
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
value = search,
onValueChange = { search = it },
label = { Text(translation["search_bar"]) }
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
item {
if (friendsLocation.isEmpty()) {
Text(
translation["no_friends_map"],
fontSize = 16.sp,
modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.Light
)
} else if (filteredFriendsLocation.isEmpty()) {
Text(
translation["no_friends_found"],
fontSize = 16.sp,
modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.Light
)
}
}
items(filteredFriendsLocation) { friendLocation ->
FriendLocationItem(friendLocation, dismiss)
}
}
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coordinatesProperty = remember {
context.config.root.global.betterLocation.getPropertyPair("coordinates")
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val savedCoordinates = rememberAsyncMutableStateList(
defaultValue = listOf(),
updateDispatcher = updateDispatcher
) {
context.database.getLocationCoordinates()
}
var showMap by remember { mutableStateOf(false) }
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
var showTeleportDialog by remember { mutableStateOf(false) }
val marker = remember { mutableStateOf<Marker?>(null) }
val mapView = remember { mutableStateOf<MapView?>(null) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates) {
context.coroutineScope.launch {
context.database.addOrUpdateLocationCoordinate(id, locationCoordinates)
updateDispatcher.dispatch()
}
}
if (showTeleportDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = { showTeleportDialog = false },
content = {
FriendLocationsDialogs(remember { context.locationManager.friendsLocation }) {
showTeleportDialog = false
context.coroutineScope.launch {
context.config.writeConfig()
}
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
) {
Text(
translation.format(
"spoofed_coordinates_title",
"latitude" to ((spoofedCoordinates?.first as? Double)?.toFloat() ?: "0.0").toString(),
"longitude" to ((spoofedCoordinates?.second as? Double)?.toFloat() ?: "0.0").toString()
),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
)
if (addSavedCoordinateDialog) {
var savedName by remember { mutableStateOf("") }
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { addSavedCoordinateDialog = false },
content = {
alertDialogs.DefaultDialogCard {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(
modifier = Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
},
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) }
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = {
addSavedCoordinateDialog = false
addSavedCoordinate(null, LocationCoordinates().apply {
this.name = savedName
this.latitude = marker.value?.position?.latitude as Double
this.longitude = marker.value?.position?.longitude as Double
})
},
enabled = savedName.isNotBlank()
) {
Text(translation["save_dialog_button"])
}
}
}
}
}
)
}
if (showMap) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showMap = false },
content = {
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
addSavedCoordinateDialog = true
}) {
showMap = false
context.config.writeConfig()
}
}
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.clipToBounds()
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { showMap = true }) {
Text(translation["choose_location_button"])
}
Button(onClick = { showTeleportDialog = true }) {
Text(translation["teleport_to_friend_button"])
}
}
}
item {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = translation["spoof_location_toggle"])
Spacer(modifier = Modifier.weight(1f))
var isSpoofing by remember { mutableStateOf(context.config.root.global.betterLocation.spoofLocation.get()) }
Switch(
checked = isSpoofing,
onCheckedChange = {
isSpoofing = it
context.config.root.global.betterLocation.spoofLocation.set(it)
}
)
}
}
item {
Text(
translation["saved_coordinates_title"],
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 16.dp)
)
}
item {
if (savedCoordinates.isEmpty()) {
Text(
translation["no_saved_coordinates_hint"],
fontSize = 16.sp,
modifier = Modifier.padding(start = 20.dp),
fontWeight = FontWeight.Light
)
}
}
items(savedCoordinates) { coordinates ->
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showDeleteDialog = false },
content = {
alertDialogs.ConfirmDialog(
title = translation["delete_dialog_title"],
message = translation["delete_dialog_message"],
onConfirm = {
showDeleteDialog = false
context.coroutineScope.launch {
context.database.removeLocationCoordinate(coordinates.id)
updateDispatcher.dispatch()
}
},
onDismiss = { showDeleteDialog = false }
)
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${coordinates.name} (${coordinates.latitude.toFloat()}, ${coordinates.longitude.toFloat()})",
fontWeight = if (spoofedCoordinates == coordinates.latitude to coordinates.longitude) FontWeight.Bold else FontWeight.Light,
modifier = Modifier
.padding(8.dp)
.weight(1f)
.clickable {
spoofedCoordinates =
coordinates.latitude to coordinates.longitude
coordinatesProperty.value.setAny(spoofedCoordinates)
context.coroutineScope.launch {
context.config.writeConfig()
}
GeoPoint(coordinates.latitude, coordinates.longitude).also {
marker.value?.position = it
mapView.value?.controller?.apply {
animateTo(it)
setZoom(16.0)
}
}
},
fontSize = 16.sp
)
FilledIconButton(onClick = {
showDeleteDialog = true
}) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
}
}
}
}
}

View File

@@ -26,9 +26,10 @@ import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.ui.manager.Navigation
import me.rhunk.snapenhance.ui.manager.Routes
class SettingsOverlay(
class RemoteOverlay(
private val context: RemoteSideContext
) {
private lateinit var dialog: Dialog
@@ -46,7 +47,7 @@ class SettingsOverlay(
}
@Composable
private fun OverlayContent() {
private fun OverlayContent(startRoute: (Routes) -> Routes.Route) {
val navHostController = rememberNavController()
LaunchedEffect(Unit) {
@@ -61,7 +62,7 @@ class SettingsOverlay(
) { innerPadding ->
navigation.Content(
innerPadding,
startDestination = navigation.routes.features.routeInfo.id
startDestination = remember { startRoute(navigation.routes).routeInfo.id }
)
}
}
@@ -74,7 +75,7 @@ class SettingsOverlay(
}
}
fun show() {
fun show(route: (Routes) -> Routes.Route) {
if (!checkForPermissions()) {
return
}
@@ -90,7 +91,7 @@ class SettingsOverlay(
if (it()) return
}
super.dismiss()
this@SettingsOverlay.context.config.writeConfig()
this@RemoteOverlay.context.config.writeConfig()
}
}
dialog.window?.apply {
@@ -113,7 +114,7 @@ class SettingsOverlay(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OverlayContent()
OverlayContent(route)
}
}
)

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilledIconButton
@@ -33,15 +34,11 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
@@ -467,7 +464,13 @@ class AlertDialogs(
}
@Composable
fun ChooseLocationDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
fun ChooseLocationDialog(
property: PropertyPair<*>,
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
saveCoordinates: (() -> Unit)? = null,
dismiss: () -> Unit = {}
) {
val coordinates = remember {
(property.value.get() as Pair<*, *>).let {
it.first.toString().toDouble() to it.second.toString().toDouble()
@@ -475,8 +478,7 @@ class AlertDialogs(
}
val context = LocalContext.current
var marker by remember { mutableStateOf<Marker?>(null) }
val mapView = remember {
mapView.value = remember {
Configuration.getInstance().apply {
osmdroidBasePath = File(context.cacheDir, "osmdroid")
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
@@ -490,7 +492,7 @@ class AlertDialogs(
controller.setZoom(10.0)
controller.setCenter(startPoint)
marker = Marker(this).apply {
marker.value = Marker(this).apply {
isDraggable = true
position = startPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
@@ -498,29 +500,32 @@ class AlertDialogs(
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
marker?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
mapView.invalidate()
return true
}
})
overlays.add(marker)
overlays.add(marker.value)
}
}
DisposableEffect(Unit) {
onDispose {
mapView.onDetach()
mapView.value?.onDetach()
}
}
var customCoordinatesDialog by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.9f),
modifier = Modifier
.fillMaxWidth()
.clipToBounds()
.fillMaxHeight(fraction = 0.9f),
) {
AndroidView(
factory = { mapView }
factory = { mapView.value!! },
)
Row(
modifier = Modifier
@@ -530,8 +535,8 @@ class AlertDialogs(
) {
FilledIconButton(
onClick = {
val lat = marker?.position?.latitude ?: coordinates.first
val lon = marker?.position?.longitude ?: coordinates.second
val lat = marker.value?.position?.latitude ?: coordinates.first
val lon = marker.value?.position?.longitude ?: coordinates.second
property.value.setAny(lat to lon)
dismiss()
}) {
@@ -543,6 +548,18 @@ class AlertDialogs(
contentDescription = null
)
}
saveCoordinates?.let {
FilledIconButton(
onClick = { it() }) {
Icon(
modifier = Modifier
.size(60.dp)
.padding(5.dp),
imageVector = Icons.Filled.Save,
contentDescription = null
)
}
}
FilledIconButton(
onClick = {
@@ -562,44 +579,48 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
DefaultDialogCard(
modifier = Modifier.align(Alignment.Center)
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
value = lat.value,
onValueChange = { lat.value = it },
label = { Text(text = "Latitude") },
singleLine = true
)
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
value = lon.value,
onValueChange = { lon.value = it },
label = { Text(text = "Longitude") },
singleLine = true
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
Dialog(onDismissRequest = {
customCoordinatesDialog = false
}) {
DefaultDialogCard(
modifier = Modifier.align(Alignment.Center)
) {
Button(onClick = {
customCoordinatesDialog = false
}) {
Text(text = translation["button.cancel"])
}
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
value = lat.value,
onValueChange = { lat.value = it },
label = { Text(text = "Latitude") },
singleLine = true
)
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
value = lon.value,
onValueChange = { lon.value = it },
label = { Text(text = "Longitude") },
singleLine = true
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
customCoordinatesDialog = false
}) {
Text(text = translation["button.cancel"])
}
Button(onClick = {
marker?.position = GeoPoint(lat.value.toDouble(), lon.value.toDouble())
mapView.controller.setCenter(marker?.position)
mapView.invalidate()
customCoordinatesDialog = false
}) {
Text(text = translation["button.ok"])
Button(onClick = {
marker.value?.position = GeoPoint(lat.value.toDouble(), lon.value.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
customCoordinatesDialog = false
}) {
Text(text = translation["button.ok"])
}
}
}
}

View File

@@ -11,6 +11,7 @@ import me.rhunk.snapenhance.bridge.ConfigStateListener;
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge;
import me.rhunk.snapenhance.bridge.AccountStorage;
import me.rhunk.snapenhance.bridge.storage.FileHandleManager;
import me.rhunk.snapenhance.bridge.location.LocationManager;
interface BridgeInterface {
/**
@@ -82,11 +83,13 @@ interface BridgeInterface {
FileHandleManager getFileHandleManager();
LocationManager getLocationManager();
oneway void registerMessagingBridge(MessagingBridge bridge);
oneway void openSettingsOverlay();
oneway void openOverlay(String type);
oneway void closeSettingsOverlay();
oneway void closeOverlay();
oneway void registerConfigStateListener(in ConfigStateListener listener);

View File

@@ -0,0 +1,13 @@
package me.rhunk.snapenhance.bridge.location;
parcelable FriendLocation {
String username;
@nullable String displayName;
@nullable String bitmojiId;
@nullable String bitmojiSelfieId;
double latitude;
double longitude;
long lastUpdated;
String locality;
List<String> localityPieces;
}

View File

@@ -0,0 +1,10 @@
package me.rhunk.snapenhance.bridge.location;
parcelable LocationCoordinates {
int id;
String name;
double latitude;
double longitude;
double radius;
}

View File

@@ -0,0 +1,7 @@
package me.rhunk.snapenhance.bridge.location;
import me.rhunk.snapenhance.bridge.location.FriendLocation;
interface LocationManager {
void provideFriendsLocation(in List<FriendLocation> friendsLocation);
}

View File

@@ -39,7 +39,8 @@
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
"scripts": "Scripts"
"scripts": "Scripts",
"better_location": "Better Location"
},
"sections": {
"home": {
@@ -141,6 +142,23 @@
"file_imported": "File imported successfully",
"file_delete_failed": "Failed to delete file",
"no_files_hint": "Here you can import files for use in Snapchat. Press the button below to import a file."
},
"better_location": {
"spoofed_coordinates_title": "Spoofed Coordinates\nLat {latitude}, Lng {longitude}",
"save_coordinates_dialog_title": "Save Coordinates",
"saved_name_dialog_hint": "Saved Name",
"save_dialog_button": "Save",
"choose_location_button": "Choose Location",
"teleport_to_friend_button": "Teleport to Friend",
"spoof_location_toggle": "Spoof Location",
"saved_coordinates_title": "Saved Coordinates",
"no_saved_coordinates_hint": "No saved coordinates",
"delete_dialog_title": "Delete Saved Coordinate",
"delete_dialog_message": "Are you sure you want to delete this saved coordinate?",
"teleport_to_friend_title": "Teleport to Friend",
"search_bar": "Search",
"no_friends_map": "No friends on the map",
"no_friends_found": "No friends found"
}
},
"dialogs": {

View File

@@ -101,6 +101,12 @@ open class ConfigContainer(
}
}
fun getPropertyPair(key: String): PropertyPair<*> {
val propertyKey = properties.keys.firstOrNull { it.name == key }
?: throw IllegalArgumentException("Property $key not found")
return PropertyPair(propertyKey, properties[propertyKey]!!)
}
operator fun getValue(t: Any?, property: KProperty<*>) = this.globalState
operator fun setValue(t: Any?, property: KProperty<*>, t1: Boolean?) { this.globalState = t1 }
}

View File

@@ -0,0 +1,14 @@
package me.rhunk.snapenhance.common.ui
enum class OverlayType(
val key: String
) {
SETTINGS("settings"),
BETTER_LOCATION("better_location");
companion object {
fun fromKey(key: String): OverlayType? {
return entries.find { it.key == key }
}
}
}

View File

@@ -119,7 +119,7 @@ class SnapEnhance {
}
hookMainActivity("onPause") {
appContext.bridgeClient.closeSettingsOverlay()
appContext.bridgeClient.closeOverlay()
appContext.isMainActivityPaused = true
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
import me.rhunk.snapenhance.bridge.*
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.location.LocationManager
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
import me.rhunk.snapenhance.bridge.scripting.IScripting
@@ -25,6 +26,7 @@ import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.common.util.toSerialized
import me.rhunk.snapenhance.core.ModContext
import java.util.concurrent.Executors
@@ -236,10 +238,12 @@ class BridgeClient(
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager }
fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }
fun openSettingsOverlay() = safeServiceCall { service.openSettingsOverlay() }
fun closeSettingsOverlay() = safeServiceCall { service.closeSettingsOverlay() }
fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) }
fun closeOverlay() = safeServiceCall { service.closeOverlay() }
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }

View File

@@ -2,87 +2,72 @@ package me.rhunk.snapenhance.core.features.impl.experiments
import android.location.Location
import android.location.LocationManager
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditLocation
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.common.util.protobuf.EditorContext
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.global.SuspendLocationUpdates
import me.rhunk.snapenhance.core.util.RandomWalking
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getId
import me.rhunk.snapenhance.mapper.impl.CallbackMapper
import java.nio.ByteBuffer
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import java.util.UUID
import kotlin.time.Duration.Companion.days
data class FriendLocation(
val userId: String,
val latitude: Double,
val longitude: Double,
val lastUpdated: Long,
val locality: String?,
val localityPieces: List<String>
)
class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams.INIT_SYNC) {
//Latitude ft / deg /Longitude ft /deg = 1.26301179736
// 4ft/s * 1 degree/364000ft (latitude) * 1s/1000ms = .000000010989011 degrees/ms
val max_speed = 4.0 / 364000.0 / 1000.0
val pause_chance = .0023 // .23% chance to pause every second = after 5 minutes 50% chance of pause
val pause_duration = 60000L //ms
val pause_random = 30000L //ms
private val locationHistory = mutableMapOf<String, FriendLocation>()
var pause_expire = 0L
var current_x = 0.0
var current_y = 0.0
var target_x = 0.0
var target_y = 0.0
var last_update_time = 0L
private fun updatePosition(){
val now = System.currentTimeMillis()
private val walkRadius by lazy {
context.config.global.betterLocation.walkRadius.getNullable()
}
if(current_x == target_x && current_y == target_y) {
val config = context.config.global.betterLocation
val walk_rad = if (config.walkRadius.get()
.toDoubleOrNull() == null
) 0.0 else (config.walkRadius.get().toDouble() / 364000.0) //Lat deg
if(last_update_time == 0L){ //Start at random position
val radius1 = sqrt(Math.random()) * walk_rad
val theta1 = Math.PI * 2.0 * Math.random()
current_x = cos(theta1) * radius1 * 1.26301179736
current_y = sin(theta1) * radius1
}
val radius2 = sqrt(Math.random()) * walk_rad
val theta2 = Math.PI * 2.0 * Math.random()
target_x = cos(theta2) * radius2 * 1.26301179736
target_y = sin(theta2) * radius2
} else if (pause_expire < now) {
val deltat = now - last_update_time
if(Math.random() > (1.0 - pause_chance).pow(deltat / 1000.0)){
pause_expire = now + pause_duration + (pause_random * Math.random()).toLong()
} else {
val max_dist = max_speed * deltat
val dist = hypot(target_x - current_x, target_y - current_y)
if (dist <= max_dist) {
current_x = target_x
current_y = target_y
} else {
val norm_x = (target_x - current_x) / dist * max_dist
val norm_y = (target_y - current_y) / dist * max_dist
current_x += norm_x
current_y += norm_y
}
}
}
last_update_time = now
private val randomWalking by lazy {
RandomWalking(walkRadius?.toDoubleOrNull())
}
private fun getLat() : Double {
updatePosition()
return (context.config.global.betterLocation.coordinates.get().first + current_x)
var spoofedLatitude = context.config.global.betterLocation.coordinates.get().first
walkRadius?.let {
spoofedLatitude += randomWalking.current_x
}
return spoofedLatitude
}
private fun getLong() : Double {
updatePosition()
return (context.config.global.betterLocation.coordinates.get().second + current_y)
var spoofedLongitude = context.config.global.betterLocation.coordinates.get().second
walkRadius?.let {
spoofedLongitude += randomWalking.current_y
}
return spoofedLongitude
}
private fun editClientUpdate(editor: EditorContext) {
val config = context.config.global.betterLocation
@@ -90,6 +75,7 @@ class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams
// SCVSLocationUpdate
edit(1) {
if (config.spoofLocation.get()) {
randomWalking.updatePosition()
remove(1)
remove(2)
addFixed32(1, getLat().toFloat()) // lat
@@ -136,6 +122,48 @@ class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams
}
}
private fun onLocationEvent(protoReader: ProtoReader) {
protoReader.eachBuffer(3, 1) {
val userId = UUID(getFixed64(1, 1) ?: return@eachBuffer, getFixed64(1, 2) ?: return@eachBuffer).toString()
val friendCluster = FriendLocation(
userId = userId,
latitude = Float.fromBits(getFixed32(4)).toDouble(),
longitude = Float.fromBits(getFixed32(5)).toDouble(),
lastUpdated = getVarInt(7, 2) ?: -1L,
locality = getString(10),
localityPieces = mutableListOf<String>().also {
forEach { index, wire ->
if (index != 11) return@forEach
it.add((wire.value as ByteArray).toString(Charsets.UTF_8) )
}
}
)
locationHistory[userId] = friendCluster
}
}
private fun openManagementOverlay() {
context.bridgeClient.getLocationManager().provideFriendsLocation(
locationHistory.values.toList().mapNotNull { locationHistory ->
val friendInfo = context.database.getFriendInfo(locationHistory.userId) ?: return@mapNotNull null
me.rhunk.snapenhance.bridge.location.FriendLocation().also {
it.username = friendInfo.mutableUsername ?: return@mapNotNull null
it.displayName = friendInfo.displayName
it.bitmojiId = friendInfo.bitmojiAvatarId
it.bitmojiSelfieId = friendInfo.bitmojiSelfieId
it.latitude = locationHistory.latitude
it.longitude = locationHistory.longitude
it.lastUpdated = locationHistory.lastUpdated
it.locality = locationHistory.locality
it.localityPieces = locationHistory.localityPieces
}
}
)
context.bridgeClient.openOverlay(OverlayType.BETTER_LOCATION)
}
override fun init() {
if (context.config.global.betterLocation.globalState != true) return
@@ -145,14 +173,39 @@ class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams
hook("isProviderEnabledForUser", HookStage.BEFORE) { it.setResult(true) }
}
Location::class.java.apply {
hook("getLatitude", HookStage.BEFORE) {
it.setResult(getLat()) }
hook("getLongitude", HookStage.BEFORE) {
it.setResult(getLong())
}
hook("getLatitude", HookStage.BEFORE) { it.setResult(getLat()) }
hook("getLongitude", HookStage.BEFORE) { it.setResult(getLong()) }
}
}
val mapFeaturesRootId = context.resources.getId("map_features_root")
val mapLayerSelectorId = context.resources.getId("map_layer_selector")
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id != mapFeaturesRootId) return@subscribe
val view = event.view as RelativeLayout
view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
view.addView(createComposeView(view.context) {
FilledIconButton(
modifier = Modifier.size(54.dp).padding(8.dp),
onClick = { openManagementOverlay() }
) {
Icon(Icons.Default.EditLocation, contentDescription = null)
}
}.apply {
layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
addRule(RelativeLayout.BELOW, mapLayerSelectorId)
addRule(RelativeLayout.ALIGN_PARENT_END)
}
})
}
override fun onViewDetachedFromWindow(v: View) {}
})
}
context.event.subscribe(UnaryCallEvent::class) { event ->
if (event.uri == "/snapchat.valis.Valis/SendClientUpdate") {
event.buffer = ProtoEditor(event.buffer).apply {
@@ -165,6 +218,16 @@ class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams
}
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("ServerStreamingEventHandler")?.hook("onEvent", HookStage.BEFORE) { param ->
val buffer = param.arg<ByteBuffer>(1).let {
it.position(0)
ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) }
}
onLocationEvent(ProtoReader(buffer))
}
}
findClass("com.snapchat.client.grpc.ClientStreamSendHandler\$CppProxy").hook("send", HookStage.BEFORE) { param ->
val array = param.arg<ByteBuffer>(0).let {
it.position(0)

View File

@@ -5,6 +5,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.core.ui.menu.AbstractMenu
import me.rhunk.snapenhance.core.util.ktx.getDimens
import me.rhunk.snapenhance.core.util.ktx.getDrawable
@@ -38,7 +39,7 @@ class SettingsGearInjector : AbstractMenu() {
isClickable = true
setOnClickListener {
this@SettingsGearInjector.context.bridgeClient.openSettingsOverlay()
this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
parent.setOnTouchListener { _, event ->

View File

@@ -0,0 +1,65 @@
package me.rhunk.snapenhance.core.util
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
class RandomWalking(
private var walkRadius: Double?,
) {
var current_x = 0.0
private set
var current_y = 0.0
private set
private var last_update_time = 0L
private var pause_expire = 0L
private var target_x = 0.0
private var target_y = 0.0
fun updatePosition() {
//Latitude ft / deg /Longitude ft /deg = 1.26301179736
// 4ft/s * 1 degree/364000ft (latitude) * 1s/1000ms = .000000010989011 degrees/ms
val max_speed = 4.0 / 364000.0 / 1000.0
val pause_chance = .0023 // .23% chance to pause every second = after 5 minutes 50% chance of pause
val pause_duration = 60000L //ms
val pause_random = 30000L //ms
val now = System.currentTimeMillis()
if(current_x == target_x && current_y == target_y) {
val walk_rad = if (walkRadius == null
) 0.0 else (walkRadius!! / 364000.0) //Lat deg
if(last_update_time == 0L){ //Start at random position
val radius1 = sqrt(Math.random()) * walk_rad
val theta1 = Math.PI * 2.0 * Math.random()
current_x = cos(theta1) * radius1 * 1.26301179736
current_y = sin(theta1) * radius1
}
val radius2 = sqrt(Math.random()) * walk_rad
val theta2 = Math.PI * 2.0 * Math.random()
target_x = cos(theta2) * radius2 * 1.26301179736
target_y = sin(theta2) * radius2
} else if (pause_expire < now) {
val deltat = now - last_update_time
if(Math.random() > (1.0 - pause_chance).pow(deltat / 1000.0)){
pause_expire = now + pause_duration + (pause_random * Math.random()).toLong()
} else {
val max_dist = max_speed * deltat
val dist = hypot(target_x - current_x, target_y - current_y)
if (dist <= max_dist) {
current_x = target_x
current_y = target_y
} else {
current_x += (target_x - current_x) / dist * max_dist
current_y += (target_y - current_y) / dist * max_dist
}
}
}
last_update_time = now
}
}

View File

@@ -16,7 +16,7 @@ class CallbackMapper : AbstractClassMapper("Callbacks") {
if (clazz.superclass == null) return@filter false
val superclassName = clazz.getSuperClassName()!!
if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate"))
if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate") && !superclassName.endsWith("EventHandler"))
|| superclassName.endsWith("\$Callback")) return@filter false
if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false