21 Commits
v1.7.1 ... 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
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
13 changed files with 1555 additions and 411 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

@@ -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

@@ -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

@@ -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()