Merge branch 'dev' into RSR
This commit is contained in:
257
README.md
257
README.md
@@ -16,7 +16,7 @@
|
||||
[](https://github.com/particle-box/PurrfectSnap/releases)
|
||||
[](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 UI—designed 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 isn’t.
|
||||
|
||||
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 Snapchat’s 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -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.** { *; }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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() }) {
|
||||
|
||||
@@ -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"])))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -99,4 +99,4 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user