Merge branch 'dev' into RSR

This commit is contained in:
ΞTΞRNAL
2026-03-04 09:11:37 +05:30
committed by GitHub
139 changed files with 25845 additions and 22295 deletions

257
README.md
View File

@@ -16,7 +16,7 @@
[![Downloads](https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/releases)
[![License](https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
[Installation](#installation) • [Features](#features) • [Changelog](#whats-new) • [Community](#community)
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
</div>
@@ -40,44 +40,9 @@ No aggressive donation requests. No minimal changes disguised as “major update
<br>
---
<br>
## 🔄 How It Works
```mermaid
graph TD
A[Install PurrfectSnap] --> B{Device Type?}
B -->|Non-Rooted| C[Auto Patcher Runs]
B -->|Rooted| D[Xposed Framework]
C --> E[Snapchat Patched Automatically]
D --> E
E --> F[Launch Snapchat]
F --> G[Access PurrfectSnap Settings]
G --> H[Configure Features]
H --> I{Enable Features}
I --> J[Media Downloader]
I --> K[Privacy Controls]
I --> L[UI Customization]
I --> M[Camera Enhancements]
I --> N[AI Intelligence Layer]
J --> O[Enhanced Snapchat Experience]
K --> O
L --> O
M --> O
N --> O
```
<br>
---
<br>
## Features
PurrfectSnap offers deep control across media, privacy, automation, and UIdesigned for both casual users and power users.
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
<table>
<tr>
@@ -246,152 +211,6 @@ Extensibility through custom scripts.
<br>
---
<br>
## What's New
Here's what makes PurrfectSnap different from upstream SnapEnhance.
<br>
### Aurora Design System
A complete visual language rebuilt from the ground up. Not just a theme, an entire design philosophy.
The interface now flows with purpose. Subtle animations guide your interactions. Every screen has been reconsidered, every transition refined. The result is an experience that feels premium without being ostentatious.
<br>
### PurrAura
The ban problem? Solved.
No more sacrificing features to stay safe. Add friends, block users, attach music; everything works as it should. No workarounds, no compromises, no disadvantages.
<br>
### Auto Patcher
Installation was complicated. Now it isnt.
We've eliminated 90% of the installation friction. No Shizuku. No LSPatch. Just a straightforward process that respects your time.
<br>
### Intelligence Layer
**Auto Reply**
Let AI handle routine responses while you focus on what matters. Configure automatic replies for chats, stories, and half-swipes. Essential for creators managing volume. Requires an API key from Google AI Studio, they offer free tier access.
**Scheduled Snaps**
Time-shifted communication. Set a snap to send at midnight for a birthday. Queue content for optimal timing:your schedule, automated.
**Message Translator**
Language barriers, eliminated. Incoming messages automatically translate to your preferred language. Set it once, communicate globally.
<br>
### Privacy Enhancements
**Granular Controls**
Whitelist and blacklist modes for typing indicators and message logging. Choose exactly who sees what, friend by friend.
**Auto Delete Messages**
Set expiration times for your messages. They disappear on your schedule, not Snapchat's.
**Message Logger Viewer**
Review exported logs directly in-app. No external tools required.
<br>
### Data Persistence
**Friend Notes Backup**
Your notes are valuable. Now they're protected. Export and restore with ease. Never lose context again.
<br>
### Device Management
**ID Spoofing**
Device bans happen. Sometimes unjustly. We offer a solution for legitimate cases, approval required with proof of wrongful ban. This feature is restricted to prevent abuse.
**Model Spoofing**
Identify as a high-end device model. Potential performance improvements, enhanced camera processing, and better feature access.
**Network Spoofing**
Spoof your network status to Wi-Fi always to prevent several restrictions on mobile data.
<br>
### Restored Functionality
Features that disappeared? They're back.
Bulk messaging works again. Friend list management is fully operational. These aren't new features, they're restored capabilities that should never have been lost.
<br>
### Discovery
**Scripts Catalog**
Browse and install scripts without leaving the app. No more hunting for import links. One-tap installation. Always current.
**Friend Tracker Catalog**
Browse and install tracking rules without leaving the app. One-tap installation. Always current.
**Friend Tracker Import/Export**
Backup and restore your Friend Tracker configurations with ease.
<br>
### Advanced Tracking
**I Can See You Rule**
Track the exact time and duration when a friend enters your chat. Precision visibility tracking for Friend Tracker.
**Auto Open Snaps Enhancement**
More granular control with queue size configuration and customizable delays.
<br>
### Visual Refinements
**AMOLED Theme**
True black. Battery-saving. The most requested feature, delivered.
**Haptic Feedback**
Subtle tactile responses. Every interaction confirmed through touch.
**Customizable Bottom Bar**
Your navigation, your rules. Rearrange tabs, remove what you don't use, set your default view. Complete flexibility.
**Search History**
Recently searched features, instantly accessible. No more hunting through menus.
<br>
### Infrastructure
**In-App Updates**
New versions install directly from within the app. No GitHub hunting. No manual downloads. Just seamless updates.
**Update Notifications**
Stay current automatically. Get notified when new releases are available.
**Config Export/Import Summary**
See exactly which features a config will enable before importing or exporting. Know what you're getting.
<br>
### Quality
Hundreds of refinements you'll feel but might not see. Smoother animations. Faster response times. Eliminated edge cases. The kind of polish that separates good from exceptional.
> Some features are intended for educational, recovery, or accessibility purposes. Misuse may violate Snapchats terms of service.
> **We are not affiliated with Snap Inc. and are not responsible for any violations by the user. Our sole purpose is for education purposes only!**
@@ -425,15 +244,79 @@ Questions? Ideas? Found a bug? Our community is active and responsive.
<br>
## Contributing
## Build from Source
We welcome contributions from developers who share our vision. Whether it's code, documentation, etc., contributions are always appreciated. Feel free to open a PR :)
<br>
### 1) Fork
---
1. Fork this repository on GitHub.
```
### 2) Generate a Release Keystore (Certificate)
Create a signing keystore (`.jks`) and keep it secure:
```bash
keytool -genkeypair -v \
-keystore purrfectsnap-release.keystore \
-storetype JKS \
-alias purrfectsnap \
-keyalg RSA \
-keysize 2048 \
-validity 10000
```
Optional local placement for Gradle signing:
```bash
mkdir -p ~/.android
cp purrfectsnap-release.keystore ~/.android/purrfectsnap-release.keystore
```
### 3) Convert Keystore to Base64
For GitHub Actions secret `PS_BASE_64`:
Linux/macOS:
```bash
base64 -w 0 purrfectsnap-release.keystore > keystore.base64
```
PowerShell (Windows):
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("purrfectsnap-release.keystore")) | Set-Content -NoNewline keystore.base64
```
### 4) Add Required GitHub Secrets (Your Fork)
Go to:
`Settings -> Secrets and variables -> Actions -> New repository secret`
Create these exact secrets (from `.github/workflows/release.yml`):
- `PS_BASE_64` = contents of `keystore.base64`
- `PS_RELEASE_KEY_ALIAS` = `purrfectsnap` (or your chosen alias)
- `PS_RELEASE_KEY_PASSWORD` = key password from `keytool`
- `PS_RELEASE_STORE_PASSWORD` = keystore password from `keytool`
### 5) Run Release Workflow
1. Open `Actions` in your fork.
2. Run `PurrfectSnap Release CI` (`workflow_dispatch`).
3. Workflow builds and signs:
- `armv8` release APK
- `armv7` release APK
4. Workflow creates a GitHub Release and uploads APK assets.
### Notes
- CI uses `JDK 21 (Temurin)`.
- Signing requires all four secrets above.
- Keep keystore and passwords private. Rotate immediately if exposed.
<br>
## Credits

View File

@@ -267,6 +267,7 @@ dependencies {
}
implementation(project(":core"))
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(project(":common"))
implementation(project(":native"))
implementation(libs.androidx.documentfile)

View File

@@ -1,6 +1,6 @@
-dontwarn de.robv.android.xposed.**
-dontwarn org.mozilla.javascript.**
-dontwarn android.app.AndroidAppHelper
-dontwarn java.lang.reflect.AnnotatedType
-keep class com.tonyodev.fetch2.** { *; }
-keep class com.tonyodev.fetch2core.** { *; }

View File

@@ -121,7 +121,7 @@ class BridgeService : Service() {
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Media conversion",
title = remoteSideContext.translation["task_media_conversion_title"],
author = null,
hash = taskId
)

View File

@@ -232,6 +232,7 @@ class FFMpegProcessor(
}
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
outputArguments += "-c:a" to "pcm_s16le"
}
Action.MERGE_AUDIO_STREAMS -> {
inputArguments.clear()
@@ -240,18 +241,23 @@ class FFMpegProcessor(
args.inputs.forEachIndexed { index, input ->
inputArguments += "-i" to input
val offset = args.inputDelayOffsets?.get(input) ?: 0L
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
if (offset > 0) {
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
filterParts.append(",adelay=$offset|$offset[a$index];")
} else {
filterParts.append("[$index:a]acopy[a$index];")
filterParts.append(",acopy[a$index];")
}
}
args.inputs.indices.forEach { index ->
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-c:a" to "libmp3lame"
outputArguments += "-b:a" to "192k"
outputArguments += "-ar" to "48000"
outputArguments += "-ac" to "2"
}
}
outputArguments += args.output.absolutePath

View File

@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
val job: Job
val writePfd: ParcelFileDescriptor
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
if (exists()) delete()
}
@@ -120,7 +120,7 @@ class CallDownloadSessionImpl(
val pendingTask = context.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Call Recording $author",
title = context.translation.format("task_call_recording_title", "author" to author),
author = author,
hash = UUID.randomUUID().toString()
)
@@ -188,4 +188,4 @@ class CallDownloadSessionImpl(
context.log.verbose("ending call")
}
}
}
}

View File

