diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/RemoteSideContext.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/RemoteSideContext.kt index 65dc9db2..97df83a7 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/RemoteSideContext.kt @@ -7,6 +7,8 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.activity.ComponentActivity import androidx.core.app.CoreComponentFactory @@ -30,12 +32,14 @@ import androidx.work.WorkManager import me.eternal.purrfectsnap.bridge.BridgeService import me.eternal.purrfectsnap.common.BuildConfig import me.eternal.purrfectsnap.common.Constants +import me.eternal.purrfectsnap.common.ReceiversConfig import me.eternal.purrfectsnap.common.action.EnumAction import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper import me.eternal.purrfectsnap.common.config.ModConfig import me.eternal.purrfectsnap.common.logger.fatalCrash +import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.eternal.purrfectsnap.common.util.constantLazyBridge import me.eternal.purrfectsnap.common.util.getPurgeTime import me.eternal.purrfectsnap.e2ee.E2EEImplementation @@ -275,6 +279,67 @@ class RemoteSideContext( androidContext.startActivity(intent) } + fun requestSocialSnapshotRefresh( + openSnapchatFirst: Boolean = true, + snapchatWarmupDelayMs: Long = 1200L, + returnDelayMs: Long = 1200L + ) { + fun sendSocialSnapshotBroadcast() { + runCatching { + androidContext.sendBroadcast( + SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} + ) + }.onFailure { + log.error("Failed to request latest social snapshot", it) + } + } + + if (!openSnapchatFirst) { + sendSocialSnapshotBroadcast() + return + } + + val snapchatIntent = androidContext.packageManager + .getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + + if (snapchatIntent == null) { + shortToast(translation["toast_snapchat_not_installed"]) + sendSocialSnapshotBroadcast() + return + } + + val returnIntent = Intent(androidContext, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + ) + } + + val mainHandler = Handler(Looper.getMainLooper()) + runCatching { + androidContext.startActivity(snapchatIntent) + mainHandler.postDelayed( + { + runCatching { + androidContext.startActivity(returnIntent) + }.onFailure { + log.error("Failed to return to PurrfectSnap after Snapchat handoff", it) + } + mainHandler.postDelayed( + { sendSocialSnapshotBroadcast() }, + returnDelayMs + ) + }, + snapchatWarmupDelayMs + ) + }.onFailure { + log.error("Failed to launch Snapchat for social snapshot refresh", it) + sendSocialSnapshotBroadcast() + } + } + private fun scheduleAnnouncementCheck() { val workManager = WorkManager.getInstance(androidContext) val constraints = Constraints.Builder() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt index 94a1d4a5..850fd814 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -93,24 +93,6 @@ fun AppDatabase.replaceMessagingData( executeAsync { database.beginTransaction() try { - val friendIds = friends.map { it.userId }.toSet() - val groupIds = groups.map { it.conversationId }.toSet() - - getFriends().forEach { friend -> - if (friend.userId !in friendIds) { - database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId)) - database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) - database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId)) - } - } - - getGroups().forEach { group -> - if (group.conversationId !in groupIds) { - database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId)) - database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId)) - } - } - friends.forEach { friend -> database.execSQL( "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 2b0aadf0..aac78b17 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -43,8 +46,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -72,6 +77,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import me.eternal.purrfectsnap.common.ui.TopBarActionButton import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList +import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette @@ -80,6 +86,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.DialogProperties import org.json.JSONArray import org.json.JSONObject +import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -162,6 +169,28 @@ class FeaturesRootSection : Routes.Route() { } } + internal fun isRandomizedProfileEnabled(): Boolean { + return context.config.root.experimental.spoof.randomizeDeviceProfile.globalState == true + } + + internal fun requestFreshRandomizedProfile() { + val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile + randomizeConfig.profileGenerationToken.set(UUID.randomUUID().toString()) + randomizeConfig.currentProfileSnapshot.set("") + } + + internal fun getRandomizedProfileSnapshot(): String { + context.config.load() + return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable() + ?.takeIf { it.isNotBlank() } + ?: (context.translation["manager.dialogs.randomize_device_profile.empty"] + ?: "No generated profile is available yet. Enable the feature in Snapchat first.") + } + + internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean { + return propertyName == "generate_fresh_profile_action" || propertyName == "view_current_profile_action" + } + fun navigateToMainRoot() { routes.navController.navigate(routeInfo.id, NavOptions.Builder() .setPopUpTo(routes.navController.graph.findStartDestination().id, false) @@ -330,9 +359,17 @@ class FeaturesRootSection : Routes.Route() { } @Composable - internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) { + internal fun PropertyAction( + property: PropertyPair<*>, + onConfigChanged: () -> Unit, + registerClickCallback: (() -> Unit) -> (() -> Unit) + ) { var showDialog by remember { mutableStateOf(false) } var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } + var showRandomProfileProgressDialog by remember { mutableStateOf(false) } + var randomProfileStatus by remember { mutableStateOf("") } + var showCurrentRandomProfileDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } @@ -348,7 +385,64 @@ class FeaturesRootSection : Routes.Route() { } val propertyValue = property.value - fun persistConfig() = context.config.writeConfig() + val randomProfileEnabled = isRandomizedProfileEnabled() + val isRandomizedProfileContainer = property.name == "randomize_device_profile" + fun persistConfig() { + context.config.writeConfig() + onConfigChanged() + } + + if (showRandomProfileProgressDialog) { + AestheticDialog( + onDismissRequest = {}, + title = context.translation["manager.dialogs.randomize_device_profile.title"] + ?: "Generating random device profile", + text = randomProfileStatus, + icon = Icons.Filled.AutoAwesome, + confirmButtonText = "", + onConfirm = {}, + loading = true, + showIcon = false, + showCloseButton = false, + confirmEnabled = false + ) + } + + if (showCurrentRandomProfileDialog) { + val profileSnapshot = getRandomizedProfileSnapshot() + val clipboardManager = LocalClipboardManager.current + AestheticDialog( + onDismissRequest = { showCurrentRandomProfileDialog = false }, + title = context.translation["manager.dialogs.randomize_device_profile.view_title"] + ?: "Current randomized profile", + text = "", + icon = Icons.Filled.Visibility, + dismissButtonText = context.translation["button.copy"] ?: "Copy", + onDismiss = { + clipboardManager.setText(AnnotatedString(profileSnapshot)) + context.shortToast( + context.translation["manager.dialogs.randomize_device_profile.copied"] + ?: "Randomized profile copied" + ) + }, + confirmButtonText = context.translation["button.positive"], + onConfirm = { showCurrentRandomProfileDialog = false }, + showCloseButton = false, + customContent = { + SelectionContainer { + Text( + text = profileSnapshot, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + color = PurrfectPalette.textSecondary, + textAlign = TextAlign.Start + ) + } + } + ) + } if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) { registerDialogOnClickCallback() @@ -518,12 +612,12 @@ class FeaturesRootSection : Routes.Route() { val hapticFeedback = LocalHapticFeedback.current Switch( checked = state, - onCheckedChange = { + onCheckedChange = { requestedState -> if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) } - state = state.not() - propertyValue.setAny(state) + state = requestedState + propertyValue.setAny(requestedState) persistConfig() }, colors = purrfectSwitchColors() @@ -566,6 +660,45 @@ class FeaturesRootSection : Routes.Route() { } DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { + if (dataType == DataProcessors.Type.STRING && isRandomizedProfileActionProperty(property.name)) { + val actionLabel = when (property.name) { + "generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile" + "view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile" + else -> property.name + } + Button( + onClick = { + if (property.name == "generate_fresh_profile_action") { + showRandomProfileProgressDialog = true + coroutineScope.launch { + randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"] + ?: "Allocating a randomized device fingerprint" + delay(260) + requestFreshRandomizedProfile() + persistConfig() + randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.finalizing"] + ?: "Finalizing the all-in-one profile and disabling manual overrides" + delay(260) + showRandomProfileProgressDialog = false + context.shortToast( + context.translation["manager.dialogs.randomize_device_profile.refresh_requested"] + ?: "Fresh randomized profile requested. Restart Snapchat to apply it." + ) + } + } else { + showCurrentRandomProfileDialog = true + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f), + contentColor = Color.White + ) + ) { + Text(actionLabel, maxLines = 1) + } + return + } + dialogComposable = { when (dataType) { DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { @@ -660,12 +793,35 @@ class FeaturesRootSection : Routes.Route() { val hapticFeedback = LocalHapticFeedback.current Switch( checked = state, - onCheckedChange = { + onCheckedChange = { requestedState -> if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) } - state = state.not() - container.globalState = state + if (isRandomizedProfileContainer && requestedState) { + showRandomProfileProgressDialog = true + coroutineScope.launch { + randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"] + ?: "Allocating a randomized device fingerprint" + delay(260) + randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.network"] + ?: "Preparing network, locale, and telephony values" + delay(260) + container.globalState = true + state = true + persistConfig() + randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.done"] + ?: "Randomized device profile generated" + delay(220) + showRandomProfileProgressDialog = false + context.log.info("Enabled randomized device profile mode from manager UI") + } + return@Switch + } + state = requestedState + container.globalState = requestedState + if (!requestedState && isRandomizedProfileContainer) { + context.log.info("Disabled randomized device profile mode from manager UI") + } persistConfig() }, colors = purrfectSwitchColors() @@ -722,7 +878,12 @@ class FeaturesRootSection : Routes.Route() { } @Composable - internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) { + internal fun PropertyCard( + property: PropertyPair<*>, + configRefreshNonce: Int, + onConfigChanged: () -> Unit, + onOpen: (() -> Unit)? = null + ) { val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" } var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) } val noticeColorMap = remember { @@ -736,6 +897,7 @@ class FeaturesRootSection : Routes.Route() { val versionCheck = remember { property.key.params.versionCheck } val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)} val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true } + val isInteractionEnabled = !isComponentDisabled val cardShape = RoundedCornerShape(22.dp) val interactionSource = remember { MutableInteractionSource() } @@ -753,8 +915,9 @@ class FeaturesRootSection : Routes.Route() { modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 7.dp) - .graphicsLayer { if (isComponentDisabled) alpha = 0.5f } + .graphicsLayer { if (!isInteractionEnabled) alpha = 0.5f } .clickable( + enabled = isInteractionEnabled, interactionSource = interactionSource, indication = null ) { @@ -852,7 +1015,7 @@ class FeaturesRootSection : Routes.Route() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { - PropertyAction(property, registerClickCallback = { callback -> + PropertyAction(property, onConfigChanged = onConfigChanged, registerClickCallback = { callback -> if (property.key.propertyTranslationPath().startsWith("rules.properties")) { clickCallback = { routes.manageRuleFeature.navigate { @@ -1406,6 +1569,7 @@ class FeaturesRootSection : Routes.Route() { ) { val density = LocalDensity.current var controlsHeight by remember { mutableStateOf(100.dp) } + var configRefreshNonce by rememberSaveable { mutableStateOf(0) } val listState = rememberLazyListState() @@ -1479,7 +1643,12 @@ class FeaturesRootSection : Routes.Route() { upsertHistory(liveSearchQuery, sharedSearchHistory) } } else null - PropertyCard(item, onOpen = onOpen) + PropertyCard( + property = item, + configRefreshNonce = configRefreshNonce, + onConfigChanged = { configRefreshNonce++ }, + onOpen = onOpen + ) } } item { Spacer(modifier = Modifier.height(12.dp)) } @@ -1634,9 +1803,14 @@ class FeaturesRootSection : Routes.Route() { onBack: (() -> Unit)? = null, ) { PropertiesView( - properties = remember { + properties = remember(configContainer.globalState) { configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair }.filter { - !it.key.params.flags.contains(ConfigFlag.HIDDEN) + !it.key.params.flags.contains(ConfigFlag.HIDDEN) && + ( + configContainer !== context.config.root.experimental.spoof.randomizeDeviceProfile || + configContainer.globalState == true || + !isRandomizedProfileActionProperty(it.key.name) + ) } }, stateKey = stateKey, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt index d92272e3..dd7920e4 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt @@ -24,11 +24,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.* import me.eternal.purrfectsnap.RemoteSideContext -import me.eternal.purrfectsnap.common.ReceiversConfig import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie -import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.eternal.purrfectsnap.storage.getFriends +import me.eternal.purrfectsnap.storage.getGroups import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage @@ -219,39 +219,70 @@ class AddFriendDialog( var hasFetchError by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - val updateSnapshot: (List, List) -> Unit = { friends, groups -> - coroutineScope.launch { - cachedFriends = friends.run { - if (pinnedIds != null) { - sortedBy { -pinnedIds.indexOf(it.userId) } - } else friends + fun applySnapshot( + friends: List, + groups: List + ) { + cachedFriends = friends.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.userId) } + } else { + this } - cachedGroups = groups.run { - if (pinnedIds != null) { - sortedBy { -pinnedIds.indexOf(it.conversationId) } - } else groups + } + cachedGroups = groups.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.conversationId) } + } else { + this } + } + if (friends.isNotEmpty() || groups.isNotEmpty()) { timeoutJob?.cancel() hasFetchError = false } } + + val updateSnapshot: (List, List) -> Unit = { friends, groups -> + coroutineScope.launch { + applySnapshot(friends, groups) + } + } + + withContext(Dispatchers.IO) { + applySnapshot( + context.database.getFriends(descOrder = true), + context.database.getGroups() + ) + } + if (context.bridgeService != null) { context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot) } else { context.database.receiveMessagingDataCallback = updateSnapshot } - SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { - runCatching { - context.androidContext.sendBroadcast(it) - }.onFailure { - context.log.error("Failed to send broadcast", it) - hasFetchError = true + context.requestSocialSnapshotRefresh() + + coroutineScope.launch(Dispatchers.IO) { + repeat(25) { + delay(1000) + val dbFriends = context.database.getFriends(descOrder = true) + val dbGroups = context.database.getGroups() + if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) { + withContext(Dispatchers.Main) { + applySnapshot(dbFriends, dbGroups) + } + return@launch + } } } + timeoutJob = coroutineScope.launch { withContext(Dispatchers.IO) { - delay(20000) - hasFetchError = true + delay(25000) + if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) { + hasFetchError = true + } } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index f392c48f..891f4c66 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -38,13 +38,11 @@ import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.eternal.purrfectsnap.R -import me.eternal.purrfectsnap.common.ReceiversConfig import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo import me.eternal.purrfectsnap.common.data.SocialScope import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie -import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.ManagerTheme @@ -63,13 +61,7 @@ class SocialRootSection : Routes.Route() { } internal fun requestLatestSnapshot() { - runCatching { - context.androidContext.sendBroadcast( - SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} - ) - }.onFailure { - context.log.error("Failed to request latest social snapshot", it) - } + context.requestSocialSnapshotRefresh() } @Composable diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt index 9a583958..861af766 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt @@ -314,6 +314,7 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) { PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button") } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt index 1cbdf70a..8bbb679e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt @@ -57,8 +57,17 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) { var searchActive by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { + context.database.receiveMessagingDataCallback = { friends, groups -> + friendList = friends + groupList = groups + } updateScopeLists() } + DisposableEffect(Unit) { + onDispose { + context.database.receiveMessagingDataCallback = { _, _ -> } + } + } val normalizedQuery = remember(searchQuery) { searchQuery.trim() } val filteredFriends = remember(friendList, normalizedQuery) { if (normalizedQuery.isBlank()) { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index eb5eaff5..db8b2942 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -921,6 +921,7 @@ object LegacyTheme : ThemeContract { PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button") } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt index 7297e26c..efaa1976 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt @@ -11,7 +11,10 @@ import android.provider.Settings import android.view.* import android.view.View.OnAttachStateChangeListener import androidx.activity.ComponentDialog +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner import androidx.activity.addCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -47,6 +50,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import java.util.UUID @@ -197,6 +202,21 @@ private fun InlineDialog( val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() } val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() } val interactionSource = remember { MutableInteractionSource() } + val fallbackBackDispatcherOwner = remember(onDismissRequest) { + object : OnBackPressedDispatcherOwner { + private val lifecycleRegistry = LifecycleRegistry(this).apply { + currentState = Lifecycle.State.RESUMED + } + private val dispatcher = OnBackPressedDispatcher(onDismissRequest) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + override val onBackPressedDispatcher: OnBackPressedDispatcher + get() = dispatcher + } + } + val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -212,43 +232,45 @@ private fun InlineDialog( ), onDismissRequest = onDismissRequest ) { - Box( - modifier = Modifier - .width(screenWidthDp) - .height(screenHeightDp) - .then( - if (dismissOnClickOutside) { - Modifier.clickable( - interactionSource = interactionSource, - indication = null, - onClick = onDismissRequest - ) - } else { - Modifier - } - ) - .semantics { dialog() }, - contentAlignment = androidx.compose.ui.Alignment.Center - ) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(animationSpec = tween(180)) + scaleIn( - initialScale = 0.92f, - animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f) - ), - exit = fadeOut(animationSpec = tween(120)) + scaleOut( - targetScale = 0.96f, - animationSpec = tween(120) - ) + CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) { + Box( + modifier = Modifier + .width(screenWidthDp) + .height(screenHeightDp) + .then( + if (dismissOnClickOutside) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onDismissRequest + ) + } else { + Modifier + } + ) + .semantics { dialog() }, + contentAlignment = androidx.compose.ui.Alignment.Center ) { - Box( - modifier = Modifier.clickable( - interactionSource = interactionSource, - indication = null, - onClick = {} + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(180)) + scaleIn( + initialScale = 0.92f, + animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f) + ), + exit = fadeOut(animationSpec = tween(120)) + scaleOut( + targetScale = 0.96f, + animationSpec = tween(120) ) ) { - content() + Box( + modifier = Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = {} + ) + ) { + content() + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index 81497cdd..ce42270a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("getVersion") { } // You can still set these for legacy use by submodules or scripts: -rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.1").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("312").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.2").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("314").get().toInt()) rootProject.ext.set("applicationId", "me.eternal.purrfectsnap") // buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate. // Include version code so each release has a different hash; use random for uniqueness within same version. diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 0567f492..ff2c85c0 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -532,6 +532,18 @@ "title": "Export Sensitive Data?", "content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)" }, + "randomize_device_profile": { + "title": "Generating random device profile", + "done": "Randomized device profile generated", + "view_title": "Current randomized profile", + "empty": "No generated profile is available yet. Enable the feature in Snapchat first.", + "refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.", + "phase": { + "allocating": "Allocating a randomized device fingerprint", + "network": "Preparing network, locale, and telephony values", + "finalizing": "Finalizing the all-in-one profile and disabling manual overrides" + } + }, "messaging_action": { "title": "Choose content types to process", "select_all_button": "Select All" @@ -898,7 +910,8 @@ "notices": { "unstable": "\u26a0 Unstable", "ban_risk": "\u26a0 This feature may cause bans", - "internal_behavior": "\u26a0 This may break Snapchat internal behaviour" + "internal_behavior": "\u26a0 This may break Snapchat internal behaviour", + "randomize_device_profile_override": "Controlled by Randomized Device Profile" }, "properties": { "downloader": { @@ -1174,6 +1187,14 @@ "name": "Hide Bitmoji Presence", "description": "Prevents your Bitmoji from popping up while in Chat" }, + "spoof_viewing_gallery_presence": { + "name": "Spoof Viewing Gallery Presence", + "description": "Keeps your Bitmoji visible in Chat while viewing chat media" + }, + "spoof_reply_camera_presence": { + "name": "Spoof Reply Camera Presence", + "description": "Keeps your Bitmoji visible in Chat while using the reply camera" + }, "hide_typing_notifications": { "name": "Hide Typing Notifications", "description": "Prevents anyone from knowing you're typing a message" @@ -2013,6 +2034,32 @@ "name": "Force Wi-Fi Transport Flag", "description": "Force network transport to report Wi-Fi instead of mobile data" }, + "randomize_device_profile": { + "name": "Randomized Device Profile", + "description": "Generate and apply a full randomized device, network, locale, and settings profile in one restart-safe profile", + "properties": { + "show_activation_overlay": { + "name": "Show Activation Overlay", + "description": "Show the in-app toast when the randomized profile becomes active" + }, + "randomize_ip_address": { + "name": "Randomize IP Address", + "description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created" + }, + "persistent_app_language": { + "name": "Persistent App Language", + "description": "Force Snapchat to stay on a specific supported app language" + }, + "generate_fresh_profile_action": { + "name": "Generate Fresh Profile", + "description": "Request a newly generated randomized profile" + }, + "view_current_profile_action": { + "name": "View Current Profile", + "description": "Inspect the latest randomized profile snapshot" + } + } + }, "spoof_device_id": { "name": "Spoof Device ID", "description": "Override the Android ID sent to Snapchat", @@ -2348,7 +2395,10 @@ "custom_android_id": { "null": "Use real Android ID" }, - "add_friend_source_spoof": { + "persistent_app_language": { + "system_default": "System Default" + }, + "add_friend_source_spoof": { "added_by_username": "By Username", "added_by_mention": "By Mention", "added_by_group_chat": "By Group Chat", @@ -2965,6 +3015,10 @@ "stopped_speaking": "Stopped Speaking", "started_peeking": "Started Peeking", "stopped_peeking": "Stopped Peeking", + "started_using_reply_camera": "Started Using Reply Camera", + "stopped_using_reply_camera": "Stopped Using Reply Camera", + "started_viewing_chat_media": "Started Viewing Chat Media", + "stopped_viewing_chat_media": "Stopped Viewing Chat Media", "message_read": "Message Read", "message_deleted": "Message Deleted", "message_saved": "Message Saved", @@ -2977,7 +3031,9 @@ "snap_replayed_twice": "Snap Replayed Twice", "snap_screenshot": "Snap Screenshot", "snap_screen_record": "Snap Screen Record", - "i_can_see_you": "I Can See You" + "i_can_see_you": "I Can See You", + "i_can_see_you_2": "I Can See You 2", + "i_can_see_you_3": "I Can See You 3" }, "cleared_from_feed": "Cleared from feed", "tracker_actions": { @@ -3186,6 +3242,10 @@ "stopped_speaking": "{friend} stopped speaking in {conversation}", "started_peeking": "{friend} started peeking in {conversation}", "stopped_peeking": "{friend} stopped peeking in {conversation}", + "started_using_reply_camera": "{friend} opened the reply camera in {conversation}", + "stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}", + "started_viewing_chat_media": "{friend} started viewing chat media in {conversation}", + "stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}", "message_read": "{friend} read a message in {conversation}", "message_deleted": "{friend} deleted a message in {conversation}", "message_saved": "{friend} saved a message in {conversation}", @@ -3198,7 +3258,9 @@ "snap_replayed_twice": "{friend} replayed a snap twice in {conversation}", "snap_screenshot": "{friend} took a screenshot in {conversation}", "snap_screen_record": "{friend} screen recorded in {conversation}", - "i_can_see_you": "{friend} activity in {conversation}: {details}" + "i_can_see_you": "{friend} activity in {conversation}: {details}", + "i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}", + "i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}" }, "friend_mutation_observer": { "notification_channel_name": "Friend Mutation Observer", @@ -3351,6 +3413,10 @@ "stopped_speaking": "Stopped speaking", "started_peeking": "Started peeking", "stopped_peeking": "Stopped peeking", + "started_using_reply_camera": "Opened reply camera", + "stopped_using_reply_camera": "Closed reply camera", + "started_viewing_chat_media": "Started viewing chat media", + "stopped_viewing_chat_media": "Stopped viewing chat media", "message_read": "Read message", "message_deleted": "Deleted message", "message_saved": "Saved message", @@ -3402,6 +3468,10 @@ "stopped_speaking": "stopped speaking", "started_peeking": "started peeking", "stopped_peeking": "stopped peeking", + "started_using_reply_camera": "opened the reply camera", + "stopped_using_reply_camera": "closed the reply camera", + "started_viewing_chat_media": "started viewing chat media", + "stopped_viewing_chat_media": "stopped viewing chat media", "message_read": "read a message", "message_deleted": "deleted a message", "message_saved": "saved a message", @@ -3414,7 +3484,9 @@ "snap_replayed_twice": "replayed a snap twice", "snap_screenshot": "took a screenshot", "snap_screen_record": "screen recorded", - "i_can_see_you": "was active" + "i_can_see_you": "was active", + "i_can_see_you_2": "was viewing gallery", + "i_can_see_you_3": "was using the reply camera" } } }, @@ -3474,6 +3546,7 @@ "disable_feature_loading_label": "Disable Feature Loading", "disable_auto_mapper_label": "Disable Auto Mapper", "disable_bypass_indicator_label": "Disable Bypass Indicator", + "disable_cant_login_button_label": "Disable Can't Login Button", "friend_list": { "manage_title": "Manage Friend List", "export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 94c1640d..cd395755 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -570,6 +570,19 @@ "title": "Export Sensitive Data?", "content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)" }, + "randomize_device_profile": { + "title": "Generating random device profile", + "done": "Randomized device profile generated", + "view_title": "Current randomized profile", + "copied": "Randomized profile copied", + "empty": "No generated profile is available yet. Enable the feature in Snapchat first.", + "refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.", + "phase": { + "allocating": "Allocating a randomized device fingerprint", + "network": "Preparing network, locale, and telephony values", + "finalizing": "Finalizing the all-in-one profile and disabling manual overrides" + } + }, "messaging_action": { "title": "Choose content types to process", "select_all_button": "Select All" @@ -1238,6 +1251,14 @@ "name": "Hide Bitmoji Presence", "description": "Prevents your Bitmoji from popping up while in Chat" }, + "spoof_viewing_gallery_presence": { + "name": "Spoof Viewing Gallery Presence", + "description": "Keeps your Bitmoji visible in Chat while viewing chat media" + }, + "spoof_reply_camera_presence": { + "name": "Spoof Reply Camera Presence", + "description": "Keeps your Bitmoji visible in Chat while using the reply camera" + }, "hide_typing_notifications": { "name": "Hide Typing Notifications", "description": "Prevents anyone from knowing you're typing a message" @@ -2119,6 +2140,418 @@ "name": "Force Wi-Fi Transport Flag", "description": "Force network transport to report Wi-Fi instead of mobile data" }, + "randomize_device_profile": { + "name": "Randomized Device Profile", + "description": "Generate and apply a full randomized device, network, locale, and settings profile", + "properties": { + "show_activation_overlay": { + "name": "Show Activation Overlay", + "description": "Show the in-app toast when the randomized profile becomes active" + }, + "randomize_ip_address": { + "name": "Randomize IP Address", + "description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created" + }, + "spoof_build_properties": { + "name": "Spoof Build Properties", + "description": "Apply randomized build fields, fingerprints, and device property values" + }, + "build_properties": { + "name": "Build Properties", + "description": "Enable build property spoofing and fine-tune its subsets", + "properties": { + "device_identity": { + "name": "Device Identity", + "description": "Enable device identity spoofing and fine-tune its values", + "properties": { + "manufacturer_model": { + "name": "Manufacturer And Model", + "description": "Randomize the reported manufacturer and model" + }, + "brand_product": { + "name": "Brand And Product", + "description": "Randomize the reported brand, device, and product values" + }, + "hardware_board": { + "name": "Hardware And Board", + "description": "Randomize the reported hardware and board values" + } + } + }, + "build_version": { + "name": "Build Version", + "description": "Enable build version spoofing and fine-tune its values", + "properties": { + "fingerprint": { + "name": "Fingerprint", + "description": "Randomize the reported build fingerprint" + }, + "display": { + "name": "Display ID", + "description": "Randomize the reported build display ID" + }, + "host": { + "name": "Host", + "description": "Randomize the reported build host" + }, + "bootloader": { + "name": "Bootloader", + "description": "Randomize the reported bootloader value" + }, + "build_time": { + "name": "Build Time", + "description": "Randomize the reported build timestamp" + } + } + }, + "abi_lists": { + "name": "ABI Lists", + "description": "Enable ABI spoofing and fine-tune its values", + "properties": { + "combined_abis": { + "name": "Combined ABI List", + "description": "Randomize the combined supported ABI list" + }, + "split_abis": { + "name": "32-bit And 64-bit ABI Lists", + "description": "Randomize the split 32-bit and 64-bit ABI lists" + } + } + }, + "system_properties": { + "name": "System Properties", + "description": "Expose randomized values through Android system property lookups", + "properties": { + "build": { + "name": "Build Properties", + "description": "Expose randomized build values through system properties" + }, + "locale": { + "name": "Locale Properties", + "description": "Expose randomized locale values through system properties" + }, + "telephony": { + "name": "Telephony Properties", + "description": "Expose randomized telephony values through system properties" + } + } + } + } + }, + "spoof_locale": { + "name": "Spoof Locale", + "description": "Apply the randomized locale and language hooks" + }, + "locale_options": { + "name": "Locale Details", + "description": "Enable locale spoofing and fine-tune its subsets", + "properties": { + "locale": { + "name": "Locale", + "description": "Enable locale spoofing and fine-tune language and region values", + "properties": { + "language": { + "name": "Language", + "description": "Randomize the reported language value" + }, + "region": { + "name": "Region", + "description": "Randomize the reported region value" + } + } + }, + "time": { + "name": "Time", + "description": "Enable time spoofing and fine-tune time-related values", + "properties": { + "time_zone_id": { + "name": "Time Zone ID", + "description": "Randomize the reported time zone ID" + }, + "time_zone_display_name": { + "name": "Time Zone Display Name", + "description": "Randomize the reported time zone display name" + }, + "auto_time": { + "name": "Auto Time", + "description": "Randomize the global auto-time setting" + }, + "auto_time_zone": { + "name": "Auto Time Zone", + "description": "Randomize the global auto-time-zone setting" + } + } + } + } + }, + "spoof_telephony": { + "name": "Spoof Telephony", + "description": "Apply randomized carrier, SIM, and phone capability values" + }, + "telephony_options": { + "name": "Telephony Details", + "description": "Enable telephony spoofing and fine-tune its subsets", + "properties": { + "mms": { + "name": "MMS", + "description": "Enable MMS spoofing and fine-tune MMS values", + "properties": { + "user_agent": { + "name": "User Agent", + "description": "Randomize the MMS user agent string" + } + } + }, + "network_identity": { + "name": "Network Identity", + "description": "Enable network identity spoofing and fine-tune network values", + "properties": { + "network_type": { + "name": "Network Type", + "description": "Randomize the reported network type" + }, + "operator_numeric": { + "name": "Operator Numeric", + "description": "Randomize the reported operator numeric code" + }, + "operator_name": { + "name": "Operator Name", + "description": "Randomize the reported operator name" + }, + "country_iso": { + "name": "Country ISO", + "description": "Randomize the reported network country ISO" + } + } + }, + "sim_identity": { + "name": "SIM Identity", + "description": "Enable SIM identity spoofing and fine-tune SIM values", + "properties": { + "country_iso": { + "name": "Country ISO", + "description": "Randomize the reported SIM country ISO" + }, + "operator_numeric": { + "name": "Operator Numeric", + "description": "Randomize the reported SIM operator numeric code" + }, + "operator_name": { + "name": "Operator Name", + "description": "Randomize the reported SIM operator name" + }, + "sim_state": { + "name": "SIM State", + "description": "Randomize the reported SIM state" + }, + "has_icc_card": { + "name": "ICC Card", + "description": "Randomize whether a SIM card is reported as present" + } + } + }, + "phone_capabilities": { + "name": "Phone Capabilities", + "description": "Enable phone capability spoofing and fine-tune capability values", + "properties": { + "phone_count": { + "name": "Phone Count", + "description": "Randomize the reported phone count" + }, + "hearing_aid": { + "name": "Hearing Aid", + "description": "Randomize hearing aid compatibility support" + }, + "tty": { + "name": "TTY", + "description": "Randomize TTY support" + }, + "world_phone": { + "name": "World Phone", + "description": "Randomize world phone support" + }, + "roaming": { + "name": "Roaming", + "description": "Randomize roaming status" + }, + "sms_voice": { + "name": "SMS And Voice", + "description": "Randomize SMS and voice capability support" + }, + "phone_type": { + "name": "Phone Type", + "description": "Randomize the reported phone type" + } + } + } + } + }, + "spoof_settings": { + "name": "Spoof Settings", + "description": "Apply the randomized Android settings overrides" + }, + "settings_options": { + "name": "Settings Details", + "description": "Enable settings spoofing and fine-tune its namespaces", + "properties": { + "secure": { + "name": "Secure Settings", + "description": "Enable secure settings spoofing and fine-tune secure values", + "properties": { + "base": { + "name": "Base", + "description": "Randomize the base secure settings values" + }, + "tts": { + "name": "Text To Speech", + "description": "Randomize text-to-speech secure settings values" + } + } + }, + "system": { + "name": "System Settings", + "description": "Enable system settings spoofing and fine-tune system values", + "properties": { + "base": { + "name": "Base", + "description": "Randomize the base system settings values" + }, + "bluetooth": { + "name": "Bluetooth", + "description": "Randomize Bluetooth-related system settings values" + } + } + }, + "global": { + "name": "Global Settings", + "description": "Enable global settings spoofing and fine-tune global values", + "properties": { + "base": { + "name": "Base", + "description": "Randomize the base global settings values" + } + } + } + } + }, + "spoof_network": { + "name": "Spoof Network", + "description": "Apply randomized Wi-Fi and DNS network values" + }, + "network_options": { + "name": "Network Details", + "description": "Enable network spoofing and fine-tune its subsets", + "properties": { + "wifi": { + "name": "Wi-Fi Info", + "description": "Enable Wi-Fi spoofing and fine-tune Wi-Fi values", + "properties": { + "ssid": { + "name": "SSID", + "description": "Randomize the reported Wi-Fi SSID" + }, + "rssi": { + "name": "Signal Strength", + "description": "Randomize the reported Wi-Fi RSSI value" + } + } + }, + "dns": { + "name": "DNS", + "description": "Enable DNS spoofing and fine-tune DNS values", + "properties": { + "servers": { + "name": "Servers", + "description": "Randomize the reported DNS server list" + }, + "search_domains": { + "name": "Search Domains", + "description": "Randomize the reported DNS search domains" + }, + "private_dns": { + "name": "Private DNS", + "description": "Randomize the reported private DNS values" + } + } + }, + "captive_portal": { + "name": "Captive Portal", + "description": "Enable captive portal spoofing and fine-tune portal values", + "properties": { + "capability": { + "name": "Capability", + "description": "Randomize the reported captive portal capability" + } + } + } + } + }, + "spoof_identifiers": { + "name": "Spoof Identifiers", + "description": "Apply randomized Android ID, advertising ID, and hardware address overrides" + }, + "identifier_options": { + "name": "Identifier Details", + "description": "Enable identifier spoofing and fine-tune its subsets", + "properties": { + "android_id": { + "name": "Android ID", + "description": "Enable Android ID spoofing and fine-tune Android ID values", + "properties": { + "string_value": { + "name": "String Value", + "description": "Randomize the string Android ID value" + }, + "long_value": { + "name": "Long Value", + "description": "Randomize the long Android ID value" + } + } + }, + "advertising_id": { + "name": "Advertising ID", + "description": "Enable advertising ID spoofing and fine-tune advertising ID values", + "properties": { + "settings_value": { + "name": "Settings Value", + "description": "Randomize the advertising ID returned through settings" + }, + "play_services": { + "name": "Play Services", + "description": "Randomize the advertising ID returned through Play Services" + } + } + }, + "hardware_addresses": { + "name": "Hardware Addresses", + "description": "Enable hardware address spoofing and fine-tune address values", + "properties": { + "wifi_mac": { + "name": "Wi-Fi MAC", + "description": "Randomize the reported Wi-Fi MAC address" + }, + "bluetooth_mac": { + "name": "Bluetooth MAC", + "description": "Randomize the reported Bluetooth MAC address" + } + } + } + } + }, + "persistent_app_language": { + "name": "Persistent App Language", + "description": "Force Snapchat to stay on a specific supported app language" + }, + "generate_fresh_profile_action": { + "name": "Generate Fresh Profile", + "description": "Request a newly generated randomized profile" + }, + "view_current_profile_action": { + "name": "View Current Profile", + "description": "Inspect the latest randomized profile snapshot" + } + } + }, "spoof_device_id": { "name": "Spoof Device ID", "description": "Override the Android ID sent to Snapchat", @@ -2471,6 +2904,9 @@ "custom_android_id": { "null": "Use real Android ID" }, + "persistent_app_language": { + "system_default": "System Default" + }, "add_friend_source_spoof": { "added_by_username": "By Username", "added_by_mention": "By Mention", @@ -3085,6 +3521,7 @@ "positive": "Yes", "negative": "No", "cancel": "Cancel", + "copy": "Copy", "save": "Save", "open": "Open", "download": "Download", @@ -3102,6 +3539,10 @@ "stopped_speaking": "Stopped Speaking", "started_peeking": "Started Peeking", "stopped_peeking": "Stopped Peeking", + "started_using_reply_camera": "Started Using Reply Camera", + "stopped_using_reply_camera": "Stopped Using Reply Camera", + "started_viewing_chat_media": "Started Viewing Chat Media", + "stopped_viewing_chat_media": "Stopped Viewing Chat Media", "message_read": "Message Read", "message_deleted": "Message Deleted", "message_saved": "Message Saved", @@ -3114,7 +3555,9 @@ "snap_replayed_twice": "Snap Replayed Twice", "snap_screenshot": "Snap Screenshot", "snap_screen_record": "Snap Screen Record", - "i_can_see_you": "I Can See You" + "i_can_see_you": "I Can See You", + "i_can_see_you_2": "I Can See You 2", + "i_can_see_you_3": "I Can See You 3" }, "cleared_from_feed": "Cleared from feed", "tracker_actions": { @@ -3350,6 +3793,10 @@ "stopped_speaking": "{friend} stopped speaking in {conversation}", "started_peeking": "{friend} started peeking in {conversation}", "stopped_peeking": "{friend} stopped peeking in {conversation}", + "started_using_reply_camera": "{friend} opened the reply camera in {conversation}", + "stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}", + "started_viewing_chat_media": "{friend} started viewing chat media in {conversation}", + "stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}", "message_read": "{friend} read a message in {conversation}", "message_deleted": "{friend} deleted a message in {conversation}", "message_saved": "{friend} saved a message in {conversation}", @@ -3362,7 +3809,9 @@ "snap_replayed_twice": "{friend} replayed a snap twice in {conversation}", "snap_screenshot": "{friend} took a screenshot in {conversation}", "snap_screen_record": "{friend} screen recorded in {conversation}", - "i_can_see_you": "{friend} activity in {conversation}: {details}" + "i_can_see_you": "{friend} activity in {conversation}: {details}", + "i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}", + "i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}" }, "friend_mutation_observer": { "notification_channel_name": "Friend Mutation Observer", @@ -3522,9 +3971,13 @@ "started_typing": "Started typing", "stopped_typing": "Stopped typing", "started_speaking": "Started speaking", - "stopped_speaking": "Stopped speaking", + "stopped_speaking": "Stopped speaking", "started_peeking": "Started peeking", "stopped_peeking": "Stopped peeking", + "started_using_reply_camera": "Opened reply camera", + "stopped_using_reply_camera": "Closed reply camera", + "started_viewing_chat_media": "Started viewing chat media", + "stopped_viewing_chat_media": "Stopped viewing chat media", "message_read": "Read message", "message_deleted": "Deleted message", "message_saved": "Saved message", @@ -3576,6 +4029,10 @@ "stopped_speaking": "stopped speaking", "started_peeking": "started peeking", "stopped_peeking": "stopped peeking", + "started_using_reply_camera": "opened the reply camera", + "stopped_using_reply_camera": "closed the reply camera", + "started_viewing_chat_media": "started viewing chat media", + "stopped_viewing_chat_media": "stopped viewing chat media", "message_read": "read a message", "message_deleted": "deleted a message", "message_saved": "saved a message", @@ -3588,7 +4045,9 @@ "snap_replayed_twice": "replayed a snap twice", "snap_screenshot": "took a screenshot", "snap_screen_record": "screen recorded", - "i_can_see_you": "was active" + "i_can_see_you": "was active", + "i_can_see_you_2": "was viewing gallery", + "i_can_see_you_3": "was using the reply camera" } } }, @@ -3648,6 +4107,7 @@ "disable_feature_loading_label": "Disable Feature Loading", "disable_auto_mapper_label": "Disable Auto Mapper", "disable_bypass_indicator_label": "Disable Bypass Indicator", + "disable_cant_login_button_label": "Disable Can't Login Button", "friend_list": { "manage_title": "Manage Friend List", "export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt index 55bd9b9c..ec8b581b 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt @@ -63,7 +63,7 @@ class Experimental : ConfigContainer() { } val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() } - val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } + val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; requireRestart() } val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt index 7cefc031..75f38545 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt @@ -207,6 +207,8 @@ class MessagingTweaks : ConfigContainer() { val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() } val hidePeekAPeek = boolean("hide_peek_a_peek") val hideBitmojiPresence = boolean("hide_bitmoji_presence") + val spoofViewingGalleryPresence = boolean("spoof_viewing_gallery_presence") + val spoofReplyCameraPresence = boolean("spoof_reply_camera_presence") val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt index 90b7ad25..72ec2c8a 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt @@ -4,10 +4,193 @@ import me.eternal.purrfectsnap.common.config.ConfigContainer import me.eternal.purrfectsnap.common.config.ConfigFlag class Spoof : ConfigContainer(hasGlobalState = true) { + companion object { + val supportedSnapchatLanguages = listOf( + "ar", "bn", "bn-BD", "bn-IN", "da", "de", "el", "en-GB", "es", "es-AR", "es-ES", "es-MX", + "fi", "fil", "fil-PH", "fr", "gu", "gu-IN", "hi", "hi-IN", "in", "it", "ja", "kn", "kn-IN", + "ko", "ml", "ml-IN", "mr", "mr-IN", "ms", "ms-MY", "nb", "nl", "pa", "pa-IN", "pl", "pt", + "pt-PT", "ro", "ru", "sv", "ta", "ta-IN", "te", "te-IN", "th", "th-TH", "tr", "ur", "ur-PK", + "vi", "vi-VN", "zh", "zh-CN", "zh-TW" + ) + } + + inner class RandomizedDeviceProfileConfig : ConfigContainer(hasGlobalState = true) { + inner class RandomizedBuildVersionConfig : ConfigContainer(hasGlobalState = true) { + val fingerprint = boolean("fingerprint", defaultValue = true) { requireRestart() } + val display = boolean("display", defaultValue = true) { requireRestart() } + val host = boolean("host", defaultValue = true) { requireRestart() } + val bootloader = boolean("bootloader", defaultValue = true) { requireRestart() } + val buildTime = boolean("build_time", defaultValue = true) { requireRestart() } + } + + inner class RandomizedDeviceIdentityConfig : ConfigContainer(hasGlobalState = true) { + val manufacturerModel = boolean("manufacturer_model", defaultValue = true) { requireRestart() } + val brandProduct = boolean("brand_product", defaultValue = true) { requireRestart() } + val hardwareBoard = boolean("hardware_board", defaultValue = true) { requireRestart() } + } + + inner class RandomizedAbiListsConfig : ConfigContainer(hasGlobalState = true) { + val combinedAbis = boolean("combined_abis", defaultValue = true) { requireRestart() } + val splitAbis = boolean("split_abis", defaultValue = true) { requireRestart() } + } + + inner class RandomizedSystemPropertiesConfig : ConfigContainer(hasGlobalState = true) { + val build = boolean("build", defaultValue = true) { requireRestart() } + val locale = boolean("locale", defaultValue = true) { requireRestart() } + val telephony = boolean("telephony", defaultValue = true) { requireRestart() } + } + + inner class RandomizedBuildPropertiesConfig : ConfigContainer(hasGlobalState = true) { + val deviceIdentity = container("device_identity", RandomizedDeviceIdentityConfig().apply { globalState = true }) + val abiLists = container("abi_lists", RandomizedAbiListsConfig().apply { globalState = true }) + val systemProperties = container("system_properties", RandomizedSystemPropertiesConfig().apply { globalState = true }) + val buildVersion = container("build_version", RandomizedBuildVersionConfig().apply { globalState = true }) + } + + inner class RandomizedLocaleValueConfig : ConfigContainer(hasGlobalState = true) { + val language = boolean("language", defaultValue = true) { requireRestart() } + val region = boolean("region", defaultValue = true) { requireRestart() } + } + + inner class RandomizedTimeConfig : ConfigContainer(hasGlobalState = true) { + val timeZoneId = boolean("time_zone_id", defaultValue = false) { requireRestart() } + val timeZoneDisplayName = boolean("time_zone_display_name", defaultValue = false) { requireRestart() } + val autoTime = boolean("auto_time", defaultValue = false) { requireRestart() } + val autoTimeZone = boolean("auto_time_zone", defaultValue = false) { requireRestart() } + } + + inner class RandomizedLocaleConfig : ConfigContainer(hasGlobalState = true) { + val locale = container("locale", RandomizedLocaleValueConfig().apply { globalState = true }) + val time = container("time", RandomizedTimeConfig().apply { globalState = true }) + } + + inner class RandomizedMmsConfig : ConfigContainer(hasGlobalState = true) { + val userAgent = boolean("user_agent", defaultValue = true) { requireRestart() } + } + + inner class RandomizedNetworkIdentityConfig : ConfigContainer(hasGlobalState = true) { + val networkType = boolean("network_type", defaultValue = true) { requireRestart() } + val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() } + val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() } + val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() } + } + + inner class RandomizedSimIdentityConfig : ConfigContainer(hasGlobalState = true) { + val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() } + val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() } + val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() } + val simState = boolean("sim_state", defaultValue = true) { requireRestart() } + val hasIccCard = boolean("has_icc_card", defaultValue = true) { requireRestart() } + } + + inner class RandomizedPhoneCapabilitiesConfig : ConfigContainer(hasGlobalState = true) { + val phoneCount = boolean("phone_count", defaultValue = true) { requireRestart() } + val hearingAid = boolean("hearing_aid", defaultValue = true) { requireRestart() } + val tty = boolean("tty", defaultValue = true) { requireRestart() } + val worldPhone = boolean("world_phone", defaultValue = true) { requireRestart() } + val roaming = boolean("roaming", defaultValue = true) { requireRestart() } + val smsVoice = boolean("sms_voice", defaultValue = true) { requireRestart() } + val phoneType = boolean("phone_type", defaultValue = true) { requireRestart() } + } + + inner class RandomizedTelephonyConfig : ConfigContainer(hasGlobalState = true) { + val mms = container("mms", RandomizedMmsConfig().apply { globalState = true }) + val networkIdentity = container("network_identity", RandomizedNetworkIdentityConfig().apply { globalState = true }) + val simIdentity = container("sim_identity", RandomizedSimIdentityConfig().apply { globalState = true }) + val phoneCapabilities = container("phone_capabilities", RandomizedPhoneCapabilitiesConfig().apply { globalState = true }) + } + + inner class RandomizedSecureSettingsConfig : ConfigContainer(hasGlobalState = true) { + val base = boolean("base", defaultValue = true) { requireRestart() } + val tts = boolean("tts", defaultValue = true) { requireRestart() } + } + + inner class RandomizedSystemSettingsConfig : ConfigContainer(hasGlobalState = true) { + val base = boolean("base", defaultValue = true) { requireRestart() } + val bluetooth = boolean("bluetooth", defaultValue = true) { requireRestart() } + } + + inner class RandomizedGlobalSettingsConfig : ConfigContainer(hasGlobalState = true) { + val base = boolean("base", defaultValue = true) { requireRestart() } + } + + inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) { + val secure = container("secure", RandomizedSecureSettingsConfig().apply { globalState = true }) + val system = container("system", RandomizedSystemSettingsConfig().apply { globalState = true }) + val global = container("global", RandomizedGlobalSettingsConfig().apply { globalState = true }) + } + + inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) { + val ssid = boolean("ssid", defaultValue = true) { requireRestart() } + val rssi = boolean("rssi", defaultValue = true) { requireRestart() } + } + + inner class RandomizedDnsConfig : ConfigContainer(hasGlobalState = true) { + val servers = boolean("servers", defaultValue = true) { requireRestart() } + val searchDomains = boolean("search_domains", defaultValue = true) { requireRestart() } + val privateDns = boolean("private_dns", defaultValue = true) { requireRestart() } + } + + inner class RandomizedCaptivePortalConfig : ConfigContainer(hasGlobalState = true) { + val capability = boolean("capability", defaultValue = true) { requireRestart() } + } + + inner class RandomizedNetworkConfig : ConfigContainer(hasGlobalState = true) { + val wifi = container("wifi", RandomizedWifiConfig().apply { globalState = true }) + val dns = container("dns", RandomizedDnsConfig().apply { globalState = true }) + val captivePortal = container("captive_portal", RandomizedCaptivePortalConfig().apply { globalState = true }) + } + + inner class RandomizedAndroidIdConfig : ConfigContainer(hasGlobalState = true) { + val stringValue = boolean("string_value", defaultValue = true) { requireRestart() } + val longValue = boolean("long_value", defaultValue = true) { requireRestart() } + } + + inner class RandomizedAdvertisingIdConfig : ConfigContainer(hasGlobalState = true) { + val settingsValue = boolean("settings_value", defaultValue = true) { requireRestart() } + val playServices = boolean("play_services", defaultValue = true) { requireRestart() } + } + + inner class RandomizedHardwareAddressesConfig : ConfigContainer(hasGlobalState = true) { + val wifiMac = boolean("wifi_mac", defaultValue = true) { requireRestart() } + val bluetoothMac = boolean("bluetooth_mac", defaultValue = true) { requireRestart() } + } + + inner class RandomizedIdentifiersConfig : ConfigContainer(hasGlobalState = true) { + val androidId = container("android_id", RandomizedAndroidIdConfig().apply { globalState = true }) + val advertisingId = container("advertising_id", RandomizedAdvertisingIdConfig().apply { globalState = true }) + val hardwareAddresses = container("hardware_addresses", RandomizedHardwareAddressesConfig().apply { globalState = true }) + } + + val showActivationOverlay = boolean("show_activation_overlay", defaultValue = false) + val randomizeIpAddress = boolean("randomize_ip_address", defaultValue = false) { requireRestart() } + val buildProperties = container("build_properties", RandomizedBuildPropertiesConfig().apply { globalState = true }) + val localeOptions = container("locale_options", RandomizedLocaleConfig().apply { globalState = true }) + val telephonyOptions = container("telephony_options", RandomizedTelephonyConfig().apply { globalState = true }) + val settingsOptions = container("settings_options", RandomizedSettingsConfig().apply { globalState = true }) + val networkOptions = container("network_options", RandomizedNetworkConfig().apply { globalState = true }) + val identifierOptions = container("identifier_options", RandomizedIdentifiersConfig().apply { globalState = true }) + val persistentAppLanguage = unique("persistent_app_language", *supportedSnapchatLanguages.toTypedArray()) { + requireRestart() + addFlags(ConfigFlag.NO_TRANSLATE) + disabledKey = "system_default" + customOptionTranslationPath = "features.options.persistent_app_language" + } + val generateFreshProfileAction = string("generate_fresh_profile_action") + val viewCurrentProfileAction = string("view_current_profile_action") + val profileGenerationToken = string("profile_generation_token") { + addFlags(ConfigFlag.HIDDEN) + } + val currentProfileSnapshot = string("current_profile_snapshot") { + addFlags(ConfigFlag.HIDDEN) + } + } + inner class SpoofDeviceIdConfig : ConfigContainer() { - val spoofAndroidId = boolean("spoof_android_id") { requireRestart() } + val spoofAndroidId = boolean("spoof_android_id") { requireRestart(); addFlags(ConfigFlag.HIDDEN) } val customAndroidId = string("custom_android_id") { requireRestart() + addFlags(ConfigFlag.HIDDEN) inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) } } } @@ -16,8 +199,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) { val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() } val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() } val forceWifiTransportFlag = boolean("force_wifi_transport_flag") { requireRestart() } - val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() } - val spoofDevice = boolean("spoof_device") { requireRestart() } + val randomizeDeviceProfile = container("randomize_device_profile", RandomizedDeviceProfileConfig()) { requireRestart() } + val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart(); addFlags(ConfigFlag.HIDDEN) } + val spoofDevice = boolean("spoof_device") { requireRestart(); addFlags(ConfigFlag.HIDDEN) } val deviceModel = unique("device_model", "none", "random", @@ -38,6 +222,7 @@ class Spoof : ConfigContainer(hasGlobalState = true) { "realme GT 6" ) { requireRestart() + addFlags(ConfigFlag.HIDDEN) customOptionTranslationPath = "features.options.device_model" } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/SessionEventsData.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/SessionEventsData.kt index 83744bfb..604603b7 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/SessionEventsData.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/SessionEventsData.kt @@ -9,7 +9,9 @@ data class FriendPresenceState( val typing: Boolean, val wasTyping: Boolean, val speaking: Boolean, - val peeking: Boolean + val peeking: Boolean, + val usingReplyCamera: Boolean, + val viewingChatMedia: Boolean ) open class SessionEvent( @@ -44,6 +46,8 @@ enum class SessionEventType( SNAP_SCREENSHOT("snap_screenshot"), SNAP_SCREEN_RECORD("snap_screen_record"), I_CAN_SEE_YOU("i_can_see_you"), + I_CAN_SEE_YOU_2("i_can_see_you_2"), + I_CAN_SEE_YOU_3("i_can_see_you_3"), } enum class TrackerEventType( @@ -58,6 +62,10 @@ enum class TrackerEventType( STOPPED_SPEAKING("stopped_speaking"), STARTED_PEEKING("started_peeking"), STOPPED_PEEKING("stopped_peeking"), + STARTED_USING_REPLY_CAMERA("started_using_reply_camera"), + STOPPED_USING_REPLY_CAMERA("stopped_using_reply_camera"), + STARTED_VIEWING_CHAT_MEDIA("started_viewing_chat_media"), + STOPPED_VIEWING_CHAT_MEDIA("stopped_viewing_chat_media"), // mcs events MESSAGE_READ("message_read"), @@ -73,6 +81,8 @@ enum class TrackerEventType( SNAP_SCREENSHOT("snap_screenshot"), SNAP_SCREEN_RECORD("snap_screen_record"), I_CAN_SEE_YOU("i_can_see_you"), + I_CAN_SEE_YOU_2("i_can_see_you_2"), + I_CAN_SEE_YOU_3("i_can_see_you_3"), } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/SecurityFeatures.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/SecurityFeatures.kt index 7e9eb4a1..ed52cf1a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/SecurityFeatures.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/SecurityFeatures.kt @@ -319,6 +319,7 @@ class SecurityFeatures( loginHelpComposable = { var showDialog by remember { mutableStateOf(false) } var isLoginScreen by remember { mutableStateOf(false) } + val disableHelpButton = context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true" LaunchedEffect(Unit) { while (true) { @@ -329,13 +330,13 @@ class SecurityFeatures( } } - if (isLoginScreen) { + if (isLoginScreen && !disableHelpButton) { LoginSignupHelpButton( onClick = { showDialog = true } ) } - if (isLoginScreen && showDialog) { + if (isLoginScreen && !disableHelpButton && showDialog) { LoginSignupHelpDialog( onDismiss = { showDialog = false } ) @@ -433,6 +434,9 @@ class SecurityFeatures( context.features.addActivityCreateListener { activity -> if (!activity.javaClass.name.endsWith("LoginSignupActivity")) return@addActivityCreateListener + if (context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true") { + return@addActivityCreateListener + } activity.findViewById(android.R.id.content).apply { visibility = ViewGroup.INVISIBLE diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt index 72491f77..166663a1 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt @@ -30,6 +30,7 @@ import me.eternal.purrfectsnap.common.data.SocialScope import me.eternal.purrfectsnap.common.ui.OverlayType import me.eternal.purrfectsnap.common.util.toSerialized import me.eternal.purrfectsnap.core.ModContext +import java.nio.charset.StandardCharsets import java.util.concurrent.Executors import kotlin.coroutines.Continuation import kotlin.coroutines.resume @@ -234,10 +235,48 @@ class BridgeClient( fun passGroupsAndFriends(groups: List, friends: List) = safeServiceCall { - service.passGroupsAndFriends( - groups.mapNotNull { it.toSerialized() }, - friends.mapNotNull { it.toSerialized() } + val serializedGroups = groups.mapNotNull { it.toSerialized() } + val serializedFriends = friends.mapNotNull { it.toSerialized() } + val maxChunkBytes = 128 * 1024 + + fun chunkSerialized(values: List): List> { + if (values.isEmpty()) return listOf(emptyList()) + val result = mutableListOf>() + val currentChunk = mutableListOf() + var currentSize = 0 + + values.forEach { value -> + val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32 + if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) { + result += currentChunk.toList() + currentChunk.clear() + currentSize = 0 + } + currentChunk += value + currentSize += valueSize + } + + if (currentChunk.isNotEmpty()) { + result += currentChunk.toList() + } + return result + } + + val groupChunks = chunkSerialized(serializedGroups) + val friendChunks = chunkSerialized(serializedFriends) + val chunkCount = maxOf(groupChunks.size, friendChunks.size) + + context.log.info( + "Sending social snapshot in $chunkCount chunk(s): " + + "${serializedGroups.size} groups, ${serializedFriends.size} friends" ) + + repeat(chunkCount) { index -> + service.passGroupsAndFriends( + groupChunks.getOrElse(index) { emptyList() }, + friendChunks.getOrElse(index) { emptyList() } + ) + } } fun getRules(targetUuid: String): List = safeServiceCall { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpoofer.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpoofer.kt index 60b9a546..2ac060d9 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpoofer.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpoofer.kt @@ -15,193 +15,358 @@ data class DeviceInfo( val host: String ) +data class DeviceBuildProfile( + val androidRelease: String, + val display: String, + val buildId: String, + val incremental: String, + val host: String, + val bootloader: String? = null +) + +data class DeviceCapabilityProfile( + val supportedAbis: List, + val supported32BitAbis: List, + val supported64BitAbis: List, + val phoneCount: Int, + val isHearingAidCompatibilitySupported: Boolean, + val isTtySupported: Boolean, + val isWorldPhone: Boolean, + val isSmsCapable: Boolean, + val isVoiceCapable: Boolean, + val phoneType: Int, + val phoneTypeString: String +) + +data class DeviceTemplate( + val marketingName: String, + val deviceInfo: DeviceInfo, + val builds: List, + val capabilities: DeviceCapabilityProfile +) + object DeviceSpoofer { + private val defaultCapabilities = DeviceCapabilityProfile( + supportedAbis = listOf("arm64-v8a", "armeabi-v7a", "armeabi"), + supported32BitAbis = listOf("armeabi-v7a", "armeabi"), + supported64BitAbis = listOf("arm64-v8a"), + phoneCount = 2, + isHearingAidCompatibilitySupported = true, + isTtySupported = false, + isWorldPhone = true, + isSmsCapable = true, + isVoiceCapable = true, + phoneType = 1, + phoneTypeString = "PHONE_TYPE_GSM" + ) + + private val singleSimCapabilities = defaultCapabilities.copy(phoneCount = 1) + private val devices = mapOf( - "Pixel 8 Pro" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 8 Pro", - brand = "google", - device = "husky", - product = "husky", - hardware = "husky", - board = "husky", - bootloader = "husky-1.0-11003666", - display = "UQ1A.231205.015", - host = "abfarm-release-rbe-64-00163" + "Pixel 8 Pro" to DeviceTemplate( + marketingName = "Pixel 8 Pro", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 8 Pro", + brand = "google", + device = "husky", + product = "husky", + hardware = "husky", + board = "husky", + bootloader = "husky-1.0-11003666", + display = "UQ1A.231205.015", + host = "abfarm-release-rbe-64-00163" + ), + builds = listOf( + DeviceBuildProfile("14", "UQ1A.231205.015", "UQ1A.231205.015", "11003666", "abfarm-release-rbe-64-00163", "husky-1.0-11003666"), + DeviceBuildProfile("15", "AP4A.250205.002", "AP4A.250205.002", "12141234", "abfarm-release-rbe-64-00171", "husky-1.0-12141234") + ), + capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false) ), - "Pixel 9 Pro XL" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 9 Pro XL", - brand = "google", - device = "komodo", - product = "komodo", - hardware = "komodo", - board = "komodo", - bootloader = "komodo-1.0-12110753", - display = "AP3A.241105.008", - host = "abfarm-release-rbe-64-00163" + "Pixel 9 Pro XL" to DeviceTemplate( + marketingName = "Pixel 9 Pro XL", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 9 Pro XL", + brand = "google", + device = "komodo", + product = "komodo", + hardware = "komodo", + board = "komodo", + bootloader = "komodo-1.0-12110753", + display = "AP3A.241105.008", + host = "abfarm-release-rbe-64-00163" + ), + builds = listOf( + DeviceBuildProfile("14", "AP3A.241105.008", "AP3A.241105.008", "12110753", "abfarm-release-rbe-64-00163", "komodo-1.0-12110753"), + DeviceBuildProfile("15", "BP1A.250105.006", "BP1A.250105.006", "13120567", "abfarm-release-rbe-65-00088", "komodo-1.0-13120567") + ), + capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false) ), - "Pixel 10" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 10", - brand = "google", - device = "frankel", - product = "frankel", - hardware = "tensor_g5", - board = "frankel", - bootloader = "frankel-1.0-12345678", - display = "BP1A.250105.002", - host = "abfarm-release-rbe-65-00200" + "Pixel 10" to DeviceTemplate( + marketingName = "Pixel 10", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 10", + brand = "google", + device = "frankel", + product = "frankel", + hardware = "tensor_g5", + board = "frankel", + bootloader = "frankel-1.0-12345678", + display = "BP1A.250105.002", + host = "abfarm-release-rbe-65-00200" + ), + builds = listOf( + DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345678", "abfarm-release-rbe-65-00200", "frankel-1.0-12345678") + ), + capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false) ), - "Pixel 10 Pro" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 10 Pro", - brand = "google", - device = "blazer", - product = "blazer", - hardware = "tensor_g5", - board = "blazer", - bootloader = "blazer-1.0-12345679", - display = "BP1A.250105.002", - host = "abfarm-release-rbe-65-00201" + "Pixel 10 Pro" to DeviceTemplate( + marketingName = "Pixel 10 Pro", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 10 Pro", + brand = "google", + device = "blazer", + product = "blazer", + hardware = "tensor_g5", + board = "blazer", + bootloader = "blazer-1.0-12345679", + display = "BP1A.250105.002", + host = "abfarm-release-rbe-65-00201" + ), + builds = listOf( + DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345679", "abfarm-release-rbe-65-00201", "blazer-1.0-12345679") + ), + capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false) ), - "Pixel 10 Pro XL" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 10 Pro XL", - brand = "google", - device = "mustang", - product = "mustang", - hardware = "tensor_g5", - board = "mustang", - bootloader = "mustang-1.0-12345680", - display = "BP1A.250105.002", - host = "abfarm-release-rbe-65-00202" + "Pixel 10 Pro XL" to DeviceTemplate( + marketingName = "Pixel 10 Pro XL", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 10 Pro XL", + brand = "google", + device = "mustang", + product = "mustang", + hardware = "tensor_g5", + board = "mustang", + bootloader = "mustang-1.0-12345680", + display = "BP1A.250105.002", + host = "abfarm-release-rbe-65-00202" + ), + builds = listOf( + DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345680", "abfarm-release-rbe-65-00202", "mustang-1.0-12345680") + ), + capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false) ), - "Pixel 10 Pro Fold" to DeviceInfo( - manufacturer = "Google", - model = "Pixel 10 Pro Fold", - brand = "google", - device = "rango", - product = "rango", - hardware = "tensor_g5", - board = "rango", - bootloader = "rango-1.0-12345681", - display = "BP1A.250105.002", - host = "abfarm-release-rbe-65-00203" + "Pixel 10 Pro Fold" to DeviceTemplate( + marketingName = "Pixel 10 Pro Fold", + deviceInfo = DeviceInfo( + manufacturer = "Google", + model = "Pixel 10 Pro Fold", + brand = "google", + device = "rango", + product = "rango", + hardware = "tensor_g5", + board = "rango", + bootloader = "rango-1.0-12345681", + display = "BP1A.250105.002", + host = "abfarm-release-rbe-65-00203" + ), + builds = listOf( + DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345681", "abfarm-release-rbe-65-00203", "rango-1.0-12345681") + ), + capabilities = singleSimCapabilities.copy(isWorldPhone = false) ), - "Galaxy S23 Ultra" to DeviceInfo( - manufacturer = "Samsung", - model = "SM-S918B", - brand = "samsung", - device = "dm3q", - product = "dm3qxx", - hardware = "qcom", - board = "kalama", - bootloader = "S918BXXU3BWJM", - display = "UP1A.231005.007.S918BXXU3BWJM", - host = "21DH7R2P" + "Galaxy S23 Ultra" to DeviceTemplate( + marketingName = "Galaxy S23 Ultra", + deviceInfo = DeviceInfo( + manufacturer = "Samsung", + model = "SM-S918B", + brand = "samsung", + device = "dm3q", + product = "dm3qxx", + hardware = "qcom", + board = "kalama", + bootloader = "S918BXXU3BWJM", + display = "UP1A.231005.007.S918BXXU3BWJM", + host = "21DH7R2P" + ), + builds = listOf( + DeviceBuildProfile("14", "UP1A.231005.007.S918BXXU3BWJM", "UP1A.231005.007", "S918BXXU3BWJM", "21DH7R2P", "S918BXXU3BWJM"), + DeviceBuildProfile("15", "AP3A.240905.015.S918BXXU4CXA1", "AP3A.240905.015", "S918BXXU4CXA1", "21DH7R2P", "S918BXXU4CXA1") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "Galaxy S24 Ultra" to DeviceInfo( - manufacturer = "Samsung", - model = "SM-S928B", - brand = "samsung", - device = "e9q", - product = "e9qxx", - hardware = "qcom", - board = "pineapple", - bootloader = "S928BXXU1AXB5", - display = "UP1A.231005.007.S928BXXU1AXB5", - host = "21DH7R2P" + "Galaxy S24 Ultra" to DeviceTemplate( + marketingName = "Galaxy S24 Ultra", + deviceInfo = DeviceInfo( + manufacturer = "Samsung", + model = "SM-S928B", + brand = "samsung", + device = "e9q", + product = "e9qxx", + hardware = "qcom", + board = "pineapple", + bootloader = "S928BXXU1AXB5", + display = "UP1A.231005.007.S928BXXU1AXB5", + host = "21DH7R2P" + ), + builds = listOf( + DeviceBuildProfile("14", "UP1A.231005.007.S928BXXU1AXB5", "UP1A.231005.007", "S928BXXU1AXB5", "21DH7R2P", "S928BXXU1AXB5"), + DeviceBuildProfile("15", "AP3A.240905.015.S928BXXU2BYD6", "AP3A.240905.015", "S928BXXU2BYD6", "21DH7R2P", "S928BXXU2BYD6") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "Galaxy S25 Ultra" to DeviceInfo( - manufacturer = "Samsung", - model = "SM-S938B", - brand = "samsung", - device = "e3q", - product = "e3qxx", - hardware = "qcom", - board = "s5e9945", - bootloader = "S938BXXU1AXL2", - display = "UP1A.231005.007.S938BXXU1AXL2", - host = "21DH7R2P" + "Galaxy S25 Ultra" to DeviceTemplate( + marketingName = "Galaxy S25 Ultra", + deviceInfo = DeviceInfo( + manufacturer = "Samsung", + model = "SM-S938B", + brand = "samsung", + device = "e3q", + product = "e3qxx", + hardware = "qcom", + board = "s5e9945", + bootloader = "S938BXXU1AXL2", + display = "UP1A.231005.007.S938BXXU1AXL2", + host = "21DH7R2P" + ), + builds = listOf( + DeviceBuildProfile("15", "AP3A.241005.019.S938BXXU1AXL2", "AP3A.241005.019", "S938BXXU1AXL2", "21DH7R2P", "S938BXXU1AXL2") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "OnePlus 15" to DeviceInfo( - manufacturer = "OnePlus", - model = "CPH2651", - brand = "OnePlus", - device = "OP5929L1", - product = "OP5929L1_EEA", - hardware = "qcom", - board = "taro", - bootloader = "unknown", - display = "CPH2651_15.0.0.503(EX01)", - host = "ubuntu-build" + "OnePlus 15" to DeviceTemplate( + marketingName = "OnePlus 15", + deviceInfo = DeviceInfo( + manufacturer = "OnePlus", + model = "CPH2651", + brand = "OnePlus", + device = "OP5929L1", + product = "OP5929L1_EEA", + hardware = "qcom", + board = "taro", + bootloader = "unknown", + display = "CPH2651_15.0.0.503(EX01)", + host = "ubuntu-build" + ), + builds = listOf( + DeviceBuildProfile("15", "CPH2651_15.0.0.503(EX01)", "CPH2651_15.0.0.503(EX01)", "15.0.0.503", "ubuntu-build"), + DeviceBuildProfile("15", "CPH2651_15.0.0.601(EX01)", "CPH2651_15.0.0.601(EX01)", "15.0.0.601", "ubuntu-build") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "OnePlus Open" to DeviceInfo( - manufacturer = "OnePlus", - model = "CPH2551", - brand = "OnePlus", - device = "OP594DL1", - product = "OP594DL1_EEA", - hardware = "qcom", - board = "taro", - bootloader = "unknown", - display = "CPH2551_14.0.0.600(EX01)", - host = "ubuntu-build" + "OnePlus Open" to DeviceTemplate( + marketingName = "OnePlus Open", + deviceInfo = DeviceInfo( + manufacturer = "OnePlus", + model = "CPH2551", + brand = "OnePlus", + device = "OP594DL1", + product = "OP594DL1_EEA", + hardware = "qcom", + board = "taro", + bootloader = "unknown", + display = "CPH2551_14.0.0.600(EX01)", + host = "ubuntu-build" + ), + builds = listOf( + DeviceBuildProfile("14", "CPH2551_14.0.0.600(EX01)", "CPH2551_14.0.0.600(EX01)", "14.0.0.600", "ubuntu-build"), + DeviceBuildProfile("15", "CPH2551_15.0.0.305(EX01)", "CPH2551_15.0.0.305(EX01)", "15.0.0.305", "ubuntu-build") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "Xiaomi 15 Ultra" to DeviceInfo( - manufacturer = "Xiaomi", - model = "25010PN30G", - brand = "Xiaomi", - device = "xuanyuan", - product = "xuanyuan_global", - hardware = "qcom", - board = "taro", - bootloader = "unknown", - display = "VK.15.0.3.0.VNGMIXM", - host = "c3-miui-ota-bd164.bj" + "Xiaomi 15 Ultra" to DeviceTemplate( + marketingName = "Xiaomi 15 Ultra", + deviceInfo = DeviceInfo( + manufacturer = "Xiaomi", + model = "25010PN30G", + brand = "Xiaomi", + device = "xuanyuan", + product = "xuanyuan_global", + hardware = "qcom", + board = "taro", + bootloader = "unknown", + display = "VK.15.0.3.0.VNGMIXM", + host = "c3-miui-ota-bd164.bj" + ), + builds = listOf( + DeviceBuildProfile("15", "VK.15.0.3.0.VNGMIXM", "VK.15.0.3.0.VNGMIXM", "15.0.3.0", "c3-miui-ota-bd164.bj"), + DeviceBuildProfile("15", "VK.15.0.6.0.VNGMIXM", "VK.15.0.6.0.VNGMIXM", "15.0.6.0", "c3-miui-ota-bd164.bj") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "OPPO Find X9 Pro" to DeviceInfo( - manufacturer = "OPPO", - model = "PHY110", - brand = "OPPO", - device = "OP595DL1", - product = "OP595DL1_EEA", - hardware = "mt6989", - board = "k6989v1_64", - bootloader = "unknown", - display = "PHY110_15.0.0.100(EX01)", - host = "ubuntu-build-server" + "OPPO Find X9 Pro" to DeviceTemplate( + marketingName = "OPPO Find X9 Pro", + deviceInfo = DeviceInfo( + manufacturer = "OPPO", + model = "PHY110", + brand = "OPPO", + device = "OP595DL1", + product = "OP595DL1_EEA", + hardware = "mt6989", + board = "k6989v1_64", + bootloader = "unknown", + display = "PHY110_15.0.0.100(EX01)", + host = "ubuntu-build-server" + ), + builds = listOf( + DeviceBuildProfile("15", "PHY110_15.0.0.100(EX01)", "PHY110_15.0.0.100(EX01)", "15.0.0.100", "ubuntu-build-server"), + DeviceBuildProfile("15", "PHY110_15.0.0.202(EX01)", "PHY110_15.0.0.202(EX01)", "15.0.0.202", "ubuntu-build-server") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "vivo X100 Pro" to DeviceInfo( - manufacturer = "vivo", - model = "V2309A", - brand = "vivo", - device = "V2309A", - product = "PD2309", - hardware = "mt6989", - board = "k6989v1_64", - bootloader = "unknown", - display = "OP557L.PD2309.14.0.0.100", - host = "compiler-server" + "vivo X100 Pro" to DeviceTemplate( + marketingName = "vivo X100 Pro", + deviceInfo = DeviceInfo( + manufacturer = "vivo", + model = "V2309A", + brand = "vivo", + device = "V2309A", + product = "PD2309", + hardware = "mt6989", + board = "k6989v1_64", + bootloader = "unknown", + display = "PD2309F_EX_A_14.0.13.2.W30", + host = "compiler-server" + ), + builds = listOf( + DeviceBuildProfile("14", "PD2309F_EX_A_14.0.13.2.W30", "PD2309F_EX_A_14.0.13.2.W30", "14.0.13.2", "compiler-server"), + DeviceBuildProfile("15", "PD2309F_EX_A_15.0.8.5.W30", "PD2309F_EX_A_15.0.8.5.W30", "15.0.8.5", "compiler-server") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ), - "realme GT 6" to DeviceInfo( - manufacturer = "realme", - model = "RMX3851", - brand = "realme", - device = "RMX3851", - product = "RMX3851_11_A.13", - hardware = "qcom", - board = "taro", - bootloader = "unknown", - display = "RMX3851_14.0.0.700(EX01)", - host = "ubuntu-server" + "realme GT 6" to DeviceTemplate( + marketingName = "realme GT 6", + deviceInfo = DeviceInfo( + manufacturer = "realme", + model = "RMX3851", + brand = "realme", + device = "RMX3851", + product = "RMX3851_11_A.13", + hardware = "qcom", + board = "taro", + bootloader = "unknown", + display = "RMX3851_14.0.0.700(EX01)", + host = "ubuntu-server" + ), + builds = listOf( + DeviceBuildProfile("14", "RMX3851_14.0.0.700(EX01)", "RMX3851_14.0.0.700(EX01)", "14.0.0.700", "ubuntu-server"), + DeviceBuildProfile("15", "RMX3851_15.0.0.205(EX01)", "RMX3851_15.0.0.205(EX01)", "15.0.0.205", "ubuntu-server") + ), + capabilities = defaultCapabilities.copy(phoneCount = 2) ) ) fun getAvailableDevices(): List = devices.keys.toList() fun getDeviceInfo(modelName: String): DeviceInfo? { + return devices[modelName]?.deviceInfo + } + + fun getDeviceTemplate(modelName: String): DeviceTemplate? { return devices[modelName] } @@ -211,6 +376,10 @@ object DeviceSpoofer { return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys" } + fun generateFingerprint(deviceInfo: DeviceInfo, buildProfile: DeviceBuildProfile): String { + return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:${buildProfile.androidRelease}/${buildProfile.buildId}/${buildProfile.incremental}:user/release-keys" + } + fun generateAndroidId(): String { val random = SecureRandom() val bytes = ByteArray(8) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt index 5eeb7dbf..e6695cf3 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt @@ -3,375 +3,958 @@ package me.eternal.purrfectsnap.core.features.impl.experiments import android.annotation.SuppressLint import android.location.Location import android.net.ConnectivityManager +import android.net.LinkAddress +import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.os.Build import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Wifi import me.eternal.purrfectsnap.core.features.Feature import me.eternal.purrfectsnap.core.util.LSPatchUpdater import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.hook +import java.lang.reflect.Modifier +import java.net.InetAddress import java.security.SecureRandom +import java.util.Locale +import java.util.TimeZone +import me.eternal.purrfectsnap.common.config.ModConfig -class DeviceSpooferHook: Feature("Device Spoofer") { - private var spoofedAndroidId: String? = null - private var spoofedDeviceInfo: DeviceInfo? = null - private var spoofedFingerprint: String? = null +class DeviceSpooferHook : Feature("Device Spoofer") { + private var spoofedAndroidId: String? = null + private var spoofedDeviceInfo: DeviceInfo? = null + private var spoofedFingerprint: String? = null + private var randomizedProfile: RandomizedDeviceProfile? = null - private fun generateAndroidId(): String { - // Always check custom ID first - this ensures changes take effect immediately - val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable() - if (!customId.isNullOrEmpty()) { - val normalizedId = customId.lowercase().trim() - if (normalizedId.length == 16 && normalizedId.all { it in '0'..'9' || it in 'a'..'f' }) { - // Only log when ID actually changes - if (spoofedAndroidId != normalizedId) { - spoofedAndroidId = normalizedId - context.log.info("Using custom Android ID: $spoofedAndroidId") - } - return spoofedAndroidId!! - } else { - context.log.warn("Invalid custom Android ID format (must be 16 hex chars), generating new one") - } - } + private fun spoofPrefs() = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - // No custom ID set - use stored or generate new one - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val storedId = sharedPrefs.getString("android_id", null) - if (storedId == null || storedId.length != 16) { - spoofedAndroidId = DeviceSpoofer.generateAndroidId() - sharedPrefs.edit().putString("android_id", spoofedAndroidId).apply() - context.log.info("Generated new Android ID: $spoofedAndroidId") - } else { - // Only use cached value if it matches stored value (handles regeneration) - if (spoofedAndroidId != storedId) { - spoofedAndroidId = storedId - context.log.info("Using stored Android ID: $spoofedAndroidId") - } - } - return spoofedAndroidId!! - } + private fun generateAndroidId(): String { + val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable() + if (!customId.isNullOrEmpty()) { + val normalizedId = customId.lowercase().trim() + if (normalizedId.length == 16 && normalizedId.all { it in '0'..'9' || it in 'a'..'f' }) { + if (spoofedAndroidId != normalizedId) { + spoofedAndroidId = normalizedId + context.log.info("Using custom Android ID: $spoofedAndroidId") + } + return spoofedAndroidId!! + } else { + context.log.warn("Invalid custom Android ID format (must be 16 hex chars), generating new one") + } + } - private fun getDeviceInfo(modelName: String): DeviceInfo? { - return DeviceSpoofer.getDeviceInfo(modelName) - } + val sharedPrefs = spoofPrefs() + val storedId = sharedPrefs.getString("android_id", null) + if (storedId == null || storedId.length != 16) { + spoofedAndroidId = DeviceSpoofer.generateAndroidId() + sharedPrefs.edit().putString("android_id", spoofedAndroidId).apply() + context.log.info("Generated new Android ID: $spoofedAndroidId") + } else if (spoofedAndroidId != storedId) { + spoofedAndroidId = storedId + context.log.info("Using stored Android ID: $spoofedAndroidId") + } + return spoofedAndroidId!! + } - private fun getSpoofedDeviceInfo(): DeviceInfo? { - val selectedModel = context.config.experimental.spoof.deviceModel.getNullable() ?: return null - if (selectedModel == "random") { - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val randomDevice = sharedPrefs.getString("random_device", null) - if (randomDevice == null) { - val availableDevices = DeviceSpoofer.getAvailableDevices() - val newRandomDevice = availableDevices.random() - sharedPrefs.edit().putString("random_device", newRandomDevice).apply() - context.log.info("Randomly selected device: $newRandomDevice") - spoofedDeviceInfo = getDeviceInfo(newRandomDevice) - } else { - context.log.info("Using stored random device: $randomDevice") - spoofedDeviceInfo = getDeviceInfo(randomDevice) - } - return spoofedDeviceInfo - } - if (selectedModel == "none" || selectedModel == "null") return null - spoofedDeviceInfo = getDeviceInfo(selectedModel) - return spoofedDeviceInfo - } + private fun getDeviceInfo(modelName: String): DeviceInfo? = DeviceSpoofer.getDeviceInfo(modelName) - private fun getSpoofedFingerprint(deviceInfo: DeviceInfo): String { - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val storedFingerprint = sharedPrefs.getString("device_fingerprint", null) - if (storedFingerprint == null) { - val buildVersion = Build.VERSION.RELEASE - spoofedFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildVersion) - sharedPrefs.edit().putString("device_fingerprint", spoofedFingerprint).apply() - context.log.info("Generated new device fingerprint: $spoofedFingerprint") - } else { - spoofedFingerprint = storedFingerprint - context.log.info("Using stored device fingerprint: $spoofedFingerprint") - } - return spoofedFingerprint!! - } + private fun getSpoofedDeviceInfo(): DeviceInfo? { + val selectedModel = context.config.experimental.spoof.deviceModel.getNullable() ?: return null + if (selectedModel == "random") { + val sharedPrefs = spoofPrefs() + val randomDevice = sharedPrefs.getString("random_device", null) + if (randomDevice == null) { + val availableDevices = DeviceSpoofer.getAvailableDevices() + val newRandomDevice = availableDevices.random() + sharedPrefs.edit().putString("random_device", newRandomDevice).apply() + context.log.info("Randomly selected device: $newRandomDevice") + spoofedDeviceInfo = getDeviceInfo(newRandomDevice) + } else { + context.log.info("Using stored random device: $randomDevice") + spoofedDeviceInfo = getDeviceInfo(randomDevice) + } + return spoofedDeviceInfo + } + if (selectedModel == "none" || selectedModel == "null") return null + spoofedDeviceInfo = getDeviceInfo(selectedModel) + return spoofedDeviceInfo + } - private fun getRandomGsfId(): String { - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val savedGsfId = sharedPrefs.getString("gsf_id", null) - if (savedGsfId != null) return savedGsfId - val random = SecureRandom() - val gsfId = (1..16).map { - "0123456789abcdef"[random.nextInt(16)] - }.joinToString("") - sharedPrefs.edit().putString("gsf_id", gsfId).apply() - return gsfId - } + private fun getSpoofedFingerprint(deviceInfo: DeviceInfo): String { + val sharedPrefs = spoofPrefs() + val storedFingerprint = sharedPrefs.getString("device_fingerprint", null) + if (storedFingerprint == null) { + val buildVersion = Build.VERSION.RELEASE + spoofedFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildVersion) + sharedPrefs.edit().putString("device_fingerprint", spoofedFingerprint).apply() + context.log.info("Generated new device fingerprint: $spoofedFingerprint") + } else { + spoofedFingerprint = storedFingerprint + context.log.info("Using stored device fingerprint: $spoofedFingerprint") + } + return spoofedFingerprint!! + } - private fun generateRandomUUID(): String { - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val savedUuid = sharedPrefs.getString("advertising_id", null) - if (savedUuid != null) return savedUuid - val random = SecureRandom() - val uuid = "%08x-%04x-%04x-%04x-%012x".format( - random.nextInt(), - random.nextInt() and 0xFFFF, - (random.nextInt() and 0x0FFF) or 0x4000, - (random.nextInt() and 0x3FFF) or 0x8000, - random.nextLong() and 0xFFFFFFFFFFFFL - ) - sharedPrefs.edit().putString("advertising_id", uuid).apply() - return uuid - } + private fun getRandomGsfId(): String { + val sharedPrefs = spoofPrefs() + val savedGsfId = sharedPrefs.getString("gsf_id", null) + if (savedGsfId != null) return savedGsfId + val random = SecureRandom() + val gsfId = (1..16).map { + "0123456789abcdef"[random.nextInt(16)] + }.joinToString("") + sharedPrefs.edit().putString("gsf_id", gsfId).apply() + return gsfId + } - private fun generateRandomMacAddress(): String { - val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) - val savedMac = sharedPrefs.getString("bluetooth_address", null) - if (savedMac != null) return savedMac - val random = SecureRandom() - val mac = (1..6).map { - "%02x".format(random.nextInt(256)) - }.joinToString(":") - sharedPrefs.edit().putString("bluetooth_address", mac).apply() - return mac - } + private fun generateRandomUUID(): String { + val sharedPrefs = spoofPrefs() + val savedUuid = sharedPrefs.getString("advertising_id", null) + if (savedUuid != null) return savedUuid + val random = SecureRandom() + val uuid = "%08x-%04x-%04x-%04x-%012x".format( + random.nextInt(), + random.nextInt() and 0xFFFF, + (random.nextInt() and 0x0FFF) or 0x4000, + (random.nextInt() and 0x3FFF) or 0x8000, + random.nextLong() and 0xFFFFFFFFFFFFL + ) + sharedPrefs.edit().putString("advertising_id", uuid).apply() + return uuid + } - private fun hookInstallerPackageName() { - context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param -> - param.setResult("com.android.vending") - } - } + private fun generateRandomMacAddress(): String { + val sharedPrefs = spoofPrefs() + val savedMac = sharedPrefs.getString("bluetooth_address", null) + if (savedMac != null) return savedMac + val random = SecureRandom() + val mac = (1..6).map { + "%02x".format(random.nextInt(256)) + }.joinToString(":") + sharedPrefs.edit().putString("bluetooth_address", mac).apply() + return mac + } - @SuppressLint("MissingPermission") - override fun init() { - val spoofDevice by context.config.experimental.spoof.spoofDevice - if (spoofDevice) { - val deviceInfo = getSpoofedDeviceInfo() - if (deviceInfo != null) { - getSpoofedFingerprint(deviceInfo) - context.log.info("Device spoofing initialized: ${deviceInfo.manufacturer} ${deviceInfo.model}") - } - } + private fun hookInstallerPackageName() { + context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param -> + param.setResult("com.android.vending") + } + } - if (LSPatchUpdater.HAS_LSPATCH) { - hookInstallerPackageName() - } + private fun getRandomizedProfile(): RandomizedDeviceProfile { + val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable() + return randomizedProfile ?: RandomizedDeviceProfileStore + .getOrCreate(context.androidContext, context.log, generationToken) + .also { profile -> + randomizedProfile = profile + persistRandomizedProfileSnapshot(profile) + } + } - if (context.config.experimental.spoof.globalState != true) return + private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) { + val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile + val snapshot = profile.toJson().toString(2) + if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return + spoofConfig.currentProfileSnapshot.set(snapshot) + runCatching { + val field = context.javaClass.getDeclaredField("_config\$delegate") + field.isAccessible = true + val lazyConfig = field.get(context) as Lazy<*> + val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching + modConfig.writeConfig(dispatchConfigListener = false) + context.log.verbose("Persisted randomized device profile snapshot to config") + }.onFailure { + context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}") + } + } - val removeMockLocationFlag by context.config.experimental.spoof.removeMockLocationFlag - val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName - val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag - val forceWifiTransportFlag by context.config.experimental.spoof.forceWifiTransportFlag - val networkOptimization by context.config.experimental.networkOptimization - val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId + private fun androidIdAsLong(androidId: String): Long { + return runCatching { androidId.toULong(16).toLong() }.getOrElse { androidId.hashCode().toLong() } + } - if(overridePlayStoreInstallerPackageName) { - hookInstallerPackageName() - } + private fun getEffectiveRandomizedLocale(profile: RandomizedDeviceProfile): Locale { + val forcedLanguage = context.config.experimental.spoof.randomizeDeviceProfile.persistentAppLanguage.getNullable() + return forcedLanguage?.let(Locale::forLanguageTag) ?: profile.locale() + } - if (removeMockLocationFlag) { - Location::class.java.hook("isMock", HookStage.BEFORE) { param -> - param.setResult(false) - } - } + private data class RandomizedProfileToggleState( + val buildProperties: Boolean, + val locale: Boolean, + val telephony: Boolean, + val settings: Boolean, + val network: Boolean, + val identifiers: Boolean + ) - if (removeVpnTransportFlag) { - ConnectivityManager::class.java.hook("getAllNetworks", HookStage.AFTER) { param -> - val instance = param.thisObject() as? ConnectivityManager ?: return@hook - val networks = param.getResult() as? Array<*> ?: return@hook + private data class RandomizedBuildToggleState( + val deviceIdentity: Boolean, + val abiLists: Boolean, + val systemProperties: Boolean, + val fingerprint: Boolean, + val display: Boolean, + val host: Boolean, + val bootloader: Boolean, + val buildTime: Boolean + ) - param.setResult(networks.filterIsInstance().filter { network -> - val capabilities = instance.getNetworkCapabilities(network) ?: return@filter false - !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - }.toTypedArray()) - } - } + private data class RandomizedLocaleToggleState( + val locale: Boolean, + val timeZone: Boolean, + val autoTime: Boolean, + val autoTimeZone: Boolean + ) - if (forceWifiTransportFlag) { - val connectivityManager = context.androidContext.getSystemService(ConnectivityManager::class.java) - val activeNetwork = connectivityManager?.activeNetwork - val initialCapabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } - val isReallyOnWifi = initialCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + private data class RandomizedTelephonyToggleState( + val mmsUserAgent: Boolean, + val networkIdentity: Boolean, + val simIdentity: Boolean, + val phoneCapabilities: Boolean + ) - onNextActivityCreate { - if (isReallyOnWifi) { - context.inAppOverlay.showStatusToast( - icon = Icons.Filled.Wifi, - text = "Connected to Real WiFi", - durationMs = 3000 - ) - } else { - context.inAppOverlay.showStatusToast( - icon = Icons.Filled.Wifi, - text = "WiFi Spoofing Active", - durationMs = 3000 - ) - } - } + private data class RandomizedSettingsToggleState( + val secure: Boolean, + val system: Boolean, + val global: Boolean + ) - NetworkCapabilities::class.java.apply { - hook("hasTransport", HookStage.BEFORE) { param -> - val transportType = param.args().getOrNull(0) as? Int - val actuallyHasWifi = runCatching { - param.invokeOriginal() as Boolean - }.getOrDefault(false) - - if (actuallyHasWifi == true && transportType == NetworkCapabilities.TRANSPORT_WIFI) { - return@hook - } - - if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { - param.setResult(true) - } else if (transportType == NetworkCapabilities.TRANSPORT_CELLULAR) { - param.setResult(false) - } - } - hook("hasCapability", HookStage.BEFORE) { param -> - val capability = param.args().getOrNull(0) as? Int - if (capability == NetworkCapabilities.NET_CAPABILITY_NOT_VPN) { - param.setResult(true) - } - } - } - findClass("android.net.NetworkInfo").apply { - hook("getType", HookStage.BEFORE) { param -> - val originalType = runCatching { - param.invokeOriginal() as Int - }.getOrDefault(-1) - - if (originalType == 1) { - return@hook - } - param.setResult(1) - } - hook("getTypeName", HookStage.BEFORE) { param -> - val originalTypeName = runCatching { - param.invokeOriginal() as String - }.getOrNull() - - if (originalTypeName?.equals("WIFI", ignoreCase = true) == true) { - return@hook - } - param.setResult("WIFI") - } - hook("isConnected", HookStage.BEFORE) { param -> - param.setResult(true) - } - } - findClass("org.chromium.base.RadioUtils").hook("isWifiConnected", HookStage.BEFORE) { param -> - val actuallyOnWifi = runCatching { - val connectivityMgr = context.androidContext.getSystemService(ConnectivityManager::class.java) - val activeNet = connectivityMgr?.activeNetwork - val caps = activeNet?.let { connectivityMgr.getNetworkCapabilities(it) } - caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - }.getOrDefault(false) - - if (!actuallyOnWifi) { - param.setResult(true) - } - } - } + private data class RandomizedNetworkToggleState( + val wifi: Boolean, + val dns: Boolean, + val captivePortal: Boolean, + val ipAddress: Boolean + ) - if (networkOptimization) { - // Internal Buffer Optimization - findClass("java.net.Socket").apply { - hook("setSendBufferSize", HookStage.BEFORE) { param -> - val size = param.arg(0) - if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer - } - hook("setReceiveBufferSize", HookStage.BEFORE) { param -> - val size = param.arg(0) - if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer - } - } - } + private data class RandomizedIdentifierToggleState( + val androidId: Boolean, + val advertisingId: Boolean, + val hardwareAddresses: Boolean + ) - if (spoofAndroidId) { - val gsfId = getRandomGsfId() - val wifiMac = generateRandomMacAddress() - findClass("android.provider.Settings\$Secure").hook("getString", HookStage.BEFORE) { param -> - val settingName = param.argNullable(1) - when (settingName) { - "android_id" -> param.setResult(generateAndroidId()) - "advertising_id" -> param.setResult(generateRandomUUID()) - "bluetooth_address" -> param.setResult(wifiMac) - } - } - findClass("android.provider.Settings\$Secure").hook("getLong", HookStage.BEFORE) { param -> - val settingName = param.argNullable(1) - if (settingName == "android_id") { - param.setResult(generateAndroidId().hashCode().toLong()) - } - } - runCatching { - findClass("android.net.wifi.WifiInfo").hook("getMacAddress", HookStage.BEFORE) { param -> - param.setResult(wifiMac) - } - } - runCatching { - findClass("android.bluetooth.BluetoothAdapter").hook("getAddress", HookStage.BEFORE) { param -> - param.setResult(wifiMac) - } - } - } + private fun getRandomizedProfileToggleState(): RandomizedProfileToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile + return RandomizedProfileToggleState( + buildProperties = config.buildProperties.globalState == true, + locale = config.localeOptions.globalState == true, + telephony = config.telephonyOptions.globalState == true, + settings = config.settingsOptions.globalState == true, + network = config.networkOptions.globalState == true || config.randomizeIpAddress.get(), + identifiers = config.identifierOptions.globalState == true + ) + } - if (spoofDevice) { - val deviceInfo = getSpoofedDeviceInfo() - if (deviceInfo != null) { - val fingerprint = getSpoofedFingerprint(deviceInfo) + private fun getRandomizedBuildToggleState(): RandomizedBuildToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.buildProperties + val buildVersion = config.buildVersion + val deviceIdentity = config.deviceIdentity + val abiLists = config.abiLists + val systemProperties = config.systemProperties + return RandomizedBuildToggleState( + deviceIdentity = deviceIdentity.globalState == true && + (deviceIdentity.manufacturerModel.get() || deviceIdentity.brandProduct.get() || deviceIdentity.hardwareBoard.get()), + abiLists = abiLists.globalState == true && (abiLists.combinedAbis.get() || abiLists.splitAbis.get()), + systemProperties = systemProperties.globalState == true && + (systemProperties.build.get() || systemProperties.locale.get() || systemProperties.telephony.get()), + fingerprint = buildVersion.globalState == true && buildVersion.fingerprint.get(), + display = buildVersion.globalState == true && buildVersion.display.get(), + host = buildVersion.globalState == true && buildVersion.host.get(), + bootloader = buildVersion.globalState == true && buildVersion.bootloader.get(), + buildTime = buildVersion.globalState == true && buildVersion.buildTime.get() + ) + } - context.log.info("Device spoofing active: ${deviceInfo.manufacturer} ${deviceInfo.model}") + private fun getRandomizedLocaleToggleState(): RandomizedLocaleToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.localeOptions + val locale = config.locale + val time = config.time + return RandomizedLocaleToggleState( + locale = locale.globalState == true && (locale.language.get() || locale.region.get()), + timeZone = time.globalState == true && (time.timeZoneId.get() || time.timeZoneDisplayName.get()), + autoTime = time.globalState == true && time.autoTime.get(), + autoTimeZone = time.globalState == true && time.autoTimeZone.get() + ) + } - Build::class.java.apply { - fields.forEach { field -> - if (!field.isAccessible) field.isAccessible = true - runCatching { - val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers") - modifiersField.isAccessible = true - modifiersField.setInt(field, field.modifiers and java.lang.reflect.Modifier.FINAL.inv()) - } - when (field.name) { - "MANUFACTURER" -> field.set(null, deviceInfo.manufacturer) - "MODEL" -> field.set(null, deviceInfo.model) - "BRAND" -> field.set(null, deviceInfo.brand) - "DEVICE" -> field.set(null, deviceInfo.device) - "PRODUCT" -> field.set(null, deviceInfo.product) - "HARDWARE" -> field.set(null, deviceInfo.hardware) - "FINGERPRINT" -> field.set(null, fingerprint) - "BOARD" -> try { field.set(null, deviceInfo.board) } catch (_: Exception) {} - "BOOTLOADER" -> try { field.set(null, deviceInfo.bootloader) } catch (_: Exception) {} - "DISPLAY" -> try { field.set(null, deviceInfo.display) } catch (_: Exception) {} - "HOST" -> try { field.set(null, deviceInfo.host) } catch (_: Exception) {} - "TIME" -> try { - val currentTime = System.currentTimeMillis() - val randomDaysAgo = (30..180).random() - val buildTime = currentTime - (randomDaysAgo * 24L * 60L * 60L * 1000L) - field.setLong(null, buildTime) - } catch (_: Exception) {} - } - } - } + private fun getRandomizedTelephonyToggleState(): RandomizedTelephonyToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.telephonyOptions + val mms = config.mms + val networkIdentity = config.networkIdentity + val simIdentity = config.simIdentity + val phoneCapabilities = config.phoneCapabilities + return RandomizedTelephonyToggleState( + mmsUserAgent = mms.globalState == true && mms.userAgent.get(), + networkIdentity = networkIdentity.globalState == true && + (networkIdentity.networkType.get() || networkIdentity.operatorNumeric.get() || networkIdentity.operatorName.get() || networkIdentity.countryIso.get()), + simIdentity = simIdentity.globalState == true && + (simIdentity.countryIso.get() || simIdentity.operatorNumeric.get() || simIdentity.operatorName.get() || simIdentity.simState.get() || simIdentity.hasIccCard.get()), + phoneCapabilities = phoneCapabilities.globalState == true && + (phoneCapabilities.phoneCount.get() || phoneCapabilities.hearingAid.get() || phoneCapabilities.tty.get() || + phoneCapabilities.worldPhone.get() || phoneCapabilities.roaming.get() || phoneCapabilities.smsVoice.get() || phoneCapabilities.phoneType.get()) + ) + } - runCatching { - findClass("android.os.SystemProperties").hook("get", HookStage.BEFORE) { param -> - val key = param.argNullable(0) ?: return@hook - when (key) { - "ro.product.manufacturer" -> param.setResult(deviceInfo.manufacturer) - "ro.product.model" -> param.setResult(deviceInfo.model) - "ro.product.brand" -> param.setResult(deviceInfo.brand) - "ro.product.device" -> param.setResult(deviceInfo.device) - "ro.product.name" -> param.setResult(deviceInfo.product) - "ro.product.board" -> param.setResult(deviceInfo.board) - "ro.hardware" -> param.setResult(deviceInfo.hardware) - "ro.build.fingerprint" -> param.setResult(fingerprint) - "ro.bootloader" -> param.setResult(deviceInfo.bootloader) - "ro.build.display.id" -> param.setResult(deviceInfo.display) - } - } - context.log.info("SystemProperties hooks installed successfully") - }.onFailure { - context.log.warn("Failed to hook SystemProperties: ${it.message}") - } - } - } - } + private fun getRandomizedSettingsToggleState(): RandomizedSettingsToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.settingsOptions + val secure = config.secure + val system = config.system + val global = config.global + return RandomizedSettingsToggleState( + secure = secure.globalState == true && (secure.base.get() || secure.tts.get()), + system = system.globalState == true && (system.base.get() || system.bluetooth.get()), + global = global.globalState == true && global.base.get() + ) + } + + private fun getRandomizedNetworkToggleState(): RandomizedNetworkToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.networkOptions + val wifi = config.wifi + val dns = config.dns + val captivePortal = config.captivePortal + return RandomizedNetworkToggleState( + wifi = wifi.globalState == true && (wifi.ssid.get() || wifi.rssi.get()), + dns = dns.globalState == true && (dns.servers.get() || dns.searchDomains.get() || dns.privateDns.get()), + captivePortal = captivePortal.globalState == true && captivePortal.capability.get(), + ipAddress = context.config.experimental.spoof.randomizeDeviceProfile.randomizeIpAddress.get() + ) + } + + private fun getRandomizedIdentifierToggleState(): RandomizedIdentifierToggleState { + val config = context.config.experimental.spoof.randomizeDeviceProfile.identifierOptions + val androidId = config.androidId + val advertisingId = config.advertisingId + val hardwareAddresses = config.hardwareAddresses + return RandomizedIdentifierToggleState( + androidId = androidId.globalState == true && (androidId.stringValue.get() || androidId.longValue.get()), + advertisingId = advertisingId.globalState == true && (advertisingId.settingsValue.get() || advertisingId.playServices.get()), + hardwareAddresses = hardwareAddresses.globalState == true && (hardwareAddresses.wifiMac.get() || hardwareAddresses.bluetoothMac.get()) + ) + } + + private fun applyBuildFieldOverrides( + manufacturer: String, + model: String, + brand: String, + device: String, + product: String, + hardware: String, + board: String, + bootloader: String, + display: String, + host: String, + fingerprint: String, + buildTime: Long, + overrideDeviceIdentity: Boolean = true, + overrideAbiLists: Boolean = true, + overrideFingerprint: Boolean = true, + overrideDisplay: Boolean = true, + overrideHost: Boolean = true, + overrideBootloader: Boolean = true, + overrideBuildTime: Boolean = true, + buildIncremental: String? = null, + buildRelease: String? = null, + supportedAbis: List? = null, + supported32BitAbis: List? = null, + supported64BitAbis: List? = null + ) { + Build::class.java.fields.forEach { field -> + if (!field.isAccessible) field.isAccessible = true + runCatching { + val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + } + when (field.name) { + "MANUFACTURER" -> if (overrideDeviceIdentity) field.set(null, manufacturer) + "MODEL" -> if (overrideDeviceIdentity) field.set(null, model) + "BRAND" -> if (overrideDeviceIdentity) field.set(null, brand) + "DEVICE" -> if (overrideDeviceIdentity) field.set(null, device) + "PRODUCT" -> if (overrideDeviceIdentity) field.set(null, product) + "HARDWARE" -> if (overrideDeviceIdentity) field.set(null, hardware) + "FINGERPRINT" -> if (overrideFingerprint) field.set(null, fingerprint) + "BOARD" -> if (overrideDeviceIdentity) runCatching { field.set(null, board) } + "BOOTLOADER" -> if (overrideBootloader) runCatching { field.set(null, bootloader) } + "DISPLAY" -> if (overrideDisplay) runCatching { field.set(null, display) } + "HOST" -> if (overrideHost) runCatching { field.set(null, host) } + "TIME" -> if (overrideBuildTime) runCatching { field.setLong(null, buildTime) } + "SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supportedAbis?.toTypedArray()) } + "SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported32BitAbis?.toTypedArray()) } + "SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported64BitAbis?.toTypedArray()) } + } + } + + Build.VERSION::class.java.fields.forEach { field -> + if (!field.isAccessible) field.isAccessible = true + runCatching { + val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + } + when (field.name) { + "RELEASE" -> {} + "INCREMENTAL" -> {} + } + } + } + + private fun installSystemPropertyHooks( + profile: RandomizedDeviceProfile, + buildToggles: RandomizedBuildToggleState, + localeToggles: RandomizedLocaleToggleState, + telephonyToggles: RandomizedTelephonyToggleState + ) { + val effectiveLocale = getEffectiveRandomizedLocale(profile) + runCatching { + findClass("android.os.SystemProperties").hook("get", HookStage.BEFORE) { param -> + when (val key = param.argNullable(0) ?: return@hook) { + "ro.product.manufacturer" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.manufacturer) else return@hook + "ro.product.model" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.model) else return@hook + "ro.product.brand" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.brand) else return@hook + "ro.product.device" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.device) else return@hook + "ro.product.name" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.product) else return@hook + "ro.product.board" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.board) else return@hook + "ro.hardware" -> if (buildToggles.deviceIdentity) param.setResult(profile.deviceInfo.hardware) else return@hook + "ro.build.fingerprint" -> if (buildToggles.fingerprint) param.setResult(profile.buildFingerprint) else return@hook + "ro.build.id" -> if (buildToggles.display) param.setResult(profile.buildDisplayId) else return@hook + "ro.build.display.id" -> if (buildToggles.display) param.setResult(profile.buildDisplayId) else return@hook + "ro.build.version.incremental" -> return@hook + "ro.build.version.release" -> return@hook + "ro.bootloader" -> if (buildToggles.bootloader) param.setResult(profile.deviceInfo.bootloader) else return@hook + "persist.sys.locale" -> if (localeToggles.locale) param.setResult(effectiveLocale.toLanguageTag()) else return@hook + "ro.product.locale" -> if (localeToggles.locale) param.setResult(effectiveLocale.toLanguageTag()) else return@hook + "ro.product.locale.language" -> if (localeToggles.locale) param.setResult(effectiveLocale.language) else return@hook + "ro.product.locale.region" -> if (localeToggles.locale) param.setResult(effectiveLocale.country) else return@hook + "persist.sys.timezone" -> if (localeToggles.timeZone) param.setResult(profile.timeZoneId) else return@hook + "gsm.operator.alpha" -> if (telephonyToggles.networkIdentity) param.setResult(profile.networkOperatorName) else return@hook + "gsm.operator.numeric" -> if (telephonyToggles.networkIdentity) param.setResult(profile.networkOperator) else return@hook + "gsm.sim.operator.alpha" -> if (telephonyToggles.simIdentity) param.setResult(profile.simOperatorName) else return@hook + "gsm.sim.operator.numeric" -> if (telephonyToggles.simIdentity) param.setResult(profile.simOperator) else return@hook + "gsm.sim.operator.iso-country" -> if (telephonyToggles.simIdentity) param.setResult(profile.simCountryIso) else return@hook + else -> return@hook + } + if (key.startsWith("gsm.") || key.startsWith("persist.sys.") || key.startsWith("ro.")) { + context.log.verbose("Random profile SystemProperties override: $key") + } + } + context.log.info("Randomized profile SystemProperties hooks installed") + }.onFailure { + context.log.warn("Failed to hook randomized SystemProperties: ${it.message}") + } + } + + private fun installLocaleHooks(profile: RandomizedDeviceProfile, toggles: RandomizedLocaleToggleState) { + val effectiveLocale = getEffectiveRandomizedLocale(profile) + runCatching { + if (toggles.locale) { + Locale::class.java.hook("getDefault", HookStage.BEFORE) { param -> + param.setResult(effectiveLocale) + } + } + if (toggles.timeZone) { + TimeZone::class.java.hook("getDefault", HookStage.BEFORE) { param -> + param.setResult(profile.timeZone()) + } + } + context.log.info("Randomized profile locale/timezone hooks installed") + }.onFailure { + context.log.warn("Failed to hook locale/timezone for randomized profile: ${it.message}") + } + } + + private fun installTelephonyHooks(profile: RandomizedDeviceProfile, toggles: RandomizedTelephonyToggleState) { + runCatching { + findClass("android.telephony.TelephonyManager").apply { + if (toggles.mmsUserAgent) { + hook("getMmsUserAgent", HookStage.BEFORE) { it.setResult(profile.mmsUserAgent) } + } + if (toggles.networkIdentity) { + hook("getNetworkType", HookStage.BEFORE) { it.setResult(profile.networkType) } + hook("getNetworkOperator", HookStage.BEFORE) { it.setResult(profile.networkOperator) } + hook("getNetworkOperatorName", HookStage.BEFORE) { it.setResult(profile.networkOperatorName) } + hook("getNetworkCountryIso", HookStage.BEFORE) { it.setResult(profile.networkCountryIso) } + } + if (toggles.simIdentity) { + hook("getSimCountryIso", HookStage.BEFORE) { it.setResult(profile.simCountryIso) } + hook("getSimOperator", HookStage.BEFORE) { it.setResult(profile.simOperator) } + hook("getSimOperatorName", HookStage.BEFORE) { it.setResult(profile.simOperatorName) } + hook("getSimState", HookStage.BEFORE) { it.setResult(profile.simState) } + hook("hasIccCard", HookStage.BEFORE) { it.setResult(profile.hasIccCard) } + } + if (toggles.phoneCapabilities) { + hook("getPhoneCount", HookStage.BEFORE) { it.setResult(profile.phoneCount) } + hook("isHearingAidCompatibilitySupported", HookStage.BEFORE) { + it.setResult(profile.isHearingAidCompatibilitySupported) + } + hook("isTtyModeSupported", HookStage.BEFORE) { it.setResult(profile.isTtySupported) } + hook("isWorldPhone", HookStage.BEFORE) { it.setResult(profile.isWorldPhone) } + hook("isNetworkRoaming", HookStage.BEFORE) { it.setResult(profile.isNetworkRoaming) } + hook("isSmsCapable", HookStage.BEFORE) { it.setResult(profile.isSmsCapable) } + hook("isVoiceCapable", HookStage.BEFORE) { it.setResult(profile.isVoiceCapable) } + hook("getPhoneType", HookStage.BEFORE) { it.setResult(profile.phoneType) } + } + } + context.log.info("Randomized profile telephony hooks installed") + }.onFailure { + context.log.warn("Failed to hook telephony for randomized profile: ${it.message}") + } + } + + private fun installSettingsHooks( + profile: RandomizedDeviceProfile, + settingToggles: RandomizedSettingsToggleState, + identifierToggles: RandomizedIdentifierToggleState, + localeToggles: RandomizedLocaleToggleState + ) { + fun hookStringSettings(className: String, values: Map) { + findClass(className).hook("getString", HookStage.BEFORE) { param -> + val key = param.argNullable(1) ?: return@hook + values[key]?.let { param.setResult(it) } + } + } + + fun hookIntSettings(className: String, values: Map) { + findClass(className).hook("getInt", HookStage.BEFORE) { param -> + val key = param.argNullable(1) ?: return@hook + values[key]?.let { param.setResult(it) } + } + } + + runCatching { + if (settingToggles.secure || identifierToggles.androidId || identifierToggles.advertisingId || identifierToggles.hardwareAddresses) { + val secureStrings = buildMap { + if (settingToggles.secure) putAll(profile.secureStringSettings) + if (identifierToggles.androidId) put("android_id", profile.androidId) + if (identifierToggles.advertisingId) put("advertising_id", profile.advertisingId) + if (identifierToggles.hardwareAddresses) put("bluetooth_address", profile.bluetoothMacAddress) + } + hookStringSettings("android.provider.Settings\$Secure", secureStrings) + } + if (settingToggles.secure) { + hookIntSettings("android.provider.Settings\$Secure", profile.secureIntSettings) + } + if (identifierToggles.androidId) { + findClass("android.provider.Settings\$Secure").hook("getLong", HookStage.BEFORE) { param -> + val key = param.argNullable(1) ?: return@hook + if (key == "android_id") { + param.setResult(androidIdAsLong(profile.androidId)) + } + } + } + + if (settingToggles.system) { + hookStringSettings("android.provider.Settings\$System", profile.systemStringSettings) + hookIntSettings("android.provider.Settings\$System", profile.systemIntSettings) + } + + val globalStrings = buildMap { + if (settingToggles.global) { + putAll(profile.globalStringSettings.filterKeys { it != "auto_time" && it != "auto_time_zone" }) + } + if (localeToggles.autoTime) { + put("auto_time", profile.globalStringSettings["auto_time"] ?: "1") + } + if (localeToggles.autoTimeZone) { + put("auto_time_zone", profile.globalStringSettings["auto_time_zone"] ?: "1") + } + } + if (globalStrings.isNotEmpty()) { + hookStringSettings("android.provider.Settings\$Global", globalStrings) + } + if (settingToggles.global) { + hookIntSettings("android.provider.Settings\$Global", profile.globalIntSettings) + } + context.log.info("Randomized profile settings hooks installed") + }.onFailure { + context.log.warn("Failed to hook settings for randomized profile: ${it.message}") + } + } + + private fun installNetworkHooks( + profile: RandomizedDeviceProfile, + networkToggles: RandomizedNetworkToggleState, + identifierToggles: RandomizedIdentifierToggleState + ) { + if (networkToggles.wifi || identifierToggles.hardwareAddresses) { + runCatching { + findClass("android.net.wifi.WifiInfo").apply { + if (networkToggles.wifi) { + hook("getSSID", HookStage.BEFORE) { it.setResult("\"${profile.wifiSsid}\"") } + hook("getRssi", HookStage.BEFORE) { it.setResult(profile.wifiRssi) } + } + if (identifierToggles.hardwareAddresses) { + hook("getMacAddress", HookStage.BEFORE) { it.setResult(profile.wifiMacAddress) } + } + } + } + } + + if (identifierToggles.hardwareAddresses) { + runCatching { + findClass("android.bluetooth.BluetoothAdapter").hook("getAddress", HookStage.BEFORE) { + it.setResult(profile.bluetoothMacAddress) + } + } + } + + if (networkToggles.dns) { + runCatching { + LinkProperties::class.java.apply { + hook("getDnsServers", HookStage.BEFORE) { param -> + param.setResult(profile.dnsServers.map { InetAddress.getByName(it) }) + } + hook("getDomains", HookStage.BEFORE) { param -> + param.setResult(profile.dnsSearchDomains) + } + hook("isPrivateDnsActive", HookStage.BEFORE) { param -> + param.setResult(profile.privateDnsActive) + } + hook("getPrivateDnsServerName", HookStage.BEFORE) { param -> + param.setResult(profile.privateDnsServerName) + } + } + } + } + + if (networkToggles.ipAddress) { + runCatching { + val spoofedAddress = InetAddress.getByName(profile.ipAddress) + val spoofedLinkAddress = runCatching { + LinkAddress::class.java.getConstructor(String::class.java) + .newInstance("${profile.ipAddress}/24") + }.getOrNull() + LinkProperties::class.java.apply { + hook("getAddresses", HookStage.BEFORE) { param -> + param.setResult(listOf(spoofedAddress)) + } + spoofedLinkAddress?.let { linkAddress -> + hook("getLinkAddresses", HookStage.BEFORE) { param -> + param.setResult(listOf(linkAddress)) + } + } + } + context.log.info("Randomized profile IP override active: ${profile.ipAddress}") + }.onFailure { + context.log.warn("Failed to hook randomized IP address: ${it.message}") + } + } + + if (networkToggles.captivePortal) { + runCatching { + NetworkCapabilities::class.java.hook("hasCapability", HookStage.BEFORE) { param -> + val capability = param.argNullable(0) ?: return@hook + if (capability == NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) { + param.setResult(profile.hasCaptivePortal) + } + } + } + } + + if (identifierToggles.advertisingId) { + val advertisingInfoClass = runCatching { + findClass("com.google.android.gms.ads.identifier.AdvertisingIdClient\$Info") + }.getOrNull() + if (advertisingInfoClass != null) { + advertisingInfoClass.apply { + hook("getId", HookStage.BEFORE) { it.setResult(profile.advertisingId) } + hook("isLimitAdTrackingEnabled", HookStage.BEFORE) { it.setResult(false) } + } + context.log.info("Randomized profile network/identifier hooks installed") + } + } + } + + private fun installRandomizedProfileHooks(profile: RandomizedDeviceProfile) { + val toggles = getRandomizedProfileToggleState() + val buildToggles = getRandomizedBuildToggleState() + val localeToggles = getRandomizedLocaleToggleState() + val telephonyToggles = getRandomizedTelephonyToggleState() + val settingsToggles = getRandomizedSettingsToggleState() + val networkToggles = getRandomizedNetworkToggleState() + val identifierToggles = getRandomizedIdentifierToggleState() + if (toggles.buildProperties) { + applyBuildFieldOverrides( + manufacturer = profile.deviceInfo.manufacturer, + model = profile.deviceInfo.model, + brand = profile.deviceInfo.brand, + device = profile.deviceInfo.device, + product = profile.deviceInfo.product, + hardware = profile.deviceInfo.hardware, + board = profile.deviceInfo.board, + bootloader = profile.deviceInfo.bootloader, + display = profile.buildDisplayId, + host = profile.buildHost, + fingerprint = profile.buildFingerprint, + buildTime = profile.buildTime, + overrideDeviceIdentity = buildToggles.deviceIdentity, + overrideAbiLists = buildToggles.abiLists, + overrideFingerprint = buildToggles.fingerprint, + overrideDisplay = buildToggles.display, + overrideHost = buildToggles.host, + overrideBootloader = buildToggles.bootloader, + overrideBuildTime = buildToggles.buildTime, + buildIncremental = profile.buildIncremental, + buildRelease = profile.androidRelease, + supportedAbis = profile.supportedAbis, + supported32BitAbis = profile.supported32BitAbis, + supported64BitAbis = profile.supported64BitAbis + ) + if (buildToggles.systemProperties || toggles.locale || toggles.telephony) { + installSystemPropertyHooks(profile, buildToggles, localeToggles, telephonyToggles) + } + } + if (toggles.locale && (localeToggles.locale || localeToggles.timeZone)) { + installLocaleHooks(profile, localeToggles) + } + if (toggles.telephony) { + installTelephonyHooks(profile, telephonyToggles) + } + if (toggles.settings || toggles.identifiers) { + installSettingsHooks(profile, settingsToggles, identifierToggles, localeToggles) + } + if (toggles.network || toggles.identifiers) { + installNetworkHooks(profile, networkToggles, identifierToggles) + } + context.log.info( + "Randomized profile active: ${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model}, " + + "androidId=${profile.androidId}, operator=${profile.simOperatorName}, locale=${profile.localeTag}" + ) + } + + @SuppressLint("MissingPermission") + override fun init() { + val randomizeDeviceProfile by context.config.experimental.spoof.randomizeDeviceProfile + val spoofDevice by context.config.experimental.spoof.spoofDevice + val randomizeDeviceProfileEnabled = randomizeDeviceProfile == true + + if (!randomizeDeviceProfileEnabled && spoofDevice) { + val deviceInfo = getSpoofedDeviceInfo() + if (deviceInfo != null) { + getSpoofedFingerprint(deviceInfo) + context.log.info("Device spoofing initialized: ${deviceInfo.manufacturer} ${deviceInfo.model}") + } + } + + if (LSPatchUpdater.HAS_LSPATCH) { + hookInstallerPackageName() + } + + if (context.config.experimental.spoof.globalState != true) return + + if (randomizeDeviceProfileEnabled) { + val profile = getRandomizedProfile() + installRandomizedProfileHooks(profile) + if (context.config.experimental.spoof.randomizeDeviceProfile.showActivationOverlay.get()) { + onNextActivityCreate { + context.inAppOverlay.showStatusToast( + icon = Icons.Filled.CheckCircle, + text = "Randomized device profile active", + durationMs = 3200 + ) + } + } + } + + val removeMockLocationFlag by context.config.experimental.spoof.removeMockLocationFlag + val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName + val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag + val forceWifiTransportFlag by context.config.experimental.spoof.forceWifiTransportFlag + val networkOptimization by context.config.experimental.networkOptimization + val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId + + if (overridePlayStoreInstallerPackageName) { + hookInstallerPackageName() + } + + if (removeMockLocationFlag) { + Location::class.java.hook("isMock", HookStage.BEFORE) { param -> + param.setResult(false) + } + } + + if (removeVpnTransportFlag) { + ConnectivityManager::class.java.hook("getAllNetworks", HookStage.AFTER) { param -> + val instance = param.thisObject() as? ConnectivityManager ?: return@hook + val networks = param.getResult() as? Array<*> ?: return@hook + + param.setResult(networks.filterIsInstance().filter { network -> + val capabilities = instance.getNetworkCapabilities(network) ?: return@filter false + !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }.toTypedArray()) + } + } + + if (forceWifiTransportFlag) { + val connectivityManager = context.androidContext.getSystemService(ConnectivityManager::class.java) + val activeNetwork = connectivityManager?.activeNetwork + val initialCapabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } + val isReallyOnWifi = initialCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + + onNextActivityCreate { + if (isReallyOnWifi) { + context.inAppOverlay.showStatusToast( + icon = Icons.Filled.Wifi, + text = "Connected to Real WiFi", + durationMs = 3000 + ) + } else { + context.inAppOverlay.showStatusToast( + icon = Icons.Filled.Wifi, + text = "WiFi Spoofing Active", + durationMs = 3000 + ) + } + } + + NetworkCapabilities::class.java.apply { + hook("hasTransport", HookStage.BEFORE) { param -> + val transportType = param.args().getOrNull(0) as? Int + val actuallyHasWifi = runCatching { + param.invokeOriginal() as Boolean + }.getOrDefault(false) + + if (actuallyHasWifi && transportType == NetworkCapabilities.TRANSPORT_WIFI) { + return@hook + } + + if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { + param.setResult(true) + } else if (transportType == NetworkCapabilities.TRANSPORT_CELLULAR) { + param.setResult(false) + } + } + hook("hasCapability", HookStage.BEFORE) { param -> + val capability = param.args().getOrNull(0) as? Int + if (capability == NetworkCapabilities.NET_CAPABILITY_NOT_VPN) { + param.setResult(true) + } + } + } + findClass("android.net.NetworkInfo").apply { + hook("getType", HookStage.BEFORE) { param -> + val originalType = runCatching { + param.invokeOriginal() as Int + }.getOrDefault(-1) + + if (originalType == 1) { + return@hook + } + param.setResult(1) + } + hook("getTypeName", HookStage.BEFORE) { param -> + val originalTypeName = runCatching { + param.invokeOriginal() as String + }.getOrNull() + + if (originalTypeName?.equals("WIFI", ignoreCase = true) == true) { + return@hook + } + param.setResult("WIFI") + } + hook("isConnected", HookStage.BEFORE) { param -> + param.setResult(true) + } + } + findClass("org.chromium.base.RadioUtils").hook("isWifiConnected", HookStage.BEFORE) { param -> + val actuallyOnWifi = runCatching { + val connectivityMgr = context.androidContext.getSystemService(ConnectivityManager::class.java) + val activeNet = connectivityMgr?.activeNetwork + val caps = activeNet?.let { connectivityMgr.getNetworkCapabilities(it) } + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + }.getOrDefault(false) + + if (!actuallyOnWifi) { + param.setResult(true) + } + } + } + + if (networkOptimization) { + findClass("java.net.Socket").apply { + hook("setSendBufferSize", HookStage.BEFORE) { param -> + val size = param.arg(0) + if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) + } + hook("setReceiveBufferSize", HookStage.BEFORE) { param -> + val size = param.arg(0) + if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) + } + } + } + + if (!randomizeDeviceProfileEnabled && spoofAndroidId) { + val gsfId = getRandomGsfId() + val wifiMac = generateRandomMacAddress() + findClass("android.provider.Settings\$Secure").hook("getString", HookStage.BEFORE) { param -> + val settingName = param.argNullable(1) + when (settingName) { + "android_id" -> param.setResult(generateAndroidId()) + "advertising_id" -> param.setResult(generateRandomUUID()) + "bluetooth_address" -> param.setResult(wifiMac) + "gsf_id" -> param.setResult(gsfId) + else -> return@hook + } + context.log.verbose("Manual Android ID spoof override: $settingName") + } + findClass("android.provider.Settings\$Secure").hook("getLong", HookStage.BEFORE) { param -> + val settingName = param.argNullable(1) + if (settingName == "android_id") { + param.setResult(androidIdAsLong(generateAndroidId())) + } + } + runCatching { + findClass("android.net.wifi.WifiInfo").hook("getMacAddress", HookStage.BEFORE) { param -> + param.setResult(wifiMac) + } + } + runCatching { + findClass("android.bluetooth.BluetoothAdapter").hook("getAddress", HookStage.BEFORE) { param -> + param.setResult(wifiMac) + } + } + } + + if (!randomizeDeviceProfileEnabled && spoofDevice) { + val deviceInfo = getSpoofedDeviceInfo() + if (deviceInfo != null) { + val fingerprint = getSpoofedFingerprint(deviceInfo) + + context.log.info("Device spoofing active: ${deviceInfo.manufacturer} ${deviceInfo.model}") + + applyBuildFieldOverrides( + manufacturer = deviceInfo.manufacturer, + model = deviceInfo.model, + brand = deviceInfo.brand, + device = deviceInfo.device, + product = deviceInfo.product, + hardware = deviceInfo.hardware, + board = deviceInfo.board, + bootloader = deviceInfo.bootloader, + display = deviceInfo.display, + host = deviceInfo.host, + fingerprint = fingerprint, + buildTime = System.currentTimeMillis() - (30L..180L).random() * 24L * 60L * 60L * 1000L + ) + + runCatching { + findClass("android.os.SystemProperties").hook("get", HookStage.BEFORE) { param -> + when (param.argNullable(0) ?: return@hook) { + "ro.product.manufacturer" -> param.setResult(deviceInfo.manufacturer) + "ro.product.model" -> param.setResult(deviceInfo.model) + "ro.product.brand" -> param.setResult(deviceInfo.brand) + "ro.product.device" -> param.setResult(deviceInfo.device) + "ro.product.name" -> param.setResult(deviceInfo.product) + "ro.product.board" -> param.setResult(deviceInfo.board) + "ro.hardware" -> param.setResult(deviceInfo.hardware) + "ro.build.fingerprint" -> param.setResult(fingerprint) + "ro.bootloader" -> param.setResult(deviceInfo.bootloader) + "ro.build.display.id" -> param.setResult(deviceInfo.display) + } + } + context.log.info("SystemProperties hooks installed successfully") + }.onFailure { + context.log.warn("Failed to hook SystemProperties: ${it.message}") + } + } + } + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt index e8c16c43..391b1043 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt @@ -77,7 +77,6 @@ class MediaFilePicker : Feature("Media File Picker") { private var bypassSplitOnce = false private var sendSingleItemHandler: ((Any) -> Boolean)? = null private var cleanupItemHandler: ((String) -> Unit)? = null - fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty() fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty() fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/RandomizedDeviceProfile.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/RandomizedDeviceProfile.kt new file mode 100644 index 00000000..0120fa74 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/RandomizedDeviceProfile.kt @@ -0,0 +1,573 @@ +package me.eternal.purrfectsnap.core.features.impl.experiments + +import android.content.Context +import me.eternal.purrfectsnap.common.logger.AbstractLogger +import org.json.JSONArray +import org.json.JSONObject +import java.net.InetAddress +import java.security.SecureRandom +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +data class RandomizedDeviceProfile( + val schemaVersion: Int, + val profileId: String, + val deviceInfo: DeviceInfo, + val androidRelease: String, + val buildIncremental: String, + val buildDisplayId: String, + val buildFingerprint: String, + val buildHost: String, + val buildTime: Long, + val supportedAbis: List, + val supported32BitAbis: List, + val supported64BitAbis: List, + val androidId: String, + val gsfId: String, + val advertisingId: String, + val wifiMacAddress: String, + val bluetoothMacAddress: String, + val ipAddress: String, + val wifiSsid: String, + val wifiRssi: Int, + val localeTag: String, + val countryIso: String, + val timeZoneId: String, + val timeZoneDisplayName: String, + val networkType: Int, + val networkOperator: String, + val networkOperatorName: String, + val networkCountryIso: String, + val simCountryIso: String, + val simOperator: String, + val simOperatorName: String, + val simState: Int, + val hasIccCard: Boolean, + val phoneCount: Int, + val isHearingAidCompatibilitySupported: Boolean, + val isTtySupported: Boolean, + val isWorldPhone: Boolean, + val isNetworkRoaming: Boolean, + val isSmsCapable: Boolean, + val isVoiceCapable: Boolean, + val phoneType: Int, + val phoneTypeString: String, + val mmsUaProfUrl: String, + val mmsUserAgent: String, + val dnsServers: List, + val dnsSearchDomains: String, + val privateDnsServerName: String, + val privateDnsActive: Boolean, + val hasCaptivePortal: Boolean, + val secureStringSettings: Map, + val secureIntSettings: Map, + val systemStringSettings: Map, + val systemIntSettings: Map, + val globalStringSettings: Map, + val globalIntSettings: Map +) { + fun locale(): Locale = Locale.forLanguageTag(localeTag) + + fun timeZone(): TimeZone = TimeZone.getTimeZone(timeZoneId) + + fun toJson(): JSONObject = JSONObject().apply { + put("schemaVersion", schemaVersion) + put("profileId", profileId) + put("deviceInfo", JSONObject().apply { + put("manufacturer", deviceInfo.manufacturer) + put("model", deviceInfo.model) + put("brand", deviceInfo.brand) + put("device", deviceInfo.device) + put("product", deviceInfo.product) + put("hardware", deviceInfo.hardware) + put("board", deviceInfo.board) + put("bootloader", deviceInfo.bootloader) + put("display", deviceInfo.display) + put("host", deviceInfo.host) + }) + put("androidRelease", androidRelease) + put("buildIncremental", buildIncremental) + put("buildDisplayId", buildDisplayId) + put("buildFingerprint", buildFingerprint) + put("buildHost", buildHost) + put("buildTime", buildTime) + put("supportedAbis", JSONArray(supportedAbis)) + put("supported32BitAbis", JSONArray(supported32BitAbis)) + put("supported64BitAbis", JSONArray(supported64BitAbis)) + put("androidId", androidId) + put("gsfId", gsfId) + put("advertisingId", advertisingId) + put("wifiMacAddress", wifiMacAddress) + put("bluetoothMacAddress", bluetoothMacAddress) + put("ipAddress", ipAddress) + put("wifiSsid", wifiSsid) + put("wifiRssi", wifiRssi) + put("localeTag", localeTag) + put("countryIso", countryIso) + put("timeZoneId", timeZoneId) + put("timeZoneDisplayName", timeZoneDisplayName) + put("networkType", networkType) + put("networkOperator", networkOperator) + put("networkOperatorName", networkOperatorName) + put("networkCountryIso", networkCountryIso) + put("simCountryIso", simCountryIso) + put("simOperator", simOperator) + put("simOperatorName", simOperatorName) + put("simState", simState) + put("hasIccCard", hasIccCard) + put("phoneCount", phoneCount) + put("isHearingAidCompatibilitySupported", isHearingAidCompatibilitySupported) + put("isTtySupported", isTtySupported) + put("isWorldPhone", isWorldPhone) + put("isNetworkRoaming", isNetworkRoaming) + put("isSmsCapable", isSmsCapable) + put("isVoiceCapable", isVoiceCapable) + put("phoneType", phoneType) + put("phoneTypeString", phoneTypeString) + put("mmsUaProfUrl", mmsUaProfUrl) + put("mmsUserAgent", mmsUserAgent) + put("dnsServers", JSONArray(dnsServers)) + put("dnsSearchDomains", dnsSearchDomains) + put("privateDnsServerName", privateDnsServerName) + put("privateDnsActive", privateDnsActive) + put("hasCaptivePortal", hasCaptivePortal) + put("secureStringSettings", JSONObject(secureStringSettings)) + put("secureIntSettings", JSONObject(secureIntSettings)) + put("systemStringSettings", JSONObject(systemStringSettings)) + put("systemIntSettings", JSONObject(systemIntSettings)) + put("globalStringSettings", JSONObject(globalStringSettings)) + put("globalIntSettings", JSONObject(globalIntSettings)) + } + + companion object { + fun fromJson(json: String): RandomizedDeviceProfile { + val root = JSONObject(json) + val deviceInfoJson = root.getJSONObject("deviceInfo") + return RandomizedDeviceProfile( + schemaVersion = root.getInt("schemaVersion"), + profileId = root.getString("profileId"), + deviceInfo = DeviceInfo( + manufacturer = deviceInfoJson.getString("manufacturer"), + model = deviceInfoJson.getString("model"), + brand = deviceInfoJson.getString("brand"), + device = deviceInfoJson.getString("device"), + product = deviceInfoJson.getString("product"), + hardware = deviceInfoJson.getString("hardware"), + board = deviceInfoJson.getString("board"), + bootloader = deviceInfoJson.getString("bootloader"), + display = deviceInfoJson.getString("display"), + host = deviceInfoJson.getString("host") + ), + androidRelease = root.getString("androidRelease"), + buildIncremental = root.getString("buildIncremental"), + buildDisplayId = root.getString("buildDisplayId"), + buildFingerprint = root.getString("buildFingerprint"), + buildHost = root.getString("buildHost"), + buildTime = root.getLong("buildTime"), + supportedAbis = jsonArrayToStringList(root.getJSONArray("supportedAbis")), + supported32BitAbis = jsonArrayToStringList(root.getJSONArray("supported32BitAbis")), + supported64BitAbis = jsonArrayToStringList(root.getJSONArray("supported64BitAbis")), + androidId = root.getString("androidId"), + gsfId = root.getString("gsfId"), + advertisingId = root.getString("advertisingId"), + wifiMacAddress = root.getString("wifiMacAddress"), + bluetoothMacAddress = root.getString("bluetoothMacAddress"), + ipAddress = root.optString("ipAddress").ifBlank { defaultFallbackIpAddress() }, + wifiSsid = root.getString("wifiSsid"), + wifiRssi = root.getInt("wifiRssi"), + localeTag = root.getString("localeTag"), + countryIso = root.getString("countryIso"), + timeZoneId = root.getString("timeZoneId"), + timeZoneDisplayName = root.getString("timeZoneDisplayName"), + networkType = root.getInt("networkType"), + networkOperator = root.getString("networkOperator"), + networkOperatorName = root.getString("networkOperatorName"), + networkCountryIso = root.getString("networkCountryIso"), + simCountryIso = root.getString("simCountryIso"), + simOperator = root.getString("simOperator"), + simOperatorName = root.getString("simOperatorName"), + simState = root.getInt("simState"), + hasIccCard = root.getBoolean("hasIccCard"), + phoneCount = root.getInt("phoneCount"), + isHearingAidCompatibilitySupported = root.getBoolean("isHearingAidCompatibilitySupported"), + isTtySupported = root.getBoolean("isTtySupported"), + isWorldPhone = root.getBoolean("isWorldPhone"), + isNetworkRoaming = root.getBoolean("isNetworkRoaming"), + isSmsCapable = root.getBoolean("isSmsCapable"), + isVoiceCapable = root.getBoolean("isVoiceCapable"), + phoneType = root.getInt("phoneType"), + phoneTypeString = root.getString("phoneTypeString"), + mmsUaProfUrl = root.getString("mmsUaProfUrl"), + mmsUserAgent = root.getString("mmsUserAgent"), + dnsServers = jsonArrayToStringList(root.getJSONArray("dnsServers")), + dnsSearchDomains = root.getString("dnsSearchDomains"), + privateDnsServerName = root.getString("privateDnsServerName"), + privateDnsActive = root.getBoolean("privateDnsActive"), + hasCaptivePortal = root.getBoolean("hasCaptivePortal"), + secureStringSettings = jsonObjectToStringMap(root.getJSONObject("secureStringSettings")), + secureIntSettings = jsonObjectToIntMap(root.getJSONObject("secureIntSettings")), + systemStringSettings = jsonObjectToStringMap(root.getJSONObject("systemStringSettings")), + systemIntSettings = jsonObjectToIntMap(root.getJSONObject("systemIntSettings")), + globalStringSettings = jsonObjectToStringMap(root.getJSONObject("globalStringSettings")), + globalIntSettings = jsonObjectToIntMap(root.getJSONObject("globalIntSettings")) + ) + } + + private fun jsonArrayToStringList(array: JSONArray): List = buildList { + for (index in 0 until array.length()) { + add(array.getString(index)) + } + } + + private fun jsonObjectToStringMap(jsonObject: JSONObject): Map { + return jsonObject.keys().asSequence().associateWith { jsonObject.getString(it) } + } + + private fun jsonObjectToIntMap(jsonObject: JSONObject): Map { + return jsonObject.keys().asSequence().associateWith { jsonObject.getInt(it) } + } + + private fun defaultFallbackIpAddress(): String = "23.42.18.101" + } +} + +object RandomizedDeviceProfileStore { + private const val prefsName = "purrfectsnap_spoof" + private const val schemaVersion = 5 + private const val profileKey = "randomized_device_profile" + private val random = SecureRandom() + + fun getOrCreate(context: Context, logger: AbstractLogger, generationToken: String?): RandomizedDeviceProfile { + val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + val requestedToken = generationToken.orEmpty() + prefs.getString(profileKey, null)?.let { raw -> + runCatching { + RandomizedDeviceProfile.fromJson(raw) + }.onSuccess { profile -> + val storedToken = prefs.getString("${profileKey}_token", "") ?: "" + if (profile.schemaVersion == schemaVersion && storedToken == requestedToken) { + logger.info("Loaded randomized device profile ${profile.profileId} (${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model})") + return profile + } + }.onFailure { + logger.warn("Failed to parse saved randomized device profile, regenerating: ${it.message}") + } + } + + val previousProfile = prefs.getString(profileKey, null)?.let { raw -> + runCatching { RandomizedDeviceProfile.fromJson(raw) }.getOrNull() + } + val profile = generateProfile(previousProfile) + prefs.edit() + .putString(profileKey, profile.toJson().toString()) + .putString("${profileKey}_token", requestedToken) + .putString("android_id", profile.androidId) + .putString("advertising_id", profile.advertisingId) + .putString("bluetooth_address", profile.bluetoothMacAddress) + .putString("gsf_id", profile.gsfId) + .putString("random_device", profile.deviceInfo.model) + .putString("device_fingerprint", profile.buildFingerprint) + .apply() + + logger.info( + "Generated randomized device profile ${profile.profileId}: " + + "${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model}, " + + "androidId=${profile.androidId}, ip=${profile.ipAddress}, locale=${profile.localeTag}, tz=${profile.timeZoneId}, " + + "carrier=${profile.simOperatorName}" + ) + return profile + } + + private fun generateProfile(previousProfile: RandomizedDeviceProfile?): RandomizedDeviceProfile { + val eligibleDevices = DeviceSpoofer.getAvailableDevices().filter { + DeviceSpoofer.getDeviceTemplate(it)?.capabilities?.let { capabilities -> + capabilities.isSmsCapable && capabilities.isVoiceCapable && capabilities.isWorldPhone + } == true + }.ifEmpty { DeviceSpoofer.getAvailableDevices() } + val deviceCandidates = eligibleDevices.filterNot { + previousProfile != null && + DeviceSpoofer.getDeviceTemplate(it)?.deviceInfo?.model == previousProfile.deviceInfo.model + }.ifEmpty { eligibleDevices } + val deviceName = pick(deviceCandidates) + val deviceTemplate = DeviceSpoofer.getDeviceTemplate(deviceName) ?: error("Missing device template for $deviceName") + val regionCandidates = regionProfiles.filterNot { + previousProfile != null && + it.localeTag == previousProfile.localeTag && + it.simOperatorName == previousProfile.simOperatorName + }.ifEmpty { regionProfiles } + val region = pick(regionCandidates) + val buildCandidates = deviceTemplate.builds.filter { it.androidRelease in region.androidReleaseOptions } + .ifEmpty { deviceTemplate.builds } + val buildProfile = pick(buildCandidates) + val deviceInfo = deviceTemplate.deviceInfo.copy( + display = buildProfile.display, + host = buildProfile.host, + bootloader = buildProfile.bootloader ?: deviceTemplate.deviceInfo.bootloader + ) + val androidRelease = buildProfile.androidRelease + val buildIncremental = buildProfile.incremental + val buildDisplayId = buildProfile.display + val buildFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildProfile) + val buildHost = buildProfile.host + val buildTime = System.currentTimeMillis() - randomLong(45L, 220L) * 24L * 60L * 60L * 1000L + val wifiMac = randomMacAddress() + val bluetoothMac = randomMacAddress() + val ipAddress = region.randomPublicIpAddress() + val locale = Locale.forLanguageTag(region.localeTag) + val timeZone = TimeZone.getTimeZone(region.timeZoneId) + val capabilities = deviceTemplate.capabilities + val secureStringSettings = mapOf( + "accessibility_enabled" to "0", + "speak_password" to "0", + "allowed_geolocation_origins" to "", + "install_non_market_apps" to "0", + "device_provisioned" to "1", + "enabled_notification_listeners" to "" + ) + val secureIntSettings = mapOf( + "input_method_selector_visibility" to 0, + "accessibility_display_inversion_enabled" to 0, + "enabled_accessibility_services" to 0, + "skip_first_use_hints" to 0, + "tts_default_synth" to 0 + ) + val systemStringSettings = mapOf( + "dtmf_tone_type" to "normal", + "mode_ringer_streams_affected" to "166", + "mute_streams_affected" to "46", + "show_password" to "1", + "user_rotation" to "0" + ) + val systemIntSettings = mapOf( + "bluetooth_discoverability" to 0, + "bluetooth_discoverability_timeout" to 120, + "date_format" to 0, + "end_button_behavior" to 2 + ) + val globalStringSettings = mapOf( + "adb_enabled" to "0", + "auto_time" to "1", + "auto_time_zone" to "1", + "development_settings_enabled" to "0", + "stay_on_while_plugged_in" to "0", + "usb_mass_storage_enabled" to "0", + "wifi_networks_available_notification_on" to "0", + "data_roaming" to "1" + ) + val globalIntSettings = mapOf( + "always_finish_activities" to 0, + "animator_duration_scale" to 1, + "http_proxy" to 0, + "network_preference" to 1, + "transition_animation_scale" to 1, + "use_google_mail" to 1, + "wait_for_debugger" to 0 + ) + + return RandomizedDeviceProfile( + schemaVersion = schemaVersion, + profileId = UUID.randomUUID().toString().substring(0, 8), + deviceInfo = deviceInfo, + androidRelease = androidRelease, + buildIncremental = buildIncremental, + buildDisplayId = buildDisplayId, + buildFingerprint = buildFingerprint, + buildHost = buildHost, + buildTime = buildTime, + supportedAbis = capabilities.supportedAbis, + supported32BitAbis = capabilities.supported32BitAbis, + supported64BitAbis = capabilities.supported64BitAbis, + androidId = randomHex(16), + gsfId = randomHex(16), + advertisingId = UUID.randomUUID().toString(), + wifiMacAddress = wifiMac, + bluetoothMacAddress = bluetoothMac, + ipAddress = ipAddress, + wifiSsid = region.randomWifiSsid(), + wifiRssi = random.nextInt(-72, -36), + localeTag = locale.toLanguageTag(), + countryIso = region.countryIso, + timeZoneId = region.timeZoneId, + timeZoneDisplayName = timeZone.getDisplayName(false, TimeZone.SHORT, locale), + networkType = 13, + networkOperator = region.networkOperator, + networkOperatorName = region.networkOperatorName, + networkCountryIso = region.countryIso.lowercase(Locale.US), + simCountryIso = region.countryIso.lowercase(Locale.US), + simOperator = region.simOperator, + simOperatorName = region.simOperatorName, + simState = 5, + hasIccCard = true, + phoneCount = capabilities.phoneCount, + isHearingAidCompatibilitySupported = capabilities.isHearingAidCompatibilitySupported, + isTtySupported = capabilities.isTtySupported, + isWorldPhone = capabilities.isWorldPhone, + isNetworkRoaming = false, + isSmsCapable = capabilities.isSmsCapable, + isVoiceCapable = capabilities.isVoiceCapable, + phoneType = capabilities.phoneType, + phoneTypeString = capabilities.phoneTypeString, + mmsUaProfUrl = "", + mmsUserAgent = region.mmsUserAgent(deviceTemplate.marketingName, androidRelease), + dnsServers = region.dnsServers.sortedBy { random.nextInt() }.take(2), + dnsSearchDomains = region.dnsSearchDomains, + privateDnsServerName = region.privateDnsServerName, + privateDnsActive = true, + hasCaptivePortal = false, + secureStringSettings = secureStringSettings, + secureIntSettings = secureIntSettings, + systemStringSettings = systemStringSettings, + systemIntSettings = systemIntSettings, + globalStringSettings = globalStringSettings, + globalIntSettings = globalIntSettings + ) + } + + private fun randomHex(length: Int): String { + val chars = CharArray(length) + val alphabet = "0123456789abcdef" + for (index in chars.indices) { + chars[index] = alphabet[random.nextInt(alphabet.length)] + } + return String(chars) + } + + private fun randomDigits(length: Int): String { + val chars = CharArray(length) + for (index in chars.indices) { + chars[index] = ('0'.code + random.nextInt(10)).toChar() + } + return String(chars) + } + + private fun randomMacAddress(): String { + val bytes = ByteArray(6) + random.nextBytes(bytes) + bytes[0] = (bytes[0].toInt() and 0xFE or 0x02).toByte() + return bytes.joinToString(":") { "%02x".format(it.toInt() and 0xFF) } + } + + private fun randomPublicIpv4(prefixes: List? = null): String { + val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run { + generateSequence { random.nextInt(1, 224) } + .first { candidate -> + candidate != 10 && + candidate != 127 && + candidate != 169 && + candidate != 172 && + candidate != 192 + } + } + val secondOctet = random.nextInt(1, 255) + val thirdOctet = random.nextInt(1, 255) + val fourthOctet = random.nextInt(2, 255) + val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet" + return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate) + } + + private fun randomLong(minInclusive: Long, maxExclusive: Long): Long { + require(maxExclusive > minInclusive) + val bound = maxExclusive - minInclusive + var bits: Long + var candidate: Long + do { + bits = random.nextLong() ushr 1 + candidate = bits % bound + } while (bits - candidate + (bound - 1) < 0L) + return minInclusive + candidate + } + + private fun pick(values: List): T = values[random.nextInt(values.size)] + + private data class RegionProfile( + val localeTag: String, + val countryIso: String, + val timeZoneId: String, + val networkOperator: String, + val networkOperatorName: String, + val simOperator: String, + val simOperatorName: String, + val dnsServers: List, + val dnsSearchDomains: String, + val privateDnsServerName: String, + val timeFormat: String, + val androidReleaseOptions: List, + val wifiPrefixes: List, + val ipPrefixes: List + ) { + fun randomWifiSsid(): String = "${pick(wifiPrefixes)}-${randomDigits(4)}" + fun randomPublicIpAddress(): String = randomPublicIpv4(ipPrefixes) + + fun mmsUserAgent(model: String, androidRelease: String): String { + return "$model/$androidRelease" + } + } + + private val regionProfiles = listOf( + RegionProfile( + localeTag = "en-US", + countryIso = "US", + timeZoneId = "America/New_York", + networkOperator = "310260", + networkOperatorName = "T-Mobile", + simOperator = "310260", + simOperatorName = "T-Mobile", + dnsServers = listOf("8.8.8.8", "8.8.4.4", "1.1.1.1"), + dnsSearchDomains = "hsd1.ny.comcast.net", + privateDnsServerName = "dns.google", + timeFormat = "12", + androidReleaseOptions = listOf("14", "15"), + wifiPrefixes = listOf("TP-Link", "NETGEAR", "XFINITY", "HomeWiFi"), + ipPrefixes = listOf(23, 24, 45, 47, 66, 67, 68, 69, 72, 73, 98, 104, 107, 108, 162, 184, 198, 199) + ), + RegionProfile( + localeTag = "en-GB", + countryIso = "GB", + timeZoneId = "Europe/London", + networkOperator = "23430", + networkOperatorName = "EE", + simOperator = "23430", + simOperatorName = "EE", + dnsServers = listOf("1.1.1.1", "1.0.0.1", "8.8.8.8"), + dnsSearchDomains = "bb.sky.com", + privateDnsServerName = "one.one.one.one", + timeFormat = "24", + androidReleaseOptions = listOf("14", "15"), + wifiPrefixes = listOf("Sky", "BT-Hub", "VirginMedia", "Linksys"), + ipPrefixes = listOf(51, 62, 77, 81, 86, 87, 88, 89, 90, 91, 92, 109, 141, 176, 185, 188) + ), + RegionProfile( + localeTag = "de-DE", + countryIso = "DE", + timeZoneId = "Europe/Berlin", + networkOperator = "26202", + networkOperatorName = "Vodafone DE", + simOperator = "26202", + simOperatorName = "Vodafone DE", + dnsServers = listOf("9.9.9.9", "149.112.112.112", "1.1.1.1"), + dnsSearchDomains = "fritz.box", + privateDnsServerName = "dns.quad9.net", + timeFormat = "24", + androidReleaseOptions = listOf("14", "15"), + wifiPrefixes = listOf("FRITZBox", "Vodafone", "Telekom", "WLAN"), + ipPrefixes = listOf(2, 5, 31, 37, 46, 79, 80, 84, 85, 87, 91, 93, 95, 109, 134, 176, 178, 188) + ), + RegionProfile( + localeTag = "en-IN", + countryIso = "IN", + timeZoneId = "Asia/Kolkata", + networkOperator = "405874", + networkOperatorName = "Jio", + simOperator = "405874", + simOperatorName = "Jio", + dnsServers = listOf("1.1.1.1", "8.8.8.8", "9.9.9.9"), + dnsSearchDomains = "airtelbroadband.in", + privateDnsServerName = "dns.google", + timeFormat = "12", + androidReleaseOptions = listOf("14", "15"), + wifiPrefixes = listOf("JioFiber", "Airtel", "ACTFibernet", "HomeNet"), + ipPrefixes = listOf(14, 27, 42, 49, 59, 61, 101, 103, 106, 117, 122, 125, 157, 182) + ) + ) +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt index ab195879..bac88cf5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt @@ -60,6 +60,14 @@ class Messaging : Feature("Messaging") { currentConversationId()?.let { stealthMode.canUseRule(it) } == true } + private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean { + return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofViewingGalleryPresence.get() + } + + private fun shouldSpoofReplyCameraPresence(stealthMode: StealthMode): Boolean { + return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofReplyCameraPresence.get() + } + private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean { return context.config.messaging.hideTypingNotifications.get() || currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true @@ -156,6 +164,8 @@ class Messaging : Feature("Messaging") { classReference.getAsClass()?.let { wrapperClass -> val bitmojiMethodNames = mutableSetOf() + val viewingGalleryMethodNames = mutableSetOf() + val replyCameraMethodNames = mutableSetOf() val typingMethodNames = mutableSetOf() val peekingMethodNames = mutableSetOf() @@ -165,14 +175,24 @@ class Messaging : Feature("Messaging") { if (parameterTypes.any { parameterType -> listOf( "PlatformChatVisibleAction", - "PlatformChatHiddenAction", - "PlatformViewingChatMediaAction", - "PlatformUsingReplyCameraAction" + "PlatformChatHiddenAction" ).any { parameterType.name.contains(it) } }) { bitmojiMethodNames.add(method.name) } + if (parameterTypes.any { parameterType -> + parameterType.name.contains("PlatformViewingChatMediaAction") + }) { + viewingGalleryMethodNames.add(method.name) + } + + if (parameterTypes.any { parameterType -> + parameterType.name.contains("PlatformUsingReplyCameraAction") + }) { + replyCameraMethodNames.add(method.name) + } + if (parameterTypes.any { parameterType -> parameterType.name.contains("PlatformTypingAction") }) { @@ -194,6 +214,22 @@ class Messaging : Feature("Messaging") { } } + viewingGalleryMethodNames.forEach { methodName -> + wrapperClass.hook(methodName, HookStage.BEFORE, { + shouldSpoofViewingGalleryPresence(stealthMode) + }) { + it.setResult(null) + } + } + + replyCameraMethodNames.forEach { methodName -> + wrapperClass.hook(methodName, HookStage.BEFORE, { + shouldSpoofReplyCameraPresence(stealthMode) + }) { + it.setResult(null) + } + } + typingMethodNames.forEach { methodName -> wrapperClass.hook(methodName, HookStage.BEFORE, { shouldHideTyping(stealthMode, hideTypingIndicator) @@ -214,8 +250,8 @@ class Messaging : Feature("Messaging") { val instance = param.thisObject() clearField(instance, "PlatformChatVisibleAction", shouldHideBitmojiPresence(stealthMode)) clearField(instance, "PlatformChatHiddenAction", shouldHideBitmojiPresence(stealthMode)) - clearField(instance, "PlatformViewingChatMediaAction", shouldHideBitmojiPresence(stealthMode)) - clearField(instance, "PlatformUsingReplyCameraAction", shouldHideBitmojiPresence(stealthMode)) + clearField(instance, "PlatformViewingChatMediaAction", shouldSpoofViewingGalleryPresence(stealthMode)) + clearField(instance, "PlatformUsingReplyCameraAction", shouldSpoofReplyCameraPresence(stealthMode)) clearField(instance, "PlatformTypingAction", shouldHideTyping(stealthMode, hideTypingIndicator)) clearField(instance, "PlatformStartPeekingAction", shouldHidePeek(stealthMode)) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/FriendTracker.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/FriendTracker.kt index 65eb5895..2075270c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/FriendTracker.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/FriendTracker.kt @@ -25,6 +25,12 @@ import java.text.DateFormat import java.util.Date class FriendTracker : Feature("Friend Tracker") { + companion object { + private const val PRESENCE_PEEKING_BIT = 8 + private const val PRESENCE_REPLY_CAMERA_BIT = 9 + private const val PRESENCE_CHAT_MEDIA_BIT = 10 + } + private val conversationPresenceState = mutableMapOf>() // conversationId -> (userId -> state) private val tracker by lazyBridge { context.bridgeClient.getTracker() } private val translation by lazy { context.translation.getCategory("friend_tracker_notifications") } @@ -37,6 +43,8 @@ class FriendTracker : Feature("Friend Tracker") { )) } } private val conversationEntries = mutableMapOf, Long>() + private val galleryEntries = mutableMapOf, Long>() + private val replyCameraEntries = mutableMapOf, Long>() private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>() fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) { @@ -104,7 +112,12 @@ class FriendTracker : Feature("Friend Tracker") { context.log.verbose("dispatching $action for $eventType in $conversationName") - val iCanSeeYouDetails = if (eventType == TrackerEventType.I_CAN_SEE_YOU) buildICanSeeYouDetails(extras) else "" + val iCanSeeYouDetails = when (eventType) { + TrackerEventType.I_CAN_SEE_YOU, + TrackerEventType.I_CAN_SEE_YOU_2, + TrackerEventType.I_CAN_SEE_YOU_3 -> buildICanSeeYouDetails(extras) + else -> "" + } val notificationText = translation[eventType.key] .replace("{friend}", authorName) .replace("{conversation}", conversationName) @@ -133,7 +146,7 @@ class FriendTracker : Feature("Friend Tracker") { } } - private fun buildICanSeeYouExtras(entry: Long?, exit: Long?, duration: Long?) = listOf( + private fun buildTimedActivityExtras(entry: Long?, exit: Long?, duration: Long?) = listOf( entry ?: -1, exit ?: -1, duration ?: -1 @@ -189,10 +202,22 @@ class FriendTracker : Feature("Friend Tracker") { (currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING - (oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING - oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING + (oldState == null || !oldState.usingReplyCamera) && currentState?.usingReplyCamera == true -> TrackerEventType.STARTED_USING_REPLY_CAMERA + oldState?.usingReplyCamera == true && (currentState == null || !currentState.usingReplyCamera) -> TrackerEventType.STOPPED_USING_REPLY_CAMERA + (oldState == null || !oldState.viewingChatMedia) && currentState?.viewingChatMedia == true -> TrackerEventType.STARTED_VIEWING_CHAT_MEDIA + oldState?.viewingChatMedia == true && (currentState == null || !currentState.viewingChatMedia) -> TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA + (oldState == null || !oldState.peeking) && + currentState?.peeking == true && + currentState.usingReplyCamera != true && + oldState?.usingReplyCamera != true -> TrackerEventType.STARTED_PEEKING + oldState?.peeking == true && + (currentState == null || !currentState.peeking) && + currentState?.usingReplyCamera != true && + oldState.usingReplyCamera != true -> TrackerEventType.STOPPED_PEEKING else -> null - } ?: return + } + + eventType ?: return when (eventType) { TrackerEventType.CONVERSATION_ENTER -> { @@ -206,7 +231,35 @@ class FriendTracker : Feature("Friend Tracker") { TrackerEventType.I_CAN_SEE_YOU, conversationId, userId, - buildICanSeeYouExtras(entry, exit, entry?.let { exit - it }) + buildTimedActivityExtras(entry, exit, entry?.let { exit - it }) + ) + } + TrackerEventType.STARTED_VIEWING_CHAT_MEDIA -> { + galleryEntries[conversationId to userId] = System.currentTimeMillis() + } + TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA -> { + val key = conversationId to userId + val exit = System.currentTimeMillis() + val entry = galleryEntries.remove(key) + dispatchEvents( + TrackerEventType.I_CAN_SEE_YOU_2, + conversationId, + userId, + buildTimedActivityExtras(entry, exit, entry?.let { exit - it }) + ) + } + TrackerEventType.STARTED_USING_REPLY_CAMERA -> { + replyCameraEntries[conversationId to userId] = System.currentTimeMillis() + } + TrackerEventType.STOPPED_USING_REPLY_CAMERA -> { + val key = conversationId to userId + val exit = System.currentTimeMillis() + val entry = replyCameraEntries.remove(key) + dispatchEvents( + TrackerEventType.I_CAN_SEE_YOU_3, + conversationId, + userId, + buildTimedActivityExtras(entry, exit, entry?.let { exit - it }) ) } else -> {} @@ -266,14 +319,20 @@ class FriendTracker : Feature("Friend Tracker") { userIds.add(participantUserId) if (participantUserId == context.database.myUserId) return@eachBuffer val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer + val usingReplyCamera = stateMap.getOrElse(PRESENCE_REPLY_CAMERA_BIT) { false } + val viewingChatMedia = stateMap.getOrElse(PRESENCE_CHAT_MEDIA_BIT) { false } + val peeking = stateMap.getOrElse(PRESENCE_PEEKING_BIT) { false } presenceMap[participantUserId] = FriendPresenceState( bitmojiPresent = stateMap[0], typing = stateMap[4], wasTyping = stateMap[5], speaking = stateMap[6] && stateMap[4], - // Snapchat appears to have shifted the peeking flag by one bit on newer builds. - peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false } + // Snapchat moved peeking by one bit on newer builds and added + // dedicated chat-presence flags for reply camera and chat media viewing. + peeking = peeking, + usingReplyCamera = usingReplyCamera, + viewingChatMedia = viewingChatMedia ) } diff --git a/gradle.properties b/gradle.properties index 41acf880..a0699832 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn nativeAbis=arm64-v8a -APP_VERSION_NAME=1.6.1 -APP_VERSION_CODE=312 +APP_VERSION_NAME=1.6.2 +APP_VERSION_CODE=314 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c