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: with:
name: purrfectsnap-armv8-debug name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk 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 run: npm install typescript -g
- name: Add Android targets for Rust - 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 - name: Build
run: ./gradlew --configuration-cache assembleArmv8Debug run: ./gradlew --configuration-cache assembleArmv8Debug
@@ -46,49 +46,6 @@ jobs:
with: with:
name: purrfectsnap-armv8-debug name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk 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: job_manager:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -136,7 +93,7 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Add Android targets for Rust - 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 - name: Build
run: ./gradlew --configuration-cache assembleCoreDebug run: ./gradlew --configuration-cache assembleCoreDebug

View File

@@ -6,6 +6,8 @@ on:
description: 'Upload to CI channel' description: 'Upload to CI channel'
required: false required: false
type: boolean type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
job_armv8: job_armv8:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -13,11 +15,11 @@ jobs:
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }} armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4.2.2
with: with:
submodules: 'recursive' submodules: 'recursive'
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4.7.0
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@@ -27,7 +29,7 @@ jobs:
- name: Setup NPM Dependencies - name: Setup NPM Dependencies
run: npm install typescript -g run: npm install typescript -g
- name: Add Android targets for Rust - 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 - name: Prepare release keystore for Gradle signing
env: env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }} 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 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 - name: Upload artifact
id: upload_apk id: upload_apk
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4.6.2
with: with:
name: purrfectsnap-armv8-release name: purrfectsnap-armv8-release
path: app/build/outputs/apk/armv8/release/*.apk 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: make_release:
needs: [job_armv7, job_armv8] needs: [job_armv8]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4.2.2
with: with:
submodules: 'recursive' submodules: 'recursive'
- name: Download all build artifacts - name: Download all build artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4.2.1
with: with:
path: ./all-apks path: ./all-apks
- name: Display all files for debug - name: Display all files for debug

View File

@@ -616,32 +616,51 @@ class DownloadProcessor (
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay 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 -> downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap() 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 -> zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream -> MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply { if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
outputStream().use { baseMediaFile = createMediaTempFile().apply {
inputStream.copyTo(it) outputStream().use { inputStream.copyTo(it) }
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
} }
} }
} }
oldDownloadedMedias.forEach { (_, value) -> oldDownloadedMedias.forEach { (_, value) -> value.delete() }
value.delete()
}
shouldMergeOverlay = true baseMediaFile?.let { baseMedia ->
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
baseMedia.delete()
}
return@launch
} }
if (shouldMergeOverlay) { if (shouldMergeOverlay) {

View File

@@ -151,22 +151,27 @@ class FFMpegProcessor(
} }
val outputArguments = ArgumentList().apply { 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 += "-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" 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) { when (args.action) {
Action.DOWNLOAD_DASH -> { Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'" outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) { if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'" outputArguments += "-t" to "'${args.duration}ms'"
} }
} }
Action.MERGE_OVERLAY -> { Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath 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)\"" 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()) { if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a" outputArguments -= "-c:a"
} }
outputArguments -= "-c:v"
args.videoCodec?.let { args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it outputArguments += "-c:v" to it
} ?: run { } ?: run {
outputArguments += "-vn" outputArguments += "-vn"
@@ -186,6 +192,7 @@ class FFMpegProcessor(
} }
} }
Action.MERGE_MEDIA -> { Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear() inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file -> val filesInfo = args.inputs.mapNotNull { file ->
runCatching { runCatching {

View File

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

View File

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

View File

@@ -140,7 +140,9 @@ class ManageRuleFeature : Routes.Route() {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) { val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key) context.database.getRuleIds(currentRuleType.key)
} }
val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } } val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) { fun setRuleState(newState: RuleState?) {
ruleState = newState ruleState = newState
@@ -166,7 +168,7 @@ class ManageRuleFeature : Routes.Route() {
onFriendState = { friend, state -> onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state) context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) { if (state) {
if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId) if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else { } else {
currentRuleIds.remove(friend.userId) currentRuleIds.remove(friend.userId)
} }
@@ -174,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state -> onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state) context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) { if (state) {
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId) if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else { } else {
currentRuleIds.remove(group.conversationId) currentRuleIds.remove(group.conversationId)
} }
}, },
getFriendState = { friend -> getFriendState = { friend ->
ruleIdsSet.contains(friend.userId) currentRuleIdSet.contains(friend.userId)
}, },
getGroupState = { group -> 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 // Real-time synchronization from the bridge
context.database.messagingDataFlow.collect { (friends, groups) -> context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) { 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) { withContext(Dispatchers.Main) {
friendList = sortedFriends friendList = sortedFriends
groupList = groups groupList = dbGroups
} }
} }
} }
@@ -172,8 +174,7 @@ class SocialRootSection : Routes.Route() {
}, },
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null }, getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null } getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
), )
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
) )
}, },
modifier = Modifier modifier = Modifier

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
} }
// You can still set these for legacy use by submodules or scripts: // 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("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("326").get().toInt()) rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap") rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate. // 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. // 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 ## v1.7.0
- Features: - Features:
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL) - Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
- Implemented app intro showcase (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 Social Tab sorting by Streak Length (tq Javalsta)
- Implemented "Chat Hold Kill" (tq C R E S T) - Implemented "Chat Hold Kill" (tq SUJΛL)
- Implemented "Snapchat Purchase Date Spoof" (tq C R E S T) - Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
- Implemented Message log export for individual chat (tq schrodingerspet) - Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Implemented message icon indicator for Memories (tq <RSR/>) - Implemented Memory message icon indicator(tq RSR)
- Implemented two new message indicator toggles, for self snaps and group. - 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 toggles for chat and snap stealth mode in friend feed menu.
- Implemented new notification card for Continous send feature. - Implemented new notification card for Continous send feature.
- Fixes: - Fixes:
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq <RSR/>) - Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
- Media resend flow bug fixes. (tq schrodingerspet) - Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq schrodingerspet) - Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Media download support through message logger. (tq schrodingerspet) - Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Snapchat Plus bug fixes to improve stability. - Snapchat Plus bug fixes to improve stability.
- Spoof Device profile Backup/Restore bug fixes. - Spoof Device profile Backup/Restore bug fixes.
- Redesgined manager app Logs filtering UI. - 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" "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": { "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) } FeatureNotice.UNSTABLE) }
val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject } val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject }
val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch } 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(), CustomTheming(),
HideTypingIndicator(), HideTypingIndicator(),
FakeSnapScore(), FakeSnapScore(),
CompanionServer(),
) )
features.values.toList().forEach { feature -> features.values.toList().forEach { feature ->

View File

@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
} && getRuleState() != null } && getRuleState() != null
} }
fun canUseRule(conversationId: String): Boolean { open fun canUseRule(conversationId: String): Boolean {
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) { if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
return false 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.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder 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.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.features.impl.ui.OperaStoryOverlay
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
@@ -155,78 +154,62 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
callback = object: DownloadCallback.Stub() { callback = object: DownloadCallback.Stub() {
override fun onSuccess(outputFile: String) { override fun onSuccess(outputFile: String) {
var finalOutputFile = outputFile var finalOutputFile = outputFile
modCtx.coroutineScope.launch(Dispatchers.IO) { runCatching {
runCatching { val file = java.io.File(outputFile)
// settle delay to ensure disk flush if (file.exists()) {
delay(120L) val header = file.inputStream().use { input ->
val file = java.io.File(outputFile) val buffer = ByteArray(16)
if (file.exists()) { input.read(buffer)
val header = file.inputStream().use { input -> buffer
val buffer = ByteArray(16) }
input.read(buffer)
buffer
}
val fileType = FileType.fromByteArray(header) val fileType = FileType.fromByteArray(header)
val expectedExt = fileType.fileExtension if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) {
val newPath = outputFile.removeSuffix(".dat").removeSuffix(".tmp") + ".mp4"
if (fileType != FileType.UNKNOWN && expectedExt != null && val newFile = java.io.File(newPath)
!outputFile.endsWith(".$expectedExt", ignoreCase = true)) { if (file.renameTo(newFile)) {
val base = outputFile.substringBeforeLast('.').takeIf { '.' in outputFile } ?: outputFile finalOutputFile = newPath
val newPath = "$base.$expectedExt" } else {
val newFile = java.io.File(newPath) file.copyTo(newFile, overwrite = true)
if (file.renameTo(newFile)) { file.delete()
finalOutputFile = newPath 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")) { if (downloadLogging.contains("success")) {
val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) modCtx.inAppOverlay.showStatusToast(
modCtx.runOnUiThread { icon = Icons.Outlined.DownloadDone,
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()),
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) 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) { override fun onProgress(message: String) {
if (isBatch || !downloadLogging.contains("progress")) return if (isBatch || !downloadLogging.contains("progress")) return
val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" } val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" }
modCtx.runOnUiThread { if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
}
} }
override fun onFailure(message: String, throwable: String?) { override fun onFailure(message: String, throwable: String?) {
if (!downloadLogging.contains("failure")) return if (!downloadLogging.contains("failure")) return
val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message
if (isBatch) { batchFailureCount.incrementAndGet(); return } if (isBatch) { batchFailureCount.incrementAndGet(); return }
modCtx.runOnUiThread { if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText)
if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
}
} }
} }
) )
@@ -347,28 +330,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val totalCount = paramMap.getStorySnapTotal() val totalCount = paramMap.getStorySnapTotal()
modCtx.runOnUiThread { modCtx.runOnUiThread {
fun tryJump(retryCount: Int = 0) { 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({ 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 val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true
if (!jumped && retryCount < 1) tryJump(retryCount + 1)
when { else if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") }
jumped -> {} }, if (retryCount == 0) 120L else 220L)
retryCount < maxRetries -> tryJump(retryCount + 1)
else -> {
synchronized(batchLock) { pendingBatchDownloadIndices = null }
modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed")
}
}
}, delayMs)
} }
tryJump() 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 val modCtx = this@MediaDownloader.context
if (mediaInfoMap.isEmpty()) return if (mediaInfoMap.isEmpty()) return
paramMap["SNAP_ID"]?.toString()?.let { snapId -> paramMap["SNAP_ID"]?.toString()?.let { snapId ->
@@ -473,6 +439,17 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
} }
val originalMediaRef = handleLocalReferences(mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.uri) val originalMediaRef = handleLocalReferences(mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.uri)
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> 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) val overlayRef = handleLocalReferences(overlay.uri)
downloadManagerClient.downloadMediaWithOverlay( downloadManagerClient.downloadMediaWithOverlay(
InputMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()), 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 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 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 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 return
} }
paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup -> paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let {
val playlistGroupString = playlistGroup.toString() val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",")
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@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
if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) 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 return
} }
val snapSource = paramMap["SNAP_SOURCE"].toString() val snapSource = paramMap["SNAP_SOURCE"].toString()
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { 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 if (!forceDownload && !shouldAutoDownload("public_stories")) return
val rawAuthor = ( val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath()
paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap, MediaDownloadSource.PUBLIC_STORY)
?: 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)
} }
private fun shouldAutoDownload(keyFilter: String? = null): Boolean = this@MediaDownloader.context.config.downloader.autoDownloadSources.get().any { keyFilter == null || it.contains(keyFilter, true) } 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 mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap!!.containsKey("video_media_info_list") val isVideo = mediaParamMap!!.containsKey("video_media_info_list")
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(mediaParamMap[if (isVideo) "video_media_info_list" else "image_media_info"]!!) 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 if (shouldAutoDownload() && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@hook
lastSeenMapParams = mediaParamMap; lastSeenMediaInfoMap = mediaInfoMap lastSeenMapParams = mediaParamMap; lastSeenMediaInfoMap = mediaInfoMap
if (pendingBatchDownloadIndices != null) { modCtx.coroutineScope.launch { processNextBatchDownload(mediaParamMap, mediaInfoMap) }; return@hook } 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) { suspend fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) {
val modCtx = this@MediaDownloader.context val modCtx = this@MediaDownloader.context
val message = modCtx.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found") val message = modCtx.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found")
val friendInfo = modCtx.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found") val friendInfo = modCtx.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found")
val decodedAttachments = message.messageContent?.let { content -> val decodedAttachments = MessageDecoder.decode(ProtoReader(message.messageContent!!)).toMutableList()
MessageDecoder.decode(ProtoReader(content)) if (decodedAttachments.isEmpty()) { modCtx.shortToast(translations["no_attachments_toast"] ?: "No Attachments"); return }
}?.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 }
if (!isPreview) { if (!isPreview) {
if (forceDownloadFirst || downloadableAttachments.size == 1 || modCtx.isMainActivityPaused) { if (forceDownloadFirst || decodedAttachments.size == 1 || modCtx.isMainActivityPaused) {
downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(downloadableAttachments.first()), forceAllowDuplicate) downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(decodedAttachments.first()), forceAllowDuplicate)
} else { } else {
withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, downloadableAttachments, forceAllowDuplicate) } withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, decodedAttachments, forceAllowDuplicate) }
} }
return return
} }
if (downloadableAttachments.size == 1) { previewAttachment(downloadableAttachments.first()); return } if (decodedAttachments.size == 1) { previewAttachment(decodedAttachments.first()); return }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val mainActivity = modCtx.mainActivity ?: return@withContext val mainActivity = modCtx.mainActivity ?: return@withContext
ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply {
var selected = 0 var selected = 0
setSingleChoiceItems(downloadableAttachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), 0) { _, w -> selected = w } 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(downloadableAttachments[selected]) } setPositiveButton(modCtx.translation["chat_action_menu.preview_button"] ?: "Preview") { _, _ -> previewAttachment(decodedAttachments[selected]) }
}.show() }.show()
} }
} }

View File

@@ -113,7 +113,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
} }
override fun init() { override fun init() {
if (autoOpenConfig.globalState == false) return if (autoOpenConfig.globalState != true) return
restorePersistence() restorePersistence()
createNotificationChannels() createNotificationChannels()
@@ -486,9 +486,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
builder.setStyle(bigTextStyle) 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 { private fun createPendingIntent(action: String): PendingIntent {
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName) 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) 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") .setContentTitle("Auto-Open")
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.") .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) private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
fun getInterface(): AutoOpenInterface { 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 const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>() private val queuedSplitItems = ArrayDeque<Any>()
private val queuedSplitItemIds = ArrayDeque<String>() private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>() private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
private var originalUnsplitItem: Any? = null private var originalUnsplitItem: Any? = null
private var reusableOriginalItem: Any? = null private var reusableOriginalItem: Any? = null
private var queuedOverrideType: String? = null private var queuedOverrideType: String? = null
private var queuedOverrideSnapDurationMs: Int? = null private var queuedOverrideSnapDurationMs: Int? = null
private var bypassSplitOnce = false private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null 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 hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty() fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
@@ -91,13 +91,13 @@ class MediaFilePicker : Feature("Media File Picker") {
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) { fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) { if (deleteTempItems) {
val cleanup = cleanupItemHandler val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri -> queuedSplitCleanupItems.values.toList().forEach { item ->
cleanup?.invoke(uri) cleanup?.invoke(item.uri, item.filePath)
} }
} }
queuedSplitItems.clear() queuedSplitItems.clear()
queuedSplitItemIds.clear() queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear() queuedSplitCleanupItems.clear()
originalUnsplitItem = null originalUnsplitItem = null
queuedOverrideType = null queuedOverrideType = null
queuedOverrideSnapDurationMs = null queuedOverrideSnapDurationMs = null
@@ -114,7 +114,7 @@ class MediaFilePicker : Feature("Media File Picker") {
items.drop(1).forEach { queuedSplitItems.addLast(it) } items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach { preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId) queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri queuedSplitCleanupItems[it.itemId] = it
} }
} }
fun sendOriginalUnsplitItem(): Boolean { fun sendOriginalUnsplitItem(): Boolean {
@@ -130,8 +130,8 @@ class MediaFilePicker : Feature("Media File Picker") {
} }
fun handleCurrentQueuedItemSuccess(): Boolean { fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId -> queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri -> queuedSplitCleanupItems.remove(itemId)?.let { item ->
cleanupItemHandler?.invoke(uri) cleanupItemHandler?.invoke(item.uri, item.filePath)
} }
} }
if (queuedSplitItems.isEmpty()) { if (queuedSplitItems.isEmpty()) {
@@ -159,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
private data class PreparedMediaItem( private data class PreparedMediaItem(
val itemId: String, val itemId: String,
val durationMs: Long, val durationMs: Long,
val uri: String val uri: String,
val filePath: String? = null
) )
private fun splitVideoIntoChunks( private fun splitVideoIntoChunks(
@@ -309,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
runCatching { resolver.delete(uri, null, null) } 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> { private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
@@ -431,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
false false
} }
} }
cleanupItemHandler = { uriString -> cleanupItemHandler = { uriString, filePath ->
runCatching { 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) context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure { }.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 if (sendItemsHookedHandler === handlerInstance) return@hook

View File

@@ -34,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType 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.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter 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.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
@@ -88,6 +90,7 @@ class SendOverride : Feature("Send Override") {
private var currentRecipientName: String = "Unknown" private var currentRecipientName: String = "Unknown"
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false) private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
private val isStopped = 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?) { private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
queuedOriginalItemRepeatCount = repeatCount queuedOriginalItemRepeatCount = repeatCount
@@ -102,24 +105,93 @@ class SendOverride : Feature("Send Override") {
queuedOriginalItemRepeatSnapDurationMs = null 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) { if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats() clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false return false
} }
val overrideType = queuedOriginalItemRepeatOverrideType ?: run { val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats() clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false return false
} }
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
processedRepeatCount++
queuedOriginalItemRepeatCount-- queuedOriginalItemRepeatCount--
updateContinuousSendNotification(context)
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
val result = MediaFilePicker.sendReusableOriginalItem() val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) { if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats() clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
} }
return result return result
} }
@@ -136,7 +208,6 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any() private val backgroundHookLock = Any()
private var backgroundHookRefs = 0 private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null private var backgroundHooks: List<Hooker.HookHandle>? = null
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
private fun createContinuousNotificationChannel() { private fun createContinuousNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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 { private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {} if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false var enableFailed = false
@@ -302,14 +309,14 @@ class SendOverride : Feature("Send Override") {
when (intent?.action) { when (intent?.action) {
ACTION_PAUSE_RESUME -> { ACTION_PAUSE_RESUME -> {
isPaused.set(!isPaused.get()) isPaused.set(!isPaused.get())
updateContinuousSendNotification() updateContinuousSendNotification(context)
} }
ACTION_STOP -> { ACTION_STOP -> {
isStopped.set(true) isStopped.set(true)
if (isPaused.get()) { if (isPaused.get()) {
isPaused.set(false) isPaused.set(false)
} }
updateContinuousSendNotification() updateContinuousSendNotification(context)
} }
} }
} }
@@ -858,10 +865,10 @@ class SendOverride : Feature("Send Override") {
completionCallback: Any? completionCallback: Any?
): Boolean { ): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false) 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) { if (overrideType != "ORIGINAL" && mediaCount > 1) {
val mediaBuffers = mutableListOf<ByteArray>() 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 (id == 3) mediaBuffers.add(buffer)
} }
if (mediaBuffers.isEmpty()) return false if (mediaBuffers.isEmpty()) return false
@@ -933,27 +940,54 @@ class SendOverride : Feature("Send Override") {
if (repeatCount <= 0) return false if (repeatCount <= 0) return false
fun sendIteration(index: Int) { fun sendIteration(index: Int) {
if (isStopped.get()) { context.coroutineScope.launch {
clearQueuedOriginalItemRepeats() while (isPaused.get() && !isStopped.get()) {
updateContinuousSendNotification() delay(500)
return }
} if (isStopped.get()) {
val callback = if (index == repeatCount - 1) { clearQueuedOriginalItemRepeats()
originalCallback updateContinuousSendNotification(context)
} else { return@launch
CallbackBuilder(sendMessageCallbackClass) }
val callback = CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") { .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) { .override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0)) invokeCallbackError(originalCallback, it.argNullable<Any>(0))
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
} }
.build() .build()
}
val preparedContent = createMessageContentFromOriginal() if (index > 0) delay(1000) // Human-like delay
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send") 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 { context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess() val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) { val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess() handleQueuedOriginalItemRepeatSuccess(context)
} else { } else {
false false
} }
@@ -1008,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread { context.runOnUiThread {
val recipientNameForTask = recipientName val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog -> createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme { PurrfectOverlayTheme {
@@ -1564,7 +1597,6 @@ class SendOverride : Feature("Send Override") {
totalRepeatCount = repeatCount totalRepeatCount = repeatCount
processedRepeatCount = 1 processedRepeatCount = 1
currentRecipientName = recipientNameForTask currentRecipientName = recipientNameForTask
updateContinuousSendNotification()
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
attachQueuedRepeatCallbacks(event) attachQueuedRepeatCallbacks(event)
@@ -1572,12 +1604,13 @@ class SendOverride : Feature("Send Override") {
invokeOriginalAndRestoreResult(event) invokeOriginalAndRestoreResult(event)
} else { } else {
clearQueuedOriginalItemRepeats() clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
} }
} else { } else {
totalRepeatCount = repeatCount totalRepeatCount = repeatCount
processedRepeatCount = 0 processedRepeatCount = 0
queuedOriginalItemRepeatCount = repeatCount
currentRecipientName = recipientNameForTask currentRecipientName = recipientNameForTask
updateContinuousSendNotification()
sendRepeatedMediaManual( sendRepeatedMediaManual(
repeatCount, repeatCount,

View File

@@ -347,6 +347,9 @@ class PerformanceMode : Feature("Performance Mode") {
TextureView::class.java.hookConstructor(HookStage.AFTER) { param -> TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
val textureView = param.thisObject<TextureView>() val textureView = param.thisObject<TextureView>()
runCatching { 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) textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
} }
} }

View File

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

View File

@@ -128,7 +128,4 @@ include(":core")
include(":valdi") include(":valdi")
include(":app") include(":app")
include(":mapper") include(":mapper")
include(":manager")
include(":native") 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()