@@ -132,16 +132,16 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = "Wrong APK installed",
title = managerContext.translation["wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = "Close",
confirmButtonText = managerContext.translation["common.close"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = "Your device is armv8, please download the armv8 apk, not armv7.",
text = managerContext.translation["wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)

View File

@@ -54,7 +54,7 @@ class Routes(
) {
companion object {
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}&includeSavedLocations={includeSavedLocations}"
const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
const val VIEW_LOGGER_HISTORY_ROUTE = "view_logger_history/{uri}"

View File

@@ -143,7 +143,7 @@ class ManageReposSection: Routes.Route() {
addRepo(url)
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}

View File

@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray
import org.json.JSONObject
@@ -136,10 +137,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"]
val parser = remember { ConfigParser() }
val savedLocations = remember {
if (includeSavedLocations) context.database.getLocationCoordinates() else null
}
val featuresByCategory = remember {
parser.parse(context.config.exportToString(exportSensitiveData))
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
@@ -191,14 +196,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -210,7 +215,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {

View File

@@ -49,10 +49,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.abs
class ConfigImportConfirmationScreen : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
@@ -65,6 +69,44 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val indentation: Int
)
companion object {
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
}
/**
* Imports saved locations from JSON array into database with de-duplication.
* Only adds locations that don't already exist (within coordinate tolerance).
*/
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
val existingLocations = context.database.getLocationCoordinates()
for (i in 0 until locationsArray.size()) {
val locationObj = locationsArray.get(i).asJsonObject
val name = locationObj.get("name")?.asString ?: continue
val latitude = locationObj.get("latitude")?.asDouble ?: continue
val longitude = locationObj.get("longitude")?.asDouble ?: continue
val radius = locationObj.get("radius")?.asDouble ?: 100.0
// Check for existing location with similar coordinates (de-duplication)
val existingMatch = existingLocations.find { existing ->
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
}
if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply {
this.name = name
this.latitude = latitude
this.longitude = longitude
this.radius = radius
}
context.database.addOrUpdateLocationCoordinate(null, newLocation)
}
// If duplicate exists, skip (do not update or delete existing)
}
}
private inner class ConfigParser {
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
val featureList = mutableListOf<ImportedFeature>()
@@ -190,7 +232,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"] ?: "Import"
val importLabel = translation["confirm_button"]
Box(
modifier = Modifier
@@ -239,14 +281,14 @@ class ConfigImportConfirmationScreen : Routes.Route() {
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -256,12 +298,17 @@ class ConfigImportConfirmationScreen : Routes.Route() {
onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
context.config.loadFromString(json)
val savedLocationsJson = context.config.loadFromString(json)
// Import saved locations if present in the JSON
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: "Unknown error")
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}

View File

@@ -112,7 +112,7 @@ class FeaturesRootSection : Routes.Route() {
} ?: routeInfo.translatedKey?.value
}
SEARCH_FEATURE_ROUTE -> {
translation["search_button"] ?: "Search"
translation["search_button"]
}
else -> {
routeInfo.translatedKey?.value
@@ -134,7 +134,10 @@ class FeaturesRootSection : Routes.Route() {
val containers = mutableMapOf<String, PropertyPair<*>>()
fun queryContainerRecursive(container: ConfigContainer) {
container.properties.forEach {
if (it.key.dataType.type == DataProcessors.Type.CONTAINER) {
if (
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
) {
containers[it.key.name] = PropertyPair(it.key, it.value)
queryContainerRecursive(it.value.get() as ConfigContainer)
}
@@ -155,10 +158,15 @@ class FeaturesRootSection : Routes.Route() {
properties
}
private fun isSearchVisibleProperty(propertyKey: PropertyKey<*>): Boolean {
return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN)
}
private data class SearchEntry(val keyword: String, val tokens: List<String>)
private fun buildSearchEntries(): List<SearchEntry> {
return allProperties.keys.mapNotNull { key ->
if (!isSearchVisibleProperty(key)) return@mapNotNull null
val name = context.translation[key.propertyName()]
val description = context.translation[key.propertyDescription()]
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
@@ -280,9 +288,11 @@ class FeaturesRootSection : Routes.Route() {
composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
backStackEntry.arguments?.getString("keyword")?.let { keyword ->
val properties = allProperties.filter {
it.key.name.contains(keyword, ignoreCase = true) ||
isSearchVisibleProperty(it.key) && (
it.key.name.contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { PropertyPair(it.key, it.value) }
PropertiesView(
@@ -668,7 +678,7 @@ class FeaturesRootSection : Routes.Route() {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = "$messageCount messages",
text = translation.format("search_results_count", "count" to messageCount.toString()),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
color = Color.White
@@ -1014,7 +1024,7 @@ class FeaturesRootSection : Routes.Route() {
},
placeholder = {
Text(
text = translation["search_button"] ?: "Search",
text = translation["search_button"],
color = Color(0xFFE0DCFF)
)
},
@@ -1060,9 +1070,11 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun SensitiveDataDialog(
onDismiss: () -> Unit,
onConfirm: (exportSensitiveData: Boolean) -> Unit
onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
val includeSavedLocations = remember { mutableStateOf(false) }
Surface(
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -1100,12 +1112,36 @@ class FeaturesRootSection : Routes.Route() {
color = PurrfectPalette.textSecondary,
modifier = Modifier.padding(horizontal = 6.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = context.translation["include_saved_locations"],
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = includeSavedLocations.value,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
includeSavedLocations.value = it
},
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Button(
onClick = { onConfirm(false) },
onClick = { onConfirm(false, includeSavedLocations.value) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
@@ -1114,7 +1150,7 @@ class FeaturesRootSection : Routes.Route() {
Text(context.translation["button.negative"])
}
Button(
onClick = { onConfirm(true) },
onClick = { onConfirm(true, includeSavedLocations.value) },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
@@ -1244,10 +1280,11 @@ class FeaturesRootSection : Routes.Route() {
if (showExportDialog) {
SensitiveDataDialog(
onDismiss = { showExportDialog = false },
onConfirm = { exportSensitiveData ->
onConfirm = { exportSensitiveData, includeSavedLocations ->
showExportDialog = false
routes.configExportSummary.navigate {
put("exportSensitiveData", exportSensitiveData.toString())
put("includeSavedLocations", includeSavedLocations.toString())
}
}
)
@@ -1283,7 +1320,7 @@ class FeaturesRootSection : Routes.Route() {
val headerTitle = activeSectionTitle ?: translation["manager.routes.features"]
val subtitleText = when {
isSearchResults -> translation["search_button"] ?: "Search"
isSearchResults -> translation["search_button"]
!activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle
else -> translation["manager.sections.features.subtitle"] ?: ""
}
@@ -1337,7 +1374,7 @@ class FeaturesRootSection : Routes.Route() {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["button.back"] ?: "Back",
contentDescription = context.translation["common.back"],
tint = Color.White
)
}
@@ -1358,7 +1395,7 @@ class FeaturesRootSection : Routes.Route() {
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
placeholder = { Text(text = translation["search_button"], color = Color(0xFFE0DCFF)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -1516,7 +1553,7 @@ class FeaturesRootSection : Routes.Route() {
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White.copy(alpha = 0.85f))
Spacer(Modifier.width(6.dp))
Text(text = translation["clear_history"] ?: "Clear history", color = Color.White)
Text(text = translation["clear_history"], color = Color.White)
}
}
}
@@ -1546,7 +1583,7 @@ class FeaturesRootSection : Routes.Route() {
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
val globalSearchProperties = remember(enableGlobalSearch) {
if (enableGlobalSearch) {
allProperties.map { PropertyPair(it.key, it.value) }
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key, it.value) }
} else {
emptyList()
}

View File

@@ -203,8 +203,8 @@ class ManageRuleFeature : Routes.Route() {
title = translation["clear_list_button"],
text = translation["dialog_clear_confirmation_text"],
icon = Icons.Default.DeleteSweep,
confirmButtonText = context.translation["clear"],
dismissButtonText = context.translation["button.cancel"],
confirmButtonText = translation["dialog_clear_confirm_button"],
dismissButtonText = translation["dialog_clear_cancel_button"],
onDismiss = { confirmationDialog = false },
onConfirm = {
context.database.clearRuleIds(currentRuleType.key)
@@ -390,7 +390,7 @@ class ManageRuleFeature : Routes.Route() {
contentColor = Color.White
)
) {
Text(text = context.translation["clear"])
Text(text = translation["dialog_clear_confirm_button"])
}
}
}

View File

@@ -315,7 +315,7 @@ class HomeLogs : Routes.Route() {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
@@ -412,7 +412,7 @@ class HomeLogs : Routes.Route() {
textAlign = TextAlign.Center
)
Text(
text = "Pull to refresh or trigger an action to see new entries.",
text = translation["refresh_hint"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp,
textAlign = TextAlign.Center,

View File

@@ -46,13 +46,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Schedule
@@ -137,6 +137,7 @@ class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
companion object {
private const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
val cardMargin = 10.dp
val pageBackgroundGradient = Brush.verticalGradient(
listOf(
@@ -408,7 +409,7 @@ class HomeRootSection : Routes.Route() {
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onWikiClick: () -> Unit,
onWebsiteClick: () -> Unit,
onTelegramClick: () -> Unit,
onGithubClick: () -> Unit,
authorName: String,
@@ -649,15 +650,15 @@ class HomeRootSection : Routes.Route() {
) {
Button(
modifier = Modifier.weight(1f),
onClick = onWikiClick,
onClick = onWebsiteClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["wiki_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
@@ -736,8 +737,25 @@ class HomeRootSection : Routes.Route() {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles().filter { it.isNotBlank() }
val prefs = remember { context.sharedPreferences }
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
val hasInitializedQuickTiles = prefs.getBoolean(QUICK_TILES_INITIALIZED_PREF, false)
when {
storedTiles.isNotEmpty() -> {
if (!hasInitializedQuickTiles) {
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
}
storedTiles
}
hasInitializedQuickTiles -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
allQuickTileNames
}
}
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"]
@@ -952,9 +970,9 @@ class HomeRootSection : Routes.Route() {
onUpdateAction = onUpdateButtonClick,
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onWikiClick = {
onWebsiteClick = {
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap/wiki",
"https://purrfectsnap.vercel.app/",
context.translation["toast_open_link_failed"]
)
},
@@ -1177,17 +1195,18 @@ class HomeRootSection : Routes.Route() {
if (showChangelogDialog && latestUpdate != null) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = "Changelog",
title = translation["changelog_dialog_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Update",
confirmButtonText = translation["changelog_dialog_update_button"],
onConfirm = {
showChangelogDialog = false
handleUpdateAction()
},
dismissButtonText = "Cancel",
dismissButtonText = translation["changelog_dialog_cancel_button"],
onDismiss = { showChangelogDialog = false },
confirmEnabled = !changelogLoading,
showCloseButton = false,
customContent = {
Column(
modifier = Modifier
@@ -1210,7 +1229,7 @@ class HomeRootSection : Routes.Route() {
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading changelog…",
text = translation["changelog_dialog_loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -1219,7 +1238,7 @@ class HomeRootSection : Routes.Route() {
changelogError != null -> {
Text(
text = changelogError ?: "Failed to load changelog",
text = changelogError ?: translation["changelog_dialog_error"],
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
@@ -1227,7 +1246,7 @@ class HomeRootSection : Routes.Route() {
else -> {
Text(
text = changelogText ?: "Changelog not available",
text = changelogText ?: translation["changelog_dialog_empty"],
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
@@ -1242,12 +1261,13 @@ class HomeRootSection : Routes.Route() {
if (showAnnouncementsDialog) {
AestheticDialog(
onDismissRequest = { showAnnouncementsDialog = false },
title = "Announcements",
title = translation["announcements_dialog_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Close",
confirmButtonText = translation["announcements_dialog_close_button"],
onConfirm = { showAnnouncementsDialog = false },
confirmEnabled = !announcementsLoading,
showCloseButton = false,
customContent = {
Column(
modifier = Modifier
@@ -1270,7 +1290,7 @@ class HomeRootSection : Routes.Route() {
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading announcements...",
text = translation["announcements_dialog_loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -1279,7 +1299,7 @@ class HomeRootSection : Routes.Route() {
announcementsError != null -> {
Text(
text = announcementsError ?: "Failed to load announcements",
text = announcementsError ?: translation["announcements_dialog_error"],
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
@@ -1287,7 +1307,7 @@ class HomeRootSection : Routes.Route() {
else -> {
Text(
text = announcementsText ?: "Announcements not available",
text = announcementsText ?: translation["announcements_dialog_empty"],
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
@@ -1311,6 +1331,7 @@ class HomeRootSection : Routes.Route() {
newList.forEach { clearTileOffset(it) }
selectedTiles.clear()
selectedTiles.addAll(newList)
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}

View File

@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -51,6 +53,7 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
@@ -143,11 +146,16 @@ class HomeSettings : Routes.Route() {
sharedPreferences: SharedPreferences,
key: String,
text: String,
defaultValue: Boolean = false
defaultValue: Boolean = false,
confirmDisableTitle: String? = null,
confirmDisableText: String? = null
) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
var showDisableDialog by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
LaunchedEffect(realKey) {
if (!sharedPreferences.contains(realKey)) {
@@ -156,6 +164,24 @@ class HomeSettings : Routes.Route() {
}
}
if (showDisableDialog) {
AestheticDialog(
onDismissRequest = { showDisableDialog = false },
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
text = confirmDisableText.orEmpty(),
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
value = false
sharedPreferences.edit().putBoolean(realKey, false).apply()
showDisableDialog = false
},
onDismiss = { showDisableDialog = false },
showCloseButton = false
)
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -164,11 +190,15 @@ class HomeSettings : Routes.Route() {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = !value
sharedPreferences
.edit() {
val nextValue = !value
if (!nextValue && confirmDisableTitle != null) {
showDisableDialog = true
} else {
value = nextValue
sharedPreferences.edit() {
putBoolean(realKey, value)
}
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -283,11 +313,15 @@ class HomeSettings : Routes.Route() {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
val sharedButtonColors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.12f),
contentColor = Color.White
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
var showResetSetupDialog by remember { mutableStateOf(false) }
@Composable
fun GlassCard(
@@ -343,6 +377,39 @@ class HomeSettings : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = android.content.Intent(
context.androidContext,
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
Column(
modifier = Modifier.fillMaxSize()
) {
@@ -546,44 +613,26 @@ class HomeSettings : Routes.Route() {
}
GlassCard {
RowTitle(title = "Reset PurrfectSnap")
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
// Clear setup progress and route back to SetupActivity
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
// Clear config to defaults
context.config.reset()
context.config.writeConfig()
// Launch setup activity fresh
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
// Close current manager activity
routes.navController.popBackStack()
showResetSetupDialog = true
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Reset and restart setup",
text = translation["reset_setup_action"],
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Reset",
contentDescription = translation["reset_setup_action"],
modifier = Modifier.padding(end = 14.dp)
)
}
@@ -697,7 +746,7 @@ class HomeSettings : Routes.Route() {
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = "Import")
Text(text = translation["import_button"])
}
}
}
@@ -712,53 +761,49 @@ class HomeSettings : Routes.Route() {
Text(translation["view_logger_history_button"])
}
if (showImportDialog) {
AlertDialog(
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import message logger") },
text = { Text("Importing will override your current message logger database. Continue?") },
confirmButton = {
TextButton(onClick = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
runCatching {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
context.messageLogger.databaseFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Unable to open selected file")
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
context.shortToast(translation["success_toast"])
context.log.info("Imported message logger from $uri", "MessageLogger")
}.onFailure {
context.log.error("Failed to import message logger", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = importLabel,
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
runCatching {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
context.messageLogger.databaseFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Unable to open selected file")
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
context.shortToast(translation["success_toast"])
context.log.info("Imported message logger from $uri", "MessageLogger")
}.onFailure {
context.log.error("Failed to import message logger", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
}
}
}.onFailure {
context.log.error("Failed to launch import picker", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
)
}
}
}) {
Text(translation["button.import"] ?: "Import")
}.onFailure {
context.log.error("Failed to launch import picker", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
)
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text(translation["button.cancel"])
}
}
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
}
@@ -901,7 +946,9 @@ class HomeSettings : Routes.Route() {
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true
defaultValue = true,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
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"])

View File

@@ -58,6 +58,8 @@ import kotlin.math.roundToInt
import kotlin.random.Random
class RetroGameScreen : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.retro_flight") }
override val content: @Composable (NavBackStackEntry) -> Unit = {
val gridWidth = 120
val gridHeight = 160
@@ -206,7 +208,7 @@ class RetroGameScreen : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = "Retro Flight",
title = translation["title"],
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(6.dp))
@@ -243,7 +245,7 @@ class RetroGameScreen : Routes.Route() {
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "GAME OVER",
text = translation["game_over_label"],
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -258,7 +260,7 @@ class RetroGameScreen : Routes.Route() {
),
shape = RoundedCornerShape(6.dp)
) {
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
}
}
}
@@ -293,7 +295,7 @@ class RetroGameScreen : Routes.Route() {
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "LEFT",
text = translation["left_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
@@ -324,7 +326,7 @@ class RetroGameScreen : Routes.Route() {
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "RIGHT",
text = translation["right_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,

View File

@@ -127,7 +127,11 @@ class BetterLocationRoot : Routes.Route() {
overflow = TextOverflow.Ellipsis
)
Text(
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
text = context.translation.format(
"spoofed_coordinates_title",
"latitude" to friendLocation.latitude.toFloat().toString(),
"longitude" to friendLocation.longitude.toFloat().toString()
),
fontSize = 11.sp,
fontWeight = FontWeight.Light,
color = Color.White.copy(alpha = 0.8f)
@@ -234,6 +238,8 @@ class BetterLocationRoot : Routes.Route() {
val coordinatesProperty = remember {
context.config.root.global.betterLocation.getPropertyPair("coordinates")
}
val providerProperty = remember { context.config.root.global.betterLocation.getPropertyPair("location_search_provider") }
val apiKeyProperty = remember { context.config.root.global.betterLocation.getPropertyPair("google_maps_api_key") }
val updateDispatcher = rememberAsyncUpdateDispatcher()
val savedCoordinates = rememberAsyncMutableStateList(
@@ -245,10 +251,17 @@ class BetterLocationRoot : Routes.Route() {
var showMap by remember { mutableStateOf(false) }
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
var showTeleportDialog by remember { mutableStateOf(false) }
var showProviderDialog by remember { mutableStateOf(false) }
var showApiKeyDialog by remember { mutableStateOf(false) }
val marker = remember { mutableStateOf<Marker?>(null) }
val mapView = remember { mutableStateOf<MapView?>(null) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) {
mutableStateOf(
(coordinatesProperty.value.getNullable() as? Pair<*, *>)
?: (0.0 to 0.0)
)
}
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
context.coroutineScope.launch {
@@ -271,6 +284,36 @@ class BetterLocationRoot : Routes.Route() {
)
}
var currentProvider by remember {
mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm")
}
var currentApiKey by remember {
mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "")
}
if (showProviderDialog) {
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
showProviderDialog = false
context.config.writeConfig()
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
}) {
alertDialogs.UniqueSelectionDialog(providerProperty)
}
}
if (showApiKeyDialog) {
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}) {
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -337,9 +380,16 @@ class BetterLocationRoot : Routes.Route() {
)
) {
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
addSavedCoordinateDialog = true
}) {
alertDialogs.ChooseLocationDialog(
property = coordinatesProperty,
marker = marker,
mapView = mapView,
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
saveCoordinates = {
addSavedCoordinateDialog = true
}
) {
showMap = false
context.config.writeConfig()
}
@@ -395,6 +445,42 @@ class BetterLocationRoot : Routes.Route() {
) {
context.config.root.global.betterLocation.suspendLocationUpdates.set(it)
}
@Composable
fun ConfigSelector(text: String, value: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.weight(1f))
Text(
text = value,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp,
modifier = Modifier.padding(start = 8.dp)
)
}
}
@Composable
fun ConfigInput(text: String, value: String, onClick: () -> Unit) {
ConfigSelector(text, if (value.isNotEmpty()) "********" else translation["options.empty"], onClick)
}
ConfigSelector(
text = translation["location_search_provider_title"],
value = translation["option_$currentProvider"]
) { showProviderDialog = true }
if (currentProvider == "google_maps") {
ConfigInput(
text = translation["google_maps_api_key_title"],
value = currentApiKey
) { showApiKeyDialog = true }
}
}
item {
GlassPanel(

View File

@@ -255,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -292,7 +292,7 @@ class ManageScriptReposSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
}
}
}
@@ -206,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
) {
item {
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
)
)
)
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
Text(
text = translation["loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
}
}
}
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
Box(
@@ -311,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = translation.format("version", "version" to (entry.version ?: "N/A")),
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
fontWeight = FontWeight.Medium,
fontSize = 11.sp,

View File

@@ -140,7 +140,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
isLoading = false
}
@@ -431,7 +431,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
val label = translation["select_folder_button"]
Box(
modifier = Modifier
.fillMaxWidth()
@@ -439,13 +439,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(68.dp),
modifier = Modifier.size(78.dp),
shape = CircleShape,
color = Color.White.copy(alpha = 0.1f),
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
shadowElevation = 14.dp,
border = BorderStroke(
1.dp,
1.5.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,17 +456,18 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(6.dp)
.size(66.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
)
)
)
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
@@ -474,7 +475,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(34.dp)
)
}
}
@@ -627,7 +628,7 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
Surface(
@@ -743,7 +744,7 @@ class ScriptingRootSection : Routes.Route() {
title = context.translation["manager.dialogs.scripting_warning.title"],
text = context.translation["manager.dialogs.scripting_warning.content"],
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"] ?: "OK",
confirmButtonText = translation["button.ok"],
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
@@ -875,7 +876,7 @@ class ScriptingRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.scripts"] ?: "Scripts",
text = translation["manager.routes.scripts"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp

View File

@@ -375,8 +375,7 @@ class AddFriendDialog(
enabled = !allFriendsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
?: "Select All",
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {

View File

@@ -91,7 +91,7 @@ class ManageScope: Routes.Route() {
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
text = "",
icon = Icons.Rounded.DeleteForever,
confirmButtonText = translation["delete_button"] ?: "Delete",
confirmButtonText = translation["delete_button"],
dismissButtonText = context.translation["button.cancel"],
onDismiss = { deleteConfirmDialog = false },
onConfirm = {
@@ -109,7 +109,7 @@ class ManageScope: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = titleText ?: "Manage",
title = titleText ?: translation["manage_scope_title"],
onBack = { routes.navController.popBackStack() },
actions = {
IconButton(onClick = { deleteConfirmDialog = true }) {

View File

@@ -63,7 +63,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
class MessagingPreview: Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview") }
private lateinit var coroutineScope: CoroutineScope
private lateinit var previewScrollState: LazyListState
@@ -79,21 +79,6 @@ class MessagingPreview: Routes.Route() {
else selectedMessages.add(messageId)
}
private fun tr(key: String, fallback: String? = null): String {
fun normalizeCandidate(candidate: String?): String? {
if (candidate.isNullOrBlank()) return null
if (candidate == key) return null
if (candidate.endsWith(".$key")) return null
return candidate
}
return normalizeCandidate(translation.getOrNull(key))
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
?: fallback
?: key
}
@Composable
private fun ActionsSheetItem(
title: String,
@@ -338,20 +323,20 @@ class MessagingPreview: Routes.Route() {
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
when {
senderId == null -> "Unknown"
senderId == myUserId -> "You"
senderId == null -> translation["sender_unknown"]
senderId == myUserId -> translation["sender_you"]
scope == SocialScope.FRIEND -> friendDisplayName
?: context.database.getFriendInfo(scopeId)?.displayName
?: context.database.getFriendInfo(scopeId)?.mutableUsername
?: "Friend"
?: translation["sender_friend"]
else -> context.database.getFriendInfo(senderId)?.displayName
?: context.database.getFriendInfo(senderId)?.mutableUsername
?: "Unknown"
?: translation["sender_unknown"]
}
}
val contentTypeLabel = remember(contentType) {
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: translation["sender_unknown"]
}
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
@@ -408,7 +393,7 @@ class MessagingPreview: Routes.Route() {
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Text(
text = senderDisplayName ?: "Unknown",
text = senderDisplayName ?: translation["sender_unknown"],
color = Color.White.copy(alpha = 0.82f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
@@ -487,7 +472,7 @@ class MessagingPreview: Routes.Route() {
.padding(40.dp),
horizontalArrangement = Arrangement.Center
) {
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
Text(translation["no_message_hint"], color = PurrfectPalette.textSecondary)
}
}
Spacer(modifier = Modifier.height(20.dp))
@@ -564,7 +549,7 @@ class MessagingPreview: Routes.Route() {
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
} ?: translation.getOrNull("processed_messages_toast")?.let {
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
} ?: "Processed ${processMessageCount.intValue} messages"
} ?: translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
context.longToast(toastText)
}
}
@@ -579,7 +564,7 @@ class MessagingPreview: Routes.Route() {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return
}
@@ -640,7 +625,7 @@ class MessagingPreview: Routes.Route() {
) {
val processedText = translation.getOrNull("processed_messages_text")?.let {
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
} ?: "Processed ${processMessageCount.intValue}"
} ?: translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
Text(processedText)
if (activeTask?.hasFixedGoal() == true) {
LinearProgressIndicator(
@@ -679,7 +664,7 @@ class MessagingPreview: Routes.Route() {
}.onFailure {
context.log.error("Failed to fetch messages", it)
context.shortToast(
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
translation.getOrNull("message_fetch_failed") ?: translation["message_fetch_failed"]
)
}
}
@@ -710,7 +695,7 @@ class MessagingPreview: Routes.Route() {
fetchNewMessages()
}.onFailure {
context.longToast(
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
translation.getOrNull("bridge_init_failed") ?: translation["bridge_init_failed"]
)
context.log.error("Failed to initialize messaging bridge", it)
}
@@ -751,11 +736,11 @@ class MessagingPreview: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = titleText ?: translation["title"] ?: "Preview",
title = titleText ?: translation["title"],
subtitle = if (selectedMessages.isNotEmpty()) {
"${selectedMessages.size} selected"
} else {
tr("subtitle", "Hold to select")
translation["subtitle"]
.substringBefore("")
.substringBefore("·")
.substringBefore("|")
@@ -798,7 +783,7 @@ class MessagingPreview: Routes.Route() {
Text(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge",
?: translation["bridge_connection_error"],
modifier = Modifier.padding(16.dp),
color = Color.White
)
@@ -831,7 +816,7 @@ class MessagingPreview: Routes.Route() {
val selectionSubtitle = if (hasSelection) {
"${selectedMessages.size} selected"
} else {
"Choose message types"
translation["choose_message_types_subtitle"]
}
Column(
@@ -862,7 +847,7 @@ class MessagingPreview: Routes.Route() {
)
Spacer(Modifier.height(14.dp))
Text(
text = tr("actions_title", "Conversation Actions"),
text = translation["actions_title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
@@ -886,31 +871,31 @@ class MessagingPreview: Routes.Route() {
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
ActionsSheetItem(
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
title = translation[saveKey],
subtitle = if (hasSelection) translation["save_selected_messages_subtitle"] else translation["save_by_content_type_subtitle"],
icon = Icons.Rounded.BookmarkAdded
) {
launchMessagingTask(MessagingTaskType.SAVE)
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
}
ActionsSheetItem(
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
title = translation[unsaveKey],
subtitle = if (hasSelection) translation["unsave_selected_messages_subtitle"] else translation["unsave_by_content_type_subtitle"],
icon = Icons.Rounded.BookmarkBorder
) {
launchMessagingTask(MessagingTaskType.UNSAVE)
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
}
ActionsSheetItem(
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
subtitle = "Marks snaps as seen",
title = translation[markKey],
subtitle = translation["mark_as_seen_subtitle"],
icon = Icons.Rounded.RemoveRedEye
) {
if (messagingBridge == null) {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return@ActionsSheetItem
}
@@ -924,8 +909,8 @@ class MessagingPreview: Routes.Route() {
runCurrentTask()
}
ActionsSheetItem(
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
title = translation[deleteKey],
subtitle = if (hasSelection) translation["delete_selected_messages_subtitle"] else translation["delete_by_content_type_subtitle"],
icon = Icons.Rounded.DeleteForever,
danger = true
) {
@@ -933,7 +918,7 @@ class MessagingPreview: Routes.Route() {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return@ActionsSheetItem
}

View File

@@ -237,7 +237,7 @@ class SocialRootSection : Routes.Route() {
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"]
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
@@ -520,7 +520,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.social"] ?: "Social",
text = translation["manager.routes.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -631,7 +631,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
text = translation["social_empty_hint"],
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)

View File

@@ -148,7 +148,7 @@ class FriendTrackerCatalog : Routes.Route() {
contentColor = Color.White
)
) {
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
Text(translation["manage_repos_description"] ?: context.translation["manager.routes.manage_friend_tracker_repos"])
}
}
)
@@ -176,7 +176,7 @@ class FriendTrackerCatalog : Routes.Route() {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
}
}
}
@@ -341,7 +341,7 @@ class FriendTrackerCatalog : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
title = translation["title"],
onBack = { routes.navController.popBackStack() },
actions = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {

View File

@@ -115,7 +115,7 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
}
}
}.onFailure {
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
}
}) {

View File

@@ -78,7 +78,7 @@ class FriendTrackerConfigImportScreen : Routes.Route() {
routes.onRuleImported?.invoke()
routes.navController.popBackStack()
}.onFailure {
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: "Unknown")))
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
}) {
Text(translation["confirm_button"])

View File

@@ -245,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -282,7 +282,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

@@ -16,10 +16,14 @@ class TrackerConfigParser(private val context: Routes.Route) {
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
val featureMap = mutableMapOf<String, MutableList<ImportedFeature>>()
val exportedData = context.context.gson.fromJson(configJson, ExportedTrackerData::class.java)
val authorLabel = context.translation["tracker_author_label"]
val enabledLabel = context.translation["tracker_enabled_label"]
val enabledValue = context.translation["tracker_enabled_value"]
val disabledValue = context.translation["tracker_disabled_value"]
exportedData.rules.forEach { rule ->
val features = mutableListOf<ImportedFeature>()
features.add(ImportedFeature(rule.name, "Author", "author", rule.author ?: "Unknown", 0))
features.add(ImportedFeature(rule.name, "Enabled", "enabled", rule.enabled, 0))
features.add(ImportedFeature(rule.name, authorLabel, "author", rule.author ?: context.context.translation["common.unknown"], 0))
features.add(ImportedFeature(rule.name, enabledLabel, "enabled", if (rule.enabled) enabledValue else disabledValue, 0))
rule.events?.forEach { event ->
features.add(ImportedFeature(rule.name, context.context.translation["tracker_events.${event.eventType}"], event.eventType, event.actions.joinToString(", ") { context.context.translation["tracker_actions.${it.key}"] }, 1))
}
@@ -30,7 +34,7 @@ class TrackerConfigParser(private val context: Routes.Route) {
fun parseValue(featureKey: String, value: Any): Any {
return when (value) {
is Boolean -> if (value) "Enabled" else "Disabled"
is Boolean -> if (value) context.translation["tracker_enabled_value"] else context.translation["tracker_disabled_value"]
is JSONArray -> {
val list = mutableListOf<String>()
for (i in 0 until value.length()) {

View File

@@ -57,8 +57,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
@@ -497,7 +499,7 @@ class AlertDialogs(
currentColor = null
}
},
label = { Text(text = "Hex Color") },
label = { Text(text = translation["dialogs.hex_color_label"]) },
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
@@ -592,6 +594,8 @@ class AlertDialogs(
property: PropertyPair<*>,
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
locationSearchProvider: String = "osm",
googleMapsApiKey: String = "",
saveCoordinates: (() -> Unit)? = null,
dismiss: () -> Unit = {}
) {
@@ -611,7 +615,20 @@ class AlertDialogs(
MapView(context).apply {
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setTileSource(TileSourceFactory.MAPNIK)
val tileSource = if (locationSearchProvider == "google_maps") {
object : OnlineTileSourceBase(
"GoogleMaps",
0, 19, 256, ".png",
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
}
}
} else {
TileSourceFactory.MAPNIK
}
setTileSource(tileSource)
val startPoint = GeoPoint(coordinates.first, coordinates.second)
controller.setZoom(10.0)
@@ -686,28 +703,56 @@ class AlertDialogs(
val resultsScrollState = rememberScrollState()
suspend fun search() {
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
.header("User-Agent", Constants.USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) {
return@use
if (locationSearchProvider == "google_maps") {
// Google Maps Search
okHttpClient.newCall(Request.Builder()
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
.appendQueryParameter("address", locationName)
.appendQueryParameter("key", googleMapsApiKey)
.build().toString())
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
if (jsonResponse.has("results")) {
val results = jsonResponse.getAsJsonArray("results")
addressResults = results.take(5).map { jsonElement ->
val result = jsonElement.asJsonObject
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
Triple(
result.get("formatted_address").asString,
geometry.get("lat").asString,
geometry.get("lng").asString
)
}
}
}
}
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
} else {
// OSM Nominatim Search (Existing Logic)
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
.appendQueryParameter("q", locationName)
.appendQueryParameter("format", "jsonv2")
.build().toString())
.header("User-Agent", Constants.OSM_USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
}
}
}
}
searchJob = null
}
@@ -771,7 +816,7 @@ class AlertDialogs(
color = Color.White
)
Text(
text = "Search or tap on the map",
text = betterLocationTranslation["search_or_tap_map_hint"],
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
@@ -825,11 +870,11 @@ class AlertDialogs(
search()
}
},
placeholder = { Text(text = "Search location...") },
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search",
contentDescription = betterLocationTranslation["search_icon_description"],
tint = Color.White
)
},
@@ -949,7 +994,7 @@ class AlertDialogs(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Searching...",
text = betterLocationTranslation["searching_label"],
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
@@ -1097,7 +1142,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lat.value,
onValueChange = { lat.value = it },
label = { Text(text = "Latitude") },
label = { Text(text = translation["latitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
@@ -1117,7 +1162,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lon.value,
onValueChange = { lon.value = it },
label = { Text(text = "Longitude") },
label = { Text(text = translation["longitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("270").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.2").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("272").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.

View File

@@ -1,3 +1,28 @@
## v1.3.2
- ⚠️ ALL USERS ARE REQUESTED TO UPDATE TO THIS VERSION AS SOON AS POSSIBLE!⚠️
- Fix: Crucial fixes
- Fix: Media File Picker and added mapper(tq to RSR)
## v1.3.0
- New: Bulk Manage Friend Requests(tq to RSR)
- Fix: Bulk Action Progress Dialogs(tq to RSR)
- Fix: Audio note send override(tq to RSR)
- Fix: Improve PurrAura Active indicator(tq to RSR)
- Fix: Prevent Story Rewatch(tq to Brandon)
- Fix: Location search(tq to Brandon)
- Fix: story download & decryption(tq to Hazelnut)
- New: Add confirmation dialogs for Reset PurrfectSnap option
- New: Google Maps Integration(tq to Brandon)
- Fix: support more translation keys
- Fix: several inconsistencies
- Fix: add proper support for scripts
- New: Swap wiki with new website
- Fix: Custom emoji is now stable!
- Fix: Poor Call recorder quality
- New: order feature for chat exports
- Fix: Gemini Auto Reply(tq to RSR)
- New: Spotlight Creator Info(tq to RSR)
## v1.2.6
- Fix: All translations are now high quality and up to date

View File

@@ -46,6 +46,8 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.androidx.documentfile)
implementation(libs.rhino)
implementation(libs.kavaref.core)
implementation(libs.kavaref.extension)
implementation(libs.rhino.android) {
exclude(group = "org.mozilla", module = "rhino-runtime")
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,8 @@
"dialogs": {
"select_language": "Select Language",
"save_folder": "Choose where to save downloads",
"select_save_folder_button": "Select Folder"
"select_save_folder_button": "Select Folder",
"hex_color_label": "Hex Colour"
},
"install_mode": {
"confirm_timeout": "I understand ({seconds}s)",
@@ -196,7 +197,7 @@
},
"sections": {
"home": {
"version_title": "v{versionName} \u00b7 by Eternal",
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
"update_title": "PurrfectSnap Update",
"update_content": "Version {version} is available!",
"update_button": "Download",
@@ -226,14 +227,27 @@
"quick_actions_empty_subtitle": "Design your dream grid with the actions you use the most.",
"quick_actions_add_tile_button": "Add tile",
"quick_actions_manage_button": "Manage",
"quick_actions_count_label": "{count} curated shortcuts"
"quick_actions_count_label": "{count} curated shortcuts",
"enabled": "Enabled",
"disabled": "Disabled",
"changelog_dialog_title": "Changelog",
"changelog_dialog_update_button": "Update",
"changelog_dialog_cancel_button": "Cancel",
"changelog_dialog_loading": "Loading changelog...",
"changelog_dialog_error": "Failed to load changelog",
"changelog_dialog_empty": "Changelog not available",
"announcements_dialog_title": "Announcements",
"announcements_dialog_close_button": "Close",
"announcements_dialog_loading": "Loading announcements...",
"announcements_dialog_error": "Failed to load announcements",
"announcements_dialog_empty": "Announcements not available"
},
"home_about": {
"about_title": "PurrfectSnap",
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by IzTIzRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, SUJI>L, Zain & scrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
@@ -241,6 +255,7 @@
},
"home_logs": {
"no_logs_hint": "No logs available",
"refresh_hint": "Pull to refresh or trigger an action to see new entries.",
"clear_logs_button": "Clear Logs",
"export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...",
@@ -255,8 +270,11 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
"message_logger_import_title": "Import message logger",
"message_logger_import_text": "Importing will override your current message logger database. Continue?",
"ui_settings_title": "UI Settings",
"haptic_feedback_label": "Haptic Feedback",
"use_system_toasts_label": "Use System Toasts",
@@ -287,16 +305,29 @@
"customize_bottom_bar_title": "Customise Bottom Bar",
"customize_bottom_bar_subtitle": "Pick which tabs show on your home screen",
"available_tabs_title": "Available Tabs",
"reset_setup_title": "Reset PurrfectSnap",
"reset_setup_action": "Reset and restart setup",
"reset_setup_dialog_title": "Are you sure?",
"reset_setup_dialog_text": "This will reset PurrfectSnap and restart setup.",
"reset_button": "Reset",
"done_button": "Done",
"clear_friend_feed": "Clear Friend Feed",
"test_mode_label": "Enable PurrAura",
"purr_aura_disable_title": "Are you sure?",
"purr_aura_disable_text": "Doing this will put your account at risk and cause bans!",
"disable_feature_loading_label": "Disable Feature Loading",
"disable_auto_mapper_label": "Disable Auto Mapper",
"disable_bypass_indicator_label": "Disable Bypass Indicator",
"open_file_failed_toast": "Failed to open file! {message}",
"import_failed_toast": "Import failed: {message}"
},
"retro_flight": {
"title": "Retro Flight",
"game_over_label": "GAME OVER",
"restart_button": "RESTART",
"left_button": "LEFT",
"right_button": "RIGHT"
},
"tasks": {
"no_tasks": "No tasks",
"merge_button": "Merge",
@@ -312,7 +343,7 @@
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
"features": {
"features": {
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
@@ -324,10 +355,11 @@
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"search_button": "Search",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
},
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
},
"bypass_status": {
"active": "PurrAura Active",
"inactive": "PurrAura Inactive"
@@ -342,7 +374,9 @@
"blacklist_state_subtext": "Everyone except {count} friends/groups will be affected by this rule",
"blacklist_state_button": "Select excluded friends/groups",
"clear_list_button": "Clear friends/groups list",
"dialog_clear_confirmation_text": "Are you sure you want to clear the list?"
"dialog_clear_confirmation_text": "Are you sure you want to clear the list?",
"dialog_clear_confirm_button": "Clear",
"dialog_clear_cancel_button": "Cancel"
},
"social": {
"friends_tab": "Friends",
@@ -358,6 +392,7 @@
"social_empty_hint": "Tap the + button to sync friends or groups."
},
"manage_scope": {
"manage_scope_title": "Manage",
"logged_stories_button": "Show Logged Stories",
"e2ee_title": "End-to-End Encryption",
"e2ee_subtitle": "Manage your shared key for this friend.",
@@ -388,16 +423,27 @@
"bridge_init_failed": "Failed to initialise messaging bridge. Make sure Snapchat is running in the background",
"message_fetch_failed": "Failed to fetch messages",
"no_message_hint": "No message",
"sender_unknown": "Unknown",
"sender_you": "You",
"sender_friend": "Friend",
"subtitle": "Hold to select",
"actions_title": "Conversation Actions",
"choose_message_types_subtitle": "Choose message types",
"save_selection_option": "Save Selection",
"save_all_option": "Save All",
"save_selected_messages_subtitle": "Save selected messages",
"save_by_content_type_subtitle": "Save by content type",
"unsave_selection_option": "Unsave Selection",
"unsave_all_option": "Unsave All",
"unsave_selected_messages_subtitle": "Unsave selected messages",
"unsave_by_content_type_subtitle": "Unsave by content type",
"mark_selection_as_seen_option": "Mark selected Snap as seen",
"mark_all_as_seen_option": "Mark all Snaps as seen",
"mark_as_seen_subtitle": "Marks snaps as seen",
"delete_selection_option": "Delete Selection",
"delete_all_option": "Delete All",
"delete_selected_messages_subtitle": "Delete selected messages",
"delete_by_content_type_subtitle": "Delete by content type",
"processed_message_toast": "Processed {count} messages",
"processed_messages_toast": "Processed {count} messages",
"processed_messages_text": "Processed {count}",
@@ -430,6 +476,10 @@
"longitude_dialog_hint": "Longitude",
"save_dialog_button": "Save",
"choose_location_button": "Pick a Location",
"search_or_tap_map_hint": "Search or tap on the map",
"search_location_placeholder": "Search location...",
"search_icon_description": "Search",
"searching_label": "Searching...",
"manual_coordinates_hint": "Fine-tune the coordinates manually.",
"saved_coordinates_subtitle": "Manage your saved spoof locations",
"teleport_to_friend_button": "Teleport to Friend",
@@ -442,7 +492,13 @@
"teleport_to_friend_title": "Teleport to Friend",
"search_bar": "Search",
"no_friends_map": "No friends on the map",
"no_friends_found": "No friends found"
"no_friends_found": "No friends found",
"include_saved_locations": "Include Saved Locations",
"include_saved_locations_description": "Export your saved location coordinates",
"location_search_provider_title": "Location Search Provider",
"google_maps_api_key_title": "Google Maps API Key",
"option_osm": "OpenStreetMap (Free)",
"option_google_maps": "Google Maps"
}
},
"dialogs": {
@@ -481,7 +537,8 @@
},
"file_imports": {
"no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section",
"settings_select_file_hint": "Select an imported file"
"settings_select_file_hint": "Select an imported file",
"settings_select_file_subtitle": "Choose a file from your imported files list"
}
},
"scripting": {
@@ -512,6 +569,8 @@
"no_settings_for_module": "This module does not have any settings",
"open_module_failed": "Failed to open module file",
"open_scripts_folder_button": "Open scripts folder",
"loaded_script": "Loaded script {name}",
"unloaded_script": "Unloaded script {name}",
"script_already_installed": "Script already installed",
"select_folder_button": "Choose Folder",
"select_scripts_folder_toast": "Please select a scripts folder first",
@@ -523,6 +582,7 @@
"no_repos_added": "No repositories added",
"repo_list_info": "Find repositories here:",
"link_text": "Repository list",
"loading": "Loading catalogue...",
"script_already_installed": "Script already installed",
"script_downloaded": "Script downloaded",
"could_not_create_file": "Could not create file",
@@ -579,6 +639,7 @@
"scope_blacklist": "Everyone except",
"events_section_title": "Events",
"events_suffix": "events",
"scopes_suffix": "scopes",
"no_events_text": "No events added yet",
"add_event_dialog_title": "Add Event",
"event_type_label": "Event Type",
@@ -622,6 +683,10 @@
"save_button": "Save",
"back_button_description": "Go back",
"expand_button_description": "Expand or collapse category",
"tracker_author_label": "Author",
"tracker_enabled_label": "Enabled",
"tracker_enabled_value": "Enabled",
"tracker_disabled_value": "Disabled",
"exported_toast": "Tracker configuration exported",
"export_failed_toast": "Failed to export tracker: {message}"
},
@@ -630,6 +695,10 @@
"confirm_button": "Import",
"back_button_description": "Go back",
"expand_button_description": "Expand or collapse category",
"tracker_author_label": "Author",
"tracker_enabled_label": "Enabled",
"tracker_enabled_value": "Enabled",
"tracker_disabled_value": "Disabled",
"imported_toast": "Tracker imported",
"import_failed_toast": "Failed to import tracker: {message}"
},
@@ -830,90 +899,7 @@
"ban_risk": "\u26a0 This feature may cause bans",
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour"
},
"options": {
"empty": "Empty",
"walk_radius": {
"empty": "Empty"
},
"spoof_battery_level": {
"empty": "Empty"
},
"custom_android_id": {
"empty": "Empty"
},
"custom_streaks_expiration_format": {
"empty": "Empty"
},
"preferred_transcription_lang": {
"empty": "Empty"
},
"custom_emoji_font": {
"empty": "Empty"
},
"custom_shared_library": {
"empty": "Empty"
},
"custom_resolution": {
"empty": "Empty"
},
"custom_path_format": {
"empty": "Empty"
},
"custom_video_codec": {
"empty": "Empty"
},
"custom_audio_codec": {
"empty": "Empty"
},
"double_tap_chat_action_custom_emoji": {
"empty": "Empty"
},
"unsaveable_messages": {
"blacklist": "Blacklist mode",
"whitelist": "Whitelist mode",
"null": "Disabled"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
}
},
"properties": {
"global": {
"name": "Global",
"description": "General module preferences and defaults",
"properties": {
"ui_settings": {
"name": "UI Settings",
"description": "Tune feedback and toast behaviour",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback",
"description": "Vibrate on supported interactions"
},
"use_system_toasts": {
"name": "Use System Toasts",
"description": "Show Android toasts instead of in-app overlays"
}
}
},
"update_settings": {
"name": "Update Settings",
"description": "Control automatic update checks",
"properties": {
"auto_update_check": {
"name": "Auto Update Check",
"description": "Check for new builds automatically"
},
"update_check_frequency": {
"name": "Update Check Frequency",
"description": "How often to check for updates"
}
}
}
}
},
"downloader": {
"name": "Downloader",
"description": "Download Snapchat Media",
@@ -1231,7 +1217,7 @@
},
"call_start_confirmation": {
"name": "Call Start Confirmation",
"description": "Shows a confirmation dialogue when starting a call"
"description": "Shows a confirmation dialog when starting a call"
},
"unlimited_conversation_pinning": {
"name": "Unlimited Conversation Pinning",
@@ -1373,7 +1359,7 @@
},
"include_camera_snaps": {
"name": "Include Camera Snaps",
"description": "Also show the override dialogue for camera snaps"
"description": "Also show the override dialog for camera snaps"
}
}
},
@@ -1497,7 +1483,7 @@
"properties": {
"friendSpecificGreeting": {
"name": "Friend Specific Greeting",
"description": "Add friend's name to auto-replies for personalisation"
"description": "Add friend's name to auto-replies for personalization"
},
"friendGreeting": {
"name": "Friend Greeting",
@@ -1675,6 +1661,34 @@
"name": "Global",
"description": "Tweak Global Snapchat Settings",
"properties": {
"ui_settings": {
"name": "UI Settings",
"description": "Tune feedback and toast behavior",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback",
"description": "Vibrate on supported interactions"
},
"use_system_toasts": {
"name": "Use System Toasts",
"description": "Show Android toasts instead of in-app overlays"
}
}
},
"update_settings": {
"name": "Update Settings",
"description": "Control automatic update checks",
"properties": {
"auto_update_check": {
"name": "Auto Update Check",
"description": "Check for new builds automatically"
},
"update_check_frequency": {
"name": "Update Check Frequency",
"description": "How often to check for updates"
}
}
},
"better_location": {
"name": "Better Location",
"description": "Enhances the Snapchat Location",
@@ -1683,6 +1697,14 @@
"name": "Spoof Location",
"description": "Spoofs your location to a specified one"
},
"location_search_provider": {
"name": "Location Search Provider",
"description": "Choose the provider for searching locations"
},
"google_maps_api_key": {
"name": "Google Maps API Key",
"description": "Required if using Google Maps provider"
},
"coordinates": {
"name": "Coordinates",
"description": "Set the coordinates of the spoofed location"
@@ -1743,26 +1765,6 @@
"name": "Auto Updater",
"description": "Automatically checks for new updates"
},
"update_settings": {
"name": "Update Settings",
"description": "Control how PurrfectSnap checks for updates",
"properties": {
"auto_update_check": {
"name": "Auto Update Check"
},
"update_check_frequency": {
"name": "Update Check Frequency"
}
}
},
"ui_settings": {
"name": "UI Settings",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
}
}
},
"disable_metrics": {
"name": "Disable Metrics",
"description": "Blocks sending specific analytic data to Snapchat"
@@ -1772,7 +1774,7 @@
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
},
"block_ads": {
"name": "Block Ads",
"name": "Block Adverts",
"description": "Prevents Advertisements from being displayed"
},
"disable_custom_tabs": {
@@ -1795,6 +1797,10 @@
"name": "Spotlight Comments Username Icon",
"description": "Choose which icon is displayed next to usernames in Spotlight comments"
},
"spotlight_creator_info": {
"name": "Spotlight Creator Info",
"description": "Show an info button on Spotlight/Discover snaps to view creator display name, username, and user ID"
},
"bypass_video_length_restriction": {
"name": "Bypass Video Length Restrictions",
"description": "Single: sends a single video\nSplit: split videos after editing"
@@ -2128,7 +2134,7 @@
},
"custom_streaks_expiration_format": {
"name": "Custom Streaks Expiration Format",
"description": "Customises the Streaks Expiration format\n\nAvailable variables:\n - %c: Streaks Count\n - %e: Hourglass Emoji\n - %d: Days\n - %h: Hours\n - %m: Minutes\n - %s: Seconds\n - %w: Remaining Time"
"description": "Customizes the Streaks Expiration format\n\nAvailable variables:\n - %c: Streaks Count\n - %e: Hourglass Emoji\n - %d: Days\n - %h: Hours\n - %m: Minutes\n - %s: Seconds\n - %w: Remaining Time"
},
"prevent_forced_logout": {
"name": "Prevent Forced Logout",
@@ -2190,6 +2196,16 @@
}
},
"options": {
"empty": "Empty",
"location_search_provider": {
"osm": "OpenStreetMap (Free)",
"google_maps": "Google Maps"
},
"unsaveable_messages": {
"blacklist": "Blacklist mode",
"whitelist": "Whitelist mode",
"null": "Disabled"
},
"app_appearance": {
"always_light": "Always Light",
"always_dark": "Always Dark",
@@ -2447,6 +2463,9 @@
"null": "Automatic"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"null": "Auto"
},
"snapchat_plus": {
@@ -2831,8 +2850,13 @@
"clear_conversations": "Clear Conversations",
"clear_friend_feed": "Clear Friend Feed ({count})",
"unfollow": "Unfollow",
"remove": "Remove"
"remove": "Remove",
"accept": "Accepted",
"ignore": "Ignored"
},
"accept_requests": "Accept requests",
"ignore_requests": "Ignore requests",
"cleared_from_feed": "Cleared from feed",
"leave_groups": "Leave {count} groups",
"left_group_success": "Left group successfully",
"failed_to_leave_group": "Failed to leave group: {error}",
@@ -2871,6 +2895,8 @@
"non_streaks": "Non Streaks",
"followed": "Followed",
"following": "Following",
"incoming": "Friend requests",
"incoming_follower": "Follower requests",
"location_on_map": "Location on Map"
},
"sort_options": {
@@ -2890,6 +2916,9 @@
"text_field_selection": "{amount} selected",
"text_field_selection_all": "All",
"export_file_format_title": "Export File Format",
"sort_order_title": "Message Order",
"sort_order_newest_to_oldest": "Newest to Oldest",
"sort_order_oldest_to_newest": "Oldest to Newest",
"message_type_filter_title": "Filter Messages by Type",
"amount_of_messages_title": "Message Count (leave blank for all)",
"download_medias_title": "Download Media"
@@ -2901,7 +2930,7 @@
"processing_chats": "Processing {amount} conversations...",
"export_fail": "Failed to export conversation {conversation}",
"writing_output": "Writing output...",
"finished": "Done! You now can close this dialogue.",
"finished": "Done! You now can close this dialog.",
"no_messages_found": "No messages found!",
"exporting_message": "Exporting {conversation}..."
},
@@ -2913,6 +2942,7 @@
"save": "Save",
"open": "Open",
"download": "Download",
"import": "Import",
"send": "Send",
"restore_original": "Restore Original",
"convert_external_media": "Convert External Media"
@@ -3198,6 +3228,25 @@
"select_date_first": "Please select a date",
"invalid_time": "Please select a future time"
},
"spotlight_creator_info": {
"title": "Creator Info",
"close": "Close",
"creator_info": "Creator Info",
"display_name": "Display name",
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading…",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
"mutual_friend": "Mutual friend",
"following": "Following",
"friend_request_sent": "Request sent",
"friend_request_received": "Request received",
"blocked": "Blocked",
"friend_removed": "Removed"
},
"auto_reply_messages": {
"dialog": {
"add_message": "Add Message",
@@ -3452,17 +3501,22 @@
},
"common": {
"cancel": "Cancel",
"close": "Close",
"add": "Add",
"ok": "OK",
"quit": "Quit",
"done": "Done",
"back": "Back",
"unknown": "Unknown",
"unknown_error": "Unknown error",
"not_available": "N/A",
"added": "Added",
"no_friends_found": "No friends found",
"exporting_memories": "Exporting memories... ({failed} failed)"
},
"clear_friend_feed": "Clear Friend Feed",
"task_media_conversion_title": "Media conversion",
"task_call_recording_title": "Call Recording {author}",
"select_date": "Select Date",
"schedule_scheduled_for": "Scheduled for {name} in {time}",
"schedule_sending_in": "Sending in {time}",
@@ -3541,6 +3595,11 @@
"toast_open_snap_failed": "Failed to open snap",
"toast_mark_message_read_failed": "Failed to mark message as read. Check logs for more details",
"toast_open_conversation_first": "You must open a conversation first",
"conversation_toolbox": {
"title": "Conversation Toolbox",
"loaded_script": "Loaded Script",
"failed_to_load": "Failed to load: {message}"
},
"toast_open_link_failed": "Failed to open link",
"ai_response_style": {
"casual": "Casual",

View File

@@ -28,7 +28,8 @@
"dialogs": {
"select_language": "Select Language",
"save_folder": "Choose where to save downloads",
"select_save_folder_button": "Select Folder"
"select_save_folder_button": "Select Folder",
"hex_color_label": "Hex Color"
},
"install_mode": {
"confirm_timeout": "I understand ({seconds}s)",
@@ -196,7 +197,7 @@
},
"sections": {
"home": {
"version_title": "v{versionName} \u00b7 by Eternal",
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
"update_title": "PurrfectSnap Update",
"update_content": "Version {version} is available!",
"update_button": "Download",
@@ -226,14 +227,27 @@
"quick_actions_empty_subtitle": "Design your dream grid with the actions you use the most.",
"quick_actions_add_tile_button": "Add tile",
"quick_actions_manage_button": "Manage",
"quick_actions_count_label": "{count} curated shortcuts"
"quick_actions_count_label": "{count} curated shortcuts",
"enabled": "Enabled",
"disabled": "Disabled",
"changelog_dialog_title": "Changelog",
"changelog_dialog_update_button": "Update",
"changelog_dialog_cancel_button": "Cancel",
"changelog_dialog_loading": "Loading changelog...",
"changelog_dialog_error": "Failed to load changelog",
"changelog_dialog_empty": "Changelog not available",
"announcements_dialog_title": "Announcements",
"announcements_dialog_close_button": "Close",
"announcements_dialog_loading": "Loading announcements...",
"announcements_dialog_error": "Failed to load announcements",
"announcements_dialog_empty": "Announcements not available"
},
"home_about": {
"about_title": "PurrfectSnap",
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by IzTIzRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, SUJI>L, Zain & scrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
@@ -241,6 +255,7 @@
},
"home_logs": {
"no_logs_hint": "No logs available",
"refresh_hint": "Pull to refresh or trigger an action to see new entries.",
"clear_logs_button": "Clear Logs",
"export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...",
@@ -255,8 +270,11 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
"message_logger_import_title": "Import message logger",
"message_logger_import_text": "Importing will override your current message logger database. Continue?",
"ui_settings_title": "UI Settings",
"haptic_feedback_label": "Haptic Feedback",
"use_system_toasts_label": "Use System Toasts",
@@ -287,16 +305,29 @@
"customize_bottom_bar_title": "Customize Bottom Bar",
"customize_bottom_bar_subtitle": "Pick which tabs show on your home screen",
"available_tabs_title": "Available Tabs",
"reset_setup_title": "Reset PurrfectSnap",
"reset_setup_action": "Reset and restart setup",
"reset_setup_dialog_title": "Are you sure?",
"reset_setup_dialog_text": "This will reset PurrfectSnap and restart setup.",
"reset_button": "Reset",
"done_button": "Done",
"clear_friend_feed": "Clear Friend Feed",
"test_mode_label": "Enable PurrAura",
"purr_aura_disable_title": "Are you sure?",
"purr_aura_disable_text": "Doing this will put your account at risk and cause bans!",
"disable_feature_loading_label": "Disable Feature Loading",
"disable_auto_mapper_label": "Disable Auto Mapper",
"disable_bypass_indicator_label": "Disable Bypass Indicator",
"open_file_failed_toast": "Failed to open file! {message}",
"import_failed_toast": "Import failed: {message}"
},
"retro_flight": {
"title": "Retro Flight",
"game_over_label": "GAME OVER",
"restart_button": "RESTART",
"left_button": "LEFT",
"right_button": "RIGHT"
},
"tasks": {
"no_tasks": "No tasks",
"merge_button": "Merge",
@@ -313,18 +344,19 @@
"remove_all_tasks_confirm": "Remove all tasks?"
},
"features": {
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
},
@@ -342,7 +374,9 @@
"blacklist_state_subtext": "Everyone except {count} friends/groups will be affected by this rule",
"blacklist_state_button": "Select excluded friends/groups",
"clear_list_button": "Clear friends/groups list",
"dialog_clear_confirmation_text": "Are you sure you want to clear the list?"
"dialog_clear_confirmation_text": "Are you sure you want to clear the list?",
"dialog_clear_confirm_button": "Clear",
"dialog_clear_cancel_button": "Cancel"
},
"social": {
"friends_tab": "Friends",
@@ -358,6 +392,7 @@
"social_empty_hint": "Tap the + button to sync friends or groups."
},
"manage_scope": {
"manage_scope_title": "Manage",
"logged_stories_button": "Show Logged Stories",
"e2ee_title": "End-to-End Encryption",
"e2ee_subtitle": "Manage your shared key for this friend.",
@@ -388,16 +423,27 @@
"bridge_init_failed": "Failed to initialize messaging bridge. Make sure Snapchat is running in the background",
"message_fetch_failed": "Failed to fetch messages",
"no_message_hint": "No message",
"sender_unknown": "Unknown",
"sender_you": "You",
"sender_friend": "Friend",
"subtitle": "Hold to select",
"actions_title": "Conversation Actions",
"choose_message_types_subtitle": "Choose message types",
"save_selection_option": "Save Selection",
"save_all_option": "Save All",
"save_selected_messages_subtitle": "Save selected messages",
"save_by_content_type_subtitle": "Save by content type",
"unsave_selection_option": "Unsave Selection",
"unsave_all_option": "Unsave All",
"unsave_selected_messages_subtitle": "Unsave selected messages",
"unsave_by_content_type_subtitle": "Unsave by content type",
"mark_selection_as_seen_option": "Mark selected Snap as seen",
"mark_all_as_seen_option": "Mark all Snaps as seen",
"mark_as_seen_subtitle": "Marks snaps as seen",
"delete_selection_option": "Delete Selection",
"delete_all_option": "Delete All",
"delete_selected_messages_subtitle": "Delete selected messages",
"delete_by_content_type_subtitle": "Delete by content type",
"processed_message_toast": "Processed {count} messages",
"processed_messages_toast": "Processed {count} messages",
"processed_messages_text": "Processed {count}",
@@ -430,6 +476,10 @@
"longitude_dialog_hint": "Longitude",
"save_dialog_button": "Save",
"choose_location_button": "Pick a Location",
"search_or_tap_map_hint": "Search or tap on the map",
"search_location_placeholder": "Search location...",
"search_icon_description": "Search",
"searching_label": "Searching...",
"manual_coordinates_hint": "Fine-tune the coordinates manually.",
"saved_coordinates_subtitle": "Manage your saved spoof locations",
"teleport_to_friend_button": "Teleport to Friend",
@@ -442,7 +492,13 @@
"teleport_to_friend_title": "Teleport to Friend",
"search_bar": "Search",
"no_friends_map": "No friends on the map",
"no_friends_found": "No friends found"
"no_friends_found": "No friends found",
"include_saved_locations": "Include Saved Locations",
"include_saved_locations_description": "Export your saved location coordinates",
"location_search_provider_title": "Location Search Provider",
"google_maps_api_key_title": "Google Maps API Key",
"option_osm": "OpenStreetMap (Free)",
"option_google_maps": "Google Maps"
}
},
"dialogs": {
@@ -481,7 +537,8 @@
},
"file_imports": {
"no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section",
"settings_select_file_hint": "Select an imported file"
"settings_select_file_hint": "Select an imported file",
"settings_select_file_subtitle": "Choose a file from your imported files list"
}
},
"scripting": {
@@ -512,6 +569,8 @@
"no_settings_for_module": "This module does not have any settings",
"open_module_failed": "Failed to open module file",
"open_scripts_folder_button": "Open scripts folder",
"loaded_script": "Loaded script {name}",
"unloaded_script": "Unloaded script {name}",
"script_already_installed": "Script already installed",
"select_folder_button": "Choose Folder",
"select_scripts_folder_toast": "Please select a scripts folder first",
@@ -523,6 +582,7 @@
"no_repos_added": "No repositories added",
"repo_list_info": "Find repositories here:",
"link_text": "Repository list",
"loading": "Loading catalog...",
"script_already_installed": "Script already installed",
"script_downloaded": "Script downloaded",
"could_not_create_file": "Could not create file",
@@ -579,6 +639,7 @@
"scope_blacklist": "Everyone except",
"events_section_title": "Events",
"events_suffix": "events",
"scopes_suffix": "scopes",
"no_events_text": "No events added yet",
"add_event_dialog_title": "Add Event",
"event_type_label": "Event Type",
@@ -622,6 +683,10 @@
"save_button": "Save",
"back_button_description": "Go back",
"expand_button_description": "Expand or collapse category",
"tracker_author_label": "Author",
"tracker_enabled_label": "Enabled",
"tracker_enabled_value": "Enabled",
"tracker_disabled_value": "Disabled",
"exported_toast": "Tracker configuration exported",
"export_failed_toast": "Failed to export tracker: {message}"
},
@@ -630,6 +695,10 @@
"confirm_button": "Import",
"back_button_description": "Go back",
"expand_button_description": "Expand or collapse category",
"tracker_author_label": "Author",
"tracker_enabled_label": "Enabled",
"tracker_enabled_value": "Enabled",
"tracker_disabled_value": "Disabled",
"imported_toast": "Tracker imported",
"import_failed_toast": "Failed to import tracker: {message}"
},
@@ -830,90 +899,7 @@
"ban_risk": "\u26a0 This feature may cause bans",
"internal_behavior": "\u26a0 This may break Snapchat internal behavior"
},
"options": {
"empty": "Empty",
"walk_radius": {
"empty": "Empty"
},
"spoof_battery_level": {
"empty": "Empty"
},
"custom_android_id": {
"empty": "Empty"
},
"custom_streaks_expiration_format": {
"empty": "Empty"
},
"preferred_transcription_lang": {
"empty": "Empty"
},
"custom_emoji_font": {
"empty": "Empty"
},
"custom_shared_library": {
"empty": "Empty"
},
"custom_resolution": {
"empty": "Empty"
},
"custom_path_format": {
"empty": "Empty"
},
"custom_video_codec": {
"empty": "Empty"
},
"custom_audio_codec": {
"empty": "Empty"
},
"double_tap_chat_action_custom_emoji": {
"empty": "Empty"
},
"unsaveable_messages": {
"blacklist": "Blacklist mode",
"whitelist": "Whitelist mode",
"null": "Disabled"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
}
},
"properties": {
"global": {
"name": "Global",
"description": "General module preferences and defaults",
"properties": {
"ui_settings": {
"name": "UI Settings",
"description": "Tune feedback and toast behavior",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback",
"description": "Vibrate on supported interactions"
},
"use_system_toasts": {
"name": "Use System Toasts",
"description": "Show Android toasts instead of in-app overlays"
}
}
},
"update_settings": {
"name": "Update Settings",
"description": "Control automatic update checks",
"properties": {
"auto_update_check": {
"name": "Auto Update Check",
"description": "Check for new builds automatically"
},
"update_check_frequency": {
"name": "Update Check Frequency",
"description": "How often to check for updates"
}
}
}
}
},
"downloader": {
"name": "Downloader",
"description": "Download Snapchat Media",
@@ -1683,6 +1669,34 @@
"name": "Global",
"description": "Tweak Global Snapchat Settings",
"properties": {
"ui_settings": {
"name": "UI Settings",
"description": "Tune feedback and toast behavior",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback",
"description": "Vibrate on supported interactions"
},
"use_system_toasts": {
"name": "Use System Toasts",
"description": "Show Android toasts instead of in-app overlays"
}
}
},
"update_settings": {
"name": "Update Settings",
"description": "Control automatic update checks",
"properties": {
"auto_update_check": {
"name": "Auto Update Check",
"description": "Check for new builds automatically"
},
"update_check_frequency": {
"name": "Update Check Frequency",
"description": "How often to check for updates"
}
}
},
"better_location": {
"name": "Better Location",
"description": "Enhances the Snapchat Location",
@@ -1691,6 +1705,14 @@
"name": "Spoof Location",
"description": "Spoofs your location to a specified one"
},
"location_search_provider": {
"name": "Location Search Provider",
"description": "Choose the provider for searching locations"
},
"google_maps_api_key": {
"name": "Google Maps API Key",
"description": "Required if using Google Maps provider"
},
"coordinates": {
"name": "Coordinates",
"description": "Set the coordinates of the spoofed location"
@@ -1751,26 +1773,6 @@
"name": "Auto Updater",
"description": "Automatically checks for new updates"
},
"update_settings": {
"name": "Update Settings",
"description": "Control how PurrfectSnap checks for updates",
"properties": {
"auto_update_check": {
"name": "Auto Update Check"
},
"update_check_frequency": {
"name": "Update Check Frequency"
}
}
},
"ui_settings": {
"name": "UI Settings",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
}
}
},
"disable_metrics": {
"name": "Disable Metrics",
"description": "Blocks sending specific analytic data to Snapchat"
@@ -1803,6 +1805,10 @@
"name": "Spotlight Comments Username Icon",
"description": "Choose which icon is displayed next to usernames in Spotlight comments"
},
"spotlight_creator_info": {
"name": "Spotlight Creator Info",
"description": "Show an info button on Spotlight/Discover snaps to view creator display name, username, and user ID"
},
"bypass_video_length_restriction": {
"name": "Bypass Video Length Restrictions",
"description": "Single: sends a single video\nSplit: split videos after editing"
@@ -2198,6 +2204,16 @@
}
},
"options": {
"empty": "Empty",
"location_search_provider": {
"osm": "OpenStreetMap (Free)",
"google_maps": "Google Maps"
},
"unsaveable_messages": {
"blacklist": "Blacklist mode",
"whitelist": "Whitelist mode",
"null": "Disabled"
},
"app_appearance": {
"always_light": "Always Light",
"always_dark": "Always Dark",
@@ -2455,6 +2471,9 @@
"null": "Automatic"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"null": "Auto"
},
"snapchat_plus": {
@@ -2904,6 +2923,9 @@
"text_field_selection": "{amount} selected",
"text_field_selection_all": "All",
"export_file_format_title": "Export File Format",
"sort_order_title": "Message Order",
"sort_order_newest_to_oldest": "Newest to Oldest",
"sort_order_oldest_to_newest": "Oldest to Newest",
"message_type_filter_title": "Filter Messages by Type",
"amount_of_messages_title": "Message Count (leave blank for all)",
"download_medias_title": "Download Media"
@@ -2927,6 +2949,7 @@
"save": "Save",
"open": "Open",
"download": "Download",
"import": "Import",
"send": "Send",
"restore_original": "Restore Original",
"convert_external_media": "Convert External Media"
@@ -3212,6 +3235,25 @@
"select_date_first": "Please select a date",
"invalid_time": "Please select a future time"
},
"spotlight_creator_info": {
"title": "Creator Info",
"close": "Close",
"creator_info": "Creator Info",
"display_name": "Display name",
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading…",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
"mutual_friend": "Mutual friend",
"following": "Following",
"friend_request_sent": "Request sent",
"friend_request_received": "Request received",
"blocked": "Blocked",
"friend_removed": "Removed"
},
"auto_reply_messages": {
"dialog": {
"add_message": "Add Message",
@@ -3466,17 +3508,22 @@
},
"common": {
"cancel": "Cancel",
"close": "Close",
"add": "Add",
"ok": "OK",
"quit": "Quit",
"done": "Done",
"back": "Back",
"unknown": "Unknown",
"unknown_error": "Unknown error",
"not_available": "N/A",
"added": "Added",
"no_friends_found": "No friends found",
"exporting_memories": "Exporting memories... ({failed} failed)"
},
"clear_friend_feed": "Clear Friend Feed",
"task_media_conversion_title": "Media conversion",
"task_call_recording_title": "Call Recording {author}",
"select_date": "Select Date",
"schedule_scheduled_for": "Scheduled for {name} in {time}",
"schedule_sending_in": "Sending in {time}",
@@ -3555,6 +3602,11 @@
"toast_open_snap_failed": "Failed to open snap",
"toast_mark_message_read_failed": "Failed to mark message as read. Check logs for more details",
"toast_open_conversation_first": "You must open a conversation first",
"conversation_toolbox": {
"title": "Conversation Toolbox",
"loaded_script": "Loaded Script",
"failed_to_load": "Failed to load: {message}"
},
"toast_open_link_failed": "Failed to open link",
"ai_response_style": {
"casual": "Casual",
@@ -3593,4 +3645,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
package me.eternal.purrfectsnap.common.util.ktx;
import com.highcapable.kavaref.KavaRef;
import com.highcapable.kavaref.condition.FieldCondition;
import com.highcapable.kavaref.resolver.FieldResolver;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
import java.util.ArrayList;
import java.util.List;
public final class KavaRefFieldBridge {
private KavaRefFieldBridge() {}
private static FieldResolver<Object> resolveField(Object instance, String fieldName) {
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
FieldResolver<Object> resolver = scope.firstFieldOrNull(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.name(fieldName);
return Unit.INSTANCE;
}
});
if (resolver != null) return resolver.of(instance);
current = current.getSuperclass();
}
return null;
}
public static Object getField(Object instance, String fieldName) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
return resolver.get();
}
public static void setField(Object instance, String fieldName, Object value) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
resolver.set(value);
}
public static String[] getAllFieldNames(Object instance) {
List<String> result = new ArrayList<>();
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
List<FieldResolver<Object>> fields = scope.field(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
return Unit.INSTANCE;
}
});
for (FieldResolver<Object> field : fields) result.add(field.getSelf().getName());
current = current.getSuperclass();
}
return result.toArray(new String[0]);
}
}

View File

@@ -4,4 +4,5 @@ object Constants {
val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android"
val MODULE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.3"
const val OSM_USER_AGENT = "PurrfectSnap/1.0"
}

View File

@@ -74,11 +74,12 @@ open class ConfigContainer(
params: ConfigParamsBuilder = {}
) = registerProperty(key, DataProcessors.INT_COLOR, PropertyValue(defaultValue, defaultValues = defaultValue?.let { listOf(it) }), params)
fun toJson(exportSensitiveData: Boolean = true): JsonObject {
fun toJson(exportSensitiveData: Boolean = true, includeSavedLocations: Boolean = true): JsonObject {
val json = JsonObject()
properties.forEach { (propertyKey, propertyValue) ->
if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach
val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData) }
if (!includeSavedLocations && propertyKey.dataType.type == DataProcessors.Type.MAP_COORDINATES) return@forEach
val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) }
json.add(propertyKey.name, serializedValue)
}
return json

View File

@@ -22,17 +22,17 @@ object DataProcessors {
class PropertyDataProcessor<T>
internal constructor(
val type: Type,
private val serialize: (T, exportSensitiveData: Boolean) -> JsonElement,
private val serialize: (T, exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> JsonElement,
private val deserialize: (JsonElement) -> T
) {
@Suppress("UNCHECKED_CAST")
fun serializeAny(value: Any, exportSensitiveData: Boolean) = serialize(value as T, exportSensitiveData)
fun serializeAny(value: Any, exportSensitiveData: Boolean, includeSavedLocations: Boolean) = serialize(value as T, exportSensitiveData, includeSavedLocations)
fun deserializeAny(value: JsonElement) = deserialize(value)
}
val STRING = PropertyDataProcessor(
type = Type.STRING,
serialize = { it, _ ->
serialize = { it, _, _ ->
if (it != null) JsonPrimitive(it)
else JsonNull.INSTANCE
},
@@ -44,7 +44,7 @@ object DataProcessors {
val BOOLEAN = PropertyDataProcessor(
type = Type.BOOLEAN,
serialize = { it, _ ->
serialize = { it, _, _ ->
if (it) JsonPrimitive(true)
else JsonPrimitive(false)
},
@@ -53,19 +53,19 @@ object DataProcessors {
val INTEGER = PropertyDataProcessor(
type = Type.INTEGER,
serialize = { it, _ -> JsonPrimitive(it) },
serialize = { it, _, _ -> JsonPrimitive(it) },
deserialize = { it.asInt },
)
val FLOAT = PropertyDataProcessor(
type = Type.FLOAT,
serialize = { it, _ -> JsonPrimitive(it) },
serialize = { it, _, _ -> JsonPrimitive(it) },
deserialize = { it.asFloat },
)
val STRING_MULTIPLE_SELECTION = PropertyDataProcessor(
type = Type.STRING_MULTIPLE_SELECTION,
serialize = { it, _ -> JsonArray().apply { it.forEach { add(it) } } },
serialize = { it, _, _ -> JsonArray().apply { it.forEach { add(it) } } },
deserialize = { obj ->
obj.asJsonArray.map { it.asString }.toMutableList()
},
@@ -73,13 +73,13 @@ object DataProcessors {
val STRING_UNIQUE_SELECTION = PropertyDataProcessor(
type = Type.STRING_UNIQUE_SELECTION,
serialize = { it, _ -> JsonPrimitive(it) },
serialize = { it, _, _ -> JsonPrimitive(it) },
deserialize = { obj -> obj.takeIf { !it.isJsonNull }?.asString?.takeIf { it != "false" && it != "true" } }
)
val MAP_COORDINATES = PropertyDataProcessor(
type = Type.MAP_COORDINATES,
serialize = { it, _ ->
serialize = { it, _, _ ->
JsonObject().apply {
addProperty("lat", it.first.takeIf { it in -90.0..90.0 } ?: 0.0)
addProperty("lng", it.second.takeIf { it in -180.0..180.0 } ?: 0.0)
@@ -94,7 +94,7 @@ object DataProcessors {
val INT_COLOR = PropertyDataProcessor(
type = Type.INT_COLOR,
serialize = { it, _ ->
serialize = { it, _, _ ->
it?.let { JsonPrimitive(it) } ?: JsonNull.INSTANCE
},
deserialize = { if (it.isJsonNull) null else it.asString.toIntOrNull() },
@@ -102,10 +102,10 @@ object DataProcessors {
fun <T : ConfigContainer> container(container: T) = PropertyDataProcessor(
type = Type.CONTAINER,
serialize = { it, exportSensitiveData ->
serialize = { it, exportSensitiveData, includeSavedLocations ->
JsonObject().apply {
addProperty("state", it.globalState)
add("properties", it.toJson(exportSensitiveData))
add("properties", it.toJson(exportSensitiveData, includeSavedLocations))
}
},
deserialize = { obj ->

View File

@@ -3,7 +3,9 @@ package me.eternal.purrfectsnap.common.config
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.bridge.ConfigStateListener
import me.eternal.purrfectsnap.bridge.storage.FileHandleManager
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
@@ -60,10 +62,25 @@ class ModConfig(
fun exportToString(
exportSensitiveData: Boolean = true,
includeSavedLocations: Boolean = true,
savedLocations: List<LocationCoordinates>? = null,
config: RootConfig = root,
): String {
return gson.toJson(config.toJson(exportSensitiveData).apply {
return gson.toJson(config.toJson(exportSensitiveData, includeSavedLocations).apply {
addProperty("_locale", locale)
if (includeSavedLocations && savedLocations != null) {
add("_saved_locations", JsonArray().apply {
savedLocations.forEach { location ->
add(JsonObject().apply {
addProperty("id", location.id)
addProperty("name", location.name)
addProperty("latitude", location.latitude)
addProperty("longitude", location.longitude)
addProperty("radius", location.radius)
})
}
})
}
})
}
@@ -133,10 +150,17 @@ class ModConfig(
}
}
fun loadFromString(string: String) {
/**
* Loads config from a JSON string.
* @return JsonArray of saved locations if present in the JSON, null otherwise
*/
fun loadFromString(string: String): JsonArray? {
val configObject = gson.fromJson(string, JsonObject::class.java)
locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE
root.fromJson(configObject)
writeConfig()
// Return saved locations array if present (for caller to handle database import)
return configObject.getAsJsonArray("_saved_locations")
}
}

View File

@@ -20,15 +20,17 @@ class Global : ConfigContainer() {
}
inner class BetterLocationConfig : ConfigContainer(hasGlobalState = true) {
val spoofLocation = boolean("spoof_location")
val coordinates = mapCoordinates("coordinates", 0.0 to 0.0) { addFlags(ConfigFlag.SENSITIVE) } // lat, long
val walkRadius = string("walk_radius") { requireRestart(); inputCheck = { it.toDoubleOrNull()?.isFinite() == true && it.toDouble() >= 0.0 } }
val alwaysUpdateLocation = boolean("always_update_location") { requireRestart() }
val suspendLocationUpdates = boolean("suspend_location_updates")
val spoofBatteryLevel = string("spoof_battery_level") { requireRestart(); inputCheck = { it.isEmpty() || it.toIntOrNull() in 0..100 } }
val spoofHeadphones = boolean("spoof_headphones") { requireRestart() }
val showBatteryLevel = boolean("show_battery_level") { requireRestart() }
}
val spoofLocation = boolean("spoof_location")
val locationSearchProvider = unique("location_search_provider", "osm", "google_maps") { addFlags(ConfigFlag.NO_DISABLE_KEY) }
val googleMapsApiKey = string("google_maps_api_key") { addFlags(ConfigFlag.SENSITIVE) }
val coordinates = mapCoordinates("coordinates", 0.0 to 0.0) { addFlags(ConfigFlag.SENSITIVE) } // lat, long
val walkRadius = string("walk_radius") { requireRestart(); inputCheck = { it.toDoubleOrNull()?.isFinite() == true && it.toDouble() >= 0.0 } }
val alwaysUpdateLocation = boolean("always_update_location") { requireRestart() }
val suspendLocationUpdates = boolean("suspend_location_updates")
val spoofBatteryLevel = string("spoof_battery_level") { requireRestart(); inputCheck = { it.isEmpty() || it.toIntOrNull() in 0..100 } }
val spoofHeadphones = boolean("spoof_headphones") { requireRestart() }
val showBatteryLevel = boolean("show_battery_level") { requireRestart() }
}
inner class MediaUploadQualityConfig : ConfigContainer() {
val forceVideoUploadSourceQuality = boolean("force_video_upload_source_quality") { requireRestart() }
@@ -48,6 +50,7 @@ class Global : ConfigContainer() {
val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed")
val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() }
val spotlightCommentsUsernameIcon = string("spotlight_comments_username_icon", "[👤]") { requireRestart() }
val spotlightCreatorInfo = boolean("spotlight_creator_info") { requireRestart() }
val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices(
FeatureNotice.BAN_RISK); requireRestart() }
val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} }

View File

@@ -9,7 +9,6 @@ import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// key and iv are base64 encoded into url safe strings
data class MediaEncryptionKeyPair(
val key: String,
val iv: String,

View File

@@ -8,6 +8,7 @@ import me.eternal.purrfectsnap.common.scripting.bindings.BindingsContext
import me.eternal.purrfectsnap.common.scripting.impl.JavaInterfaces
import me.eternal.purrfectsnap.common.scripting.impl.Networking
import me.eternal.purrfectsnap.common.scripting.impl.Protobuf
import me.eternal.purrfectsnap.common.util.ktx.KavaRefFieldBridge
import me.eternal.purrfectsnap.common.scripting.ktx.contextScope
import me.eternal.purrfectsnap.common.scripting.ktx.putFunction
import me.eternal.purrfectsnap.common.scripting.ktx.scriptable
@@ -81,18 +82,18 @@ class JSModule(
val obj = args?.get(0) ?: return@putFunction Undefined.instance
val name = args[1].toString()
val value = args[2]
val field = obj.javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance
field.isAccessible = true
field.set(obj, value.toPrimitiveValue(lazy { field.type.name }))
runCatching {
KavaRefFieldBridge.getField(obj, name)?.javaClass?.name
}.getOrNull()?.let { typeName ->
KavaRefFieldBridge.setField(obj, name, value.toPrimitiveValue(lazy { typeName }))
} ?: return@putFunction Undefined.instance
Undefined.instance
}
moduleObject.putFunction("getField") { args ->
val obj = args?.get(0) ?: return@putFunction Undefined.instance
val name = args[1].toString()
val field = obj.javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance
field.isAccessible = true
field.get(obj)
runCatching { KavaRefFieldBridge.getField(obj, name) }.getOrNull() ?: Undefined.instance
}
moduleObject.putFunction("sleep") { args ->
@@ -141,9 +142,14 @@ class JSModule(
}
}
// Expose static fields if they can be read through class reflection.
clazz.declaredFields.filter { Modifier.isStatic(it.modifiers) }.forEach { field ->
field.isAccessible = true
defineProperty(field.name, { field.get(null) }, { value -> field.set(null, value) }, 0)
defineProperty(
field.name,
{ runCatching { field.get(null) }.getOrNull() },
{ value -> runCatching { field.set(null, value) } },
0
)
}
if (get("newInstance") == null) {

View File

@@ -2,7 +2,17 @@ package me.eternal.purrfectsnap.common.scripting.ui
import android.app.Activity
import android.app.AlertDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.scripting.bindings.AbstractBinding
import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide
import me.eternal.purrfectsnap.common.scripting.ktx.contextScope
@@ -125,13 +135,46 @@ class InterfaceManager : AbstractBinding("interface-manager", BindingSide.COMMON
@JSFunction fun createAlertDialog(activity: Activity, builder: (AlertDialog.Builder) -> Unit, callback: (interfaceBuilder: InterfaceBuilder, alertDialog: AlertDialog) -> Unit): AlertDialog {
return createComposeAlertDialog(activity, builder = builder) { alertDialog ->
ScriptInterface(interfaceBuilder = remember {
val interfaceBuilder = remember {
InterfaceBuilder().also {
contextScope {
callback(it, alertDialog)
}
}
})
}
val shape = RoundedCornerShape(22.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
brush = Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.52f),
Color(0xFF5FD8FF).copy(alpha = 0.30f)
)
),
shape = shape
),
color = Color.Transparent,
shape = shape
) {
androidx.compose.foundation.layout.Column(
modifier = Modifier
.background(
brush = Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.97f),
Color(0xFF1A143A).copy(alpha = 0.94f),
)
),
shape = shape
)
.padding(10.dp)
) {
ScriptInterface(interfaceBuilder = interfaceBuilder)
}
}
}
}
@@ -140,4 +183,4 @@ class InterfaceManager : AbstractBinding("interface-manager", BindingSide.COMMON
}
override fun getObject() = this
}
}

View File

@@ -1,18 +1,27 @@
package me.eternal.purrfectsnap.common.scripting.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.OutlinedButton
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.interaction.MutableInteractionSource
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.logger.AbstractLogger
import me.eternal.purrfectsnap.common.scripting.ui.components.Node
@@ -66,7 +75,7 @@ private fun DrawNode(node: Node) {
Text(
text = cachedAttributes["label"] as String,
fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp,
color = (cachedAttributes["color"] as? Long)?.let { Color(it) } ?: Color.Unspecified
color = (cachedAttributes["color"] as? Long)?.let { Color(it) } ?: Color.White
)
}
@@ -130,7 +139,15 @@ private fun DrawNode(node: Node) {
node.setAttribute("state", state)
(cachedAttributes["callback"] as? (Boolean) -> Unit)?.let { it(state) }
}
}
},
colors = androidx.compose.material3.SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF8C7BFF).copy(alpha = 0.66f),
uncheckedThumbColor = Color.White.copy(alpha = 0.9f),
uncheckedTrackColor = Color.White.copy(alpha = 0.20f),
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
)
)
}
NodeType.SLIDER -> {
@@ -148,26 +165,55 @@ private fun DrawNode(node: Node) {
},
valueRange = (cachedAttributes["min"] as Int).toFloat()..(cachedAttributes["max"] as Int).toFloat(),
steps = cachedAttributes["step"] as Int,
colors = SliderDefaults.colors(
thumbColor = Color(0xFF8C7BFF),
activeTrackColor = Color(0xFF8C7BFF).copy(alpha = 0.72f),
inactiveTrackColor = Color.White.copy(alpha = 0.20f),
)
)
}
NodeType.BUTTON -> {
OutlinedButton(onClick = {
runCallbackSafe {
(cachedAttributes["callback"] as? () -> Unit)?.let { it() }
}
}) {
NodeLabel()
val buttonShape = RoundedCornerShape(999.dp)
Box(
modifier = Modifier
.clip(buttonShape)
.background(Color.White.copy(alpha = 0.08f), buttonShape)
.border(1.dp, Color.White.copy(alpha = 0.20f), buttonShape)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {
runCallbackSafe {
(cachedAttributes["callback"] as? () -> Unit)?.let { it() }
}
}
)
.padding(horizontal = 16.dp, vertical = 9.dp),
contentAlignment = Alignment.Center
) {
Text(
text = cachedAttributes["label"] as String,
color = Color.White,
fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp,
fontWeight = FontWeight.SemiBold
)
}
}
NodeType.TEXT_INPUT -> {
var textInputValue by remember {
mutableStateOf(cachedAttributes["value"].toString())
}
TextField(
val inputShape = RoundedCornerShape(14.dp)
BasicTextField(
value = textInputValue,
readOnly = cachedAttributes["readonly"] as? Boolean ?: false,
singleLine = cachedAttributes["singleLine"] as? Boolean ?: true,
maxLines = cachedAttributes["maxLines"] as? Int ?: 1,
textStyle = TextStyle(
color = Color.White,
fontSize = 14.sp
),
cursorBrush = SolidColor(Color(0xFF8C7BFF)),
onValueChange = { value ->
runCallbackSafe {
textInputValue = value
@@ -175,7 +221,24 @@ private fun DrawNode(node: Node) {
(cachedAttributes["callback"] as? (String) -> Unit)?.let { it(value) }
}
},
placeholder = { Text(cachedAttributes["placeholder"].toString()) }
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.08f), inputShape)
.border(1.dp, Color.White.copy(alpha = 0.18f), inputShape)
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
if (textInputValue.isEmpty()) {
Text(
text = cachedAttributes["placeholder"].toString(),
color = Color.White.copy(alpha = 0.6f),
fontSize = 14.sp
)
}
innerTextField()
}
}
)
}
else -> {}
@@ -205,4 +268,4 @@ fun ScriptInterface(interfaceBuilder: InterfaceBuilder) {
}
}
}
}
}

View File

@@ -30,6 +30,13 @@
"/snap.security.ArgosService/GetTokens",
"com.snap.playstate.net.ReadReceiptHttpInterface.batchUploadReadReceipts",
"com.snap.identity.network.suggestion.BqSuggestFriendHttpInterface.fetchLegacySuggestedFriend",
"com.snap.identity.FriendingHttpInterface.submitSuggestedFriendsAction"
"com.snap.identity.FriendingHttpInterface.submitSuggestedFriendsAction",
"/snapchat.janus.api.LoginService/",
"/snapchat.janus.api.RegistrationService/",
"com.snap.identity.network.suggestion.BqSuggestFriendHttpInterface.fetchHighAvailableSuggestedFriend",
"com.snap.identity.network.suggestion.BqSuggestFriendHttpInterface.fetchNotificationSuggestedFriends",
"com.snap.identity.FriendingHttpInterface.getFriends",
"snaptoken",
"snaptokentype"
]
}

View File

@@ -24,6 +24,9 @@ android {
}
dependencies {
implementation(libs.yukihookapi.api)
implementation(libs.kavaref.core)
implementation(libs.kavaref.extension)
compileOnly(files("libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(libs.coroutines)
implementation(libs.recyclerview)

View File

@@ -0,0 +1,71 @@
package me.eternal.purrfectsnap.core.util.hook;
import com.highcapable.yukihookapi.hook.core.api.helper.YukiHookHelper;
import com.highcapable.yukihookapi.hook.core.api.proxy.YukiHookCallback;
import com.highcapable.yukihookapi.hook.core.api.proxy.YukiMemberHook;
import com.highcapable.yukihookapi.hook.core.api.result.YukiHookResult;
import java.lang.reflect.Member;
public final class YukiHookBridge {
private YukiHookBridge() {}
public interface ParamCallback {
void accept(Object param);
}
public static Object newMemberHook(ParamCallback before, ParamCallback after) {
return new YukiMemberHook() {
@Override
public void beforeHookedMember$yukihookapi_core_release(YukiHookCallback.Param param) {
if (before != null) before.accept(param);
}
@Override
public void afterHookedMember$yukihookapi_core_release(YukiHookCallback.Param param) {
if (after != null) after.accept(param);
}
};
}
public static Object hookMember(Member member, Object callback) {
return YukiHookHelper.INSTANCE.hookMember$yukihookapi_core_release(member, (YukiHookCallback) callback);
}
public static Object invokeOriginal(Member member, Object instance, Object[] args) {
return YukiHookHelper.INSTANCE.invokeOriginalMember$yukihookapi_core_release(member, instance, args);
}
public static void unhook(Object hookResult) {
YukiHookResult result = (YukiHookResult) hookResult;
result.getHookedMember().remove$yukihookapi_core_release();
}
public static Member member(Object param) {
return ((YukiHookCallback.Param) param).getMember();
}
public static Object instance(Object param) {
return ((YukiHookCallback.Param) param).getInstance();
}
public static Object[] args(Object param) {
return ((YukiHookCallback.Param) param).getArgs();
}
public static Object result(Object param) {
return ((YukiHookCallback.Param) param).getResult();
}
public static void setResult(Object param, Object result) {
((YukiHookCallback.Param) param).setResult(result);
}
public static Throwable throwable(Object param) {
return ((YukiHookCallback.Param) param).getThrowable();
}
public static void setThrowable(Object param, Throwable throwable) {
((YukiHookCallback.Param) param).setThrowable(throwable);
}
}

View File

@@ -0,0 +1,127 @@
package me.eternal.purrfectsnap.core.util.ktx;
import com.highcapable.kavaref.KavaRef;
import com.highcapable.kavaref.condition.FieldCondition;
import com.highcapable.kavaref.resolver.FieldResolver;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
import java.util.ArrayList;
import java.util.List;
public final class KavaRefFieldBridge {
private KavaRefFieldBridge() {}
private static FieldResolver<Object> resolveField(Object instance, String fieldName) {
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
FieldResolver<Object> resolver = scope.firstFieldOrNull(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.name(fieldName);
return Unit.INSTANCE;
}
});
if (resolver != null) {
return resolver.of(instance);
}
current = current.getSuperclass();
}
return null;
}
public static Object getField(Object instance, String fieldName) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
return resolver.get();
}
public static void setField(Object instance, String fieldName, Object value) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
resolver.set(value);
}
public static Class<?> getFieldType(Object instance, String fieldName) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
return resolver.getSelf().getType();
}
public static String[] findFieldNamesByType(Object instance, Class<?> fieldType) {
List<String> result = new ArrayList<>();
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
List<FieldResolver<Object>> fields = scope.field(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.type(fieldType);
return Unit.INSTANCE;
}
});
for (FieldResolver<Object> field : fields) {
result.add(field.getSelf().getName());
}
current = current.getSuperclass();
}
return result.toArray(new String[0]);
}
public static String[] getAllFieldNames(Object instance) {
List<String> result = new ArrayList<>();
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
List<FieldResolver<Object>> fields = scope.field(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
return Unit.INSTANCE;
}
});
for (FieldResolver<Object> field : fields) {
result.add(field.getSelf().getName());
}
current = current.getSuperclass();
}
return result.toArray(new String[0]);
}
public static Object getStaticField(Class<?> clazz, String fieldName) throws NoSuchFieldException {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) clazz);
FieldResolver<Object> resolver = scope.firstFieldOrNull(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.name(fieldName);
return Unit.INSTANCE;
}
});
if (resolver == null) throw new NoSuchFieldException(clazz.getName() + "#" + fieldName);
return resolver.get();
}
public static Object findStaticFieldByType(Class<?> clazz, Class<?> fieldType) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) clazz);
List<FieldResolver<Object>> fields = scope.field(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.type(fieldType);
return Unit.INSTANCE;
}
});
for (FieldResolver<Object> field : fields) {
try {
Object value = field.get();
if (value != null && value.getClass() == fieldType) return value;
} catch (Throwable ignored) {}
}
return null;
}
}

View File

@@ -10,13 +10,15 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
class XposedLoader : IXposedHookLoadPackage {
override fun handleLoadPackage(p0: XC_LoadPackage.LoadPackageParam) {
if (p0.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) {
if (param.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return
// prevent loading in sub-processes
if (p0.processName.contains(":")) return
XposedBridge.log("Loading PurrfectSnap v${BuildConfig.VERSION_NAME}#${BuildConfig.GIT_HASH} (package: ${BuildConfig.APPLICATION_ID})")
Application::class.java.hook("attach", HookStage.BEFORE) { param ->
PurrfectSnap().init(param.arg(0))
if (param.processName.contains(":")) return
XposedBridge.log(
"Loading PurrfectSnap v${BuildConfig.VERSION_NAME}#${BuildConfig.GIT_HASH} (package: ${BuildConfig.APPLICATION_ID})"
)
Application::class.java.hook("attach", HookStage.BEFORE) { hookParam ->
PurrfectSnap().init(hookParam.arg(0))
}
}
}
}

View File

@@ -67,6 +67,7 @@ import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.ktx.findStaticObjectFieldByType
import me.eternal.purrfectsnap.mapper.impl.FriendRelationshipChangerMapper
import java.text.DateFormat
import java.util.Date
@@ -1500,13 +1501,10 @@ class BulkMessagingAction : AbstractAction() {
val addFriendMethodName = addFriend14Method.get() ?: return@runCatching context.log.error("Could not find add friend method name")
val sourceTypeClass = sourceType.getAsClass() ?: return@runCatching context.log.error("Could not find source type class")
val pageTypeClass = pageType.getAsClass() ?: return@runCatching context.log.error("Could not find page type class")
val method = f9lClass.declaredMethods.firstOrNull { it.name == addFriendMethodName }
val method = f9lClass.methods.firstOrNull { it.name == addFriendMethodName }
?: f9lClass.declaredMethods.firstOrNull { it.name == addFriendMethodName }
?: return@runCatching context.log.error("Could not find $addFriendMethodName method")
fun findStaticField(clazz: Class<*>): Any? = clazz.declaredFields.firstOrNull { field ->
java.lang.reflect.Modifier.isStatic(field.modifiers) && field.type == clazz
}?.let { field ->
runCatching { field.isAccessible = true; field.get(null)?.takeIf { it.javaClass == clazz } }.getOrNull()
}
fun findStaticField(clazz: Class<*>): Any? = clazz.findStaticObjectFieldByType(clazz)
val enumClass = method.parameterTypes[2]
val enumConstants = enumClass.enumConstants ?: enumClass.getMethod("values").invoke(null) as? Array<*>
?: return@runCatching context.log.error("Could not get enum constants")

View File

@@ -74,6 +74,7 @@ import me.eternal.purrfectsnap.core.logger.CoreLogger
import me.eternal.purrfectsnap.core.messaging.ConversationExporter
import me.eternal.purrfectsnap.core.messaging.ExportFormat
import me.eternal.purrfectsnap.core.messaging.ExportParams
import me.eternal.purrfectsnap.core.messaging.ExportSortOrder
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import java.io.File
import kotlin.math.absoluteValue
@@ -180,7 +181,9 @@ class ExportChatMessages : AbstractAction() {
var downloadMedias by remember { mutableStateOf(false) }
var showConversationPicker by remember { mutableStateOf(false) }
var showFormatPicker by remember { mutableStateOf(false) }
var showOrderPicker by remember { mutableStateOf(false) }
var showMessageTypePicker by remember { mutableStateOf(false) }
var exportSortOrder by remember { mutableStateOf(ExportSortOrder.NEWEST_TO_OLDEST) }
val colorOverrides = remember { mutableStateMapOf<String, String>() }
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
@@ -309,6 +312,19 @@ class ExportChatMessages : AbstractAction() {
tint = MaterialTheme.colorScheme.surfaceVariant
)
SectionLabel(t("sort_order_title"))
GlassField(
value = t(
if (exportSortOrder == ExportSortOrder.OLDEST_TO_NEWEST) {
"sort_order_oldest_to_newest"
} else {
"sort_order_newest_to_oldest"
}
),
onClick = { showOrderPicker = true },
tint = MaterialTheme.colorScheme.surfaceVariant
)
SectionLabel(t("message_type_filter_title"))
GlassField(
value = messageTypeFilter.takeIf { it.isNotEmpty() }?.let {
@@ -417,6 +433,7 @@ class ExportChatMessages : AbstractAction() {
selection,
ExportParams(
exportFormat = exportType,
sortOrder = exportSortOrder,
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
amountOfMessages = amountOfMessages.takeIf { it != -1 },
downloadMedias = downloadMedias,
@@ -610,6 +627,38 @@ class ExportChatMessages : AbstractAction() {
)
}
}
if (showOrderPicker) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
.clickable { showOrderPicker = false }
)
SelectorPopup(
title = t("sort_order_title"),
onDismiss = { showOrderPicker = false }
) {
SelectorRow(
title = t("sort_order_newest_to_oldest"),
subtitle = null,
checked = exportSortOrder == ExportSortOrder.NEWEST_TO_OLDEST,
onToggle = {
exportSortOrder = ExportSortOrder.NEWEST_TO_OLDEST
showOrderPicker = false
}
)
SelectorRow(
title = t("sort_order_oldest_to_newest"),
subtitle = null,
checked = exportSortOrder == ExportSortOrder.OLDEST_TO_NEWEST,
onToggle = {
exportSortOrder = ExportSortOrder.OLDEST_TO_NEWEST
showOrderPicker = false
}
)
}
}
}
}
@@ -1159,6 +1208,13 @@ class ExportChatMessages : AbstractAction() {
}.getOrDefault(emptyList())
}
private fun messageSortKey(message: Message): Long {
return message.orderKey
?: message.messageMetadata?.createdAt
?: message.messageDescriptor?.messageId
?: Long.MIN_VALUE
}
private suspend fun exportFullConversation(
feedEntry: FriendFeedEntry,
exportParams: ExportParams,
@@ -1209,58 +1265,75 @@ class ExportChatMessages : AbstractAction() {
var foundMessageCount = 0
val exportedOrderKeys = mutableSetOf<Long>()
val fetchedMessages = mutableListOf<Message>()
val seenMessageKeys = mutableSetOf<String>()
var lastMessageId: Long? = null
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
if (messageKey != null && seenMessageKeys.add(messageKey)) {
fetchedMessages.add(message)
}
lastMessageId = message.messageDescriptor?.messageId
}
if (lastMessageId == null) {
if (lastMessageId == null && fetchedMessages.isEmpty()) {
logDialog(translation["no_messages_found"])
}
while (lastMessageId != null) {
val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList()
if (fetchedMessages.isEmpty()) break
val pagedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500)
if (pagedMessages.isEmpty()) break
fetchedMessages.firstOrNull()?.let {
pagedMessages.firstOrNull()?.let {
lastMessageId = it.messageDescriptor!!.messageId!!
}
exportParams.messageTypeFilter?.let { filter ->
fetchedMessages.removeIf { message ->
!filter.contains(message.messageContent?.contentType ?: return@removeIf false)
pagedMessages.forEach { message ->
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
if (messageKey != null && seenMessageKeys.add(messageKey)) {
fetchedMessages.add(message)
}
}
}
val remainingLimit = exportParams.amountOfMessages?.let { it - foundMessageCount } ?: Int.MAX_VALUE
if (remainingLimit <= 0) break
val messagesToWrite = fetchedMessages.reversed().let { messages ->
if (messages.size <= remainingLimit) messages else messages.subList(0, remainingLimit)
val filteredMessages = exportParams.messageTypeFilter?.let { filter ->
fetchedMessages.filter { message ->
val contentType = message.messageContent?.contentType ?: return@filter false
filter.contains(contentType)
}
} ?: fetchedMessages
messagesToWrite.forEach { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
}
val sortedMessages = when (exportParams.sortOrder) {
ExportSortOrder.OLDEST_TO_NEWEST -> filteredMessages.sortedBy { messageSortKey(it) }
ExportSortOrder.NEWEST_TO_OLDEST -> filteredMessages.sortedByDescending { messageSortKey(it) }
}
val messagesToWrite = exportParams.amountOfMessages?.let { limit ->
sortedMessages.take(limit)
} ?: sortedMessages
messagesToWrite.forEach { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
setStatus("Exporting (found ${foundMessageCount})")
}
if (loggerMessages.isNotEmpty() && (exportParams.amountOfMessages == null || foundMessageCount < exportParams.amountOfMessages)) {
val parsedLoggerMessages = loggerMessages.mapNotNull { conversationExporter.parseLoggedMessage(it) }
for (loggedMessage in parsedLoggerMessages.asReversed()) {
val sortedLoggerMessages = when (exportParams.sortOrder) {
ExportSortOrder.OLDEST_TO_NEWEST -> parsedLoggerMessages.sortedBy { it.orderKey }
ExportSortOrder.NEWEST_TO_OLDEST -> parsedLoggerMessages.sortedByDescending { it.orderKey }
}
for (loggedMessage in sortedLoggerMessages) {
if (exportedOrderKeys.contains(loggedMessage.orderKey)) continue
val filter = exportParams.messageTypeFilter
if (filter != null && !filter.contains(loggedMessage.contentType)) continue
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) break
conversationExporter.readLoggedMessage(loggedMessage)
foundMessageCount++
setStatus("Exporting (found ${foundMessageCount})")
}
}

View File

@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.core.action.AbstractAction
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.core.features.impl.experiments.AddFriendSourceSpoof
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.util.ktx.findStaticObjectFieldByType
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.wrapper.impl.Snapchatter
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
@@ -98,20 +99,13 @@ class ManageFriendList : AbstractAction() {
val sourceTypeClass = sourceType.getAsClass() ?: return@runCatching context.log.error("Could not find source type class")
val pageTypeClass = pageType.getAsClass() ?: return@runCatching context.log.error("Could not find page type class")
val method = f9lClass.declaredMethods.firstOrNull { it.name == addFriendMethodName }
val method = f9lClass.methods.firstOrNull { it.name == addFriendMethodName }
?: f9lClass.declaredMethods.firstOrNull { it.name == addFriendMethodName }
?: return@runCatching context.log.error("Could not find $addFriendMethodName method")
// Helper function to find static field by trying fallback
fun findStaticField(clazz: Class<*>): Any? {
// Fallback: find any static field of the same type
return clazz.declaredFields.firstOrNull { field ->
java.lang.reflect.Modifier.isStatic(field.modifiers) && field.type == clazz
}?.let { field ->
runCatching {
field.isAccessible = true
field.get(null)?.takeIf { it.javaClass == clazz }
}.getOrNull()
}
return clazz.findStaticObjectFieldByType(clazz)
}
// Get enum constant for USERNAME

View File

@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.*
import android.util.Log
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Semaphore
@@ -106,17 +105,17 @@ class BridgeClient(
this@BridgeClient
)
} else {
XposedHelpers.callMethod(
val handler = Handler(HandlerThread("BridgeClient").apply { start() }.looper)
this::class.java.methods.firstOrNull {
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
}?.invoke(
this,
"bindServiceAsUser",
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
Handler(HandlerThread("BridgeClient").apply {
start()
}.looper),
handler,
Process.myUserHandle()
)
) ?: throw NoSuchMethodException("bindServiceAsUser")
}
}.onFailure {
onFailure(it)

View File

@@ -1,6 +1,5 @@
package me.eternal.purrfectsnap.core.features.impl
import de.robv.android.xposed.XposedHelpers
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -126,8 +125,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
) { param ->
val enumData = param.arg<Any>(0)
val key = enumData.toString()
val setValue: (Any?) -> Unit = { value ->
val valueHolder = XposedHelpers.callMethod(enumData, configEnumMapping["getValue"]?.getAsString())
val setValue: (Any?) -> Unit = setValue@{ value ->
val valueHolder = enumData::class.java.methods.firstOrNull {
it.name == configEnumMapping["getValue"]?.getAsString() && it.parameterCount == 0
}?.invoke(enumData) ?: return@setValue
valueHolder.setObjectField(configEnumMapping["defaultValueField"]?.getAsString()!!, value)
}
@@ -181,4 +182,4 @@ class ConfigurationOverride : Feature("Configuration Override") {
}
}
}
}
}

View File

@@ -50,7 +50,7 @@ class MixerStories : Feature("MixerStories") {
ProtoEditor(buffer).apply {
edit {
getOrNull(2)?.removeIf {
it.toReader().getVarInt(7, 4) == 1L
(it.toReader().getVarInt(7, 4) ?: 0L) > 1L
}
}
}.toByteArray()

View File

@@ -184,6 +184,29 @@ class CallRecorder : Feature("Call Recorder") {
onCallStarted(conversationId)
}
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
if (requestedLength <= 0 || maxLength <= 0) return null
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
val available = (maxLength - safeOffset).coerceAtLeast(0)
val safeLength = requestedLength.coerceAtMost(available)
if (safeLength <= 0) return null
return safeOffset to safeLength
}
private fun copyAudioRecordByteBuffer(data: ByteBuffer, bytesRead: Int): ByteArray? {
if (bytesRead <= 0) return null
val currentPosition = data.position()
val start = (currentPosition - bytesRead).coerceAtLeast(0)
val end = currentPosition.coerceAtMost(data.limit())
if (end <= start) return null
return ByteArray(end - start).also { out ->
val dup = data.duplicate()
dup.position(start)
dup.limit(end)
dup.get(out)
}
}
override fun init() {
if (callRecorderConfig.callRecorder.getNullable() == null) return
@@ -221,10 +244,18 @@ class CallRecorder : Feature("Call Recorder") {
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val buffer = when (val data = param.arg<Any>(0)) {
is ByteBuffer -> ByteArray(result).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + result)
is ShortArray -> ByteArray(result * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, result)
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
is ByteArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val (safeOffset, safeLength) = clampCopyRange(offset, result, data.size) ?: return@hook
data.copyOfRange(safeOffset, safeOffset + safeLength)
}
is ShortArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val (safeOffset, safeLength) = clampCopyRange(offset, result, data.size) ?: return@hook
ByteArray(safeLength * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
else -> return@hook
}
@@ -260,14 +291,31 @@ class CallRecorder : Feature("Call Recorder") {
hook("write", HookStage.BEFORE) { param ->
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val data = param.arg<Any>(0)
val size = if (param.args().size > 2) param.arg(2) else if (data is ByteArray) data.size else if (data is ShortArray) data.size else if (data is ByteBuffer) data.remaining() else 0
if (size <= 0) return@hook
val buffer = when (data) {
is ByteBuffer -> ByteArray(size).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + size)
is ShortArray -> ByteArray(size * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, size)
is ByteBuffer -> {
val requestedSize = param.argNullable<Int>(1) ?: data.remaining()
val safeSize = requestedSize.coerceAtMost(data.remaining()).coerceAtLeast(0)
if (safeSize <= 0) return@hook
ByteArray(safeSize).also {
val pos = data.position()
data.get(it, 0, safeSize)
data.position(pos)
}
}
is ByteArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
val (safeOffset, safeLength) = clampCopyRange(offset, requestedSize, data.size) ?: return@hook
data.copyOfRange(safeOffset, safeOffset + safeLength)
}
is ShortArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
val (safeOffset, safeLength) = clampCopyRange(offset, requestedSize, data.size) ?: return@hook
ByteArray(safeLength * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
else -> return@hook
}

View File

@@ -46,18 +46,26 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair
import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import java.nio.file.Paths
import java.util.UUID
import kotlin.coroutines.suspendCoroutine
import kotlin.math.absoluteValue
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class SnapChapterInfo(
val offset: Long,
val duration: Long?
)
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
@@ -204,17 +212,23 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, paramMap: ParamMap) {
private fun downloadOperaMedia(
downloadManagerClient: DownloadManagerClient,
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
paramMap: ParamMap
) {
if (mediaInfoMap.isEmpty()) return
// Story Snap Entry (images)
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
downloadManagerClient.downloadSingleMedia(
storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"),
DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)),
(storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (key, iv) ->
MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false)
}
(storySnapEntry.mediaKey to storySnapEntry.mediaIv)
.takeIf { it.first != null && it.second != null }
?.let { (key, iv) -> MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) }
)
return
}
@@ -223,6 +237,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
// Overlay (if present)
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
val overlayReference = handleLocalReferences(overlay.uri)
@@ -242,6 +257,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
return
}
// Single media (video/DASH)
downloadManagerClient.downloadSingleMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
@@ -267,41 +283,40 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
forceDownload: Boolean,
forceAllowDuplicate: Boolean = false
) {
//messages
// ─── Messages ─────────────────────────
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
val conversationMessage = context.database.getConversationMessageFromId(messageId)!!
val messageId = id.substringAfterLast(":").toLong()
val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let
val conversationId = conversationMessage.clientConversationId!!
if (!forceDownload && !canUseRule(conversationId)) {
return
}
if (!forceDownload && !canUseRule(conversationId)) return@let
val senderId = conversationMessage.senderId!!
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() &&
senderId == context.database.myUserId
) return@let
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return
val author = context.database.getFriendInfo(senderId) ?: return
val author = context.database.getFriendInfo(senderId) ?: return@let
val authorUsername = author.usernameForSorting!!
val mediaId = paramMap["MEDIA_ID"]?.toString()?.let {
if (it.contains("-")) it.substringAfter("-")
else it
}?.substringBefore(".")
downloadOperaMedia(provideDownloadManagerClient(
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
mediaAuthor = authorUsername,
creationTimestamp = conversationMessage.creationTimestamp,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
), mediaInfoMap, paramMap)
val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: ""
downloadOperaMedia(
provideDownloadManagerClient(
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
mediaAuthor = authorUsername,
creationTimestamp = conversationMessage.creationTimestamp,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
),
mediaInfoMap,
paramMap
)
return
}
//private stories
// ─── Private Friend Story ─────────────────────────
paramMap["PLAYLIST_V2_GROUP"]?.takeIf {
forceDownload || shouldAutoDownload("friend_stories")
}?.let { playlistGroup ->
@@ -319,7 +334,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
}
@@ -335,18 +349,23 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!canUseRule(author.userId!!)) return
}
downloadOperaMedia(provideDownloadManagerClient(
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaAuthor = authorName,
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull(),
downloadSource = MediaDownloadSource.STORY,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate,
), mediaInfoMap, paramMap)
downloadOperaMedia(
provideDownloadManagerClient(
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaAuthor = authorName,
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull(),
downloadSource = MediaDownloadSource.STORY,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
),
mediaInfoMap,
paramMap
)
return
}
// ─── Public Stories / Spotlight ───────────────────
val snapSource = paramMap["SNAP_SOURCE"].toString()
//spotlight
@@ -511,11 +530,14 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*>
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) {
return@onOperaViewStateCallback
}
val mediaParamMap: ParamMap = operaLayerList
.asSequence()
.mapNotNull { layerObj ->
layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() }
}
.firstOrNull {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
} ?: return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")
@@ -717,7 +739,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!isPreview) {
if (forceDownloadFirst ||
decodedAttachments.size == 1 ||
context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
context.isMainActivityPaused
) {
downloadMessageAttachments(friendInfo, message, authorName,
listOf(decodedAttachments.first()),

View File

@@ -99,4 +99,4 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
}
}
}
}

View File

@@ -259,7 +259,8 @@ class BetterLocation : Feature("Better Location") {
}.apply {
layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
addRule(RelativeLayout.ALIGN_PARENT_LEFT)
setMargins(0, (60 * context.resources.displayMetrics.density).toInt(), 0, 0)
// Keep the button below the top map chips (Memories/Visited/Popular/Favorites).
setMargins(0, (88 * context.resources.displayMetrics.density).toInt(), 0, 0)
}
})
}

View File

@@ -1,52 +1,84 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.graphics.Typeface
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.core.ModContext
import java.io.File
import java.io.FileOutputStream
private var cacheFontPath: String? = null
private var cacheFontName: String? = null
private var cachedFontPath: String? = null
private var cachedFontName: String? = null
fun getCustomEmojiFontPath(
context: ModContext
): String? {
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()?.takeIf { it.isNotBlank() } ?: return null
val safeFileName = customFileName.substringAfterLast("/")
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()
?.trim()
?.takeIf { it.isNotBlank() } ?: return null
val safeFileName = customFileName.substringAfterLast("/").substringAfterLast("\\")
if (safeFileName.isBlank()) return null
fun clearCachedPath(cacheFile: File? = null): String? {
runCatching { cacheFile?.delete() }
cachedFontPath = null
cachedFontName = safeFileName
return null
}
fun validateFontFile(file: File): Boolean {
return runCatching {
Typeface.createFromFile(file)
true
}.onFailure {
context.log.warn("Custom emoji font validation failed for ${file.absolutePath}: ${it.message}")
}.getOrDefault(false)
}
return runCatching {
val handle = context.fileHandlerManager.getFileHandle(
FileHandleScope.USER_IMPORT.key,
customFileName
) ?: return@runCatching null
) ?: return@runCatching clearCachedPath()
if (!handle.exists()) {
File(context.androidContext.filesDir, "emoji_fonts")
.resolve(safeFileName)
.takeIf { it.exists() }
?.delete()
cacheFontPath = ""
cacheFontName = safeFileName
return@runCatching null
val oldCacheFile = File(context.androidContext.cacheDir, "emoji_fonts").resolve(safeFileName)
return@runCatching clearCachedPath(oldCacheFile)
}
val persistentDir = File(context.androidContext.filesDir, "emoji_fonts").apply {
val persistentDir = File(context.androidContext.cacheDir, "emoji_fonts").apply {
mkdirs()
}
val persistentFile = File(persistentDir, safeFileName)
handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
if (!persistentFile.exists() || pfd.statSize != persistentFile.length()) {
FileOutputStream(persistentFile).use { output ->
val needsCopy = handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
val sourceSize = pfd.statSize
!persistentFile.exists() || (sourceSize > 0 && sourceSize != persistentFile.length())
} ?: true
if (needsCopy) {
val tempFile = File(persistentDir, "$safeFileName.tmp")
handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
FileOutputStream(tempFile).use { output ->
android.os.ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
input.copyTo(output)
}
}
}
if (!tempFile.renameTo(persistentFile)) {
tempFile.copyTo(persistentFile, overwrite = true)
tempFile.delete()
}
}
cacheFontName = safeFileName
cacheFontPath = persistentFile.absolutePath
cacheFontPath?.takeIf { it.isNotEmpty() }
if (!validateFontFile(persistentFile)) {
return@runCatching clearCachedPath(persistentFile)
}
cachedFontName = safeFileName
cachedFontPath = persistentFile.absolutePath
cachedFontPath?.takeIf { it.isNotEmpty() }
}.onFailure {
context.log.error("Failed to get custom emoji font", it)
clearCachedPath()
}.getOrNull()
}

View File

@@ -2,6 +2,8 @@ package me.eternal.purrfectsnap.core.features.impl.global
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.ktx.findFieldNamesByType
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
@@ -54,11 +56,10 @@ class SnapchatPlus: Feature("SnapchatPlus") {
val instance = param.thisObject<Any>()
val firstArg = param.argNullable<Any>(0) ?: return@hook
instance::class.java.declaredFields.filter { it.type == firstArg::class.java }.forEach {
it.isAccessible = true
it.set(instance, firstArg)
instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName ->
instance.setObjectField(fieldName, firstArg)
}
}
}
}
}
}

View File

@@ -489,7 +489,8 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
"openrouter" -> ai.aiModel.get().ifBlank { "deepseek/deepseek-r1-0528:free" }
"deepseek" -> ai.aiModel.get().ifBlank { "deepseek-chat" }
"openai" -> ai.aiModel.get().ifBlank { "gpt-4o-mini" }
else -> ai.aiModel.get().ifBlank { "gemini-2.5-flash" }
"gemini" -> ai.aiModel.get().ifBlank { "gemini-2.0-flash" }
else -> ai.aiModel.get().ifBlank { "gemini-2.0-flash" }
}
val timeoutSec = ai.aiRequestTimeout.get()
val maxRetries = ai.aiRetryAttempts.get()
@@ -513,75 +514,13 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
)
}
suspend fun makeRequest(): String = withTimeout(timeoutSec * 1000L) {
withContext(Dispatchers.IO) {
when (provider) {
"openrouter", "openai", "deepseek" -> {
val payload = JsonObject().apply {
addProperty("model", model)
add("messages", JsonArray().apply {
messages.forEach { msg ->
add(JsonObject().apply {
addProperty("role", msg.role)
addProperty("content", msg.content)
})
}
})
if (maxTokens > 0) addProperty("max_tokens", maxTokens)
addProperty("temperature", temp)
}
val apiUrl = when (provider) {
"openrouter" -> "https://openrouter.ai/api/v1/chat/completions"
"openai" -> "https://api.openai.com/v1/chat/completions"
else -> "https://api.deepseek.com/v1/chat/completions"
}
val reqBuilder = Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
if (provider == "openrouter") {
reqBuilder
.addHeader("HTTP-Referer", "https://purrfectsnap.app")
.addHeader("X-Title", "PurrfectSnap")
}
val request = reqBuilder
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("choices")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString
?.trim()
.orEmpty()
}
}
else -> throw IllegalArgumentException("Unsupported AI provider: $provider")
}
}
}
var lastError: Throwable? = null
repeat(maxRetries + 1) { attempt ->
val result = runCatching {
makeRequest()
performAiRequest(provider, model, apiKey, messages, timeoutSec, temp, maxTokens)
}.getOrElse { err ->
lastError = err
context.log.warn("AI reply attempt ${attempt + 1} failed for provider $provider model $model timeout ${timeoutSec}s conversation $conversationId: ${err.localizedMessage}; ${err.stackTraceToString()}")
context.log.warn("AI reply attempt ${attempt + 1} failed for provider $provider model $model timeout ${timeoutSec}s conversation $conversationId: ${err.message ?: err.localizedMessage}")
if (attempt < maxRetries) {
delay(1000L * (attempt + 1))
null
@@ -600,7 +539,7 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
context.log.warn("AI reply failed after ${maxRetries + 1} attempts: ${lastError?.message}")
return if (ai.aiFallbackToTemplate.get()) fallback else ""
}
private suspend fun handleHalfSwipe(conversationId: String, userId: String, duration: Long) {
val currentTime = System.currentTimeMillis()
@@ -641,7 +580,7 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
context.log.error("Error handling half-swipe auto-reply", e)
}
}
private suspend fun generateAiReplyForHalfSwipe(senderId: String, conversationId: String, fallback: String): String {
val ai = context.config.messaging.autoReply.aiConfig
val provider = ai.aiProvider.get()
@@ -650,7 +589,8 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
"openrouter" -> ai.aiModel.get().ifBlank { "deepseek/deepseek-r1-0528:free" }
"deepseek" -> ai.aiModel.get().ifBlank { "deepseek-chat" }
"openai" -> ai.aiModel.get().ifBlank { "gpt-4o-mini" }
else -> ai.aiModel.get().ifBlank { "gemini-2.5-flash" }
"gemini" -> ai.aiModel.get().ifBlank { "gemini-2.0-flash" }
else -> ai.aiModel.get().ifBlank { "gemini-2.0-flash" }
}
val timeoutSec = ai.aiRequestTimeout.get()
val maxRetries = ai.aiRetryAttempts.get()
@@ -687,75 +627,13 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
)
}
suspend fun makeRequest(): String = withTimeout(timeoutSec * 1000L) {
withContext(Dispatchers.IO) {
when (provider) {
"openrouter", "openai", "deepseek" -> {
val payload = JsonObject().apply {
addProperty("model", model)
add("messages", JsonArray().apply {
messages.forEach { msg ->
add(JsonObject().apply {
addProperty("role", msg.role)
addProperty("content", msg.content)
})
}
})
if (maxTokens > 0) addProperty("max_tokens", maxTokens)
addProperty("temperature", temp)
}
val apiUrl = when (provider) {
"openrouter" -> "https://openrouter.ai/api/v1/chat/completions"
"openai" -> "https://api.openai.com/v1/chat/completions"
else -> "https://api.deepseek.com/v1/chat/completions"
}
val reqBuilder = Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
if (provider == "openrouter") {
reqBuilder
.addHeader("HTTP-Referer", "https://purrfectsnap.app")
.addHeader("X-Title", "PurrfectSnap")
}
val request = reqBuilder
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("choices")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString
?.trim()
.orEmpty()
}
}
else -> throw IllegalArgumentException("Unsupported AI provider: $provider")
}
}
}
var lastError: Throwable? = null
repeat(maxRetries + 1) { attempt ->
val result = runCatching {
makeRequest()
performAiRequest(provider, model, apiKey, messages, timeoutSec, temp, maxTokens)
}.getOrElse { err ->
lastError = err
context.log.warn("AI half-swipe reply attempt ${attempt + 1} failed for provider $provider model $model timeout ${timeoutSec}s conversation $conversationId: ${err.localizedMessage}; ${err.stackTraceToString()}")
context.log.warn("AI half-swipe reply attempt ${attempt + 1} failed for provider $provider model $model timeout ${timeoutSec}s conversation $conversationId: ${err.message ?: err.localizedMessage}")
if (attempt < maxRetries) {
delay(1000L * (attempt + 1))
null
@@ -775,6 +653,145 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
return if (ai.aiFallbackToTemplate.get()) fallback else ""
}
private suspend fun performAiRequest(
provider: String,
model: String,
apiKey: String,
messages: List<AIMessage>,
timeoutSec: Int,
temp: Double,
maxTokens: Int
): String = withTimeout(timeoutSec * 1000L) {
withContext(Dispatchers.IO) {
when (provider) {
"openrouter", "openai", "deepseek" -> {
val payload = JsonObject().apply {
addProperty("model", model)
add("messages", JsonArray().apply {
messages.forEach { msg ->
add(JsonObject().apply {
addProperty("role", msg.role)
addProperty("content", msg.content)
})
}
})
if (maxTokens > 0) addProperty("max_tokens", maxTokens)
addProperty("temperature", temp)
}
val apiUrl = when (provider) {
"openrouter" -> "https://openrouter.ai/api/v1/chat/completions"
"openai" -> "https://api.openai.com/v1/chat/completions"
else -> "https://api.deepseek.com/v1/chat/completions"
}
val reqBuilder = Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
if (provider == "openrouter") {
reqBuilder
.addHeader("HTTP-Referer", "https://purrfectsnap.app")
.addHeader("X-Title", "PurrfectSnap")
}
val request = reqBuilder
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("choices")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString
?.trim()
.orEmpty()
}
}
"gemini" -> {
val payload = JsonObject().apply {
val contents = JsonArray()
val systemParts = JsonArray()
val mergedMessages = mutableListOf<Pair<String, StringBuilder>>()
messages.forEach { msg ->
if (msg.role == "system") {
systemParts.add(JsonObject().apply { addProperty("text", msg.content) })
} else {
val geminiRole = if (msg.role == "assistant") "model" else "user"
if (mergedMessages.isNotEmpty() && mergedMessages.last().first == geminiRole) {
mergedMessages.last().second.append("\n").append(msg.content)
} else {
mergedMessages.add(geminiRole to StringBuilder(msg.content))
}
}
}
mergedMessages.forEach { (role, content) ->
if (contents.size() == 0 && role == "model") return@forEach // Gemini contents turn must start with user
contents.add(JsonObject().apply {
addProperty("role", role)
add("parts", JsonArray().apply {
add(JsonObject().apply { addProperty("text", content.toString()) })
})
})
}
add("contents", contents)
if (systemParts.size() > 0) {
add("system_instruction", JsonObject().apply {
add("parts", systemParts)
})
}
add("generationConfig", JsonObject().apply {
addProperty("temperature", temp)
if (maxTokens > 0) addProperty("maxOutputTokens", maxTokens)
})
}
val apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$apiKey"
val request = Request.Builder()
.url(apiUrl)
.addHeader("Content-Type", "application/json")
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("candidates")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("content")
?.getAsJsonArray("parts")
?.firstOrNull()?.asJsonObject
?.get("text")
?.asString
?.trim()
.orEmpty()
}
}
else -> throw IllegalArgumentException("Unsupported AI provider: $provider")
}
}
}
// === AI helper structures ===
private data class AIMessage(val role: String, val content: String)
private data class ConversationMessage(

View File

@@ -11,7 +11,6 @@ import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.os.UserHandle
import de.robv.android.xposed.XposedBridge
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.FileType
@@ -29,6 +28,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDeco
import me.eternal.purrfectsnap.core.features.impl.experiments.BetterTranscript
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.YukiHookCompat
import me.eternal.purrfectsnap.core.util.hook.findRestrictedConstructor
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -48,7 +48,7 @@ class Notifications : Feature("Notifications") {
val userHandle: UserHandle
) {
fun send() {
XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf(
YukiHookCompat.invokeOriginal(notifyAsUserMethod, notificationManager, arrayOf(
tag, id, notification, userHandle
))
}

View File

@@ -22,8 +22,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
@@ -40,7 +38,9 @@ import me.eternal.purrfectsnap.core.messaging.MessageSender
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import java.text.SimpleDateFormat
@@ -65,7 +65,7 @@ class SendOverride : Feature("Send Override") {
private var notificationIdCounter = 1000
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<XC_MethodHook.Unhook>? = null
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
@@ -98,7 +98,7 @@ class SendOverride : Feature("Send Override") {
return runCatching {
val duplexClass = findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy")
val appStateMethod = duplexClass.methods.firstOrNull { it.name == "appStateChanged" } ?: return false
val hooks = mutableListOf<XC_MethodHook.Unhook>()
val hooks = mutableListOf<Hooker.HookHandle>()
hooks.addAll(
duplexClass.hook("appStateChanged", HookStage.BEFORE) { param ->
if (param.arg<Any>(0).toString() == "INACTIVE") param.setResult(null)
@@ -193,9 +193,7 @@ class SendOverride : Feature("Send Override") {
}
}
runCatching {
val field = result.messageContent.instanceNonNull().javaClass.getDeclaredField("mAllowsTranscription")
field.isAccessible = true
field.set(result.messageContent.instanceNonNull(), false)
result.messageContent.instanceNonNull().setObjectField("mAllowsTranscription", false)
}
}
}.toByteArray()
@@ -343,6 +341,23 @@ class SendOverride : Feature("Send Override") {
}
}
}
// Handle SNAP messages (field 11)
val snapAtRoot = protoReader.followPath(11) != null
val snapNested = protoReader.followPath(4, 4, 11) != null
if (snapAtRoot || snapNested) {
if (snapNested) {
edit(4, 4, 11) {
remove(7)
addVarInt(7, savePolicy)
}
} else {
edit(11) {
remove(7)
addVarInt(7, savePolicy)
}
}
}
}.toByteArray()
}
}
@@ -445,6 +460,12 @@ class SendOverride : Feature("Send Override") {
remove(4)
addVarInt(4, 5) // APP_SOURCE_CAMERA
}
// Enforce save policy directly on SNAP message body.
edit(11) {
remove(7)
addVarInt(7, savePolicyValue)
}
}.toByteArray()
}
"NOTE" -> {
@@ -489,35 +510,39 @@ class SendOverride : Feature("Send Override") {
audioNoteProto
}
// Set mSavePolicy on Java object if prevent audio is enabled
if (shouldPreventSave) {
try {
val savePolicyEnumClass = runCatching {
XposedHelpers.findClass("com.snapchat.client.messaging.SavePolicy",
localMessageContent.instanceNonNull().javaClass.classLoader)
}
}
if (postSavePolicy != null) {
try {
val savePolicyEnumClass = runCatching {
Class.forName(
"com.snapchat.client.messaging.SavePolicy",
false,
localMessageContent.instanceNonNull().javaClass.classLoader
)
}.getOrNull()
if (savePolicyEnumClass != null && savePolicyEnumClass.isEnum) {
@Suppress("UNCHECKED_CAST")
val enumClass = savePolicyEnumClass as Class<out Enum<*>>
val policyName = when (postSavePolicy) {
1 -> "PROHIBITED"
2 -> "VIEWER_SAVABLE"
else -> null
}
if (policyName != null) {
val policyEnum = runCatching {
java.lang.Enum.valueOf(enumClass, policyName)
}.getOrNull()
if (savePolicyEnumClass != null && savePolicyEnumClass.isEnum) {
@Suppress("UNCHECKED_CAST")
val enumClass = savePolicyEnumClass as Class<out Enum<*>>
val prohibitedEnum = runCatching {
java.lang.Enum.valueOf(enumClass, "PROHIBITED")
}.getOrNull()
if (prohibitedEnum != null) {
val savePolicyField = localMessageContent.instanceNonNull().javaClass.declaredFields
.find { it.name == "mSavePolicy" }
if (savePolicyField != null) {
savePolicyField.isAccessible = true
XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum)
}
}
if (policyEnum != null) {
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
}
} catch (e: Exception) {
context.log.warn("SendOverride: Failed to set mSavePolicy for NOTE: ${e.message}")
}
}
} catch (e: Exception) {
context.log.warn("SendOverride: Failed to set mSavePolicy: ${e.message}")
}
}

View File

@@ -24,6 +24,8 @@ import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.util.ktx.KavaRefFieldBridge
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import java.util.concurrent.Executors
import kotlin.system.measureTimeMillis
@@ -175,11 +177,12 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
}
//serialize all properties of messageJsonObject and put mMessageContent & mMetadata in the message object
messageInstance::class.java.declaredFields.forEach { field ->
if (field.name != "mMessageContent" && field.name != "mMetadata") return@forEach
field.isAccessible = true
deletedMessageObject[field.name]?.let { fieldValue ->
field.set(messageInstance, context.gson.fromJson(fieldValue, field.type))
listOf("mMessageContent", "mMetadata").forEach { fieldName ->
deletedMessageObject[fieldName]?.let { fieldValue ->
runCatching {
val fieldType = KavaRefFieldBridge.getFieldType(messageInstance, fieldName)
messageInstance.setObjectField(fieldName, context.gson.fromJson(fieldValue, fieldType))
}
}
}

View File

@@ -1,6 +1,5 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import de.robv.android.xposed.XposedHelpers
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
@@ -8,6 +7,7 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
class UnsaveableMessages : MessagingRuleFeature(
"Unsaveable Messages",
@@ -55,8 +55,9 @@ class UnsaveableMessages : MessagingRuleFeature(
// Set mSavePolicy on Java object
try {
val savePolicyEnumClass = XposedHelpers.findClass(
val savePolicyEnumClass = Class.forName(
"com.snapchat.client.messaging.SavePolicy",
false,
localMessageContent.instanceNonNull().javaClass.classLoader
)
@@ -66,7 +67,7 @@ class UnsaveableMessages : MessagingRuleFeature(
savePolicyEnumClass as Class<out Enum<*>>,
"PROHIBITED"
)
XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum)
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", prohibitedEnum)
}
} catch (e: Exception) {
context.log.warn("UnsaveableMessages: Failed to set mSavePolicy: ${e.message}")

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -25,7 +26,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@@ -143,7 +143,7 @@ class ConversationToolbox : Feature("Conversation Toolbox") {
.verticalScroll(rememberScrollState())
) {
Text(
"Conversation Toolbox",
context.translation["conversation_toolbox.title"],
fontSize = 18.sp,
modifier = Modifier
.fillMaxWidth()
@@ -160,14 +160,16 @@ class ConversationToolbox : Feature("Conversation Toolbox") {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(10.dp, itemShape, clip = true)
.clip(itemShape)
.background(Color.White.copy(alpha = 0.06f), itemShape)
.border(1.dp, Color.White.copy(alpha = 0.10f), itemShape)
) {
Row(
modifier = Modifier
.clickable { expandedComposableCache[title] = !expanded }
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { expandedComposableCache[title] = !expanded }
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -196,7 +198,10 @@ class ConversationToolbox : Feature("Conversation Toolbox") {
composable(alertDialog, openedConversationId)
}.onFailure { throwable ->
Text(
"Failed to load: ${throwable.message}",
context.translation.format(
"conversation_toolbox.failed_to_load",
"message" to (throwable.message ?: "unknown error")
),
color = PurrfectOverlayPalette.textSecondary,
fontSize = 12.sp
)

View File

@@ -0,0 +1,503 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.Snapchatter
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import java.text.SimpleDateFormat
import java.util.*
class SpotlightCreatorInfo : Feature("SpotlightCreatorInfo") {
private data class CreatorInfo(
val snapId: String,
val creatorDisplayName: String,
val creatorUserId: String?,
val timestamp: Long?,
val friendLinkType: FriendLinkType? = null,
)
@Volatile
private var currentCreatorInfo: CreatorInfo? = null
@Volatile
private var isInSpotlight: Boolean = false
@Volatile
private var currentSnapId: String? = null
@Volatile
private var lastSpotlightUpdateTime: Long = 0
private val userIdCache = mutableMapOf<String, String>()
override fun init() {
if (!context.config.global.spotlightCreatorInfo.get()) return
onNextActivityCreate {
context.event.subscribe(BindViewEvent::class) { event ->
val modelStr = event.prevModel.toString()
if (!modelStr.contains("Spotlight") && !modelStr.contains("SINGLE_SNAP_STORY")) return@subscribe
val userId = modelStr.takeIf { it.contains("userId=") }
?.substringAfter("userId=")
?.substringBefore(",")
?.substringBefore(")")
?.takeIf { it.isNotBlank() && it != "null" }
val snapId = modelStr.takeIf { it.contains("snapId=") || modelStr.contains("SNAP_ID") }
?.substringAfter("snapId=")
?.substringBefore(",")
?.substringBefore(")")
?: modelStr.takeIf { it.contains("SNAP_ID") }
?.substringAfter("SNAP_ID=")
?.substringBefore(",")
?.substringBefore(")")
if (userId != null && snapId != null) {
userIdCache[snapId] = userId
}
}
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
classReference.get()?.hook(
methodName.get() ?: return@forEach,
HookStage.AFTER
) onCreatorInfoHook@{ param ->
val viewState = (param.thisObject<Any>()).getObjectField(viewStateField.get()!!).toString()
val operaLayerList = (param.thisObject<Any>()).getObjectField(layerListField.get()!!) as ArrayList<*>
if (operaLayerList.isEmpty()) {
if (isInSpotlight) {
isInSpotlight = false
currentCreatorInfo = null
currentSnapId = null
lastSpotlightUpdateTime = 0
}
return@onCreatorInfoHook
}
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString()
if (snapSource != "SINGLE_SNAP_STORY") {
if (isInSpotlight) {
isInSpotlight = false
currentCreatorInfo = null
currentSnapId = null
lastSpotlightUpdateTime = 0
}
return@onCreatorInfoHook
}
if (viewState != "FULLY_DISPLAYED") return@onCreatorInfoHook
val snapId = mediaParamMap["SNAP_ID"]?.toString()
if (snapId == null || snapId == currentSnapId) return@onCreatorInfoHook
val creatorDisplayName = mediaParamMap["CREATOR_DISPLAY_NAME"]?.toString()
var creatorUserId = mediaParamMap["USER_ID"]?.toString()
?: mediaParamMap["CREATOR_USER_ID"]?.toString()
?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
val playableStorySnapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
if (creatorUserId == null && playableStorySnapRecord != null) {
val recordStr = playableStorySnapRecord.toString()
creatorUserId = recordStr.substringAfter("userId=", "")
.substringBefore(",")
.substringBefore(")")
.takeIf { it.isNotBlank() && it != "null" }
}
if (creatorUserId == null) creatorUserId = userIdCache[snapId]
val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=")
?.substringBefore(",")?.toLongOrNull()
val timestamp = creationTimestamp ?: mediaParamMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull()
if (creatorDisplayName != null || creatorUserId != null) {
val friendInfo: FriendInfo? = creatorUserId?.let { context.database.getFriendInfo(it) }
val linkType = friendInfo?.let { FriendLinkType.fromValue(it.friendLinkType) }
currentSnapId = snapId
isInSpotlight = true
lastSpotlightUpdateTime = System.currentTimeMillis()
currentCreatorInfo = CreatorInfo(
snapId = snapId,
creatorDisplayName = creatorDisplayName ?: context.translation.getOrNull("common.unknown") ?: "Unknown",
creatorUserId = creatorUserId,
timestamp = timestamp,
friendLinkType = linkType
)
} else {
if (isInSpotlight) {
isInSpotlight = false
currentCreatorInfo = null
currentSnapId = null
lastSpotlightUpdateTime = 0
}
}
}
}
}
context.inAppOverlay.addCustomComposable {
var info by remember { mutableStateOf<CreatorInfo?>(null) }
var inSpotlight by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
while (true) {
val wasInSpotlight = inSpotlight
info = currentCreatorInfo
inSpotlight = isInSpotlight
if (wasInSpotlight && !inSpotlight) showDialog = false
if (inSpotlight && info != null && lastSpotlightUpdateTime > 0 && !showDialog) {
if (System.currentTimeMillis() - lastSpotlightUpdateTime > 4000) {
inSpotlight = false
info = null
showDialog = false
}
}
kotlinx.coroutines.delay(100)
}
}
if (inSpotlight && info != null) {
InfoIconOverlay(
onClick = {
showDialog = true
lastSpotlightUpdateTime = System.currentTimeMillis()
}
)
}
if (showDialog && info != null) {
CreatorInfoDialog(
creatorInfo = info!!,
onDismiss = { showDialog = false }
)
}
}
}
}
@Composable
private fun InfoIconOverlay(onClick: () -> Unit) {
val translation = remember { context.translation.getCategory("spotlight_creator_info") }
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp),
contentAlignment = Alignment.TopCenter
) {
Surface(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(1.dp, PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.6f)),
shadowElevation = 8.dp
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectOverlayPalette.cardOverlayColor
)
),
CircleShape
)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = translation.getOrNull("creator_info") ?: "Creator Info",
tint = PurrfectOverlayPalette.textPrimary,
modifier = Modifier.size(22.dp)
)
}
}
}
}
@Composable
private fun CreatorInfoDialog(creatorInfo: CreatorInfo, onDismiss: () -> Unit) {
var snapchatterInfo by remember { mutableStateOf<Snapchatter?>(null) }
var isLoading by remember { mutableStateOf(false) }
val translation = remember { context.translation.getCategory("spotlight_creator_info") }
LaunchedEffect(creatorInfo.snapId) {
snapchatterInfo = null
isLoading = false
if (creatorInfo.creatorUserId != null) {
isLoading = true
withContext(Dispatchers.IO) {
val info = runCatching {
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(creatorInfo.creatorUserId!!)).firstOrNull()
}.onFailure {
context.log.error("Failed to fetch creator info for ${creatorInfo.creatorUserId}", it)
}.getOrNull()
withContext(Dispatchers.Main) {
snapchatterInfo = info
isLoading = false
}
}
}
}
Dialog(onDismissRequest = onDismiss) {
PurrfectOverlayTheme {
val shape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = shape,
color = Color.Transparent,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
shadowElevation = 0.dp,
tonalElevation = 0.dp
) {
Box(
modifier = Modifier
.background(PurrfectOverlayPalette.cardOverlay, shape)
.padding(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation.getOrNull("title") ?: "Creator Info",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = PurrfectOverlayPalette.textPrimary
)
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Close,
contentDescription = translation.getOrNull("close") ?: "Close",
modifier = Modifier.size(20.dp),
tint = PurrfectOverlayPalette.textSecondary
)
}
}
HorizontalDivider(color = PurrfectOverlayPalette.textSecondary.copy(alpha = 0.2f))
creatorInfo.timestamp?.let { ts ->
InfoRow(
icon = Icons.Default.Schedule,
label = translation.getOrNull("posted_on") ?: "Posted",
value = formatDate(ts)
)
}
val displayName = snapchatterInfo?.displayName ?: creatorInfo.creatorDisplayName
InfoRow(
icon = Icons.Default.Person,
label = translation.getOrNull("display_name") ?: "Display name",
value = displayName
)
if (isLoading) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectOverlayPalette.glowPrimary
)
Text(
translation.getOrNull("loading_username") ?: "Loading…",
fontSize = 13.sp,
color = PurrfectOverlayPalette.textSecondary
)
}
} else {
val username = snapchatterInfo?.username
InfoRowWithCopy(
icon = Icons.Default.AccountCircle,
label = translation.getOrNull("username") ?: "Username",
value = username ?: (context.translation.getOrNull("common.unknown") ?: ""),
canCopy = username != null,
onCopy = {
username?.let {
context.androidContext.copyToClipboard(it, translation.getOrNull("username") ?: "Username")
context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("username_copied") ?: "Copied")
}
}
)
}
InfoRowWithCopy(
icon = Icons.Default.Fingerprint,
label = translation.getOrNull("user_id") ?: "User ID",
value = creatorInfo.creatorUserId ?: (context.translation.getOrNull("common.unknown") ?: ""),
canCopy = creatorInfo.creatorUserId != null,
onCopy = {
creatorInfo.creatorUserId?.let {
context.androidContext.copyToClipboard(it, translation.getOrNull("user_id") ?: "User ID")
context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("user_id_copied") ?: "Copied")
}
}
)
creatorInfo.friendLinkType?.let { linkType ->
val statusText = when (linkType) {
FriendLinkType.MUTUAL -> translation.getOrNull("mutual_friend") ?: "Mutual friend"
FriendLinkType.FOLLOWING -> translation.getOrNull("following") ?: "Following"
FriendLinkType.OUTGOING -> translation.getOrNull("friend_request_sent") ?: "Request sent"
FriendLinkType.INCOMING -> translation.getOrNull("friend_request_received") ?: "Request received"
FriendLinkType.BLOCKED -> translation.getOrNull("blocked") ?: "Blocked"
FriendLinkType.DELETED -> translation.getOrNull("friend_removed") ?: "Removed"
else -> null
}
if (statusText != null) {
InfoRow(
icon = Icons.Default.Person,
label = translation.getOrNull("friend_status") ?: "Friend status",
value = statusText
)
}
}
}
}
}
}
}
}
@Composable
private fun InfoRow(
icon: ImageVector,
label: String,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(20.dp),
tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f)
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
fontSize = 11.sp,
color = PurrfectOverlayPalette.textSecondary
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = PurrfectOverlayPalette.textPrimary
)
}
}
}
@Composable
private fun InfoRowWithCopy(
icon: ImageVector,
label: String,
value: String,
canCopy: Boolean,
onCopy: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(20.dp),
tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f)
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
fontSize = 11.sp,
color = PurrfectOverlayPalette.textSecondary
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = PurrfectOverlayPalette.textPrimary
)
}
if (canCopy) {
IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(18.dp),
tint = PurrfectOverlayPalette.glowSecondary
)
}
}
}
}
private fun formatDate(timestamp: Long): String {
val ts = if (timestamp < 10000000000L) timestamp * 1000 else timestamp
return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts))
}
}

