Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbcd20d65 | ||
|
|
79a1114069 | ||
|
|
d724e5c615 | ||
|
|
5651d48d96 | ||
|
|
8f8c696431 | ||
|
|
e408a505ab | ||
|
|
ded49b8b1b | ||
|
|
d9593fa629 | ||
|
|
cab8c69f72 | ||
|
|
616ae0d3b3 | ||
|
|
c0c5503864 | ||
|
|
98e04a81cb | ||
|
|
947bb399c8 | ||
|
|
e23bf443a1 | ||
|
|
760d652f5f | ||
|
|
22ee866205 | ||
|
|
ce3361992c | ||
|
|
768a3b7c09 | ||
|
|
b0715b3f27 | ||
|
|
b6435f9010 | ||
|
|
e0431bd001 | ||
|
|
d86d9db508 | ||
|
|
068b272b5e |
98
.github/workflows/debug.yml
vendored
98
.github/workflows/debug.yml
vendored
@@ -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
|
|
||||||
|
|||||||
47
.github/workflows/pull_request.yml
vendored
47
.github/workflows/pull_request.yml
vendored
@@ -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
|
||||||
|
|||||||
114
.github/workflows/release.yml
vendored
114
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -151,22 +151,27 @@ class FFMpegProcessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val outputArguments = ArgumentList().apply {
|
val outputArguments = ArgumentList().apply {
|
||||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
|
||||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
|
||||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
||||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
|
||||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
|
||||||
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun applyVideoArguments() {
|
||||||
|
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||||
|
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||||
|
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||||
|
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||||
|
}
|
||||||
|
|
||||||
when (args.action) {
|
when (args.action) {
|
||||||
Action.DOWNLOAD_DASH -> {
|
Action.DOWNLOAD_DASH -> {
|
||||||
|
applyVideoArguments()
|
||||||
outputArguments += "-ss" to "'${args.startTime}ms'"
|
outputArguments += "-ss" to "'${args.startTime}ms'"
|
||||||
if (args.duration != null) {
|
if (args.duration != null) {
|
||||||
outputArguments += "-t" to "'${args.duration}ms'"
|
outputArguments += "-t" to "'${args.duration}ms'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action.MERGE_OVERLAY -> {
|
Action.MERGE_OVERLAY -> {
|
||||||
|
applyVideoArguments()
|
||||||
inputArguments += "-i" to args.overlay!!.absolutePath
|
inputArguments += "-i" to args.overlay!!.absolutePath
|
||||||
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
||||||
}
|
}
|
||||||
@@ -174,8 +179,9 @@ class FFMpegProcessor(
|
|||||||
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
||||||
outputArguments -= "-c:a"
|
outputArguments -= "-c:a"
|
||||||
}
|
}
|
||||||
outputArguments -= "-c:v"
|
|
||||||
args.videoCodec?.let {
|
args.videoCodec?.let {
|
||||||
|
applyVideoArguments()
|
||||||
|
outputArguments -= "-c:v"
|
||||||
outputArguments += "-c:v" to it
|
outputArguments += "-c:v" to it
|
||||||
} ?: run {
|
} ?: run {
|
||||||
outputArguments += "-vn"
|
outputArguments += "-vn"
|
||||||
@@ -186,6 +192,7 @@ class FFMpegProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action.MERGE_MEDIA -> {
|
Action.MERGE_MEDIA -> {
|
||||||
|
applyVideoArguments()
|
||||||
inputArguments.clear()
|
inputArguments.clear()
|
||||||
val filesInfo = args.inputs.mapNotNull { file ->
|
val filesInfo = args.inputs.mapNotNull { file ->
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|||||||
@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
|
|||||||
database.beginTransaction()
|
database.beginTransaction()
|
||||||
try {
|
try {
|
||||||
friends.forEach { friend ->
|
friends.forEach { friend ->
|
||||||
|
// Industrial Filter: Only update existing friends, never auto-insert new ones.
|
||||||
database.execSQL(
|
database.execSQL(
|
||||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
|
||||||
arrayOf<Any?>(
|
arrayOf<Any?>(
|
||||||
friend.userId,
|
|
||||||
friend.dmConversationId,
|
friend.dmConversationId,
|
||||||
friend.displayName,
|
friend.displayName,
|
||||||
friend.mutableUsername,
|
friend.mutableUsername,
|
||||||
friend.bitmojiId,
|
friend.bitmojiId,
|
||||||
friend.selfieId
|
friend.selfieId,
|
||||||
|
friend.userId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
|
// Industrial Filter: Only update existing groups.
|
||||||
database.execSQL(
|
database.execSQL(
|
||||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
|
||||||
arrayOf<Any?>(
|
arrayOf<Any?>(
|
||||||
group.conversationId,
|
|
||||||
group.name,
|
group.name,
|
||||||
group.participantsCount
|
group.participantsCount,
|
||||||
|
group.conversationId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
|
|||||||
database.endTransaction()
|
database.endTransaction()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify all observers with the updated data from the database
|
// Notify all observers with the raw sync data (AddFriendDialog needs this)
|
||||||
val allFriends = getFriends(descOrder = true)
|
messagingDataFlow.tryEmit(friends to groups)
|
||||||
val allGroups = getGroups()
|
|
||||||
messagingDataFlow.tryEmit(allFriends to allGroups)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (shouldShowAbiWarning) {
|
if (shouldShowAbiWarning) {
|
||||||
AestheticDialog(
|
AestheticDialog(
|
||||||
onDismissRequest = {},
|
onDismissRequest = {},
|
||||||
title = managerContext.translation["wrong_apk_title"],
|
title = managerContext.translation["setup.activity.wrong_apk_title"],
|
||||||
text = "",
|
text = "",
|
||||||
icon = Icons.Filled.Warning,
|
icon = Icons.Filled.Warning,
|
||||||
confirmButtonText = managerContext.translation["common.close"],
|
confirmButtonText = managerContext.translation["setup.activity.close_button"],
|
||||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
opaque = true,
|
opaque = true,
|
||||||
customContent = {
|
customContent = {
|
||||||
Text(
|
Text(
|
||||||
text = managerContext.translation["wrong_apk_message"],
|
text = managerContext.translation["setup.activity.wrong_apk_message"],
|
||||||
color = PurrfectPalette.textSecondary,
|
color = PurrfectPalette.textSecondary,
|
||||||
lineHeight = 18.sp
|
lineHeight = 18.sp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ class ManageRuleFeature : Routes.Route() {
|
|||||||
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||||
context.database.getRuleIds(currentRuleType.key)
|
context.database.getRuleIds(currentRuleType.key)
|
||||||
}
|
}
|
||||||
val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
|
val currentRuleIdSet = remember(currentRuleIds.size) {
|
||||||
|
currentRuleIds.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
fun setRuleState(newState: RuleState?) {
|
fun setRuleState(newState: RuleState?) {
|
||||||
ruleState = newState
|
ruleState = newState
|
||||||
@@ -166,7 +168,7 @@ class ManageRuleFeature : Routes.Route() {
|
|||||||
onFriendState = { friend, state ->
|
onFriendState = { friend, state ->
|
||||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||||
if (state) {
|
if (state) {
|
||||||
if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||||
} else {
|
} else {
|
||||||
currentRuleIds.remove(friend.userId)
|
currentRuleIds.remove(friend.userId)
|
||||||
}
|
}
|
||||||
@@ -174,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
|
|||||||
onGroupState = { group, state ->
|
onGroupState = { group, state ->
|
||||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||||
if (state) {
|
if (state) {
|
||||||
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||||
} else {
|
} else {
|
||||||
currentRuleIds.remove(group.conversationId)
|
currentRuleIds.remove(group.conversationId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getFriendState = { friend ->
|
getFriendState = { friend ->
|
||||||
ruleIdsSet.contains(friend.userId)
|
currentRuleIdSet.contains(friend.userId)
|
||||||
},
|
},
|
||||||
getGroupState = { group ->
|
getGroupState = { group ->
|
||||||
ruleIdsSet.contains(group.conversationId)
|
currentRuleIdSet.contains(group.conversationId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ class SocialRootSection : Routes.Route() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Real-time synchronization from the bridge
|
// Real-time synchronization from the bridge
|
||||||
context.database.messagingDataFlow.collect { (friends, groups) ->
|
context.database.messagingDataFlow.collect {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val sortedFriends = context.sortSocialFriends(friends)
|
val dbFriends = context.database.getFriends(descOrder = true)
|
||||||
|
val dbGroups = context.database.getGroups()
|
||||||
|
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
friendList = sortedFriends
|
friendList = sortedFriends
|
||||||
groupList = groups
|
groupList = dbGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,8 +174,7 @@ class SocialRootSection : Routes.Route() {
|
|||||||
},
|
},
|
||||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||||
),
|
)
|
||||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// You can still set these for legacy use by submodules or scripts:
|
// You can still set these for legacy use by submodules or scripts:
|
||||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.0").get())
|
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
|
||||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("326").get().toInt())
|
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
|
||||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
|
## v1.7.1
|
||||||
|
- Auto-Open Engine ghost notification fix.
|
||||||
|
- Continous Send notifiction bug fix.
|
||||||
|
- Disappering chats fix.
|
||||||
|
- Social page automatic selection bug fix.
|
||||||
|
|
||||||
## v1.7.0
|
## v1.7.0
|
||||||
- Features:
|
- Features:
|
||||||
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
|
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
|
||||||
- Implemented app intro showcase (tq ΞTΞRNAL)
|
- Implemented app intro showcase (tq ΞTΞRNAL)
|
||||||
- Implemented "Spoof follower count" (tq <RSR/>)
|
- Implemented "Spoof follower count" (tq RSR)
|
||||||
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
|
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
|
||||||
- Implemented "Chat Hold Kill" (tq C R E S T)
|
- Implemented "Chat Hold Kill" (tq SUJΛL)
|
||||||
- Implemented "Snapchat Purchase Date Spoof" (tq C R E S T)
|
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
|
||||||
- Implemented Message log export for individual chat (tq schrodingerspet)
|
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||||
- Implemented message icon indicator for Memories (tq <RSR/>)
|
- Implemented Memory message icon indicator(tq RSR)
|
||||||
- Implemented two new message indicator toggles, for self snaps and group.
|
- Implemented two new message indicator toggles, for self snaps and group.
|
||||||
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
|
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
|
||||||
- Implemented new notification card for Continous send feature.
|
- Implemented new notification card for Continous send feature.
|
||||||
|
|
||||||
- Fixes:
|
- Fixes:
|
||||||
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq <RSR/>)
|
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
|
||||||
- Media resend flow bug fixes. (tq schrodingerspet)
|
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||||
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq schrodingerspet)
|
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||||
- Media download support through message logger. (tq schrodingerspet)
|
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||||
- Snapchat Plus bug fixes to improve stability.
|
- Snapchat Plus bug fixes to improve stability.
|
||||||
- Spoof Device profile Backup/Restore bug fixes.
|
- Spoof Device profile Backup/Restore bug fixes.
|
||||||
- Redesgined manager app Logs filtering UI.
|
- Redesgined manager app Logs filtering UI.
|
||||||
|
|||||||
@@ -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 (1024–65535). Default is 8484."
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "Auth Token",
|
||||||
|
"description": "Secret token required to access the dashboard. Must not be empty. Requires a restart."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -168,6 +168,7 @@ class FeatureManager(
|
|||||||
CustomTheming(),
|
CustomTheming(),
|
||||||
HideTypingIndicator(),
|
HideTypingIndicator(),
|
||||||
FakeSnapScore(),
|
FakeSnapScore(),
|
||||||
|
CompanionServer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
features.values.toList().forEach { feature ->
|
features.values.toList().forEach { feature ->
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
|||||||
} && getRuleState() != null
|
} && getRuleState() != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canUseRule(conversationId: String): Boolean {
|
open fun canUseRule(conversationId: String): Boolean {
|
||||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun init() {
|
override fun init() {
|
||||||
if (autoOpenConfig.globalState == false) return
|
if (autoOpenConfig.globalState != true) return
|
||||||
|
|
||||||
restorePersistence()
|
restorePersistence()
|
||||||
createNotificationChannels()
|
createNotificationChannels()
|
||||||
@@ -486,9 +486,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
|||||||
builder.setStyle(bigTextStyle)
|
builder.setStyle(bigTextStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPendingIntent(action: String): PendingIntent {
|
private fun createPendingIntent(action: String): PendingIntent {
|
||||||
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
|
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
|
||||||
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
@@ -560,9 +559,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
|||||||
.setContentTitle("Auto-Open")
|
.setContentTitle("Auto-Open")
|
||||||
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
|
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
|
||||||
|
|
||||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||||
|
|
||||||
fun getInterface(): AutoOpenInterface {
|
fun getInterface(): AutoOpenInterface {
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||||
private val queuedSplitItems = ArrayDeque<Any>()
|
private val queuedSplitItems = ArrayDeque<Any>()
|
||||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
|
||||||
private var originalUnsplitItem: Any? = null
|
private var originalUnsplitItem: Any? = null
|
||||||
private var reusableOriginalItem: Any? = null
|
private var reusableOriginalItem: Any? = null
|
||||||
private var queuedOverrideType: String? = null
|
private var queuedOverrideType: String? = null
|
||||||
private var queuedOverrideSnapDurationMs: Int? = null
|
private var queuedOverrideSnapDurationMs: Int? = null
|
||||||
private var bypassSplitOnce = false
|
private var bypassSplitOnce = false
|
||||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
|
||||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||||
@@ -91,13 +91,13 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||||
if (deleteTempItems) {
|
if (deleteTempItems) {
|
||||||
val cleanup = cleanupItemHandler
|
val cleanup = cleanupItemHandler
|
||||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
queuedSplitCleanupItems.values.toList().forEach { item ->
|
||||||
cleanup?.invoke(uri)
|
cleanup?.invoke(item.uri, item.filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queuedSplitItems.clear()
|
queuedSplitItems.clear()
|
||||||
queuedSplitItemIds.clear()
|
queuedSplitItemIds.clear()
|
||||||
queuedSplitCleanupUris.clear()
|
queuedSplitCleanupItems.clear()
|
||||||
originalUnsplitItem = null
|
originalUnsplitItem = null
|
||||||
queuedOverrideType = null
|
queuedOverrideType = null
|
||||||
queuedOverrideSnapDurationMs = null
|
queuedOverrideSnapDurationMs = null
|
||||||
@@ -114,7 +114,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||||
preparedItems.forEach {
|
preparedItems.forEach {
|
||||||
queuedSplitItemIds.addLast(it.itemId)
|
queuedSplitItemIds.addLast(it.itemId)
|
||||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
queuedSplitCleanupItems[it.itemId] = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun sendOriginalUnsplitItem(): Boolean {
|
fun sendOriginalUnsplitItem(): Boolean {
|
||||||
@@ -130,8 +130,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
}
|
}
|
||||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
queuedSplitCleanupItems.remove(itemId)?.let { item ->
|
||||||
cleanupItemHandler?.invoke(uri)
|
cleanupItemHandler?.invoke(item.uri, item.filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (queuedSplitItems.isEmpty()) {
|
if (queuedSplitItems.isEmpty()) {
|
||||||
@@ -159,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
private data class PreparedMediaItem(
|
private data class PreparedMediaItem(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val uri: String
|
val uri: String,
|
||||||
|
val filePath: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun splitVideoIntoChunks(
|
private fun splitVideoIntoChunks(
|
||||||
@@ -309,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
runCatching { resolver.delete(uri, null, null) }
|
runCatching { resolver.delete(uri, null, null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
return PreparedMediaItem(
|
||||||
|
itemId = itemId,
|
||||||
|
durationMs = durationMs,
|
||||||
|
uri = uri.toString(),
|
||||||
|
filePath = file.absolutePath
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||||
@@ -431,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanupItemHandler = { uriString ->
|
cleanupItemHandler = { uriString, filePath ->
|
||||||
runCatching {
|
runCatching {
|
||||||
|
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
|
||||||
|
filePath?.let { path ->
|
||||||
|
val file = File(path)
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
}
|
||||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
if (it.message?.contains("no access") == false) {
|
||||||
|
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||||
import me.eternal.purrfectsnap.common.data.ContentType
|
import me.eternal.purrfectsnap.common.data.ContentType
|
||||||
|
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
|
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
|
||||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||||
|
import me.eternal.purrfectsnap.core.ModContext
|
||||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||||
@@ -88,6 +90,7 @@ class SendOverride : Feature("Send Override") {
|
|||||||
private var currentRecipientName: String = "Unknown"
|
private var currentRecipientName: String = "Unknown"
|
||||||
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
|
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
|
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
|
||||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
||||||
queuedOriginalItemRepeatCount = repeatCount
|
queuedOriginalItemRepeatCount = repeatCount
|
||||||
@@ -102,24 +105,93 @@ class SendOverride : Feature("Send Override") {
|
|||||||
queuedOriginalItemRepeatSnapDurationMs = null
|
queuedOriginalItemRepeatSnapDurationMs = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
private fun updateContinuousSendNotification(context: ModContext) {
|
||||||
|
if (!engineActive.get()) return
|
||||||
|
|
||||||
|
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||||
|
val remaining = queuedOriginalItemRepeatCount
|
||||||
|
val processed = processedRepeatCount
|
||||||
|
val total = totalRepeatCount
|
||||||
|
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
|
||||||
|
|
||||||
|
if (!isWorking) {
|
||||||
|
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||||
|
showCompletionNotification(context, processed, total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressPercent = if (total > 0) (processed * 100) / total else 0
|
||||||
|
|
||||||
|
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||||
|
.setColor(0xFF3498DB.toInt())
|
||||||
|
.setContentTitle("Sending Snaps to $currentRecipientName")
|
||||||
|
.setContentText("Progress: $processed / $total ($progressPercent%)")
|
||||||
|
.setSubText("$processed / $total")
|
||||||
|
.setProgress(total, processed, false)
|
||||||
|
|
||||||
|
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
|
||||||
|
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
|
||||||
|
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
|
||||||
|
|
||||||
|
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
|
||||||
|
val isError = sent < total && !isStopped.get()
|
||||||
|
val title = when {
|
||||||
|
isStopped.get() -> "Continuous Send Stopped"
|
||||||
|
isError -> "Continuous Send Failed"
|
||||||
|
else -> "Continuous Send Finished"
|
||||||
|
}
|
||||||
|
val content = "Sent $sent / $total snaps to $currentRecipientName"
|
||||||
|
|
||||||
|
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||||
|
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||||
|
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
||||||
|
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(content)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
|
||||||
|
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
|
||||||
|
val intent = Intent(action).setPackage(context.androidContext.packageName)
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context.androidContext,
|
||||||
|
action.hashCode(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
|
||||||
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
||||||
clearQueuedOriginalItemRepeats()
|
clearQueuedOriginalItemRepeats()
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||||
clearQueuedOriginalItemRepeats()
|
clearQueuedOriginalItemRepeats()
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
|
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
|
||||||
|
|
||||||
|
processedRepeatCount++
|
||||||
queuedOriginalItemRepeatCount--
|
queuedOriginalItemRepeatCount--
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
|
|
||||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||||
if (!result) {
|
if (!result) {
|
||||||
queuedOriginalItemRepeatCount++
|
|
||||||
clearQueuedOriginalItemRepeats()
|
clearQueuedOriginalItemRepeats()
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -136,7 +208,6 @@ class SendOverride : Feature("Send Override") {
|
|||||||
private val backgroundHookLock = Any()
|
private val backgroundHookLock = Any()
|
||||||
private var backgroundHookRefs = 0
|
private var backgroundHookRefs = 0
|
||||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
|
||||||
|
|
||||||
private fun createContinuousNotificationChannel() {
|
private fun createContinuousNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@@ -151,70 +222,6 @@ class SendOverride : Feature("Send Override") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateContinuousSendNotification() {
|
|
||||||
if (!engineActive.get()) return
|
|
||||||
|
|
||||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
|
||||||
val remaining = queuedOriginalItemRepeatCount
|
|
||||||
val processed = processedRepeatCount
|
|
||||||
val total = totalRepeatCount
|
|
||||||
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
|
|
||||||
|
|
||||||
if (!isWorking) {
|
|
||||||
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
|
||||||
showCompletionNotification(processed, total)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val progressPercent = if (total > 0) (processed * 100) / total else 0
|
|
||||||
|
|
||||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_popup_sync) // The Industrial Loop icon
|
|
||||||
.setColor(0xFF3498DB.toInt()) // Industrial Purple/Blue tint
|
|
||||||
.setContentTitle("Sending Snaps to $currentRecipientName")
|
|
||||||
.setContentText("Progress: $processed / $total ($progressPercent%)")
|
|
||||||
.setSubText("$processed / $total")
|
|
||||||
.setProgress(total, processed, false)
|
|
||||||
|
|
||||||
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
|
|
||||||
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(ACTION_PAUSE_RESUME)).build())
|
|
||||||
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build())
|
|
||||||
|
|
||||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showCompletionNotification(sent: Int, total: Int) {
|
|
||||||
val isError = sent < total && !isStopped.get()
|
|
||||||
val title = when {
|
|
||||||
isStopped.get() -> "Continuous Send Stopped"
|
|
||||||
isError -> "Continuous Send Failed"
|
|
||||||
else -> "Continuous Send Finished"
|
|
||||||
}
|
|
||||||
val content = "Sent $sent / $total snaps to $currentRecipientName"
|
|
||||||
|
|
||||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
|
||||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
|
||||||
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
|
||||||
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(content)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
|
|
||||||
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPendingIntent(action: String): PendingIntent {
|
|
||||||
val intent = Intent(action).setPackage(context.androidContext.packageName)
|
|
||||||
return PendingIntent.getBroadcast(
|
|
||||||
context.androidContext,
|
|
||||||
action.hashCode(),
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||||
var enableFailed = false
|
var enableFailed = false
|
||||||
@@ -302,14 +309,14 @@ class SendOverride : Feature("Send Override") {
|
|||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_PAUSE_RESUME -> {
|
ACTION_PAUSE_RESUME -> {
|
||||||
isPaused.set(!isPaused.get())
|
isPaused.set(!isPaused.get())
|
||||||
updateContinuousSendNotification()
|
updateContinuousSendNotification(context)
|
||||||
}
|
}
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
isStopped.set(true)
|
isStopped.set(true)
|
||||||
if (isPaused.get()) {
|
if (isPaused.get()) {
|
||||||
isPaused.set(false)
|
isPaused.set(false)
|
||||||
}
|
}
|
||||||
updateContinuousSendNotification()
|
updateContinuousSendNotification(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,10 +865,10 @@ class SendOverride : Feature("Send Override") {
|
|||||||
completionCallback: Any?
|
completionCallback: Any?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
|
||||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||||
val mediaBuffers = mutableListOf<ByteArray>()
|
val mediaBuffers = mutableListOf<ByteArray>()
|
||||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
|
||||||
if (id == 3) mediaBuffers.add(buffer)
|
if (id == 3) mediaBuffers.add(buffer)
|
||||||
}
|
}
|
||||||
if (mediaBuffers.isEmpty()) return false
|
if (mediaBuffers.isEmpty()) return false
|
||||||
@@ -933,27 +940,54 @@ class SendOverride : Feature("Send Override") {
|
|||||||
if (repeatCount <= 0) return false
|
if (repeatCount <= 0) return false
|
||||||
|
|
||||||
fun sendIteration(index: Int) {
|
fun sendIteration(index: Int) {
|
||||||
if (isStopped.get()) {
|
context.coroutineScope.launch {
|
||||||
clearQueuedOriginalItemRepeats()
|
while (isPaused.get() && !isStopped.get()) {
|
||||||
updateContinuousSendNotification()
|
delay(500)
|
||||||
return
|
}
|
||||||
}
|
if (isStopped.get()) {
|
||||||
val callback = if (index == repeatCount - 1) {
|
clearQueuedOriginalItemRepeats()
|
||||||
originalCallback
|
updateContinuousSendNotification(context)
|
||||||
} else {
|
return@launch
|
||||||
CallbackBuilder(sendMessageCallbackClass)
|
}
|
||||||
|
|
||||||
|
val callback = CallbackBuilder(sendMessageCallbackClass)
|
||||||
.override("onSuccess") {
|
.override("onSuccess") {
|
||||||
sendIteration(index + 1)
|
processedRepeatCount++
|
||||||
|
queuedOriginalItemRepeatCount--
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
|
|
||||||
|
if (index < repeatCount - 1) {
|
||||||
|
sendIteration(index + 1)
|
||||||
|
} else {
|
||||||
|
// Batch Finished: Trigger original Snapchat callback
|
||||||
|
originalCallback?.let { cb ->
|
||||||
|
runCatching {
|
||||||
|
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
|
||||||
|
if (method != null) {
|
||||||
|
if (method.parameterCount == 0) {
|
||||||
|
method.invoke(cb)
|
||||||
|
} else {
|
||||||
|
// Pass null for all required parameters to safely trigger the completion UI
|
||||||
|
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.override("onError", shouldUnhook = false) {
|
.override("onError", shouldUnhook = false) {
|
||||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||||
|
clearQueuedOriginalItemRepeats()
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
|
||||||
|
|
||||||
val preparedContent = createMessageContentFromOriginal()
|
if (index > 0) delay(1000) // Human-like delay
|
||||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
|
||||||
invokeCallbackError(originalCallback, "Failed to send")
|
val preparedContent = createMessageContentFromOriginal()
|
||||||
|
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||||
|
invokeCallbackError(originalCallback, "Failed to send")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1014,7 @@ class SendOverride : Feature("Send Override") {
|
|||||||
context.runOnUiThread {
|
context.runOnUiThread {
|
||||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||||
val handledRepeat = if (!handledSplit) {
|
val handledRepeat = if (!handledSplit) {
|
||||||
handleQueuedOriginalItemRepeatSuccess()
|
handleQueuedOriginalItemRepeatSuccess(context)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
|
|||||||
|
|
||||||
context.runOnUiThread {
|
context.runOnUiThread {
|
||||||
val recipientNameForTask = recipientName
|
val recipientNameForTask = recipientName
|
||||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
|
||||||
|
|
||||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||||
PurrfectOverlayTheme {
|
PurrfectOverlayTheme {
|
||||||
@@ -1564,7 +1597,6 @@ class SendOverride : Feature("Send Override") {
|
|||||||
totalRepeatCount = repeatCount
|
totalRepeatCount = repeatCount
|
||||||
processedRepeatCount = 1
|
processedRepeatCount = 1
|
||||||
currentRecipientName = recipientNameForTask
|
currentRecipientName = recipientNameForTask
|
||||||
updateContinuousSendNotification()
|
|
||||||
|
|
||||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
||||||
attachQueuedRepeatCallbacks(event)
|
attachQueuedRepeatCallbacks(event)
|
||||||
@@ -1572,12 +1604,13 @@ class SendOverride : Feature("Send Override") {
|
|||||||
invokeOriginalAndRestoreResult(event)
|
invokeOriginalAndRestoreResult(event)
|
||||||
} else {
|
} else {
|
||||||
clearQueuedOriginalItemRepeats()
|
clearQueuedOriginalItemRepeats()
|
||||||
|
updateContinuousSendNotification(context)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
totalRepeatCount = repeatCount
|
totalRepeatCount = repeatCount
|
||||||
processedRepeatCount = 0
|
processedRepeatCount = 0
|
||||||
|
queuedOriginalItemRepeatCount = repeatCount
|
||||||
currentRecipientName = recipientNameForTask
|
currentRecipientName = recipientNameForTask
|
||||||
updateContinuousSendNotification()
|
|
||||||
|
|
||||||
sendRepeatedMediaManual(
|
sendRepeatedMediaManual(
|
||||||
repeatCount,
|
repeatCount,
|
||||||
|
|||||||
@@ -347,6 +347,9 @@ class PerformanceMode : Feature("Performance Mode") {
|
|||||||
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||||
val textureView = param.thisObject<TextureView>()
|
val textureView = param.thisObject<TextureView>()
|
||||||
runCatching {
|
runCatching {
|
||||||
|
// Universal Guard: Only accelerate views owned by Snapchat.
|
||||||
|
// This prevents crashes in native hardware providers across all devices.
|
||||||
|
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
|
||||||
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
|||||||
org.gradle.configuration-cache.problems=warn
|
org.gradle.configuration-cache.problems=warn
|
||||||
nativeAbis=arm64-v8a
|
nativeAbis=arm64-v8a
|
||||||
|
|
||||||
APP_VERSION_NAME=1.7.0
|
APP_VERSION_NAME=1.7.1
|
||||||
APP_VERSION_CODE=326
|
APP_VERSION_CODE=327
|
||||||
debug_build_hash=18fe2a814d0e2eb5
|
debug_build_hash=18fe2a814d0e2eb5
|
||||||
psIntegrityPinnedSha256=
|
psIntegrityPinnedSha256=
|
||||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||||
|
|||||||
@@ -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
286
tools/snap_story_dl.py
Normal 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()
|
||||||
Reference in New Issue
Block a user