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

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

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

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