View File

@@ -2,12 +2,12 @@ package me.eternal.purrfectsnap.core.logger
import android.annotation.SuppressLint
import android.util.Log
import de.robv.android.xposed.XposedBridge
import me.eternal.purrfectsnap.common.logger.AbstractLogger
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.core.bridge.BridgeClient
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.YukiHookCompat
import me.eternal.purrfectsnap.core.util.hook.hook
@SuppressLint("PrivateApi")
@@ -20,13 +20,12 @@ class CoreLogger(
fun xposedLog(message: Any?, tag: String = TAG) {
Log.println(Log.INFO, tag, message.toString())
XposedBridge.log("$tag: $message")
Log.i(tag, message.toString())
}
fun xposedLog(message: Any?, throwable: Throwable, tag: String = TAG) {
Log.println(Log.INFO, tag, message.toString())
XposedBridge.log("$tag: $message")
XposedBridge.log(throwable)
Log.i(tag, message.toString(), throwable)
}
}
@@ -46,7 +45,7 @@ class CoreLogger(
internalLog(tag, LogLevel.fromPriority(priority) ?: LogLevel.INFO, message)
}
invokeOriginalPrintLog = { priority, tag, message ->
XposedBridge.invokeOriginalMethod(
YukiHookCompat.invokeOriginal(
printLnMethod,
null,
arrayOf<Any?>(priority, tag, message)

View File

@@ -2,8 +2,14 @@ package me.eternal.purrfectsnap.core.messaging
import me.eternal.purrfectsnap.common.data.ContentType
enum class ExportSortOrder {
NEWEST_TO_OLDEST,
OLDEST_TO_NEWEST
}
class ExportParams(
val exportFormat: ExportFormat = ExportFormat.HTML,
val sortOrder: ExportSortOrder = ExportSortOrder.NEWEST_TO_OLDEST,
val messageTypeFilter: List<ContentType>? = null,
val amountOfMessages: Int? = null,
val downloadMedias: Boolean = false,

View File

@@ -89,7 +89,15 @@ class MessageSender(
}
private fun internalSendMessage(conversations: List<SnapUUID>, localMessageContentTemplate: String, callback: Any) {
val sendMessageWithContentMethod = context.classCache.conversationManager.declaredMethods.first { it.name == "sendMessageWithContent" }
val sendMessageWithContentMethod = sequence {
var current: Class<*>? = context.classCache.conversationManager
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { clazz -> clazz.declaredMethods.asSequence() }
.firstOrNull { it.name == "sendMessageWithContent" }
?: throw NoSuchMethodException("sendMessageWithContent")
val localMessageContent = context.gson.fromJson(localMessageContentTemplate, context.classCache.localMessageContent)
val messageDestinations = MessageDestinations(AbstractWrapper.newEmptyInstance(context.classCache.messageDestinations)).also {

View File

@@ -98,11 +98,13 @@ class CoreScriptHooker: AbstractBinding("hooker", BindingSide.CORE) {
}
fun findMethod(clazz: Class<*>, methodName: String): Member? {
return clazz.declaredMethods.find { it.name == methodName }
return collectMethods(clazz).firstOrNull { it.name == methodName }
}
fun findMethodWithParameters(clazz: Class<*>, methodName: String, vararg types: String): Member? {
return clazz.declaredMethods.find { method -> method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types }
return collectMethods(clazz).firstOrNull { method ->
method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types
}
}
fun findMethod(className: String, methodName: String): Member? {
@@ -159,10 +161,45 @@ class CoreScriptHooker: AbstractBinding("hooker", BindingSide.CORE) {
fun hookAllConstructors(className: String, stage: String, callback: HookCallback)
= findClassSafe(className)?.let { hookAllConstructors(it, stage, callback) }
private fun collectMethods(clazz: Class<*>): List<Method> {
val methods = LinkedHashMap<String, Method>()
val visited = HashSet<Class<*>>()
var currentClass: Class<*>? = clazz
while (currentClass != null && currentClass != Any::class.java && currentClass != Object::class.java) {
collectMethodsRecursive(currentClass, methods, visited)
currentClass = currentClass.superclass
}
return methods.values.toList()
}
private fun collectMethodsRecursive(
clazz: Class<*>,
methods: MutableMap<String, Method>,
visited: MutableSet<Class<*>>
) {
if (!visited.add(clazz)) return
clazz.declaredMethods.forEach { method ->
val signature = buildString {
append(method.name)
append("#")
method.parameterTypes.forEach {
append(it.name)
append(";")
}
}
methods.putIfAbsent(signature, method)
}
clazz.interfaces.forEach { collectMethodsRecursive(it, methods, visited) }
}
override fun onDispose() {
hooks.forEach { it() }
hooks.clear()
}
override fun getObject() = this
}
}

Some files were not shown because too many files have changed in this diff Show More