23 Commits
v1.7.0 ... dev

Author SHA1 Message Date
root
cdbcd20d65 Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features) 2026-05-30 13:37:30 +02:00
ᴋᴀʟᴀᴅɪɴ
79a1114069 v1.7.1 2026-04-27 03:32:36 +05:30
ᴋᴀʟᴀᴅɪɴ
d724e5c615 Fix: Auto-Open Ghost Notification & Social page friends selection bug (#145) 2026-04-27 03:21:33 +05:30
Majjo
5651d48d96 debug: fix strategy E/F to use eachBuffer instead of followPath, dump snap item bytes 2026-04-21 17:11:13 +02:00
Majjo
8f8c696431 fix: resolve fallbackUserId out of scope in strategy E/F 2026-04-21 15:43:12 +02:00
Majjo
e408a505ab debug: add strategy E/F for europe-west1 batch_stories + snap descriptor hex dump 2026-04-20 22:39:52 +02:00
Majjo
ded49b8b1b debug: hex dump batch_stories responses >1KB that parse 0 stories 2026-04-20 15:17:45 +02:00
Majjo
d9593fa629 feat: add getBusinessStoryManifest capture + multi-strategy proto probe + no_match hex dump 2026-04-20 14:22:19 +02:00
Majjo
cab8c69f72 feat: story debug panel, log level filters, verbose story event logging 2026-04-20 01:15:25 +02:00
Majjo
616ae0d3b3 feat: add Developer > Debug Stories config with ZIP dump 2026-04-20 00:38:02 +02:00
Majjo
c0c5503864 fix: save ZIP base media directly to gallery, avoid downloadRemoteMedia NPE 2026-04-20 00:34:44 +02:00
Majjo
98e04a81cb fix: capture story_lookup/batch_story_lookup endpoints for story media URLs 2026-04-20 00:29:57 +02:00
Majjo
947bb399c8 ci: remove armv7 jobs, build arm64-v8a only 2026-04-19 23:54:48 +02:00
Majjo
e23bf443a1 fix: discard overlay segment from ZIP container when saving stories 2026-04-19 12:49:18 +02:00
Majjo
760d652f5f feat: story capture API + Python downloader script 2026-04-18 21:01:07 +02:00
Majjo
22ee866205 fix: remove duplicate JS block causing SyntaxError in dashboard 2026-04-18 20:04:08 +02:00
Majjo
ce3361992c feat: full request/response inspection with detail panel, hex dump, JSON pretty-print 2026-04-18 19:13:34 +02:00
Majjo
768a3b7c09 feat: cookie-based session auth for companion server (login page, POST /login, /logout) 2026-04-18 18:12:56 +02:00
Majjo
b0715b3f27 fix: serve dashboard unauthenticated, validate token via /ping endpoint 2026-04-18 17:41:28 +02:00
Majjo
b6435f9010 fix: add language strings for companion server; fix startup logging and null safety 2026-04-18 16:57:47 +02:00
Majjo
e0431bd001 feat: add Companion Server for live log and network monitoring 2026-04-18 16:23:51 +02:00
Majjo
d86d9db508 ci: update actions to Node 24 compatible versions 2026-04-18 15:24:46 +02:00
Majjo
068b272b5e fix: skip overlay merge for stories, highlights, and spotlight 2026-04-18 15:17:12 +02:00
26 changed files with 1766 additions and 559 deletions

View File

@@ -105,101 +105,3 @@ jobs:
with:
name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
job_armv7:
runs-on: ubuntu-latest
outputs:
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/debug"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Debug-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk

View File

@@ -26,7 +26,7 @@ jobs:
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleArmv8Debug
@@ -46,49 +46,6 @@ jobs:
with:
name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
job_armv7:
runs-on: macos-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Rename APK file
run: |
mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
job_manager:
runs-on: ubuntu-latest
@@ -136,7 +93,7 @@ jobs:
run: chmod +x gradlew
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleCoreDebug

View File

@@ -6,6 +6,8 @@ on:
description: 'Upload to CI channel'
required: false
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
job_armv8:
runs-on: ubuntu-latest
@@ -13,11 +15,11 @@ jobs:
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v4.7.0
with:
java-version: '21'
distribution: 'temurin'
@@ -27,7 +29,7 @@ jobs:
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
@@ -101,118 +103,20 @@ jobs:
mv app/build/outputs/apk/armv8/release/purrfectsnap-armv8Release-signed.apk app/build/outputs/apk/armv8/release/purrfectsnap_${{ env.version }}-armv8Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
with:
name: purrfectsnap-armv8-release
path: app/build/outputs/apk/armv8/release/*.apk
job_armv7:
runs-on: ubuntu-latest
outputs:
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Release
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/release"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Release-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/release/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/release/purrfectsnap-armv7Release-signed.apk app/build/outputs/apk/armv7/release/purrfectsnap_${{ env.version }}-armv7Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-release
path: app/build/outputs/apk/armv7/release/*.apk
make_release:
needs: [job_armv7, job_armv8]
needs: [job_armv8]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
- name: Download all build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v4.2.1
with:
path: ./all-apks
- name: Display all files for debug

View File

@@ -616,32 +616,51 @@ class DownloadProcessor (
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
//if there is a zip file, extract it and save only the base media (discard overlay)
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
// Debug Stories: save raw ZIP to the configured debug folder
val debugConfig = remoteSideContext.config.root.developer.debugStories
if (debugConfig.enabled.get()) {
val debugFolder = debugConfig.saveFolder.get().orEmpty().trim()
if (debugFolder.isNotBlank()) {
runCatching {
val debugDir = android.net.Uri.parse(debugFolder).let { uri ->
androidx.documentfile.provider.DocumentFile.fromTreeUri(remoteSideContext.androidContext, uri)
}
val timestamp = System.currentTimeMillis()
val debugFile = debugDir?.createFile("application/zip", "story_debug_${timestamp}.zip")
debugFile?.uri?.let { fileUri ->
remoteSideContext.androidContext.contentResolver.openOutputStream(fileUri)?.use { out ->
zipFile.inputStream().use { it.copyTo(out) }
}
}
}.onFailure {
remoteSideContext.log.warn("Debug Stories: failed to save zip: ${it.message}", "DownloadProcessor")
}
} else {
remoteSideContext.log.warn("Debug Stories: enabled but no folder configured", "DownloadProcessor")
}
}
var baseMediaFile: File? = null
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
baseMediaFile = createMediaTempFile().apply {
outputStream().use { inputStream.copyTo(it) }
}
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
oldDownloadedMedias.forEach { (_, value) -> value.delete() }
shouldMergeOverlay = true
baseMediaFile?.let { baseMedia ->
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
baseMedia.delete()
}
return@launch
}
if (shouldMergeOverlay) {

View File

@@ -151,22 +151,27 @@ class FFMpegProcessor(
}
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
}
fun applyVideoArguments() {
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
}
when (args.action) {
Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'"
}
}
Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
}
@@ -174,8 +179,9 @@ class FFMpegProcessor(
if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a"
}
outputArguments -= "-c:v"
args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it
} ?: run {
outputArguments += "-vn"
@@ -186,6 +192,7 @@ class FFMpegProcessor(
}
}
Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file ->
runCatching {

View File

@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
database.beginTransaction()
try {
friends.forEach { friend ->
// Industrial Filter: Only update existing friends, never auto-insert new ones.
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
friend.selfieId,
friend.userId
)
)
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
}
groups.forEach { group ->
// Industrial Filter: Only update existing groups.
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
group.participantsCount,
group.conversationId
)
)
}
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
database.endTransaction()
}
// Notify all observers with the updated data from the database
val allFriends = getFriends(descOrder = true)
val allGroups = getGroups()
messagingDataFlow.tryEmit(allFriends to allGroups)
// Notify all observers with the raw sync data (AddFriendDialog needs this)
messagingDataFlow.tryEmit(friends to groups)
}
}

View File

@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"],
title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
confirmButtonText = managerContext.translation["setup.activity.close_button"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)

View File

@@ -140,7 +140,9 @@ class ManageRuleFeature : Routes.Route() {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key)
}
val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) {
ruleState = newState
@@ -166,7 +168,7 @@ class ManageRuleFeature : Routes.Route() {
onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) {
if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else {
currentRuleIds.remove(friend.userId)
}
@@ -174,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) {
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else {
currentRuleIds.remove(group.conversationId)
}
},
getFriendState = { friend ->
ruleIdsSet.contains(friend.userId)
currentRuleIdSet.contains(friend.userId)
},
getGroupState = { group ->
ruleIdsSet.contains(group.conversationId)
currentRuleIdSet.contains(group.conversationId)
}
)
)

View File

@@ -67,12 +67,14 @@ class SocialRootSection : Routes.Route() {
}
// Real-time synchronization from the bridge
context.database.messagingDataFlow.collect { (friends, groups) ->
context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends)
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = groups
groupList = dbGroups
}
}
}
@@ -172,8 +174,7 @@ class SocialRootSection : Routes.Route() {
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
),
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
)
)
},
modifier = Modifier

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("326").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,22 +1,28 @@
## v1.7.1
- Auto-Open Engine ghost notification fix.
- Continous Send notifiction bug fix.
- Disappering chats fix.
- Social page automatic selection bug fix.
## v1.7.0
- Features:
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
- Implemented app intro showcase (tq ΞTΞRNAL)
- Implemented "Spoof follower count" (tq <RSR/>)
- Implemented "Spoof follower count" (tq RSR)
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
- Implemented "Chat Hold Kill" (tq C R E S T)
- Implemented "Snapchat Purchase Date Spoof" (tq C R E S T)
- Implemented Message log export for individual chat (tq schrodingerspet)
- Implemented message icon indicator for Memories (tq <RSR/>)
- Implemented "Chat Hold Kill" (tq SUJΛL)
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Implemented Memory message icon indicator(tq RSR)
- Implemented two new message indicator toggles, for self snaps and group.
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
- Implemented new notification card for Continous send feature.
- Fixes:
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq <RSR/>)
- Media resend flow bug fixes. (tq schrodingerspet)
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq schrodingerspet)
- Media download support through message logger. (tq schrodingerspet)
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Snapchat Plus bug fixes to improve stability.
- Spoof Device profile Backup/Restore bug fixes.
- Redesgined manager app Logs filtering UI.

View File

@@ -2888,6 +2888,24 @@
"description": "Automatically deletes cached events that are older than the specified amount of time"
}
}
},
"companion_server": {
"name": "Companion Server",
"description": "Local web server that streams live logs and network calls to a browser dashboard",
"properties": {
"enabled": {
"name": "Enabled",
"description": "Start the companion web server inside Snapchat's process. Requires a restart."
},
"port": {
"name": "Port",
"description": "Port the server listens on (102465535). Default is 8484."
},
"token": {
"name": "Auth Token",
"description": "Secret token required to access the dashboard. Must not be empty. Requires a restart."
}
}
}
},
"options": {

View File

@@ -0,0 +1,10 @@
package me.eternal.purrfectsnap.common.config.impl
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
class CompanionServerConfig : ConfigContainer() {
val enabled = boolean("enabled", false) { requireRestart() }
val port = integer("port", 8484)
val token = string("token", "") { addFlags(ConfigFlag.SENSITIVE); requireRestart() }
}

View File

@@ -0,0 +1,13 @@
package me.eternal.purrfectsnap.common.config.impl
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
class DeveloperConfig : ConfigContainer() {
inner class DebugStoriesConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER, ConfigFlag.SENSITIVE) }
}
val debugStories = container("debug_stories", DebugStoriesConfig())
}

View File

@@ -19,4 +19,6 @@ class RootConfig : ConfigContainer() {
FeatureNotice.UNSTABLE) }
val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject }
val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch }
val companionServer = container("companion_server", CompanionServerConfig()) { icon = Icons.Default.Monitor }
val developer = container("developer", DeveloperConfig()) { icon = Icons.Default.Code }
}

View File

@@ -168,6 +168,7 @@ class FeatureManager(
CustomTheming(),
HideTypingIndicator(),
FakeSnapScore(),
CompanionServer(),
)
features.values.toList().forEach { feature ->

View File

@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
} && getRuleState() != null
}
fun canUseRule(conversationId: String): Boolean {
open fun canUseRule(conversationId: String): Boolean {
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
return false
}

View File

@@ -57,7 +57,6 @@ import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
@@ -155,78 +154,62 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
callback = object: DownloadCallback.Stub() {
override fun onSuccess(outputFile: String) {
var finalOutputFile = outputFile
modCtx.coroutineScope.launch(Dispatchers.IO) {
runCatching {
// settle delay to ensure disk flush
delay(120L)
val file = java.io.File(outputFile)
if (file.exists()) {
val header = file.inputStream().use { input ->
val buffer = ByteArray(16)
input.read(buffer)
buffer
}
runCatching {
val file = java.io.File(outputFile)
if (file.exists()) {
val header = file.inputStream().use { input ->
val buffer = ByteArray(16)
input.read(buffer)
buffer
}
val fileType = FileType.fromByteArray(header)
val expectedExt = fileType.fileExtension
if (fileType != FileType.UNKNOWN && expectedExt != null &&
!outputFile.endsWith(".$expectedExt", ignoreCase = true)) {
val base = outputFile.substringBeforeLast('.').takeIf { '.' in outputFile } ?: outputFile
val newPath = "$base.$expectedExt"
val newFile = java.io.File(newPath)
if (file.renameTo(newFile)) {
finalOutputFile = newPath
} else {
file.copyTo(newFile, overwrite = true)
file.delete()
finalOutputFile = newPath
}
val fileType = FileType.fromByteArray(header)
if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) {
val newPath = outputFile.removeSuffix(".dat").removeSuffix(".tmp") + ".mp4"
val newFile = java.io.File(newPath)
if (file.renameTo(newFile)) {
finalOutputFile = newPath
} else {
file.copyTo(newFile, overwrite = true)
file.delete()
finalOutputFile = newPath
}
}
}.onFailure { logError("Post-Processing Failed for $outputFile", it) }
if (isBatch) {
batchSuccessCount.incrementAndGet()
if (downloadLogging.contains("success")) {
modCtx.runOnUiThread {
modCtx.inAppOverlay.showStatusToast(
icon = Icons.Outlined.DownloadDone,
text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()),
durationMs = 1300
)
}
}
return@launch
}
}.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) }
if (isBatch) {
batchSuccessCount.incrementAndGet()
if (downloadLogging.contains("success")) {
val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name)
modCtx.runOnUiThread {
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300)
}
modCtx.inAppOverlay.showStatusToast(
icon = Icons.Outlined.DownloadDone,
text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()),
durationMs = 1300
)
}
return
}
if (downloadLogging.contains("success")) {
val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name)
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300)
}
}
override fun onProgress(message: String) {
if (isBatch || !downloadLogging.contains("progress")) return
val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" }
modCtx.runOnUiThread {
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
}
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
}
override fun onFailure(message: String, throwable: String?) {
if (!downloadLogging.contains("failure")) return
val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message
if (isBatch) { batchFailureCount.incrementAndGet(); return }
modCtx.runOnUiThread {
if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
}
if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
}
}
)
@@ -347,28 +330,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val totalCount = paramMap.getStorySnapTotal()
modCtx.runOnUiThread {
fun tryJump(retryCount: Int = 0) {
val maxRetries = 4
val delayMs = when {
retryCount == 0 -> 180L
retryCount == 1 -> 280L
retryCount == 2 -> 400L
else -> 550L
}
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (synchronized(batchLock) { pendingBatchDownloadIndices } == null) return@postDelayed
val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true
when {
jumped -> {}
retryCount < maxRetries -> tryJump(retryCount + 1)
else -> {
synchronized(batchLock) { pendingBatchDownloadIndices = null }
modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed")
}
}
}, delayMs)
if (!jumped && retryCount < 1) tryJump(retryCount + 1)
else if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") }
}, if (retryCount == 0) 120L else 220L)
}
tryJump()
}
@@ -459,7 +425,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private suspend fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, paramMap: ParamMap) {
private suspend fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, paramMap: ParamMap, downloadSource: MediaDownloadSource) {
val modCtx = this@MediaDownloader.context
if (mediaInfoMap.isEmpty()) return
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
@@ -473,6 +439,17 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
val originalMediaRef = handleLocalReferences(mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.uri)
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
// Skip overlay merging for all story types
if (downloadSource == MediaDownloadSource.PUBLIC_STORY ||
downloadSource == MediaDownloadSource.STORY ||
downloadSource == MediaDownloadSource.SPOTLIGHT) {
downloadManagerClient.downloadSingleMedia(
originalMediaRef,
DownloadMediaType.fromUri(Uri.parse(originalMediaRef)),
mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()
)
return
}
val overlayRef = handleLocalReferences(overlay.uri)
downloadManagerClient.downloadMediaWithOverlay(
InputMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()),
@@ -494,42 +471,23 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val msg = modCtx.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let
if (!forceDownload && (!canUseRule(msg.clientConversationId!!) || (modCtx.config.downloader.preventSelfAutoDownload.get() && msg.senderId == modCtx.database.myUserId))) return@let
val author = modCtx.database.getFriendInfo(msg.senderId!!) ?: return@let
downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap, MediaDownloadSource.CHAT_MEDIA)
return
}
paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup ->
val playlistGroupString = playlistGroup.toString()
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let {
if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null
} ?: if (playlistGroupString.contains("storyUserId=")) {
playlistGroupString.substringAfter("storyUserId=").substringBefore(",")
} else {
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }?.invoke(playlistGroup)?.toString()?.split(":")?.getOrNull(2) ?: return@let
val conversationMessage = modCtx.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
val conversationParticipants = modCtx.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
}
val author = modCtx.database.getFriendInfo(if (storyUserId == null || storyUserId == "null") modCtx.database.myUserId else storyUserId) ?: return@let
paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let {
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",")
val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@let
if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) return@let
downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap, MediaDownloadSource.STORY)
return
}
val snapSource = paramMap["SNAP_SOURCE"].toString()
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) {
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), (paramMap["CREATOR_DISPLAY_NAME"]?.toString() ?: "unknown").sanitizeForPath(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), paramMap["CREATOR_DISPLAY_NAME"].toString(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap, MediaDownloadSource.SPOTLIGHT); return
}
if (!forceDownload && !shouldAutoDownload("public_stories")) return
val rawAuthor = (
paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername }
?: paramMap["USERNAME"]?.toString()?.takeIf { it.contains("value=") }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",")
?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { it.contains("username=") }?.substringAfter("username=")?.substringBefore(",")
?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() }
?: paramMap["TIME_STAMP"]?.toString()
?: "unknown"
)
val author = rawAuthor.sanitizeForPath().replace(":", "_").replace("/", "_").replace("\\", "_").replace("?", "_").replace("*", "_").replace("\"", "_").replace("<", "_").replace(">", "_").replace("|", "_")
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath()
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap, MediaDownloadSource.PUBLIC_STORY)
}
private fun shouldAutoDownload(keyFilter: String? = null): Boolean = this@MediaDownloader.context.config.downloader.autoDownloadSources.get().any { keyFilter == null || it.contains(keyFilter, true) }
@@ -552,7 +510,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap!!.containsKey("video_media_info_list")
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(mediaParamMap[if (isVideo) "video_media_info_list" else "image_media_info"]!!)
if (modCtx.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) mediaInfoMap[SplitMediaAssetType.OVERLAY] = MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
// Check if this is story-type content (public story, friend story, or spotlight)
// Stories should NOT have overlays merged
val isStoryContent = mediaParamMap.containsKey("PLAYLIST_V2_GROUP") ||
mediaParamMap["SNAP_SOURCE"]?.toString() == "SINGLE_SNAP_STORY" ||
mediaParamMap.containsKey("SNAP_ID")
if (!isStoryContent && modCtx.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) mediaInfoMap[SplitMediaAssetType.OVERLAY] = MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
if (shouldAutoDownload() && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@hook
lastSeenMapParams = mediaParamMap; lastSeenMediaInfoMap = mediaInfoMap
if (pendingBatchDownloadIndices != null) { modCtx.coroutineScope.launch { processNextBatchDownload(mediaParamMap, mediaInfoMap) }; return@hook }
@@ -564,63 +527,30 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private fun resolveLoggedMessageAttachments(conversationId: String, clientMessageId: Long): List<DecodedAttachment> {
val messageLogger = context.feature(MessageLogger::class)
if (!messageLogger.isEnabled) return emptyList()
val loggedMessageObject = runCatching {
messageLogger.getMessageObject(conversationId, clientMessageId)
}.getOrNull() ?: return emptyList()
val loggedMessageContent = loggedMessageObject.getAsJsonObject("mMessageContent") ?: return emptyList()
return runCatching {
MessageDecoder.decode(loggedMessageContent)
}.getOrDefault(emptyList())
}
suspend fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) {
val modCtx = this@MediaDownloader.context
val message = modCtx.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found")
val friendInfo = modCtx.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found")
val decodedAttachments = message.messageContent?.let { content ->
MessageDecoder.decode(ProtoReader(content))
}?.toMutableList() ?: mutableListOf()
if (decodedAttachments.isEmpty()) {
val messageLogger = context.feature(MessageLogger::class)
message.clientConversationId?.let { conversationId ->
val isDeletedMessage = runCatching {
ContentType.fromId(message.contentType) == ContentType.STATUS ||
(messageLogger.isEnabled && messageLogger.isMessageDeleted(conversationId, messageId))
}.getOrDefault(false)
if (isDeletedMessage) {
decodedAttachments.addAll(resolveLoggedMessageAttachments(conversationId, messageId))
}
}
}
val downloadableAttachments = decodedAttachments.filter {
it.boltKey != null || it.directUrl != null
}.toMutableList()
if (downloadableAttachments.isEmpty()) { modCtx.shortToast(translations["no_attachments_toast"] ?: "No Attachments"); return }
val decodedAttachments = MessageDecoder.decode(ProtoReader(message.messageContent!!)).toMutableList()
if (decodedAttachments.isEmpty()) { modCtx.shortToast(translations["no_attachments_toast"] ?: "No Attachments"); return }
if (!isPreview) {
if (forceDownloadFirst || downloadableAttachments.size == 1 || modCtx.isMainActivityPaused) {
downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(downloadableAttachments.first()), forceAllowDuplicate)
if (forceDownloadFirst || decodedAttachments.size == 1 || modCtx.isMainActivityPaused) {
downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(decodedAttachments.first()), forceAllowDuplicate)
} else {
withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, downloadableAttachments, forceAllowDuplicate) }
withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, decodedAttachments, forceAllowDuplicate) }
}
return
}
if (downloadableAttachments.size == 1) { previewAttachment(downloadableAttachments.first()); return }
if (decodedAttachments.size == 1) { previewAttachment(decodedAttachments.first()); return }
withContext(Dispatchers.Main) {
val mainActivity = modCtx.mainActivity ?: return@withContext
ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply {
var selected = 0
setSingleChoiceItems(downloadableAttachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), 0) { _, w -> selected = w }
setPositiveButton(modCtx.translation["chat_action_menu.preview_button"] ?: "Preview") { _, _ -> previewAttachment(downloadableAttachments[selected]) }
setSingleChoiceItems(decodedAttachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), 0) { _, w -> selected = w }
setPositiveButton(modCtx.translation["chat_action_menu.preview_button"] ?: "Preview") { _, _ -> previewAttachment(decodedAttachments[selected]) }
}.show()
}
}

View File

@@ -113,7 +113,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
override fun init() {
if (autoOpenConfig.globalState == false) return
if (autoOpenConfig.globalState != true) return
restorePersistence()
createNotificationChannels()
@@ -486,9 +486,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
builder.setStyle(bigTextStyle)
}
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
}
private fun createPendingIntent(action: String): PendingIntent {
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
@@ -560,9 +559,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
.setContentTitle("Auto-Open")
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
}
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
fun getInterface(): AutoOpenInterface {

View File

@@ -70,14 +70,14 @@ class MediaFilePicker : Feature("Media File Picker") {
private const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>()
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
private var originalUnsplitItem: Any? = null
private var reusableOriginalItem: Any? = null
private var queuedOverrideType: String? = null
private var queuedOverrideSnapDurationMs: Int? = null
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
private var cleanupItemHandler: ((String) -> Unit)? = null
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
@@ -91,13 +91,13 @@ class MediaFilePicker : Feature("Media File Picker") {
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) {
val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri ->
cleanup?.invoke(uri)
queuedSplitCleanupItems.values.toList().forEach { item ->
cleanup?.invoke(item.uri, item.filePath)
}
}
queuedSplitItems.clear()
queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear()
queuedSplitCleanupItems.clear()
originalUnsplitItem = null
queuedOverrideType = null
queuedOverrideSnapDurationMs = null
@@ -114,7 +114,7 @@ class MediaFilePicker : Feature("Media File Picker") {
items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri
queuedSplitCleanupItems[it.itemId] = it
}
}
fun sendOriginalUnsplitItem(): Boolean {
@@ -130,8 +130,8 @@ class MediaFilePicker : Feature("Media File Picker") {
}
fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
cleanupItemHandler?.invoke(uri)
queuedSplitCleanupItems.remove(itemId)?.let { item ->
cleanupItemHandler?.invoke(item.uri, item.filePath)
}
}
if (queuedSplitItems.isEmpty()) {
@@ -159,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
private data class PreparedMediaItem(
val itemId: String,
val durationMs: Long,
val uri: String
val uri: String,
val filePath: String? = null
)
private fun splitVideoIntoChunks(
@@ -309,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
runCatching { resolver.delete(uri, null, null) }
}
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
return PreparedMediaItem(
itemId = itemId,
durationMs = durationMs,
uri = uri.toString(),
filePath = file.absolutePath
)
}
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
@@ -431,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
false
}
}
cleanupItemHandler = { uriString ->
cleanupItemHandler = { uriString, filePath ->
runCatching {
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
filePath?.let { path ->
val file = File(path)
if (file.exists()) file.delete()
}
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
if (it.message?.contains("no access") == false) {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
}
}
}
if (sendItemsHookedHandler === handlerInstance) return@hook

View File

@@ -34,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
@@ -88,6 +90,7 @@ class SendOverride : Feature("Send Override") {
private var currentRecipientName: String = "Unknown"
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
queuedOriginalItemRepeatCount = repeatCount
@@ -102,24 +105,93 @@ class SendOverride : Feature("Send Override") {
queuedOriginalItemRepeatSnapDurationMs = null
}
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
private fun updateContinuousSendNotification(context: ModContext) {
if (!engineActive.get()) return
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val remaining = queuedOriginalItemRepeatCount
val processed = processedRepeatCount
val total = totalRepeatCount
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
if (!isWorking) {
notificationManager.cancel(STATUS_NOTIFICATION_ID)
showCompletionNotification(context, processed, total)
return
}
val progressPercent = if (total > 0) (processed * 100) / total else 0
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setColor(0xFF3498DB.toInt())
.setContentTitle("Sending Snaps to $currentRecipientName")
.setContentText("Progress: $processed / $total ($progressPercent%)")
.setSubText("$processed / $total")
.setProgress(total, processed, false)
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
}
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
val isError = sent < total && !isStopped.get()
val title = when {
isStopped.get() -> "Continuous Send Stopped"
isError -> "Continuous Send Failed"
else -> "Continuous Send Finished"
}
val content = "Sent $sent / $total snaps to $currentRecipientName"
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
.setContentTitle(title)
.setContentText(content)
.setAutoCancel(true)
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
}
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
val intent = Intent(action).setPackage(context.androidContext.packageName)
return PendingIntent.getBroadcast(
context.androidContext,
action.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
processedRepeatCount++
queuedOriginalItemRepeatCount--
updateContinuousSendNotification(context)
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
return result
}
@@ -136,7 +208,6 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
private fun createContinuousNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -151,70 +222,6 @@ class SendOverride : Feature("Send Override") {
}
}
private fun updateContinuousSendNotification() {
if (!engineActive.get()) return
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val remaining = queuedOriginalItemRepeatCount
val processed = processedRepeatCount
val total = totalRepeatCount
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
if (!isWorking) {
notificationManager.cancel(STATUS_NOTIFICATION_ID)
showCompletionNotification(processed, total)
return
}
val progressPercent = if (total > 0) (processed * 100) / total else 0
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSmallIcon(android.R.drawable.ic_popup_sync) // The Industrial Loop icon
.setColor(0xFF3498DB.toInt()) // Industrial Purple/Blue tint
.setContentTitle("Sending Snaps to $currentRecipientName")
.setContentText("Progress: $processed / $total ($progressPercent%)")
.setSubText("$processed / $total")
.setProgress(total, processed, false)
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(ACTION_PAUSE_RESUME)).build())
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build())
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
}
private fun showCompletionNotification(sent: Int, total: Int) {
val isError = sent < total && !isStopped.get()
val title = when {
isStopped.get() -> "Continuous Send Stopped"
isError -> "Continuous Send Failed"
else -> "Continuous Send Finished"
}
val content = "Sent $sent / $total snaps to $currentRecipientName"
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
.setContentTitle(title)
.setContentText(content)
.setAutoCancel(true)
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
}
private fun createPendingIntent(action: String): PendingIntent {
val intent = Intent(action).setPackage(context.androidContext.packageName)
return PendingIntent.getBroadcast(
context.androidContext,
action.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -302,14 +309,14 @@ class SendOverride : Feature("Send Override") {
when (intent?.action) {
ACTION_PAUSE_RESUME -> {
isPaused.set(!isPaused.get())
updateContinuousSendNotification()
updateContinuousSendNotification(context)
}
ACTION_STOP -> {
isStopped.set(true)
if (isPaused.get()) {
isPaused.set(false)
}
updateContinuousSendNotification()
updateContinuousSendNotification(context)
}
}
}
@@ -858,10 +865,10 @@ class SendOverride : Feature("Send Override") {
completionCallback: Any?
): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val mediaBuffers = mutableListOf<ByteArray>()
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
@@ -933,27 +940,54 @@ class SendOverride : Feature("Send Override") {
if (repeatCount <= 0) return false
fun sendIteration(index: Int) {
if (isStopped.get()) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification()
return
}
val callback = if (index == repeatCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
context.coroutineScope.launch {
while (isPaused.get() && !isStopped.get()) {
delay(500)
}
if (isStopped.get()) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return@launch
}
val callback = CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendIteration(index + 1)
processedRepeatCount++
queuedOriginalItemRepeatCount--
updateContinuousSendNotification(context)
if (index < repeatCount - 1) {
sendIteration(index + 1)
} else {
// Batch Finished: Trigger original Snapchat callback
originalCallback?.let { cb ->
runCatching {
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
if (method != null) {
if (method.parameterCount == 0) {
method.invoke(cb)
} else {
// Pass null for all required parameters to safely trigger the completion UI
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
}
}
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
}
}
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
.build()
}
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
if (index > 0) delay(1000) // Human-like delay
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
}
}
}
@@ -980,7 +1014,7 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess()
handleQueuedOriginalItemRepeatSuccess(context)
} else {
false
}
@@ -1008,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
@@ -1564,7 +1597,6 @@ class SendOverride : Feature("Send Override") {
totalRepeatCount = repeatCount
processedRepeatCount = 1
currentRecipientName = recipientNameForTask
updateContinuousSendNotification()
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
attachQueuedRepeatCallbacks(event)
@@ -1572,12 +1604,13 @@ class SendOverride : Feature("Send Override") {
invokeOriginalAndRestoreResult(event)
} else {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
} else {
totalRepeatCount = repeatCount
processedRepeatCount = 0
queuedOriginalItemRepeatCount = repeatCount
currentRecipientName = recipientNameForTask
updateContinuousSendNotification()
sendRepeatedMediaManual(
repeatCount,

View File

@@ -347,6 +347,9 @@ class PerformanceMode : Feature("Performance Mode") {
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
val textureView = param.thisObject<TextureView>()
runCatching {
// Universal Guard: Only accelerate views owned by Snapchat.
// This prevents crashes in native hardware providers across all devices.
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
}

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.7.0
APP_VERSION_CODE=326
APP_VERSION_NAME=1.7.1
APP_VERSION_CODE=327
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

View File

@@ -128,7 +128,4 @@ include(":core")
include(":valdi")
include(":app")
include(":mapper")
include(":manager")
include(":native")
project(":manager").projectDir = file("mapper")

286
tools/snap_story_dl.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
PurrSnap Story Downloader
=========================
Downloads and decrypts Snapchat friend stories using metadata captured by the
PurrfectSnap Companion Server running on your rooted Android device.
How it works
------------
1. The PurrfectSnap Xposed mod intercepts the df-mixer-prod/stories HTTP response
while you browse the Snapchat story feed normally.
2. It extracts each story's CDN URL, AES-128 key, and IV from the response proto.
3. This script authenticates to the Companion Server (using your own token),
fetches that metadata, downloads each encrypted file directly from Snapchat's
CDN, and decrypts it locally.
No Snapchat credentials are used or transmitted by this script.
The Snapchat session on the device is not affected.
Requirements
------------
pip install pycryptodome requests
Usage
-----
python snap_story_dl.py --server http://192.168.1.5:8484 --token YOUR_TOKEN
python snap_story_dl.py --server http://192.168.1.5:8484 --token YOUR_TOKEN --user USER_ID_HERE
python snap_story_dl.py --server http://192.168.1.5:8484 --token YOUR_TOKEN --list-users
Note: Browse to the Snapchat story feed on the device BEFORE running this script
so that the mod has a chance to intercept and cache the story metadata.
"""
import argparse
import base64
import hashlib
import json
import sys
from pathlib import Path
from typing import Optional
import requests
from Crypto.Cipher import AES
# ── Companion Server auth ─────────────────────────────────────────────────────
def login(server: str, token: str) -> requests.Session:
"""POST /login with the token; returns a Session with the session cookie set."""
session = requests.Session()
r = session.post(
f"{server}/login",
data={"token": token},
allow_redirects=False,
timeout=10,
)
if "session=" not in r.headers.get("Set-Cookie", ""):
print("ERROR: Login failed — wrong token or server not reachable.", file=sys.stderr)
sys.exit(1)
print(f" Logged in to {server}")
return session
# ── Story metadata from Companion Server ──────────────────────────────────────
def fetch_stories(session: requests.Session, server: str, user_id: Optional[str]) -> list[dict]:
"""
GET /stories or GET /stories?user_id=xxx
Returns a flat list of story dicts:
{ userId, url, key, iv, postedAt, createdAt, capturedAt }
"""
url = f"{server}/stories"
if user_id:
url += f"?user_id={user_id}"
r = session.get(url, timeout=15)
r.raise_for_status()
data = r.json()
if user_id:
# API returns list of story objects directly
return [{"userId": user_id, **s} for s in (data if isinstance(data, list) else [])]
# API returns list of { userId, count, stories: [...] }
flat: list[dict] = []
for group in data if isinstance(data, list) else []:
for story in group.get("stories", []):
flat.append({"userId": group["userId"], **story})
return flat
def list_users(session: requests.Session, server: str) -> None:
r = session.get(f"{server}/stories", timeout=15)
r.raise_for_status()
groups = r.json()
if not groups:
print("No stories captured yet. Open Snapchat and browse the story feed first.")
return
print(f"{'User ID':<40} Stories")
print("-" * 55)
for g in groups:
print(f"{g['userId']:<40} {g['count']}")
# ── Decryption ────────────────────────────────────────────────────────────────
def _b64decode_padded(s: str) -> bytes:
"""Base64-decode with automatic padding."""
s = s.strip()
pad = (4 - len(s) % 4) % 4
return base64.b64decode(s + "=" * pad)
def decrypt_story(encrypted: bytes, key_b64: str, iv_b64: str) -> bytes:
"""
AES-128-CBC decrypt.
Snapchat uses PKCS5/7 padding for most media; strip it if present.
Falls back gracefully if padding is absent (NoPadding mode).
"""
key = _b64decode_padded(key_b64)
iv = _b64decode_padded(iv_b64)
if len(key) not in (16, 24, 32):
raise ValueError(f"Unexpected key length {len(key)} bytes")
if len(iv) != 16:
raise ValueError(f"Unexpected IV length {len(iv)} bytes")
cipher = AES.new(key, AES.MODE_CBC, iv)
data = cipher.decrypt(encrypted)
# Strip PKCS7 padding if present and plausible
if data and 1 <= data[-1] <= 16:
pad_len = data[-1]
if data[-pad_len:] == bytes([pad_len] * pad_len):
data = data[:-pad_len]
return data
# ── File type detection ───────────────────────────────────────────────────────
def detect_ext(data: bytes) -> str:
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[4:8] == b"ftyp":
return ".mp4"
if data[:4] in (b"GIF8", b"GIF9"):
return ".gif"
return ".bin"
# ── Download + decrypt ────────────────────────────────────────────────────────
_SNAP_CDN_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Safari/537.36"
),
"Accept": "*/*",
}
def download_and_decrypt(story: dict, out_dir: Path) -> Optional[Path]:
url = story.get("url", "")
key_b64 = story.get("key", "")
iv_b64 = story.get("iv", "")
user_id = story.get("userId", "unknown")
posted = story.get("postedAt", 0)
if not url or not key_b64 or not iv_b64:
return None
# Deterministic filename: userId + postedAt + URL hash
url_hash = hashlib.md5(url.encode()).hexdigest()[:10]
stem = f"{user_id}_{posted}_{url_hash}"
# Skip if any extension of this stem already exists
for ext in (".jpg", ".mp4", ".png", ".webp", ".gif", ".bin"):
if (out_dir / (stem + ext)).exists():
return out_dir / (stem + ext)
r = requests.get(url, headers=_SNAP_CDN_HEADERS, timeout=60)
r.raise_for_status()
plaintext = decrypt_story(r.content, key_b64, iv_b64)
ext = detect_ext(plaintext)
dest = out_dir / (stem + ext)
dest.write_bytes(plaintext)
return dest
# ── CLI ───────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Download + decrypt Snapchat stories via PurrfectSnap Companion Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--server",
default="http://192.168.1.x:8484",
metavar="URL",
help="Companion Server address, e.g. http://192.168.1.5:8484",
)
parser.add_argument(
"--token",
required=True,
metavar="TOKEN",
help="Companion Server access token (set in PurrfectSnap settings)",
)
parser.add_argument(
"--user",
default=None,
metavar="USER_ID",
help="Snapchat internal user ID to filter (omit for all captured users)",
)
parser.add_argument(
"--out",
default="stories_out",
metavar="DIR",
help="Output directory (created if absent). Default: stories_out/",
)
parser.add_argument(
"--list-users",
action="store_true",
help="List captured user IDs and story counts, then exit",
)
args = parser.parse_args()
if args.server.endswith("x:8484"):
print("ERROR: Set --server to your device's LAN IP, e.g. http://192.168.1.5:8484", file=sys.stderr)
sys.exit(1)
print(f"Connecting to {args.server}")
session = login(args.server, args.token)
if args.list_users:
list_users(session, args.server)
return
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("Fetching story metadata …")
stories = fetch_stories(session, args.server, args.user)
if not stories:
print(
"No stories found.\n"
"Open Snapchat on the device, browse the Friends story feed (scroll down to see all),\n"
"then re-run this script."
)
return
print(f"Found {len(stories)} stories. Downloading …\n")
ok = fail = skip = 0
for story in stories:
uid = story.get("userId", "?")
url = story.get("url", "?")[:60]
try:
path = download_and_decrypt(story, out_dir)
if path is None:
skip += 1
elif path.stat().st_size > 0:
print(f" [{uid[:20]}] {path.name}")
ok += 1
else:
skip += 1
except Exception as e:
print(f" FAIL [{uid[:20]}] {url}… — {e}", file=sys.stderr)
fail += 1
print(f"\nDone — {ok} saved, {skip} skipped (already existed), {fail} failed")
print(f"Output: {out_dir.resolve()}")
if __name__ == "__main__":
main()