141 Commits
v1.6.2 ... dev

Author SHA1 Message Date
root
cdbcd20d65 Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features) 2026-05-30 13:37:30 +02:00
ᴋᴀʟᴀᴅɪɴ
79a1114069 v1.7.1 2026-04-27 03:32:36 +05:30
ᴋᴀʟᴀᴅɪɴ
d724e5c615 Fix: Auto-Open Ghost Notification & Social page friends selection bug (#145) 2026-04-27 03:21:33 +05:30
ᴋᴀʟᴀᴅɪɴ
73225b7151 v1.7.0 2026-04-26 14:59:37 +05:30
ᴋᴀʟᴀᴅɪɴ
546c9e1410 Merge pull request #143 from Kaladin
Features, Stability and Bug fixes.
2026-04-26 05:50:59 +05:30
DarkKnight2122
c0c395b100 FFmpeg stability fixes 2026-04-26 05:16:02 +05:30
DarkKnight2122
beede425df Sync(Feat): AI & showcase and Spoof Followers 2026-04-26 04:18:12 +05:30
RSR/
ed66ae2998 feat: Spoof Followers Count by RSR (#142) 2026-04-25 18:59:46 +05:30
DarkKnight2122
9934bd2f23 Update checker refactor 2026-04-25 17:58:28 +05:30
eternal
cb2c0dded9 feat: AI & showcase 2026-04-25 14:42:47 +05:30
DarkKnight2122
d4a5a60cc1 Manager app stutters and lag fixes 2026-04-25 09:51:16 +05:30
DarkKnight2122
cbe9754fba Disaperaing chats via adblock fix 2026-04-25 07:01:54 +05:30
DarkKnight2122
f3fb9667d8 Chat Preview text bug fix 2026-04-25 05:57:41 +05:30
DarkKnight2122
347115f8d3 Snapchat Plus bug fixes 2026-04-25 04:57:36 +05:30
DarkKnight2122
d19257869b Spoof Backup bug fixes 2026-04-25 04:21:32 +05:30
DarkKnight2122
5978717065 Social Tab bug fixes 2026-04-24 23:06:21 +05:30
DarkKnight2122
c988c489e7 Performance Mode bug fixes 2026-04-24 19:30:09 +05:30
DarkKnight2122
0e549a1565 Revert: Performance Mode PR #126 2026-04-24 15:02:26 +05:30
DarkKnight2122
2be1c3258d Message indicator bug fix 2026-04-23 22:59:11 +05:30
DarkKnight2122
c13759e99a Sync upstream changes 2026-04-23 20:56:09 +05:30
DarkKnight2122
fb94a2e8a3 Continous Send bug fixes 2026-04-23 13:20:52 +05:30
Majjo
5651d48d96 debug: fix strategy E/F to use eachBuffer instead of followPath, dump snap item bytes 2026-04-21 17:11:13 +02:00
Majjo
8f8c696431 fix: resolve fallbackUserId out of scope in strategy E/F 2026-04-21 15:43:12 +02:00
Majjo
e408a505ab debug: add strategy E/F for europe-west1 batch_stories + snap descriptor hex dump 2026-04-20 22:39:52 +02:00
DarkKnight2122
335cc00dfb Unlimited Local pins bug fix 2026-04-20 18:51:15 +05:30
Majjo
ded49b8b1b debug: hex dump batch_stories responses >1KB that parse 0 stories 2026-04-20 15:17:45 +02:00
Majjo
d9593fa629 feat: add getBusinessStoryManifest capture + multi-strategy proto probe + no_match hex dump 2026-04-20 14:22:19 +02:00
DarkKnight2122
a20496329c Downloader allow duplicate and ffmpeg shortest sync fixes 2026-04-20 15:41:19 +05:30
ᴋᴀʟᴀᴅɪɴ
29f05a2d7f Merge pull request #141 from RSR
New: memories indicator and Redesign all indicators , now the lock icon "only to me" work with external media and memories
2026-04-20 13:20:52 +05:30
RSR/
ac2e39eccf New: memories indicator and Redesign all indicators 2026-04-20 05:47:21 +04:00
DarkKnight2122
a18fc8ce37 Media Downloader audio duration mapping fix 2026-04-20 04:58:23 +05:30
Majjo
cab8c69f72 feat: story debug panel, log level filters, verbose story event logging 2026-04-20 01:15:25 +02:00
DarkKnight2122
4bd13a9d09 Media Downloader stability fixes 2026-04-20 04:25:49 +05:30
Majjo
616ae0d3b3 feat: add Developer > Debug Stories config with ZIP dump 2026-04-20 00:38:02 +02:00
Majjo
c0c5503864 fix: save ZIP base media directly to gallery, avoid downloadRemoteMedia NPE 2026-04-20 00:34:44 +02:00
Majjo
98e04a81cb fix: capture story_lookup/batch_story_lookup endpoints for story media URLs 2026-04-20 00:29:57 +02:00
DarkKnight2122
61dcfd9b57 Auto open notification card bug fixes 2026-04-20 03:34:18 +05:30
Majjo
947bb399c8 ci: remove armv7 jobs, build arm64-v8a only 2026-04-19 23:54:48 +02:00
DarkKnight2122
fa5c7e0771 Hardened bridge stability with 15s idle timeout 2026-04-20 03:18:56 +05:30
DarkKnight2122
dde94aeffc Logs filtering UI bug fix 2026-04-20 02:13:35 +05:30
DarkKnight2122
4e79a22b43 UI Fixes in Friend feed 2026-04-20 02:10:07 +05:30
ᴋᴀʟᴀᴅɪɴ
5037f393f7 Merge pull request #140 from schrodingerspet
fix(downloader): download logger-backed deleted media from chat context menu
2026-04-20 01:58:55 +05:30
schrodingerspet
7d30e3b632 fix(downloader): support logger-backed deleted media in chat download menu
Add a fallback for Message Logger deleted media when the normal attachment payload is unavailable, while keeping standard download behavior unchanged.
2026-04-20 01:11:00 +05:30
ΞTΞRNAL
05b4e01d27 Merge pull request #139 from schrodingerspet/fix/message-logger-import-persistence
fix(ui): enforce Hide Friend Feed Entry on main friend feed
2026-04-20 00:04:09 +05:30
schrodingerspet
5b12844a24 fix(ui): hide selected users from the real friend feed
Use more reliable hidden-ID matching for friend-feed rows and add a fallback hide during binding to prevent row reappearance.

Keep Best Friends, send, and search behavior unchanged.
2026-04-19 23:39:21 +05:30
Majjo
e23bf443a1 fix: discard overlay segment from ZIP container when saving stories 2026-04-19 12:49:18 +02:00
ᴋᴀʟᴀᴅɪɴ
b5a31bee7b Merge pull request #138 from schrodingerspet
fix(message-logger): make backup import persistent and validated
2026-04-19 14:07:13 +05:30
ᴋᴀʟᴀᴅɪɴ
9901812db6 Merge pull request #137 from schrodingerspet
fix(bridge): prevent background DeadObjectException crash on OnePlus
2026-04-19 14:06:07 +05:30
schrodingerspet
0767289210 fix(message-logger): make backup import persistent and validated
Add a safer validated import flow in LoggerWrapper with proper failure handling and persistence across restart.

Update Message Logger import actions to report real success or failure.
2026-04-19 12:24:26 +05:30
schrodingerspet
5de180336e fix(bridge): prevent background DeadObjectException crash on OnePlus
Handle bridge binder death during service connection and remote init calls.

Add stale-reference cleanup and safer reconnect handling.
2026-04-19 11:40:07 +05:30
Aditya Akolkar
632df791bd Merge pull request #136 from schrodingerspet/fix/media-resend-duration-pr
fix: preserve queued snap duration for media resend flow
2026-04-19 11:35:20 +05:30
Aditya Akolkar
c09a5df615 Merge pull request #1 from schrodingerspet/fix/media-resend-duration-clean
fix: preserve queued snap duration for media resend flow
2026-04-19 11:17:45 +05:30
schrodingerspet
9d5504dee0 fix: preserve queued snap duration for media resend flow
Keep selected snap duration when processing queued split items and repeated sends.
2026-04-19 11:16:34 +05:30
schrodingerspet
0ad9d0a5dc Revert "fix: preserve queued snap duration for media resend flow"
This reverts commit ec6e4deae8.
2026-04-19 11:16:25 +05:30
schrodingerspet
7499b08494 Merge branch 'feat/message-logger-export-individual-chat' into dev 2026-04-19 10:55:13 +05:30
schrodingerspet
ec6e4deae8 fix: preserve queued snap duration for media resend flow
Keep selected snap duration when processing queued split items and repeated sends.
2026-04-19 10:48:38 +05:30
ᴋᴀʟᴀᴅɪɴ
839ab8232a Merge pull request #134 from schrodingerspet
feat: Add message logger export individual chat.
2026-04-19 02:27:57 +05:30
ᴋᴀʟᴀᴅɪɴ
50def2be77 Merge pull request #133 from Javalsta
Organize social tab by streak length.
2026-04-19 02:25:34 +05:30
ᴋᴀʟᴀᴅɪɴ
cf1f691762 Merge pull request #132 from RSR
fix: the 🔒 icon indicator for snaps
2026-04-19 02:23:49 +05:30
schrodingerspet
22bbadf926 feat: add message logger export individual chat 2026-04-19 01:01:38 +05:30
Majjo
760d652f5f feat: story capture API + Python downloader script 2026-04-18 21:01:07 +02:00
Majjo
22ee866205 fix: remove duplicate JS block causing SyntaxError in dashboard 2026-04-18 20:04:08 +02:00
Majjo
ce3361992c feat: full request/response inspection with detail panel, hex dump, JSON pretty-print 2026-04-18 19:13:34 +02:00
Majjo
768a3b7c09 feat: cookie-based session auth for companion server (login page, POST /login, /logout) 2026-04-18 18:12:56 +02:00
Majjo
b0715b3f27 fix: serve dashboard unauthenticated, validate token via /ping endpoint 2026-04-18 17:41:28 +02:00
Majjo
b6435f9010 fix: add language strings for companion server; fix startup logging and null safety 2026-04-18 16:57:47 +02:00
Majjo
e0431bd001 feat: add Companion Server for live log and network monitoring 2026-04-18 16:23:51 +02:00
Majjo
d86d9db508 ci: update actions to Node 24 compatible versions 2026-04-18 15:24:46 +02:00
Majjo
068b272b5e fix: skip overlay merge for stories, highlights, and spotlight 2026-04-18 15:17:12 +02:00
Javalsta
0aa1317950 add feature 2026-04-17 23:35:10 +01:00
RSR/
5a4fdd3664 Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-04-18 02:26:47 +04:00
RSR/
65086b79b3 fix: the 🔒 icon ,
It is supposed to work on all devices
2026-04-18 02:25:46 +04:00
ᴋᴀʟᴀᴅɪɴ
e201d7046c Merge pull request #129 from imCrest/dev
Sync: Latest improvements and multiple fixes by Sujal
2026-04-17 23:55:39 +05:30
ᴋᴀʟᴀᴅɪɴ
1e55a83ca0 Merge branch 'dev' into dev 2026-04-17 23:43:45 +05:30
ᴋᴀʟᴀᴅɪɴ
f95a4d8552 Merge pull request #131 from DarkKnight2122
Concurrent Downloads and Proactive Memory Management for media downloader engine
2026-04-17 23:04:00 +05:30
ᴋᴀʟᴀᴅɪɴ
ff097fbe41 Merge pull request #130 from Daplugg23
fix: resolve dropped friends list by properly handling chunked social…
2026-04-17 23:00:14 +05:30
ᴋᴀʟᴀᴅɪɴ
4b92304d84 Merge pull request #128 from imCrest/patch-1
Refactor SPOTLIGHT_5TH_TAB_ENABLED property override
2026-04-17 22:46:14 +05:30
imCrest
9c226d1bb9 Sync: Update from upstream 2026-04-17 14:03:43 +00:00
imCrest
7005fdf567 Sync: Update from upstream 2026-04-17 12:34:24 +00:00
imCrest
10a820e93e Sync: Update from upstream 2026-04-17 12:16:08 +00:00
imCrest
cd8d358fa6 Sync: Update from upstream 2026-04-17 12:03:36 +00:00
imCrest
73616f2158 Sync: Update from upstream 2026-04-17 11:25:16 +00:00
imCrest
8e1628e28c Merge pull request #15 from imCrest/imCrest/fix-disable-spotlight-feed-tab 2026-04-17 11:02:43 +00:00
imCrest
9223d2debc fix-disable-spotlight-chat-media 2026-04-17 10:13:24 +00:00
ᴋᴀʟᴀᴅɪɴ
d61eb68d8b v1.6.9 2026-04-17 01:23:19 +05:30
imCrest
3dccadc5c0 Sync upstream 2026-04-16 12:42:06 +00:00
imCrest
98f0db6a15 Harden macOS native build by clearing DYLD override vars 2026-04-15 06:17:26 +00:00
daplugg23
9fa96125b2 fix: resolve dropped friends list by properly handling chunked social snapshots
The social snapshot chunking mechanism (introduced in v1.6.2) split the friends list correctly on the BridgeClient, but the BridgeService was discarding subsequent chunks. This updates the Manager to explicitly process and combine all received chunks via the database before updating the AddFriendDialog UI.
2026-04-14 19:52:31 -05:00
imCrest
992a48fa81 Stabilize UITweaks block structure for CI Kotlin parsing 2026-04-14 22:08:57 +00:00
imCrest
360540edb4 Refactor spotlight nav filter helper to avoid parsing/side-effect issues 2026-04-14 21:57:34 +00:00
imCrest
4a17989c96 Narrow spotlight tab hiding to navigation ids only 2026-04-14 21:36:46 +00:00
imCrest
81796c8824 Refine spotlight-tab hiding to avoid friend-feed media breakage 2026-04-14 21:20:03 +00:00
imCrest
73ed1a9647 Fix disable spotlight to affect home tab without breaking chat media 2026-04-14 20:56:23 +00:00
imCrest
2faa368722 Merge pull request #5 from imCrest/imCrest/add-customizable-purchase-date-feature-7fai8g 2026-04-14 17:43:15 +00:00
imCrest
2913cfe794 Merge pull request #4 from imCrest/imCrest/add-customizable-purchase-date-feature-di5qqb
Add Snapchat Plus purchase date setting and chat-hold kill actions (UI + core)
2026-04-14 17:12:25 +00:00
Sujal Sahu
4e3ef029a1 fix(PR): Multiple fixes by Kaladin
- Auto Open Engine refactor and implemented the engine stop button in the notification card.
- Media downloader stabilization fixes including Batch download fix.
- Implemented a new log filtering menu.
- Cargo Dependency updated to latest stable versions.
- Minor bug fixes.
2026-04-14 14:59:54 +00:00
C R E S T
3e07d4c419 Refactor SPOTLIGHT_5TH_TAB_ENABLED property override 2026-04-14 19:00:20 +05:30
DarkKnight2122
83e8865266 UI fixes and stabilization 2026-04-14 18:19:17 +05:30
ΞTΞRNAL
dbf6de8a65 fix(PR): Multiple fixes by Kaladin
- Auto Open Engine refactor and implemented the engine stop button in the notification card.
- Media downloader stabilization fixes including Batch download fix.
- Implemented a new log filtering menu.
- Cargo Dependency updated to latest stable versions.
- Minor bug fixes.
2026-04-14 17:39:18 +05:30
C R E S T
6f4e746a66 Refactor custom purchase date handling in SnapchatPlus 2026-04-14 17:27:54 +05:30
C R E S T
df76d13904 Refactor snapchatPlusPurchaseDate initialization 2026-04-14 17:27:03 +05:30
C R E S T
7b7bceec80 Update settings in en_US.json
Removed chat button hold kill settings and added Snapchat Plus purchase date.
2026-04-14 17:25:29 +05:30
C R E S T
61bcb6d743 Fix missing newline at end of AlertDialogs.kt 2026-04-14 17:21:18 +05:30
C R E S T
0689cc2005 Replace ValueGlowChip with Button for purchase date 2026-04-14 17:20:22 +05:30
DarkKnight2122
279e847d00 Auto Open Engine refactor and Media Downloader stabilization. 2026-04-14 16:31:45 +05:30
C R E S T
a859d4825b Implement long click listener for chat button
Add long click listener to handle app termination based on user settings.
2026-04-14 16:09:00 +05:30
C R E S T
25dd438da2 Add terminateModuleProcess function to BridgeClient 2026-04-14 16:07:59 +05:30
C R E S T
2c9f5d45e2 Add ChatButtonHoldKill configuration options 2026-04-14 16:07:07 +05:30
C R E S T
e89628e5c2 Add chat button hold kill feature
Added chat button hold kill feature with properties.
2026-04-14 16:06:15 +05:30
C R E S T
6f0639b8e9 Add terminateModuleProcess method to BridgeInterface 2026-04-14 16:04:36 +05:30
C R E S T
ba607fb419 Add process termination functionality to BridgeService 2026-04-14 16:02:47 +05:30
C R E S T
b682ee8432 Add custom purchase date handling for SnapchatPlus 2026-04-14 15:37:20 +05:30
C R E S T
7d7488d05e Add Snapchat Plus purchase date configurations
Added custom purchase date and purchase date fields for Snapchat Plus with input validation.
2026-04-14 15:36:36 +05:30
C R E S T
4f0db150b0 Enhance Snapchat Plus with custom purchase date options
Added properties for custom purchase date in Snapchat Plus.
2026-04-14 15:35:24 +05:30
C R E S T
810afd88f7 Implement DatePickerPropertyDialog for date selection
Added a DatePickerPropertyDialog composable for selecting dates.
2026-04-14 15:34:12 +05:30
C R E S T
ed6786e65b Add handling for Snapchat Plus purchase date property 2026-04-14 15:31:56 +05:30
ΞTΞRNAL
0db1c5036e feat(PR): enhanced stealth mode by tnewman-afk
enhanced stealth-mode
2026-04-12 23:33:00 +05:30
ΞTΞRNAL
cc7539d262 fix(PR): Multiple fixes by schrodingerspet
feat(performance-mode): fix max profile lifecycle/resume corruption
2026-04-12 23:24:57 +05:30
schrodingerspet
ca37953476 revert(config): remove legacy performance profile migration 2026-04-12 23:20:00 +05:30
schrodingerspet
050dea3214 fix(native): disable OMVLL by default on macOS CI 2026-04-12 22:45:20 +05:30
schrodingerspet
724d7efb1e fix(native): add macOS NDK host-toolchain fallback for CI 2026-04-12 22:29:14 +05:30
schrodingerspet
a855983c46 fix(ci): add manager module alias to mapper 2026-04-12 21:33:47 +05:30
schrodingerspet
7dd9ada91d feat(performance-mode): fix max profile lifecycle/resume corruption 2026-04-12 16:14:51 +05:30
ΞTΞRNAL
3d48b14def New announcement!
Added a temporary fix for conversations disappearing.
2026-04-11 16:49:55 +05:30
Javalsta
6d1eb89adf attempt1 2026-04-10 23:26:21 +01:00
ΞTΞRNAL
b44558babd fix(PR): Auto Open stabilization and Spotlight/Story video download fixes by Kaladin
Auto Open stabilization and Spotlight/Story video download fixes.
2026-04-05 15:37:00 +05:30
ΞTΞRNAL
e97a37d18e v1.6.8 2026-04-05 15:35:39 +05:30
DarkKnight2122
281d55689a Spotlight/Story video download fix 2026-04-05 03:28:17 +05:30
DarkKnight2122
7475998961 Optimize background engine & sync with Notifications for instant previews 2026-04-05 00:53:56 +05:30
ΞTΞRNAL
f01b6c2f9a add fresh changelogs
Updated changelog for version 1.6.6 with multiple fixes and new features, including pre-fetch snaps toggle and disk optimization improvements.
2026-04-02 20:48:00 +05:30
ΞTΞRNAL
98f8d6ebea fix(PR): auto open notification fix by Kaladin
fix: auto open notification fix
2026-04-02 20:39:32 +05:30
DarkKnight2122
015226cb40 fix: auto open notification fix 2026-04-02 19:43:59 +05:30
ΞTΞRNAL
ad06b0dffb v1.6.6 2026-04-02 19:00:08 +05:30
ΞTΞRNAL
4b4d64e8eb fix version bump 2026-04-02 14:18:43 +05:30
ΞTΞRNAL
3eec22c615 fix(PR): Restore Auto Open stability by Kaladin
Restore Auto Open stability
2026-04-02 14:01:08 +05:30
ΞTΞRNAL
9a2e6065dc Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-04-02 13:59:43 +05:30
ΞTΞRNAL
8e00c29a59 v1.6.5 2026-04-02 13:57:36 +05:30
DarkKnight2122
9ec9517ec5 fix(core): Restore Auto Open stability 2026-04-02 06:45:13 +05:30
ΞTΞRNAL
e822fc20b4 New announcement!
Added recommendation for using Performance Mode feature in Snapchat.
2026-04-02 02:41:12 +05:30
ΞTΞRNAL
f3c794fc47 v1.6.4 2026-04-02 00:22:02 +05:30
ΞTΞRNAL
6e6d311c23 v1.6.3 2026-04-01 16:36:35 +05:30
124 changed files with 13443 additions and 5295 deletions

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

@@ -4,9 +4,11 @@ local.properties
/.idea/
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
.local/
native/.omvll/
cloudflare/**/.wrangler/
cloudflare/**/node_modules/
@@ -20,4 +22,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs
.vs

View File

@@ -1 +1 @@
- Test
- Temporary fix for conversations disappearing: Turn off Block Ads feature

View File

@@ -113,7 +113,6 @@ android {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
@@ -166,6 +165,9 @@ android {
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
} else {
// Keep local release builds installable when private release credentials are unavailable.
signingConfig = signingConfigs.getByName("debug")
}
}
debug {
@@ -356,6 +358,7 @@ afterEvaluate {
}
}
}
}
properties["debug_flavor"]?.let {

View File

@@ -5,6 +5,9 @@ import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -219,20 +222,42 @@ class BridgeService : Service() {
triggerScopeSync(SocialScope.getByName(scope), id, true)
}
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
override fun passGroupsAndFriends(
groups: List<String>,
friends: List<String>
friends: List<String>,
chunkIndex: Int,
totalChunks: Int
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
return
synchronized(friendAccumulator) {
if (chunkIndex == 0) {
friendAccumulator.clear()
groupAccumulator.clear()
}
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
if (chunkIndex == totalChunks - 1) {
val finalFriends = friendAccumulator.toList()
val finalGroups = groupAccumulator.toList()
friendAccumulator.clear()
groupAccumulator.clear()
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(finalFriends, finalGroups)
}
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
}
}
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}
override fun getScopeNotes(id: String): String? {

View File

@@ -121,6 +121,7 @@ class DownloadProcessor (
inputFile.outputStream().use {
bitmap.compress(compressFormat, 100, it)
}
bitmap.recycle()
fileType = FileType.fromFile(inputFile)
}
}
@@ -282,7 +283,7 @@ class DownloadProcessor (
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length()) {
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
@@ -375,7 +376,7 @@ class DownloadProcessor (
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
@@ -615,32 +616,51 @@ class DownloadProcessor (
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
//if there is a zip file, extract it and save only the base media (discard overlay)
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
// Debug Stories: save raw ZIP to the configured debug folder
val debugConfig = remoteSideContext.config.root.developer.debugStories
if (debugConfig.enabled.get()) {
val debugFolder = debugConfig.saveFolder.get().orEmpty().trim()
if (debugFolder.isNotBlank()) {
runCatching {
val debugDir = android.net.Uri.parse(debugFolder).let { uri ->
androidx.documentfile.provider.DocumentFile.fromTreeUri(remoteSideContext.androidContext, uri)
}
val timestamp = System.currentTimeMillis()
val debugFile = debugDir?.createFile("application/zip", "story_debug_${timestamp}.zip")
debugFile?.uri?.let { fileUri ->
remoteSideContext.androidContext.contentResolver.openOutputStream(fileUri)?.use { out ->
zipFile.inputStream().use { it.copyTo(out) }
}
}
}.onFailure {
remoteSideContext.log.warn("Debug Stories: failed to save zip: ${it.message}", "DownloadProcessor")
}
} else {
remoteSideContext.log.warn("Debug Stories: enabled but no folder configured", "DownloadProcessor")
}
}
var baseMediaFile: File? = null
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
baseMediaFile = createMediaTempFile().apply {
outputStream().use { inputStream.copyTo(it) }
}
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
oldDownloadedMedias.forEach { (_, value) -> value.delete() }
shouldMergeOverlay = true
baseMediaFile?.let { baseMedia ->
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
baseMedia.delete()
}
return@launch
}
if (shouldMergeOverlay) {
@@ -648,8 +668,45 @@ class DownloadProcessor (
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
val mediaFileType = FileType.fromFile(media)
val overlayFileType = FileType.fromFile(overlayMedia)
val renamedMedia = renameFromFileType(media, mediaFileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType)
if (mediaFileType.isImage && overlayFileType.isImage) {
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image")
val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image")
val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap)
val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg"))
val compressFormat = when (mediaFileType) {
FileType.PNG -> Bitmap.CompressFormat.PNG
FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
else -> Bitmap.CompressFormat.JPEG
}
mergedImage.outputStream().use {
mergedBitmap.compress(compressFormat, 100, it)
}
originalBitmap.recycle()
overlayBitmap.recycle()
mergedBitmap.recycle()
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
mergedImage.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}.onFailure {
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
}
}
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))

View File

@@ -90,6 +90,12 @@ class FFMpegProcessor(
)
private val sharedExecutor = Executors.newSingleThreadExecutor()
protected fun finalize() {
runCatching { sharedExecutor.shutdown() }
}
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
@@ -127,7 +133,7 @@ class FFMpegProcessor(
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
else -> return@logFunction
}, log.message)
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}, { onStatistics(it) }, sharedExecutor)
}
suspend fun execute(args: Request) {
@@ -145,22 +151,27 @@ class FFMpegProcessor(
}
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
}
fun applyVideoArguments() {
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
}
when (args.action) {
Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'"
}
}
Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
}
@@ -168,8 +179,9 @@ class FFMpegProcessor(
if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a"
}
outputArguments -= "-c:v"
args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it
} ?: run {
outputArguments += "-vn"
@@ -180,6 +192,7 @@ class FFMpegProcessor(
}
}
Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file ->
runCatching {
@@ -187,45 +200,47 @@ class FFMpegProcessor(
}.getOrNull()?.let { file to it }
}
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
try {
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
}
inputArguments += "-i" to file
}
inputArguments += "-i" to file
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
outputArguments += "-map" to "[aout]"
outputArguments += "-map" to "[vout]"
} finally {
filesInfo.forEach { it.second.close() }
}
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
filesInfo.forEach { it.second.close() }
}
Action.DOWNLOAD_AUDIO_STREAM -> {
outputArguments.clear()
@@ -256,8 +271,8 @@ class FFMpegProcessor(
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-filter_complex" to filterParts.toString()
outputArguments += "-map" to "[aout]"
}
}
outputArguments += args.output.absolutePath

View File

@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -15,7 +17,11 @@ class AppDatabase(
val executor: ExecutorService = Executors.newSingleThreadExecutor()
lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
// Multi-subscriber event stream for messaging data updates
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun executeAsync(block: () -> Unit) {
executor.execute {
@@ -113,6 +119,22 @@ class AppDatabase(
"id CHAR(36) PRIMARY KEY",
"content TEXT",
),
"assistant_registry" to listOf(
"id VARCHAR PRIMARY KEY",
"kind VARCHAR",
"title VARCHAR",
"category VARCHAR",
"path TEXT",
"description TEXT",
"settingKey VARCHAR",
"screenRoute VARCHAR",
"allowedActions TEXT",
"allowedValues TEXT",
"aliases TEXT",
"commonTypos TEXT",
"examples TEXT",
"searchTokens TEXT",
),
))
}
}

View File

@@ -0,0 +1,93 @@
package me.eternal.purrfectsnap.storage
import android.content.ContentValues
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import org.json.JSONArray
data class AssistantRegistryEntry(
val id: String,
val kind: String,
val title: String,
val category: String,
val path: String,
val description: String,
val settingKey: String? = null,
val screenRoute: String? = null,
val allowedActions: List<String> = emptyList(),
val allowedValues: List<String> = emptyList(),
val aliases: List<String> = emptyList(),
val commonTypos: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val searchTokens: List<String> = emptyList()
)
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
private fun parseStringList(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}.getOrDefault(emptyList())
}
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
database.beginTransaction()
try {
database.execSQL("DELETE FROM assistant_registry")
entries.forEach { entry ->
database.insert(
"assistant_registry",
null,
ContentValues().apply {
put("id", entry.id)
put("kind", entry.kind)
put("title", entry.title)
put("category", entry.category)
put("path", entry.path)
put("description", entry.description)
put("settingKey", entry.settingKey)
put("screenRoute", entry.screenRoute)
put("allowedActions", entry.allowedActions.toJsonArrayString())
put("allowedValues", entry.allowedValues.toJsonArrayString())
put("aliases", entry.aliases.toJsonArrayString())
put("commonTypos", entry.commonTypos.toJsonArrayString())
put("examples", entry.examples.toJsonArrayString())
put("searchTokens", entry.searchTokens.toJsonArrayString())
}
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
val entries = mutableListOf<AssistantRegistryEntry>()
while (cursor.moveToNext()) {
entries += AssistantRegistryEntry(
id = cursor.getStringOrNull("id") ?: continue,
kind = cursor.getStringOrNull("kind") ?: "feature",
title = cursor.getStringOrNull("title") ?: "",
category = cursor.getStringOrNull("category") ?: "",
path = cursor.getStringOrNull("path") ?: "",
description = cursor.getStringOrNull("description") ?: "",
settingKey = cursor.getStringOrNull("settingKey"),
screenRoute = cursor.getStringOrNull("screenRoute"),
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
aliases = parseStringList(cursor.getStringOrNull("aliases")),
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
examples = parseStringList(cursor.getStringOrNull("examples")),
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
)
}
entries
}
}

View File

@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.isStealthRule
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.util.ktx.getInteger
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
@@ -94,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
database.beginTransaction()
try {
friends.forEach { friend ->
// Industrial Filter: Only update existing friends, never auto-insert new ones.
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
friend.selfieId,
friend.userId
)
)
@@ -121,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
}
groups.forEach { group ->
// Industrial Filter: Only update existing groups.
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
group.participantsCount,
group.conversationId
)
)
}
@@ -135,6 +140,9 @@ fun AppDatabase.replaceMessagingData(
} finally {
database.endTransaction()
}
// Notify all observers with the raw sync data (AddFriendDialog needs this)
messagingDataFlow.tryEmit(friends to groups)
}
}
@@ -154,12 +162,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
context.log.error("Failed to parse rule", it)
}
}
rules
rules.normalizeStealthRules().toList()
}
}
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
val ruleType = MessagingRuleType.getByName(type)
if (ruleType?.isStealthRule() == true) {
val updatedStealthRules = getRules(targetUuid)
.withNormalizedRuleToggle(ruleType, enabled)
.filter { it.isStealthRule() }
database.beginTransaction()
try {
database.execSQL(
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
arrayOf(
targetUuid,
MessagingRuleType.STEALTH.key,
MessagingRuleType.SNAP_STEALTH.key,
MessagingRuleType.CHAT_STEALTH.key
)
)
updatedStealthRules.forEach { stealthRule ->
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
arrayOf(targetUuid, stealthRule.key)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
return@executeAsync
}
if (enabled) {
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",

View File

@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
.setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher))
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -269,7 +269,7 @@ fun FloatingTopBar(
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -113,8 +113,8 @@ object Updater {
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
} else {
fetchLatestRelease(channel)
}

View File

@@ -77,6 +77,7 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.core.features.impl.experiments.RandomizedDeviceProfile
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
@@ -180,7 +181,6 @@ class FeaturesRootSection : Routes.Route() {
}
internal fun getRandomizedProfileSnapshot(): String {
context.config.load()
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
?.takeIf { it.isNotBlank() }
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
@@ -188,7 +188,77 @@ class FeaturesRootSection : Routes.Route() {
}
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
return propertyName == "generate_fresh_profile_action" || propertyName == "view_current_profile_action"
return propertyName == "generate_fresh_profile_action" ||
propertyName == "view_current_profile_action" ||
propertyName == "backup_profile_action" ||
propertyName == "restore_profile_action"
}
internal fun backupRandomizedProfile(onConfigChanged: () -> Unit) {
val profileSnapshot = getRandomizedProfileSnapshot()
if (profileSnapshot.startsWith("No generated profile")) {
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.empty"]
?: "No generated profile is available yet. Enable the feature in Snapchat first."
)
return
}
activityLauncher {
saveFile("randomized-device-profile.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.bufferedWriter()?.use {
it.write(profileSnapshot)
} ?: error("Failed to open backup destination")
onConfigChanged()
context.shortToast("Randomized profile backup saved")
}.onFailure {
context.log.error("Failed to back up randomized profile", it)
context.shortToast("Failed to back up randomized profile")
}
}
}
}
internal fun restoreRandomizedProfile(onConfigChanged: () -> Unit) {
activityLauncher {
openFile("application/json") { uri ->
runCatching {
val importedJson = context.androidContext.contentResolver.openInputStream(uri.toUri())
?.bufferedReader()
?.use { it.readText() }
?.trim()
?: error("Failed to read randomized profile backup")
val profile = RandomizedDeviceProfile.fromJson(importedJson)
val generationToken = UUID.randomUUID().toString()
val profileJson = profile.toJson().toString()
// Save to local prefs for legacy compatibility
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
.edit()
.putString("randomized_device_profile", profileJson)
.putString("randomized_device_profile_token", generationToken)
.putString("android_id", profile.androidId)
.putString("advertising_id", profile.advertisingId)
.putString("bluetooth_address", profile.bluetoothMacAddress)
.putString("gsf_id", profile.gsfId)
.putString("random_device", profile.deviceInfo.model)
.putString("device_fingerprint", profile.buildFingerprint)
.apply()
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(generationToken)
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
randomizeConfig.profileData.set(profileJson) // Shared storage fix
context.config.writeConfig()
onConfigChanged()
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")
}.onFailure {
context.log.error("Failed to restore randomized profile", it)
context.shortToast("Failed to restore randomized profile")
}
}
}
}
fun navigateToMainRoot() {
@@ -664,6 +734,8 @@ class FeaturesRootSection : Routes.Route() {
val actionLabel = when (property.name) {
"generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile"
"view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile"
"backup_profile_action" -> context.translation[property.key.propertyName()] ?: "Backup Profile"
"restore_profile_action" -> context.translation[property.key.propertyName()] ?: "Restore Profile"
else -> property.name
}
Button(
@@ -685,8 +757,12 @@ class FeaturesRootSection : Routes.Route() {
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
)
}
} else {
} else if (property.name == "view_current_profile_action") {
showCurrentRandomProfileDialog = true
} else if (property.name == "backup_profile_action") {
backupRandomizedProfile(onConfigChanged)
} else if (property.name == "restore_profile_action") {
restoreRandomizedProfile(onConfigChanged)
}
},
colors = ButtonDefaults.buttonColors(
@@ -707,11 +783,14 @@ class FeaturesRootSection : Routes.Route() {
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSleepWindowProperty = property.key.name.contains("sleep_window")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
} else if (isSleepWindowProperty) {
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
} else if (isSnapchatPlusPurchaseDateProperty) {
alertDialogs.DatePickerPropertyDialog(property) { showDialog = false }
} else {
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
}
@@ -729,6 +808,7 @@ class FeaturesRootSection : Routes.Route() {
)
} else {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
val messageCount = try {
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
@@ -750,6 +830,16 @@ class FeaturesRootSection : Routes.Route() {
color = Color.White
)
}
} else if (isSnapchatPlusPurchaseDateProperty) {
Button(
onClick = click,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Text(translation["button.set"] ?: "Set")
}
} else {
IconButton(onClick = click) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)

View File

@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -38,10 +35,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.storage.clearRuleIds
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.storage.setRule
@@ -140,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key)
}
val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) {
ruleState = newState
@@ -163,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
fun showAddFriendDialog() {
addFriendDialog = AddFriendDialog(
context = context,
pinnedIds = currentRuleIds,
pinnedIds = currentRuleIds.toList(),
actionHandler = Actions(
onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(friend.userId)
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else {
currentRuleIds.remove(friend.userId)
}
@@ -176,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(group.conversationId)
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else {
currentRuleIds.remove(group.conversationId)
}
},
getFriendState = { friend ->
currentRuleIds.contains(friend.userId)
currentRuleIdSet.contains(friend.userId)
},
getGroupState = { group ->
currentRuleIds.contains(group.conversationId)
currentRuleIdSet.contains(group.conversationId)
}
)
)
@@ -230,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.zIndex(10f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
.padding(top = topBarHeight + 10.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
item {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()],
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
}
}
}
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"],
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
item {
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"] ?: "Disabled",
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
}
}
val manageLabel = when (ruleState) {
@@ -291,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
else -> null
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"],
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"] ?: "Whitelist",
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
) {
Text(text = translation["whitelist_state_button"])
}
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"],
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"])
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"])
Text(text = translation["whitelist_state_button"] ?: "Manage")
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"] ?: "Blacklist",
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"] ?: "Manage")
}
}
}
item {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"] ?: "Clear List",
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
}
}
}
}
item {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
}

View File

@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
name: String,
imageRes: Int,
avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
routes.retroGame.navigate()
}
},
modifier = modifier.scaleOnPress(tapSource),
modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
interactionSource = tapSource,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(14.dp),
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.Center
) {
Surface(
modifier = Modifier.size(64.dp),
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
PurrfectMarqueeText(
Text(
text = name,
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Report
@@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() {
internal fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onFilter: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
@@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = onFilter) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filter Logs",
tint = PurrfectPalette.glowSecondary
)
}
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
@@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() {
LogLevel.WARN -> Icons.Outlined.Warning
}
enum class LogCategory(val translationKey: String, val tags: List<String>) {
CORE("log_category_core", listOf("core", "hook", "module", "mappings")),
AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")),
MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")),
BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")),
SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")),
TRACKER("log_category_tracker", listOf("tracker", "friendtracker"))
}
val enabledCategories = mutableStateMapOf<LogCategory, Boolean>().apply {
LogCategory.entries.forEach { put(it, true) }
}
internal fun getCategoryForLog(line: LogLine): LogCategory? {
val tag = line.tag.lowercase()
val message = line.message.lowercase()
return LogCategory.entries.find { category ->
category.tags.any { tag.contains(it) || message.contains("[$it]") }
}
}
internal fun shouldHideLog(line: LogLine): Boolean {
val category = getCategoryForLog(line)
if (category != null && enabledCategories[category] == false) return true
val message = line.message.lowercase()
val tag = line.tag.lowercase()
return message.startsWith("blocked ep") ||

View File

@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = contentDescription, tint = Color.White)
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = it,
color = Color.White,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
@Composable
private fun RowScope.HomeActionChips() {
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT
)
TopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"]
label = context.translation["manager.routes.home_logs"],
modifier = Modifier
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"]
label = translation["manager.routes.home_about"],
modifier = Modifier
) { routes.about.navigate() }
}
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.wrapContentWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
}

View File

@@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() {
internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
"monthly" -> 30L
else -> 1L
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
@@ -112,10 +87,10 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.putString("update_channel", "stable")
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()

View File

@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("script")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -26,9 +26,11 @@ import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.getFriends
import me.eternal.purrfectsnap.storage.getGroups
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
@@ -223,32 +225,34 @@ class AddFriendDialog(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else {
this
coroutineScope.launch(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
val sortedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
// Priority sort for whitelisted groups
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.conversationId)
val bSelected = whitelistedIds.contains(b.conversationId)
if (aSelected != bSelected) if (aSelected) -1 else 1
else a.name.compareTo(b.name, ignoreCase = true)
}
}
}
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
this
withContext(Dispatchers.Main) {
cachedFriends = sortedFriends
cachedGroups = sortedGroups
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
}
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
applySnapshot(friends, groups)
}
}
// Initial database load
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
@@ -256,24 +260,11 @@ class AddFriendDialog(
)
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
// Real-time synchronization flow
context.requestSocialSnapshotRefresh()
coroutineScope.launch(Dispatchers.IO) {
repeat(25) {
delay(1000)
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) {
withContext(Dispatchers.Main) {
applySnapshot(dbFriends, dbGroups)
}
return@launch
}
coroutineScope.launch {
context.database.messagingDataFlow.collect { (friends, groups) ->
applySnapshot(friends, groups)
}
}
@@ -290,7 +281,6 @@ class AddFriendDialog(
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
@@ -350,6 +340,7 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
@@ -360,6 +351,16 @@ class AddFriendDialog(
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
val selectedGroupCount by remember(filteredGroups) {
derivedStateOf {
filteredGroups.count { group ->
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
}
}
}
val hasGroupsSelected = selectedGroupCount > 0
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
DialogHeader(searchKeyword)
LazyColumn(
@@ -369,14 +370,54 @@ class AddFriendDialog(
) {
item {
if (filteredGroups.isNotEmpty()) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 8.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = true
actionHandler.onGroupState(group, true)
}
}
},
enabled = !allGroupsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = false
actionHandler.onGroupState(group, false)
}
}
},
enabled = hasGroupsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}
}
}
}
@@ -421,11 +462,7 @@ class AddFriendDialog(
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
@@ -441,11 +478,7 @@ class AddFriendDialog(
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}

View File

@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import java.io.File
import java.text.DateFormat
import java.util.Date
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
override val title: @Composable () -> Unit = {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val text = remember(navBackStackEntry) {
navBackStackEntry?.arguments?.getString("id")?.let {
context.database.getFriendInfo(it)?.displayName
}
val userId = navBackStackEntry?.arguments?.getString("id")
val displayName by rememberAsyncMutableState(defaultValue = null) {
userId?.let { context.database.getFriendInfo(it)?.displayName }
}
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.database.getFriendInfo(userId) }
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
context.database.getFriendInfo(userId)
}
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
val rules = rememberAsyncMutableStateList(listOf()) {
context.database.getRules(id)
context.database.getRules(id).normalizeStealthRules().toList()
}
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
val previousRules = rules.toSet()
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
val changedRules = (previousRules + updatedRules).filter { rule ->
(rule in previousRules) != (rule in updatedRules)
}
rules.clear()
rules.addAll(updatedRules)
changedRules.forEach { changedRule ->
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
}
}
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember(rules.size) {
mutableStateOf(rules.any { it.key == ruleType.key })
}
val ruleState = context.config.root.rules.getRuleState(ruleType)
val ruleEnabled = rules.any { it.key == ruleType.key }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.database.setRule(id, ruleType.key, it)
ruleEnabled = it
updateRules(ruleType, it)
},
colors = purrfectSwitchColors()
)

View File

@@ -0,0 +1,30 @@
package me.eternal.purrfectsnap.ui.manager.pages.social
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.storage.getFriends
internal fun RemoteSideContext.sortSocialFriends(
friends: List<MessagingFriendInfo>,
pinnedIds: List<String>? = null
): List<MessagingFriendInfo> {
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
return friends.sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.userId)
val bSelected = whitelistedIds.contains(b.userId)
if (aSelected != bSelected) {
return@sortedWith if (aSelected) -1 else 1
}
if (sortByStreakLength) {
val aStreak = a.streaks?.length ?: 0
val bStreak = b.streaks?.length ?: 0
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
}
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
}
}

View File

@@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
@@ -53,10 +52,32 @@ class SocialRootSection : Routes.Route() {
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
@Composable
fun SocialDataController() {
LaunchedEffect(Unit) {
// Initial data fetch from the database
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
// Real-time synchronization from the bridge
context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
}
}
}
@@ -124,11 +145,6 @@ class SocialRootSection : Routes.Route() {
addFriendDialog?.Content {
addFriendDialog = null
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
FloatingActionButton(
@@ -158,8 +174,7 @@ class SocialRootSection : Routes.Route() {
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
),
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
)
)
},
modifier = Modifier

View File

@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
color = Color.White,
modifier = Modifier.padding(bottom = 4.dp)
)
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
}
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
modifier: Modifier = Modifier,
expandedWidth: Dp? = null,
collapsedWidth: Dp = 36.dp,
haptic: HapticFeedback,
onClick: () -> Unit,
) {
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
)
if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Clip,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.widthIn(max = (75 * shrinkFactor).dp)
)
val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
if (labelAlpha > 0.02f) {
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.weight(1f, fill = false)
)
}
}
}
}
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.APHELION,
shrinkFactor = shrinkFactor,
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
)
AphelionTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
expandedWidth = 88.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() }
AphelionTopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
expandedWidth = 96.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() }
}
@@ -226,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
@@ -283,7 +301,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
HeroBadge(translation.format("hero_version_label", "version" to versionName))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge(translation.format("hero_build_label", "build" to it))
}
@@ -444,17 +462,17 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
hasInitialized -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
context.coroutineScope.launch(Dispatchers.IO) {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
}
allQuickTileNames
}
}
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
Updater.getLatestRelease(Channel.STABLE)
}
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
@@ -502,7 +520,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -540,7 +558,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
fullChangelogLoading = true
fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -646,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
.padding(horizontal = 16.dp)
.height(headerHeight)
) {
Text(
text = "PurrfectSnap",
color = Color.White.copy(alpha = stickyBrandingAlpha),
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
modifier = Modifier.align(Alignment.Center)
)
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
modifier = Modifier
.align(Alignment.Center)
.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
haptic = haptic
) { showFullChangelogDialog = true; loadFullChangelog() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
}
}
@@ -692,7 +700,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,

View File

@@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import kotlinx.coroutines.launch
@@ -37,20 +41,17 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() {
isRefreshing = true
coroutineScope.launch(Dispatchers.IO) {
val readerResult = runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
context.log.newReader { /* items are batch-added from reader logic below */ }
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
withContext(Dispatchers.Main) { isRefreshing = false }
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
@@ -69,6 +70,78 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
}
}
@Composable
fun LogFilterDialog() {
Dialog(onDismissRequest = { showFilterDialog = false }) {
PurrfectOverlayTheme {
PurrfectGlassCard(
title = translation["filter_logs_title"] ?: "Log Filters",
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories[category] = !(enabledCategories[category] ?: true)
refreshLogs()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.3f),
checkmarkColor = Color.White
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
)
}
}
}
}
Button(
onClick = { showFilterDialog = false },
modifier = Modifier.fillMaxWidth().height(54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
}
}
if (showFilterDialog) {
LogFilterDialog()
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
refreshLogs()
@@ -132,6 +205,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
color = Color.White
)
}
IconButton(onClick = { showFilterDialog = true }) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
}
IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import com.google.gson.JsonParser
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
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.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.storage.findFriend
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -222,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
}
}
@@ -257,11 +263,494 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
var showExportOptionsDialog by remember { mutableStateOf(false) }
var showConversationExportDialog by remember { mutableStateOf(false) }
var showConversationFormatDialog by remember { mutableStateOf(false) }
var conversationSearchQuery by remember { mutableStateOf("") }
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
data class ConversationSearchTarget(
val target: LoggerConversationExportTarget,
val friendDisplayName: String?,
val friendUsername: String?,
val chatDisplayName: String?,
val groupDisplayName: String?,
val readableUsernames: List<String>,
val readableIdentifiers: List<String>,
val isDirectChat: Boolean,
val isGroupChat: Boolean,
val sortOrder: Int
)
data class ConversationExportFormat(
val extension: String,
val mimeType: String,
val label: String
)
data class ParsedConversationMessage(
val senderId: String,
val senderUsername: String,
val timestamp: Long,
val contentType: ContentType,
val messageText: String?,
val attachments: List<DecodedAttachment>
)
fun String.isUuidLike(): Boolean {
val value = trim()
if (value.length != 36) return false
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
return value.filterIndexed { index, _ ->
index != 8 && index != 13 && index != 18 && index != 23
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
fun String.isLikelyInternalId(): Boolean {
val value = trim()
if (value.isUuidLike()) return true
if (value.length >= 10 && value.all(Char::isDigit)) return true
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
val digitCount = value.count(Char::isDigit)
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
if (digitCount >= 4 && alphaCount >= 4) return true
}
return false
}
fun String.toReadableIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.isLikelyInternalId()) return null
if (!value.any { it.isLetter() }) return null
return value
}
fun String.toSearchIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.equals("myai", ignoreCase = true)) return null
return value
}
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
context.messageLogger.getConversationExportTargets()
}
val exportSearchTargets by rememberAsyncMutableState(
defaultValue = emptyList<ConversationSearchTarget>(),
keys = arrayOf(exportTargets)
) {
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
exportTargets.mapIndexedNotNull { index, target ->
val friend = context.database.findFriend(target.conversationId)
val group = context.database.getGroupInfo(target.conversationId)
val chatDisplayName = target.groupTitle
?.toReadableIdentityOrNull()
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
val searchableUsernames = target.usernames
.mapNotNull { it.toSearchIdentityOrNull() }
.distinct()
val readableUsernames = searchableUsernames
.mapNotNull { it.toReadableIdentityOrNull() }
.distinct()
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
target.userIds.mapNotNull { userId ->
friendIdentityCache.getOrPut(userId) {
context.database.getFriendInfo(userId)?.let {
it.displayName?.toReadableIdentityOrNull() to
it.mutableUsername.toReadableIdentityOrNull()
}
}?.takeIf { it.first != null || it.second != null }
}
} else {
emptyList()
}
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
?: chatDisplayName?.takeIf { hasManyParticipants }
val isGroupChat = groupDisplayName != null || hasManyParticipants
val isDirectChat = !isGroupChat
val readableIdentifiers = buildList {
add(target.conversationId)
addAll(target.userIds)
resolvedFriendDisplayName?.let { add(it) }
resolvedFriendUsername?.let { add(it) }
chatDisplayName?.let { add(it) }
groupDisplayName?.let { add(it) }
addAll(searchableUsernames)
addAll(readableUsernames)
}.distinct()
ConversationSearchTarget(
target = target,
friendDisplayName = resolvedFriendDisplayName,
friendUsername = resolvedFriendUsername,
chatDisplayName = chatDisplayName,
groupDisplayName = groupDisplayName,
readableUsernames = readableUsernames,
readableIdentifiers = readableIdentifiers,
isDirectChat = isDirectChat,
isGroupChat = isGroupChat,
sortOrder = index
)
}.sortedWith(
compareBy<ConversationSearchTarget> {
when {
it.isDirectChat -> 0
it.isGroupChat -> 1
else -> 2
}
}.thenBy { it.sortOrder }
)
}
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
val query = conversationSearchQuery.trim()
if (query.isBlank()) {
exportSearchTargets
} else {
exportSearchTargets.filter { searchTarget ->
searchTarget.readableIdentifiers.any {
it.contains(query, ignoreCase = true)
}
}
}
}
val exportFormats = remember {
listOf(
ConversationExportFormat("db", "application/octet-stream", ".db"),
ConversationExportFormat("html", "text/html", "HTML"),
ConversationExportFormat("txt", "text/plain", "TXT")
)
}
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
searchTarget.friendDisplayName?.let { displayName ->
val username = searchTarget.friendUsername
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
"$displayName • @$username"
} else {
displayName
}
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
}
searchTarget.friendUsername?.let { username ->
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
}
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
}
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
val friendName = if (searchTarget.readableUsernames.size == 1) {
searchTarget.readableUsernames.first()
} else {
searchTarget.readableUsernames.joinToString(", ")
}
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
}
searchTarget.groupDisplayName?.let {
return loggerHistoryTranslation.format("list_group_format", "name" to it)
}
if (searchTarget.readableUsernames.isNotEmpty()) {
return loggerHistoryTranslation.format(
"list_group_format",
"name" to searchTarget.readableUsernames.joinToString(", ")
)
}
return if (searchTarget.isGroupChat) {
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
} else {
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
}
}
fun showExportError(throwable: Throwable) {
context.log.error("Failed to export message logger", throwable)
context.shortToast(
translation.format(
"message_logger_export_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun showImportError(throwable: Throwable) {
context.log.error("Failed to import message logger", throwable)
context.shortToast(
translation.format(
"import_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull()
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
val contentBytes = runCatching {
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
}.getOrNull()
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
val messageText = contentBytes?.getMessageText(contentType)
val attachments = runCatching {
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
}.getOrDefault(emptyList())
return ParsedConversationMessage(
senderId = message.userId,
senderUsername = message.username,
timestamp = message.sendTimestamp,
contentType = contentType,
messageText = messageText,
attachments = attachments
)
}
fun htmlEscape(input: String): String {
val escaped = StringBuilder(input.length)
input.forEach { char ->
when (char) {
'&' -> escaped.append("&amp;")
'<' -> escaped.append("&lt;")
'>' -> escaped.append("&gt;")
'"' -> escaped.append("&quot;")
'\'' -> escaped.append("&#39;")
else -> escaped.append(char)
}
}
return escaped.toString()
}
fun writeConversationExportFile(
target: LoggerConversationExportTarget,
format: ConversationExportFormat,
outputFile: File
): Int {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
throw IllegalArgumentException("Conversation ID cannot be empty")
}
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
val dateFormatter = DateFormat.getDateTimeInstance()
val senderCache = mutableMapOf<String, String>()
fun formatSenderLabel(senderId: String, senderUsername: String): String {
val friendInfo = context.database.getFriendInfo(senderId)
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
?: senderUsername.toReadableIdentityOrNull()
return when {
senderDisplayName != null &&
senderReadableUsername != null &&
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
"$senderDisplayName (@$senderReadableUsername)"
senderDisplayName != null -> senderDisplayName
senderReadableUsername != null -> "@$senderReadableUsername"
else -> translation["sender_unknown"] ?: "Unknown sender"
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
val isHtmlFormat = format.extension == "html"
if (isHtmlFormat) {
writer.appendLine("<!DOCTYPE html>")
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
writer.appendLine(
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
".content{white-space:pre-wrap;word-break:break-word;}" +
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
)
writer.appendLine("</head><body>")
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
} else {
writer.appendLine(conversationTitle)
writer.appendLine("")
}
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
conversationId = conversationId,
userIds = target.userIds,
orderAscending = true
) { loggedMessage ->
val parsed = parseConversationMessage(loggedMessage)
val senderInfo = senderCache.getOrPut(parsed.senderId) {
formatSenderLabel(parsed.senderId, parsed.senderUsername)
}
val senderLabel = senderInfo
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
loggerHistoryTranslation["empty_message"]
} else {
parsed.contentType.name.lowercase()
}
if (isHtmlFormat) {
writer.appendLine("<div class=\"message\">")
writer.appendLine(
"<div class=\"meta\">${
htmlEscape(
"${dateFormatter.format(Date(parsed.timestamp))}$senderLabel${
parsed.contentType.name.lowercase()
}"
)
}</div>"
)
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
if (parsed.attachments.isNotEmpty()) {
writer.appendLine("<ul class=\"attachments\">")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
if (directUrl != null) {
writer.appendLine(
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
htmlEscape(attachmentLabel)
}</a></li>"
)
} else {
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
}
}
writer.appendLine("</ul>")
}
writer.appendLine("</div>")
} else {
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine(" - $attachmentLabel: $attachmentValue")
}
writer.appendLine("")
}
}
if (exportedMessageCount == 0) {
if (isHtmlFormat) {
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
} else {
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
}
}
if (isHtmlFormat) {
writer.appendLine("</body></html>")
}
exportedMessageCount
}
}
fun exportFullDatabase() {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
}
}.onFailure { showExportError(it) }
}
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
context.shortToast(translation["message_logger_missing_conversation_toast"])
return
}
val fileNameSuffix = conversationId
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.take(24)
.ifBlank { "chat" }
runCatching {
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
scope.launch {
runCatching {
val exportedMessageCount = withContext(Dispatchers.IO) {
val tempFile = File(
context.androidContext.cacheDir,
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
)
try {
val messageCount = if (format.extension == "db") {
context.messageLogger.exportConversationDatabase(
outputFile = tempFile,
conversationId = conversationId,
userIds = target.userIds
).messageCount
} else {
writeConversationExportFile(target, format, tempFile)
}
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
tempFile.inputStream().use { input -> input.copyTo(output) }
} ?: throw IllegalStateException("Failed to open output stream")
messageCount
} finally {
tempFile.delete()
}
}
if (exportedMessageCount == 0) {
context.shortToast(translation["message_logger_empty_chat_toast"])
} else {
context.shortToast(translation["success_toast"])
}
}.onFailure { showExportError(it) }
}
}
}.onFailure { showExportError(it) }
}
fun dismissConversationExportDialog() {
showConversationExportDialog = false
selectedConversationForExport = null
conversationSearchQuery = ""
}
fun dismissConversationFormatDialog() {
showConversationFormatDialog = false
pendingConversationExportTarget = null
}
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
@@ -269,7 +758,222 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = context.translation["button.import"],
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
scope.launch {
runCatching {
val importResult = withContext(Dispatchers.IO) {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
context.messageLogger.importDatabase(input)
} ?: throw IllegalStateException("Failed to open selected backup file")
}
storedMessagesCount = importResult.messageCount
storedStoriesCount = importResult.storyCount
context.shortToast(translation["success_toast"])
}.onFailure { showImportError(it) }
}
}
}.onFailure { showImportError(it) }
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
if (showExportOptionsDialog) {
AestheticDialog(
onDismissRequest = { showExportOptionsDialog = false },
title = translation["message_logger_export_title"] ?: "Export Message Logger",
text = translation["message_logger_export_text"] ?: "Choose what to export.",
icon = Icons.Filled.SaveAlt,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { showExportOptionsDialog = false },
showCloseButton = false,
customContent = {
Button(
onClick = {
showExportOptionsDialog = false
pendingConversationExportTarget = null
showConversationExportDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
}
Button(
onClick = {
showExportOptionsDialog = false
exportFullDatabase()
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
}
}
)
}
if (showConversationExportDialog) {
AestheticDialog(
onDismissRequest = { dismissConversationExportDialog() },
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
icon = Icons.Filled.Search,
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
pendingConversationExportTarget = selectedTarget
dismissConversationExportDialog()
showConversationFormatDialog = true
},
onDismiss = { dismissConversationExportDialog() },
showCloseButton = false,
confirmEnabled = selectedConversationForExport != null,
customContent = {
OutlinedTextField(
value = conversationSearchQuery,
onValueChange = { conversationSearchQuery = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
{
IconButton(onClick = { conversationSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
if (filteredExportTargets.isEmpty()) {
Text(
text = translation["message_logger_no_chats_found"] ?: "No chats found",
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredExportTargets.size) { index ->
val searchTarget = filteredExportTargets[index]
val target = searchTarget.target
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
OutlinedButton(
onClick = { selectedConversationForExport = searchTarget.target },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
border = BorderStroke(
1.dp,
if (isSelected) {
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
} else {
Color.White.copy(alpha = 0.18f)
}
)
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = formatExportTarget(searchTarget),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val secondaryLabel = when {
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
else -> null
}
if (secondaryLabel != null) {
Text(
text = secondaryLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
}
}
}
}
)
}
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
AestheticDialog(
onDismissRequest = { dismissConversationFormatDialog() },
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
icon = Icons.Filled.Description,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { dismissConversationFormatDialog() },
showCloseButton = false,
customContent = {
exportFormats.forEach { format ->
val formatLabel = when (format.extension) {
"db" -> translation["message_logger_export_format_db"] ?: ".db"
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
else -> translation["message_logger_export_format_txt"] ?: "TXT"
}
Button(
onClick = {
val exportTarget = pendingConversationExportTarget ?: return@Button
dismissConversationFormatDialog()
exportConversation(exportTarget, format)
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(formatLabel)
}
}
}
)
}
}
}

View File

@@ -39,15 +39,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.sortSocialFriends
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
// Controller handles data loading and synchronization
SocialDataController()
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
@@ -56,19 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
friendList = friends
groupList = groups
}
updateScopeLists()
}
DisposableEffect(Unit) {
onDispose {
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
// Filter logic based on the parent's synchronized data lists
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList

View File

@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("friend_tracker")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
val setupPrefs = setupContext.sharedPreferences
val setupRoutes = Routes(setupContext).apply {
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
}
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
val requiredScreens = mutableListOf<SetupScreen>().apply {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(IntroShowcaseScreen().apply { route = "introShowcase" })
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen(
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
val bottomPadding = 118.dp + navBarPadding
Column(
modifier = Modifier
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
.navigationBarsPadding()
.padding(bottom = 32.dp)
)
setupAiPrompt?.let { prompt ->
ManagerAssistantDialog(
context = setupContext,
routes = setupRoutes,
initialUserMessage = prompt,
onDismiss = { setupAiPrompt = null }
)
}
}
}
}
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language
)
is IntroShowcaseScreen -> SetupStepMeta(
route = route,
title = "Welcome",
subtitle = "Preview what PurrfectSnap can do",
icon = Icons.Filled.AutoAwesome
)
is InstallModeScreen -> SetupStepMeta(
route = route,
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
}
@Composable
private fun SetupTopBar() {
private fun SetupTopBar(onAskAi: () -> Unit) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "PurrfectSnap",
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
Surface(
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.08f),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable(onClick = onAskAi)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
Text(
text = "Ask AI",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
class IntroShowcaseScreen : SetupScreen() {
private val slides = listOf(
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
)
@Composable
override fun Content() {
LaunchedEffect(Unit) { allowNext(true) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(5000)
currentIndex = (currentIndex + 1) % slides.size
}
}
SetupCard {
StepTitle(
title = "Welcome to PurrfectSnap",
subtitle = "A quick look before setup begins",
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Image(
painter = painterResource(slides[index].first),
contentDescription = slides[index].second,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
)
}
Text(
text = slides[index].second,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
slides.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(if (index == currentIndex) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
)
}
}
}
}
}

View File

@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val assistantRoutes = remember {
Routes(context).apply {
}
}
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
ManagerAssistantDialog(
context = context,
routes = assistantRoutes,
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
showImprovementLogging = false,
onDismiss = { showIssuesDialog = false }
)
}

View File

@@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
@@ -427,9 +431,10 @@ class AlertDialogs(
DefaultDialogCard {
var fieldValue by remember {
mutableStateOf(property.value.get().toString().let {
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
TextFieldValue(
text = it,
selection = TextRange(it.length)
text = t,
selection = TextRange(t.length)
)
})
}
@@ -443,10 +448,21 @@ class AlertDialogs(
}
.focusRequester(focusRequester),
value = fieldValue,
onValueChange = { fieldValue = it },
keyboardOptions = when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
onValueChange = { newVal ->
fieldValue = if (property.key.params.digitsOnlyInput) {
val filtered = newVal.text.filter { ch -> ch.isDigit() }
if (newVal.text != filtered) {
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
}
newVal.copy(text = filtered)
} else {
newVal
}
},
keyboardOptions = when {
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,
@@ -512,6 +528,68 @@ class AlertDialogs(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
val context = LocalContext.current
val zoneId = remember { ZoneId.systemDefault() }
val initialSelectedDateMillis = remember(property.value.get()) {
runCatching {
LocalDate
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
}.getOrNull()
}
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
DefaultDialogCard {
DatePicker(
state = datePickerState,
showModeToggle = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
) {
Button(
onClick = { dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val selectedDate = datePickerState.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
}
if (selectedDate == null) {
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
return@Button
}
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
dismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Text(text = translation["button.ok"])
}
}
}
}
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
@@ -1615,4 +1693,3 @@ class AlertDialogs(
}
}
}

View File

@@ -466,22 +466,26 @@ private class DialogWrapper(
this.onDismissRequest = onDismissRequest
this.properties = properties
setLayoutDirection(layoutDirection)
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
val dialogWindow = window
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
} == true
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
// Undo fixed size in internalOnLayout, which would suppress size changes when
// usePlatformDefaultWidth is true.
window?.setLayout(
dialogWindow.setLayout(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
@OptIn(ExperimentalComposeUiApi::class)
if (properties.decorFitsSystemWindows) {
window?.setSoftInputMode(defaultSoftInputMode)
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
} else {
@Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

View File

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

View File

@@ -1,4 +1,5 @@
## v1.1.0
- Fix: Download Context Menu now resolves Message Logger deleted media when available.
- Fix: message preview overlap in French.
- Fix: transparent gallery media send override dialog.
- Fix: message logger autopurge "Never" no longer resets to 3 days.

View File

@@ -1,3 +1,98 @@
## v1.7.1
- Auto-Open Engine ghost notification fix.
- Continous Send notifiction bug fix.
- Disappering chats fix.
- Social page automatic selection bug fix.
## v1.7.0
- Features:
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
- Implemented app intro showcase (tq ΞTΞRNAL)
- Implemented "Spoof follower count" (tq RSR)
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
- Implemented "Chat Hold Kill" (tq SUJΛL)
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Implemented Memory message icon indicator(tq RSR)
- Implemented two new message indicator toggles, for self snaps and group.
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
- Implemented new notification card for Continous send feature.
- Fixes:
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Snapchat Plus bug fixes to improve stability.
- Spoof Device profile Backup/Restore bug fixes.
- Redesgined manager app Logs filtering UI.
- Media Downloader stability and bug fixes.
- Performance Mode: Fixed Max Performance mode persistence after app restart.
- Chat Preview: Realignment of the chat preview layout.
- Update Checker: Refactored the app update checker to follow a single, stable release track with automated daily checks.
- Auto-Open engine: Expanded Notification UI has been refactored and redundant stats has been removed.
- Social tab: Converted the rules configuration page to a Lazy Column architecture. This eliminates UI freezes when managing large friend lists.
- Chat feed scroll stutter: Implemented a thread-safe memory cache for chat suppression logic. This instant memory check allows Snapchat chat feed to scroll at 100% native speed.
- GPU Offloading: Forced hardware-layer acceleration for all camera and video preview views to reduce CPU thermal load and power consumption.
- Refactored all runBlocking calls and main-thread database queries across 12 major pages to eliminate the UI freezes/stutters and the scrolling, navigation, and page transitions are now locked at high frame rates.
- Migrated heavy bridge data processing (JSON parsing and DB writes) to background IO coroutines, preventing ANR (App Not Responding) crashes during large data synchronizations.
## v1.6.9
- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta)
- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet)
- Fix: Fixed the resume/reopen UI break when max performance mode is turned on, and other bug fixes. (tq schrodingerspet)
- New: Implemented an Auto Open Stop Button directly within the notification card.
- New: Added a log filter menu in logs page to isolate Auto-Open, Media downloads, friend tracker, and Core logs.
- Fix: Completely rewritten Auto Open Engine to optimize the auto open engine.
- Fix: Fixed Batch Story Download feature not working.
- FIx: Minor bug fixes for media downloader in stories and spotlight.
- Fix: Bug fixes to improve custom emojis stability.
## v1.6.8
- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations
- Fix: Crash issues for some devices
- Fix: Block Ads
- Fix: Snapchat automatically restarting if kept idle
- Fix: Feed Flickering issue for some devices
- Fix: Implemented Dual-Thread Sync Architecture with a 1,000 depth that proactively scans the database for missed history, thus resolving the "failed to open..." and "Tap to load" snaps before marking them, clearing historical gaps automatically(tq to Kaladin)
- New: Implemented Screen Guard where Auto Open Engine now detects screen state and stops all notification work while the device is locked, thus reducing excessive battery drain and heat spike during background processing(tq to Kaladin)
- Fix: Disk read/write frequency increased from 1s to 5 minutes, to eliminate constant background disk friction, thus reducing overheating of the device while processing(tq to Kaladin)
- Fix: Slid chat switching delays from 2.0s down to 40ms, thus achieving near-instant processing of 1000+ snap bursts(tq to Kaladin)
- Fix: Removed "Auto Open pause while Gaming" toggle(tq to Kaladin)
- Fix: Reworked "Snap Pre-Fetch" toggle to the global Messaging settings page(tq to Kaladin)
- Fix: Refactored Auto Open engine notification cards to declutter it and removed redundant stats(tq to Kaladin)
- Fix: Corrected speed labels in the Auto Open notification to show "Idle" when monitoring and accurate notions (Full Speed/Throttled) when active(tq to Kaladin)
## v1.6.6
- Fix: Persistent auto open snap disabled notification(tq to Kaladin)
- Fix: Crash issues for some devices
- Fix: upload quality for snaps sent through send override
- Fix: Refactored the notification card for Auto Open to remove the looping progress bar while the engine is in Monitoring stage(tq to Kaladin)
- New: Implemented a "Pre Fetch snaps" toggle, when enabled this will pre-fetch the snaps in the auto open queue and load them for the engine to open them efficiently and thus reducing the "Failed to Open..." status(tq to Kaladin)
- Fix: The Auto Open engine now only fires the status update if the status text, processed count, or queue structural state actually changes. This reduces system wake-ups during idle periods which helps in battery drain management(tq to Kaladin)
- Fix: Disk Optimization: Previous versions wrote the auto open queue to the disk every 25 snaps, this update modified this to a 3-minute windowed save. This reduces Disk I/O by ~75%, keeping the device from overheating excessively during Auto Open processes(tq to Kaladin)
## v1.6.5
- New: Performance mode will now be set to max by default!
- Fix: Several optimizations to the performance mode feature which will make your snapchat experience more smooth!
- Fix: Crash issues for some devices after enabling spoof
- Fix: Notification Icon for the Announcements(tq to Kaladin)
- Fix: Auto Open Engine not processing, stuck on monitor/retry/failed loop causing overheating of the devices(tq to Kaladin)
- Fix: Refactor the Notification card for the Auto Open to remove dynamic progress bar to add the native progress bar(tq to Kaladin)
- Fix: Auto Open Statistics have been refactored to now show the dynamic queue status(tq to Kaladin)
- New: Auto Open Thermal Protection/Throttle toggle, when turned on the processing will be doubled down to reduce the temps of the device to maintain a study temp of 40 C and below(tq to Kaladin)
## v1.6.4
- New: Performance mode feature!(Smooth & Max)
- Fix: Failed to init feature Device Spoofer for some devices
- Fix: Increase the default queue of auto open snaps
- Fix: user_conversation error spam
## v1.6.3
- Fix: Blank Global Settings page
- New: Backup & Restore option for Randomized Device Profile feature!
## v1.6.2
- New: Randomized Device Profile Feature!
- Show Activation Overlay

View File

@@ -71,7 +71,7 @@ interface BridgeInterface {
* @param groups list of groups (MessagingGroupInfo as parcelable)
* @param friends list of friends (MessagingFriendInfo as parcelable)
*/
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends, int chunkIndex, int totalChunks);
@nullable String getScopeNotes(String id);

File diff suppressed because it is too large Load Diff

View File

@@ -270,6 +270,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
@@ -779,11 +799,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -1089,6 +1125,10 @@
"name": "Show Streak Expiration Info",
"description": "Shows a Streak Expiration timer next to the Streaks counter"
},
"sort_social_tab_by_streak_length": {
"name": "Sort Social Tab by Streak Length",
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
},
"hide_friend_feed_entry": {
"name": "Hide Friend Feed Entry",
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
@@ -1885,7 +1925,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2057,6 +2103,14 @@
"view_current_profile_action": {
"name": "View Current Profile",
"description": "Inspect the latest randomized profile snapshot"
},
"backup_profile_action": {
"name": "Backup Profile",
"description": "Export the full randomized profile as a backup file"
},
"restore_profile_action": {
"name": "Restore Profile",
"description": "Import and restore a previously backed up randomized profile"
}
}
},
@@ -2283,7 +2337,9 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
@@ -2377,6 +2433,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
@@ -2601,7 +2667,10 @@
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "Mark conversation as read when sending a message",

View File

@@ -197,12 +197,12 @@
},
"sections": {
"home": {
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
"version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
"update_title": "PurrfectSnap Update",
"update_content": "Version {version} is available!",
"update_button": "Download",
"hero_tagline": "An Xposed Module meant to enhance your Snapchat experience",
"hero_version_label": "Version: {version} - {channel}",
"hero_version_label": "Version: {version}",
"hero_build_label": "Build: {build}",
"update_ready_label": "Ready to install",
"purr_aura_active_label": "PurrAura Active!",
@@ -247,15 +247,24 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
"github_button": "GitHub",
"telegram_button": "Telegram"
},
"home_logs": {
"no_logs_hint": "No logs available",
"refresh_hint": "Pull to refresh or trigger an action to see new entries.",
"filter_logs_title": "Filter Log Categories",
"filter_logs_menu_item": "Filter Logs",
"filter_logs_done_button": "Done",
"log_category_core": "Core",
"log_category_auto_open": "Auto-Open",
"log_category_media": "Media",
"log_category_bridge": "Bridge",
"log_category_system": "System",
"log_category_tracker": "Tracker",
"clear_logs_button": "Clear Logs",
"export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...",
@@ -272,6 +281,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
@@ -348,21 +377,22 @@
"remove_all_tasks_confirm": "Remove all tasks?"
},
"features": {
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Explore and manage premium features"
"subtitle": "Explore and manage premium features",
"digits_only_toast": "Only numbers are allowed."
},
"bypass_status": {
"active": "PurrAura Active",
@@ -818,11 +848,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -1131,6 +1177,10 @@
"name": "Show Streak Expiration Info",
"description": "Shows a Streak Expiration timer next to the Streaks counter"
},
"sort_social_tab_by_streak_length": {
"name": "Sort Social Tab by Streak Length",
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
},
"hide_friend_feed_entry": {
"name": "Hide Friend Feed Entry",
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
@@ -1215,6 +1265,10 @@
"name": "Settings Menu",
"description": "Choose between the new and legacy settings menu layouts"
},
"chat_hold_kill_actions": {
"name": "PurrfectSnap Chat Hold Kill",
"description": "Hold the chat/settings header button to kill selected app(s). Leave all options off to disable."
},
"spoof_snap_score": {
"name": "Spoof Snap Score",
"description": "Spoof your Snap Score (local only)",
@@ -1224,6 +1278,16 @@
"description": "The custom Snap Score you want to display (max 9,999,999)"
}
}
},
"spoof_followers_count": {
"name": "Spoof Followers Count",
"description": "Spoof your follower count on your profile (local only).",
"properties": {
"custom_followers_count": {
"name": "Custom Followers Count",
"description": "Number to show (digits only)."
}
}
}
}
},
@@ -1481,6 +1545,10 @@
"name": "Bypass Message Action Restrictions",
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
},
"pre_fetch_snaps": {
"name": "Snap Pre-Fetch",
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
},
"remove_groups_locked_status": {
"name": "Remove Groups Locked Status",
"description": "Allows you to view group information after being kicked"
@@ -1648,6 +1716,7 @@
"status_monitoring": "Monitoring",
"status_active": "Active",
"status_paused": "Paused",
"thermal_status_title": "Thermal Cooling (Throttled)",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Statistics",
@@ -1658,6 +1727,14 @@
"name": "Allow Running in Background",
"description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery"
},
"delay_between_snaps": {
"name": "Delay Between Snaps",
"description": "The delay in milliseconds between opening each individual Snap"
},
"delay_between_conversations": {
"name": "Delay Between Conversations",
"description": "The delay in milliseconds when switching to open Snaps from a different conversation"
},
"min_delay": {
"name": "Min Delay (ms)",
"description": "Minimum delay in milliseconds before opening a snap"
@@ -1682,10 +1759,6 @@
"name": "Auto Open Compact Notification",
"description": "Use a smaller, single-line notification for status updates"
},
"show_progress_bar": {
"name": "Show Progress Bar",
"description": "Display a visual progress bar in the status notification"
},
"show_lifetime_stats": {
"name": "Show Lifetime Statistics",
"description": "Include the total number of snaps opened since installation in the notification"
@@ -1694,7 +1767,15 @@
"name": "Show Queue Preview",
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
},
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
"thermal_protection": {
"name": "Thermal Protection",
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
},
"only_on_wifi": {
"name": "Auto Open only on Wi-Fi",
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
},
"content_type_snap": "Snap",
"only_when_idle": {
"name": "Auto Open Schedule",
"description": "Configure a specific time window where the engine will throttle its speed."
@@ -1703,37 +1784,9 @@
"name": "Auto Open Scheduler",
"description": "Define the start and end times for scheduled throttled processing."
},
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
}
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
"safe_processing": {
"name": "Auto Open with stealth pace",
"description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
}
}
},
@@ -1791,6 +1844,36 @@
}
}
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
}
}
},
"scheduled_send_allow_running_in_background": {
"name": "Allow Scheduled Send to Run in Background",
"description": "Keep scheduled messages processing while Snapchat is in the background"
@@ -1879,6 +1962,10 @@
"name": "Snapchat Plus",
"description": "Enables Snapchat Plus features\nSome Server-sided features may not work"
},
"snapchat_plus_purchase_date": {
"name": "Snapchat Plus Purchase Date",
"description": "Tap Save to choose a date from calendar (leave empty to use default)"
},
"media_upload_quality": {
"name": "Media Upload Quality",
"description": "Overrides the media upload quality",
@@ -1909,6 +1996,16 @@
"name": "Disable Metrics",
"description": "Blocks sending specific analytic data to Snapchat"
},
"performance_mode": {
"name": "Performance Mode",
"description": "Applies an app-wide speed profile for navigation, preview loading, background work, and camera responsiveness",
"properties": {
"performance_profile": {
"name": "Performance Profile",
"description": "Select how aggressively PurrfectSnap pushes app-wide performance tuning"
}
}
},
"disable_story_sections": {
"name": "Disable Story Sections",
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
@@ -1995,7 +2092,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2056,7 +2159,15 @@
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
},
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
"camera_tweaks": {
"name": "Upgraded Camera Engine",
"description": "Enables professional hardware ISP processing modes for better dynamic range"
},
"audio_video": {
"name": "Upgraded Audio and Video",
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
},
"video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
},
@@ -2126,6 +2237,10 @@
"name": "Disable Bitmoji",
"description": "Disables Friends Profile Bitmoji"
},
"debug_font_redirect": {
"name": "Debug Native Font Redirect",
"description": "Logs native font interception. For developer use only."
},
"custom_emoji_font": {
"name": "Custom Emoji Font",
"description": "Allows you to use a custom emoji font. Only works with .ttf fonts"
@@ -2410,7 +2525,7 @@
"name": "Settings Details",
"description": "Enable settings spoofing and fine-tune its namespaces",
"properties": {
"secure": {
"secure_settings": {
"name": "Secure Settings",
"description": "Enable secure settings spoofing and fine-tune secure values",
"properties": {
@@ -2424,7 +2539,7 @@
}
}
},
"system": {
"system_settings": {
"name": "System Settings",
"description": "Enable system settings spoofing and fine-tune system values",
"properties": {
@@ -2438,7 +2553,7 @@
}
}
},
"global": {
"global_settings": {
"name": "Global Settings",
"description": "Enable global settings spoofing and fine-tune global values",
"properties": {
@@ -2565,6 +2680,14 @@
"view_current_profile_action": {
"name": "View Current Profile",
"description": "Inspect the latest randomized profile snapshot"
},
"backup_profile_action": {
"name": "Backup Profile",
"description": "Export the full randomized profile as a backup file"
},
"restore_profile_action": {
"name": "Restore Profile",
"description": "Import and restore a previously backed up randomized profile"
}
}
},
@@ -2614,7 +2737,11 @@
}
}
},
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": {
"network_optimization": {
"name": "Improved Network Connectivity",
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
},
"better_transcript": {
"name": "Better Transcript",
"description": "Improves the voice note transcript",
"properties": {
@@ -2761,6 +2888,24 @@
"description": "Automatically deletes cached events that are older than the specified amount of time"
}
}
},
"companion_server": {
"name": "Companion Server",
"description": "Local web server that streams live logs and network calls to a browser dashboard",
"properties": {
"enabled": {
"name": "Enabled",
"description": "Start the companion web server inside Snapchat's process. Requires a restart."
},
"port": {
"name": "Port",
"description": "Port the server listens on (102465535). Default is 8484."
},
"token": {
"name": "Auth Token",
"description": "Secret token required to access the dashboard. Must not be empty. Requires a restart."
}
}
}
},
"options": {
@@ -2795,7 +2940,9 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
@@ -2837,6 +2984,10 @@
"default": "Default",
"legacy": "Legacy"
},
"chat_hold_kill_actions": {
"kill_snapchat": "Kill Snapchat",
"kill_purrfectsnap": "Kill PurrfectSnap"
},
"path_format": {
"create_author_folder": "Create folder for each author",
"create_source_folder": "Create folder for each media source type",
@@ -2902,6 +3053,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
@@ -3010,7 +3171,8 @@
"NOTE": "Audio Note",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"null": "Snapchat Default"
"null": "Snapchat Default",
"multiple_media_toast": "You can only send one media at a time"
},
"strip_media_metadata": {
"hide_caption_text": "Hide Caption Text",
@@ -3057,6 +3219,11 @@
"custom_image_upload_format": {
"null": "Automatic"
},
"performance_profile": {
"smooth": "Smooth",
"max": "Max",
"null": "Disabled"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
@@ -3137,7 +3304,10 @@
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "Mark conversation as read when sending a message",
@@ -3539,6 +3709,7 @@
"cancel": "Cancel",
"copy": "Copy",
"save": "Save",
"set": "Set",
"open": "Open",
"download": "Download",
"import": "Import",
@@ -3650,17 +3821,19 @@
"failed_gallery_toast": "Failed saving to gallery {error}",
"dash_no_chapter": "No chapter found",
"dash_dialog": {
"title": "Download dash media",
"title": "DASH Download",
"download_all": "Download All",
"segment_text": "Segment {from} - {to}"
"snap_text": "Snap {from} - {to}"
},
"story_snap_dialog": {
"title": "Download story snaps",
"download_all": "Download All",
"select_all": "Select All",
"deselect_all": "Deselect All",
"snap_item": "Snap {index} of {total}"
},
"batch_download_complete_toast": "All snaps downloaded",
"batch_progress_toast": "Downloading {current}/{total}",
"batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible."
},
"streaks_reminder": {
@@ -3715,7 +3888,12 @@
"export_failed_toast": "Failed to export account. Check logs for more info.",
"forced_logout_toast": "Removed account due to forced logout"
},
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
"auto_open_snaps": {
"title": "Auto Open Snaps",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Count",
"priority_title": "Auto Open Snaps (Priority)",
"auto_open_schedule": {
"title": "Auto Open Scheduler",
"start": "Start",
@@ -3732,7 +3910,6 @@
"action_pause": "Pause",
"action_resume": "Resume",
"action_clear": "Clear Queue",
"action_reset": "Reset Count",
"error_content": "Failed to open snap from {sender}: {error}",
"resumed_feedback": "Auto Open Resumed",
"paused_feedback": "Auto Open Paused",
@@ -3740,6 +3917,7 @@
"paused_message": "Processing paused. Queue preserved ({count} snaps)",
"status_paused": "Paused",
"status_monitoring": "Monitoring",
"thermal_status_title": "Thermal Cooling (Throttled)",
"status_active": "Active",
"status_failed": "Failed to open {sender}",
"status_retrying": "Retrying in background...",
@@ -3748,9 +3926,9 @@
"speed_throttled": "Throttled",
"estimated_time": "Estimated Time",
"notification_statistics": "STATISTICS",
"notification_total_opened": "Lifetime Opened",
"notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_no_snaps_queue": "Monitoring snaps in background...",
"notification_no_snaps_queue": "No snaps in queue.",
"queue_cleared": "Queue cleared and statistics reset",
"queue_cleared_title": "Queue cleared",
"queue_cleared_reset": "Queue Cleared & Reset",
@@ -3765,12 +3943,8 @@
"conversation_type_group_chat": "Group Chat",
"conversation_type_chat": "Chat",
"notification_status": "Status",
"notification_statistics": "STATISTICS",
"notification_queue_size": "Queue Size",
"notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_processing_continue": "Processing will continue automatically...",
"notification_no_snaps_queue": "No snaps in queue.",
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
"content_type_photo_video_snap": "Photo/Video Snap",
"conversation_type_group_with_name": "Group: {name}",
@@ -3858,7 +4032,6 @@
"material3_strings": {
"date_range_picker_start_headline": "From",
"date_range_picker_end_headline": "To",
"date_range_picker_title": "Select date range",
"date_picker_switch_to_calendar_mode": "Calendar",
"date_picker_switch_to_input_mode": "Input",
"date_range_picker_scroll_to_previous_month": "Previous month",
@@ -3899,7 +4072,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading",
"loading_username": "Loading\u2026",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
@@ -3984,10 +4157,10 @@
"search": {
"placeholder": "Search"
},
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"search_by": "Search by",
"since": "Since",
"until": "Until",
@@ -4194,8 +4367,6 @@
"added": "Added",
"no_friends_found": "No friends found",
"no_messages": "No messages",
"message": "Message",
"type_message": "Type message...",
"exporting_memories": "Exporting memories... ({failed} failed)"
},
"clear_friend_feed": "Clear Friend Feed",
@@ -4321,8 +4492,7 @@
"deepseek": "DeepSeek",
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
,
},
"tasks_no_tasks": "No tasks",
"tasks_no_active_tasks": "No active tasks",
"tasks_no_scheduled_tasks": "No scheduled snaps",
@@ -4331,8 +4501,8 @@
"tasks_clear_button_description": "Clear tasks",
"tasks_delete_button": "Delete",
"tasks_merge_button": "Merge",
"tasks_summary_active": "{active} active · {recent} recent",
"tasks_summary_idle": "Idle · {recent} recent",
"tasks_summary_active": "{active} active \u2022 {recent} recent",
"tasks_summary_idle": "Idle \u2022 {recent} recent",
"tasks_running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"tasks_failed_to_open_file": "Failed to open file",
@@ -4341,11 +4511,4 @@
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
}
}

View File

@@ -2544,7 +2544,10 @@
"platform_indicator": "वह प्लेटफ़ॉर्म आइकन जोड़ता है जहाँ से मीडिया भेजा गया था (उदा. Android, iOS, Web)",
"location_indicator": "Snaps में \ud83d\udccd आइकन जोड़ता है जब उन्हें लोकेशन सक्षम के साथ भेजा गया हो",
"ovf_editor_indicator": "इंगित करता है कि क्या कोई Snap OVF एडिटर का उपयोग करके भेजा गया है",
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है"
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है",
"memories_indicator": "मेमोरीज़ से फिर से भेजे गए Snaps में \ud83d\udcd6 आइकन जोड़ता है",
"skip_own_indicators": "अपने स्वयं के भेजे गए Snaps पर संकेतक आइकन छुपाता है \ud83d\udc64",
"disable_indicators_in_groups": "UI अव्यवस्था को कम करने के लिए समूह वार्तालापों में सभी संकेतकों को अक्षम करता है \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें",

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import com.google.gson.GsonBuilder
@@ -20,6 +21,7 @@ import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import java.io.File
import java.io.InputStream
import java.util.UUID
class LoggedMessage(
@@ -70,10 +72,74 @@ data class TrackerLog(
}
}
data class LoggerConversationExportTarget(
val conversationId: String,
val groupTitle: String?,
val usernames: List<String>,
val userIds: List<String>,
val messageCount: Int
)
data class ConversationExportResult(
val messageCount: Int,
val chatEditCount: Int,
val trackerEventCount: Int
)
data class DatabaseImportResult(
val messageCount: Int,
val storyCount: Int
)
class LoggerWrapper(
val databaseFile: File,
private val readOnly: Boolean = false
): LoggerInterface.Stub() {
companion object {
private val MESSAGE_LOGGER_SCHEMA = mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
)
}
constructor(context: Context, uri: Uri? = null): this(
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
uri != null
@@ -84,68 +150,190 @@ class LoggerWrapper(
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val gson by lazy { GsonBuilder().create() }
private fun openDatabase(file: File, readOnly: Boolean): SQLiteDatabase {
val dbFlags = if (readOnly) {
SQLiteDatabase.OPEN_READONLY
} else {
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
}
return SQLiteDatabase.openDatabase(file.absolutePath, null, dbFlags).also { openedDatabase ->
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
}
}
}
private fun closeDatabaseLocked() {
_database?.takeIf { it.isOpen }?.close()
_database = null
}
private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run {
_database?.close()
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
))
}
closeDatabaseLocked()
val openedDatabase = openDatabase(databaseFile, readOnly)
_database = openedDatabase
openedDatabase
}
}
protected fun finalize() {
_database?.close()
synchronized(this) {
closeDatabaseLocked()
}
}
fun init() {
}
private fun resolveDatabaseSidecars(file: File): List<File> {
return listOf(
file,
File("${file.absolutePath}-wal"),
File("${file.absolutePath}-shm"),
File("${file.absolutePath}-journal")
)
}
private fun deleteIfExists(file: File) {
if (file.exists() && !file.delete()) {
throw IllegalStateException("Failed to delete ${file.name}")
}
}
private fun replaceFile(source: File, target: File) {
if (!source.exists()) {
throw IllegalStateException("Missing source file ${source.name}")
}
if (source.renameTo(target)) return
source.inputStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!source.delete()) {
throw IllegalStateException("Failed to delete temporary file ${source.name}")
}
}
private fun requireCompatibleSchema(db: SQLiteDatabase) {
MESSAGE_LOGGER_SCHEMA.forEach { (tableName, columns) ->
val existingColumns = mutableListOf<String>()
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
while (cursor.moveToNext()) {
val columnName = cursor.getStringOrNull("name") ?: continue
val columnType = cursor.getStringOrNull("type") ?: ""
existingColumns.add("$columnName $columnType".trim())
}
}
if (existingColumns.isEmpty()) {
throw IllegalStateException("Selected database is missing required table $tableName")
}
val missingColumns = columns.filter { expectedColumn ->
!expectedColumn.uppercase().startsWith("PRIMARY KEY") &&
existingColumns.none { existingColumn -> expectedColumn.startsWith(existingColumn) }
}
if (missingColumns.isNotEmpty()) {
throw IllegalStateException(
"Selected database has incompatible schema for $tableName: ${missingColumns.joinToString()}"
)
}
}
}
private fun requireIntegrity(db: SQLiteDatabase) {
val integrityResult = db.rawQuery("PRAGMA integrity_check(1)", null).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
}
if (integrityResult == null || !integrityResult.equals("ok", ignoreCase = true)) {
throw IllegalStateException("Selected database failed integrity check: ${integrityResult ?: "unknown"}")
}
}
private fun getTableCount(db: SQLiteDatabase, tableName: String): Int {
return db.rawQuery("SELECT COUNT(*) FROM $tableName", null).use { cursor ->
if (!cursor.moveToFirst()) 0 else cursor.getInt(0)
}
}
fun importDatabase(inputStream: InputStream): DatabaseImportResult {
if (readOnly) {
throw IllegalStateException("Cannot import into read-only logger")
}
val databaseDir = databaseFile.parentFile
?: throw IllegalStateException("Cannot resolve message logger database directory")
if (!databaseDir.exists() && !databaseDir.mkdirs()) {
throw IllegalStateException("Failed to create message logger directory")
}
val tempFile = File(
databaseDir,
"${databaseFile.name}.import-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
val backupFile = File(
databaseDir,
"${databaseFile.name}.backup-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
try {
inputStream.use { input ->
tempFile.outputStream().use { output ->
val copiedBytes = input.copyTo(output)
if (copiedBytes <= 0L) {
throw IllegalStateException("Selected backup is empty")
}
}
}
openDatabase(tempFile, readOnly = true).use { importedDatabase ->
requireIntegrity(importedDatabase)
requireCompatibleSchema(importedDatabase)
}
synchronized(this) {
closeDatabaseLocked()
val hadExistingDatabase = databaseFile.exists()
if (hadExistingDatabase) {
databaseFile.inputStream().use { input ->
backupFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
try {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
replaceFile(tempFile, databaseFile)
resolveDatabaseSidecars(databaseFile).filter { it != databaseFile }.forEach(::deleteIfExists)
val reopenedDatabase = openDatabase(databaseFile, readOnly = false)
val importResult = DatabaseImportResult(
messageCount = getTableCount(reopenedDatabase, "messages"),
storyCount = getTableCount(reopenedDatabase, "stories")
)
_database = reopenedDatabase
deleteIfExists(backupFile)
return importResult
} catch (throwable: Throwable) {
runCatching {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
if (backupFile.exists()) {
replaceFile(backupFile, databaseFile)
}
}
closeDatabaseLocked()
throw throwable
}
}
} finally {
runCatching { deleteIfExists(tempFile) }
runCatching { deleteIfExists(backupFile) }
}
}
override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray {
if (conversationId.any {
runCatching { UUID.fromString(it) }.isFailure
@@ -425,6 +613,223 @@ class LoggerWrapper(
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
}
fun getConversationExportTargets(): List<LoggerConversationExportTarget> {
val groupedConversations = mutableListOf<Triple<String, String?, Int>>()
database.rawQuery(
"SELECT conversation_id, MAX(group_title) AS group_title, COUNT(*) AS message_count, MAX(send_timestamp) AS last_timestamp " +
"FROM messages WHERE conversation_id IS NOT NULL AND TRIM(conversation_id) != '' " +
"GROUP BY conversation_id ORDER BY last_timestamp DESC",
null
).use { cursor ->
while (cursor.moveToNext()) {
val conversationId = cursor.getStringOrNull("conversation_id")?.takeIf { it.isNotBlank() } ?: continue
groupedConversations.add(
Triple(
conversationId,
cursor.getStringOrNull("group_title"),
cursor.getIntOrNull("message_count") ?: 0
)
)
}
}
return groupedConversations.map { (conversationId, groupTitle, messageCount) ->
val userIds = linkedSetOf<String>()
val usernames = linkedSetOf<String>()
database.rawQuery(
"SELECT DISTINCT user_id, username FROM messages WHERE conversation_id = ?",
arrayOf(conversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { userIds.add(it) }
cursor.getStringOrNull("username")?.takeIf { it.isNotBlank() }?.let { usernames.add(it) }
}
}
LoggerConversationExportTarget(
conversationId = conversationId,
groupTitle = groupTitle,
usernames = usernames.toList(),
userIds = userIds.toList(),
messageCount = messageCount
)
}
}
fun exportConversationDatabase(
outputFile: File,
conversationId: String,
userIds: Collection<String> = emptyList()
): ConversationExportResult {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
.toMutableSet()
.also { ids ->
if (ids.isEmpty()) {
database.rawQuery(
"SELECT DISTINCT user_id FROM messages WHERE conversation_id = ? AND user_id IS NOT NULL AND TRIM(user_id) != ''",
arrayOf(normalizedConversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { ids.add(it) }
}
}
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
val outputDatabase = SQLiteDatabase.openDatabase(
outputFile.absolutePath,
null,
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
)
var transactionStarted = false
try {
SQLiteDatabaseHelper.createTablesFromSchema(outputDatabase, MESSAGE_LOGGER_SCHEMA)
outputDatabase.beginTransaction()
transactionStarted = true
val messageWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val messageWhereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val messageCount = copyQueryRows(
sourceQuery = "SELECT * FROM messages WHERE $messageWhereClause ORDER BY send_timestamp ASC",
sourceArgs = messageWhereArgs,
targetDatabase = outputDatabase,
targetTable = "messages"
)
val chatEditCount = copyQueryRows(
sourceQuery = "SELECT * FROM chat_edits WHERE conversation_id = ? AND message_id IN (SELECT message_id FROM messages WHERE $messageWhereClause) ORDER BY added_timestamp ASC",
sourceArgs = arrayOf(normalizedConversationId, *messageWhereArgs),
targetDatabase = outputDatabase,
targetTable = "chat_edits"
)
val trackerWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val trackerArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val trackerEventCount = copyQueryRows(
sourceQuery = "SELECT * FROM tracker_events WHERE $trackerWhereClause ORDER BY timestamp ASC",
sourceArgs = trackerArgs,
targetDatabase = outputDatabase,
targetTable = "tracker_events"
)
outputDatabase.setTransactionSuccessful()
return ConversationExportResult(
messageCount = messageCount,
chatEditCount = chatEditCount,
trackerEventCount = trackerEventCount
)
} finally {
if (transactionStarted) {
outputDatabase.endTransaction()
}
outputDatabase.close()
}
}
private fun cursorToContentValues(cursor: Cursor): ContentValues {
return ContentValues(cursor.columnCount).apply {
for (columnIndex in 0 until cursor.columnCount) {
val columnName = cursor.getColumnName(columnIndex)
when (cursor.getType(columnIndex)) {
Cursor.FIELD_TYPE_NULL -> putNull(columnName)
Cursor.FIELD_TYPE_INTEGER -> put(columnName, cursor.getLong(columnIndex))
Cursor.FIELD_TYPE_FLOAT -> put(columnName, cursor.getDouble(columnIndex))
Cursor.FIELD_TYPE_STRING -> put(columnName, cursor.getString(columnIndex))
Cursor.FIELD_TYPE_BLOB -> put(columnName, cursor.getBlob(columnIndex))
}
}
}
}
private fun copyQueryRows(
sourceQuery: String,
sourceArgs: Array<String>? = null,
targetDatabase: SQLiteDatabase,
targetTable: String
): Int {
var rowCount = 0
database.rawQuery(sourceQuery, sourceArgs).use { cursor ->
while (cursor.moveToNext()) {
targetDatabase.insert(targetTable, null, cursorToContentValues(cursor))
rowCount++
}
}
return rowCount
}
private fun cursorToLoggedMessage(cursor: Cursor): LoggedMessage? {
return LoggedMessage(
messageId = cursor.getLongOrNull("message_id") ?: return null,
conversationId = cursor.getStringOrNull("conversation_id") ?: return null,
userId = cursor.getStringOrNull("user_id") ?: return null,
username = cursor.getStringOrNull("username") ?: return null,
sendTimestamp = cursor.getLongOrNull("send_timestamp") ?: return null,
addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return null,
groupTitle = cursor.getStringOrNull("group_title"),
messageData = cursor.getBlobOrNull("message_data") ?: return null
)
}
fun forEachConversationMessage(
conversationId: String,
userIds: Collection<String> = emptyList(),
orderAscending: Boolean = true,
block: (LoggedMessage) -> Unit
): Int {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
val whereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val whereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
var total = 0
database.rawQuery(
"SELECT * FROM messages WHERE $whereClause ORDER BY send_timestamp ${if (orderAscending) "ASC" else "DESC"}",
whereArgs
).use { cursor ->
while (cursor.moveToNext()) {
cursorToLoggedMessage(cursor)?.let { loggedMessage ->
block(loggedMessage)
total++
}
}
}
return total
}
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
val edits = mutableListOf<LoggedChatEdit>()
database.rawQuery(
@@ -483,16 +888,7 @@ class LoggerWrapper(
arrayOf(conversationId, fromTimestamp.toString())
).use {
while (it.moveToNext() && messages.size < limit) {
val message = LoggedMessage(
messageId = it.getLongOrNull("message_id") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue,
userId = it.getStringOrNull("user_id") ?: continue,
username = it.getStringOrNull("username") ?: continue,
sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue,
addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue,
groupTitle = it.getStringOrNull("group_title"),
messageData = it.getBlobOrNull("message_data") ?: continue
)
val message = cursorToLoggedMessage(it) ?: continue
if (filter != null && !filter(message)) continue
messages.add(message)
}

View File

@@ -81,6 +81,7 @@ class ConfigParams(
var inputCheck: ((String) -> Boolean)? = { true },
var filenameFilter: ((String) -> Boolean)? = null,
var versionCheck: VersionCheck? = null,
var digitsOnlyInput: Boolean = false,
) {
val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()

View File

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

View File

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

View File

@@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() {
val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag
val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") {
addFlags(ConfigFlag.NO_TRANSLATE)
}
val constantRateFactor = integer("constant_rate_factor", 30)
val videoBitrate = integer("video_bitrate", 5000)
}.apply { set("veryfast") }
val constantRateFactor = integer("constant_rate_factor", 22)
val videoBitrate = integer("video_bitrate", 8000)
val audioBitrate = integer("audio_bitrate", 128)
val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }

View File

@@ -35,6 +35,7 @@ class Experimental : ConfigContainer() {
class NativeHooks : ConfigContainer() {
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
val disableBitmoji = boolean("disable_bitmoji")
val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) }
val customEmojiFont = string("custom_emoji_font") {
requireRestart()
addFlags(ConfigFlag.USER_IMPORT)

View File

@@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
import me.eternal.purrfectsnap.common.config.FeatureNotice
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class Global : ConfigContainer() {
companion object {
@@ -38,9 +40,22 @@ class Global : ConfigContainer() {
val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) }
}
inner class PerformanceModeConfig : ConfigContainer() {
val profile = unique("performance_profile", "smooth", "max") {
requireRestart()
}
}
val betterLocation = container("better_location", BetterLocationConfig())
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") {
requireRestart()
inputCheck = {
it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess
}
}
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
val disableMetrics = boolean("disable_metrics") { requireRestart() }
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
@@ -63,8 +78,6 @@ class Global : ConfigContainer() {
inner class UpdateSettings : ConfigContainer() {
val autoUpdateCheck = boolean("auto_update_check", true)
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
val updateChannel = unique("update_channel", "stable", "prerelease")
}
inner class UISettings : ConfigContainer() {

View File

@@ -166,23 +166,27 @@ class MessagingTweaks : ConfigContainer() {
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() }
}
val queueSize = integer("queue_size", defaultValue = 10) {
val queueSize = integer("queue_size", defaultValue = 700) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
}
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
}
val delayBetweenSnaps = integer("delay_between_snaps", defaultValue = 100) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
}
val delayBetweenConversations = integer("delay_between_conversations", defaultValue = 500) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 3000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
}
val compactNotification = boolean("compact_notification", false)
val showProgressBar = boolean("show_progress_bar", true)
val showLifetimeStats = boolean("show_lifetime_stats", false)
val showQueuePreview = boolean("show_queue_preview", true)
val thermalProtection = boolean("thermal_protection", false)
// Resource Intelligence: Smart triggers for battery and data safety
val onlyOnWifi = boolean("only_on_wifi", false)
val pauseDuringGaming = boolean("pause_during_gaming", false)
val safeProcessing = boolean("safe_processing", true)
val onlyWhenIdle = boolean("only_when_idle", false)
val sleepWindow = string("sleep_window", defaultValue = "23:00-07:00") {
@@ -209,6 +213,38 @@ class MessagingTweaks : ConfigContainer() {
val showNotification = boolean("show_notification", defaultValue = true)
}
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
val anonymousStoryViewing = boolean("anonymous_story_viewing")
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
@@ -296,40 +332,9 @@ class MessagingTweaks : ConfigContainer() {
val doubleTapChatActionCustomEmoji = string("double_tap_chat_action_custom_emoji") {
inputCheck = { it.length == 2 && it.toByteArray(Charsets.UTF_8).size >= 4 } }
val autoReply = container("auto_reply", AutoReplyConfig()) { requireRestart() }
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val autoDeleteSentMessages = container("auto_delete_sent_messages", AutoDeleteSentMessagesConfig()) { requireRestart() }
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val preFetchSnaps = boolean("pre_fetch_snaps", false)
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
}

View File

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

View File

@@ -115,9 +115,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
}
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) {
val secure = container("secure", RandomizedSecureSettingsConfig().apply { globalState = true })
val system = container("system", RandomizedSystemSettingsConfig().apply { globalState = true })
val global = container("global", RandomizedGlobalSettingsConfig().apply { globalState = true })
val secureSettings = container("secure_settings", RandomizedSecureSettingsConfig().apply { globalState = true })
val systemSettings = container("system_settings", RandomizedSystemSettingsConfig().apply { globalState = true })
val globalSettings = container("global_settings", RandomizedGlobalSettingsConfig().apply { globalState = true })
}
inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) {
@@ -178,12 +178,17 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
}
val generateFreshProfileAction = string("generate_fresh_profile_action")
val viewCurrentProfileAction = string("view_current_profile_action")
val backupProfileAction = string("backup_profile_action")
val restoreProfileAction = string("restore_profile_action")
val profileGenerationToken = string("profile_generation_token") {
addFlags(ConfigFlag.HIDDEN)
}
val currentProfileSnapshot = string("current_profile_snapshot") {
addFlags(ConfigFlag.HIDDEN)
}
val profileData = string("profile_data") {
addFlags(ConfigFlag.HIDDEN)
}
}
inner class SpoofDeviceIdConfig : ConfigContainer() {

View File

@@ -33,6 +33,7 @@ class UserInterfaceTweaks : ConfigContainer() {
val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() }
val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() }
val sortSocialTabByStreakLength = boolean("sort_social_tab_by_streak_length").apply { set(true) }
val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() }
val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() }
val hideQuickAddSuggestions = boolean("hide_quick_add_suggestions") { requireRestart() }
@@ -56,13 +57,14 @@ class UserInterfaceTweaks : ConfigContainer() {
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() }
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator", "skip_own_indicators", "disable_indicators_in_groups") { requireRestart() }
val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() }
val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") {
requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR)
}
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
val chatHoldKillActions = multiple("chat_hold_kill_actions", "kill_snapchat", "kill_purrfectsnap") { requireRestart() }
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
val customSnapScore = string("custom_snap_score") {
@@ -75,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
}
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) {
val customFollowersCount = string("custom_followers_count") {
requireRestart()
digitsOnlyInput = true
inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } }
}
}
val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() }
}

View File

@@ -72,24 +72,10 @@ enum class FileType(
if (majorBrand in imageBrands) return false
return majorBrand in setOf(
"mp41",
"mp42",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"cmfc",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video
"mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6",
"avc1", "dash", "cmfc", "msnv", "3gp4", "3gp5", "3gp6", "3g2a", "3g2b",
"mp4v", "mp4a", "m4v ", "m4a ", "f4v ", "f4a "
) || majorBrand.isNotEmpty()
}
fun fromFile(file: File): FileType {

View File

@@ -49,6 +49,8 @@ enum class MessagingRuleType(
val configNotices: Array<FeatureNotice> = emptyArray()
) {
STEALTH("stealth", true, Icons.Outlined.TrackChanges),
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = true),
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = true),
HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"),
AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline),
AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"),
@@ -63,6 +65,7 @@ enum class MessagingRuleType(
AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist");
fun translateOptionKey(optionKey: String): String {
if (key.contains("stealth")) return "features.options.friend_feed_menu_buttons.$key"
return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name"
}
@@ -71,6 +74,58 @@ enum class MessagingRuleType(
}
}
private val partialStealthRuleTypes = setOf(
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH
)
private val allStealthRuleTypes = partialStealthRuleTypes + MessagingRuleType.STEALTH
fun MessagingRuleType.isStealthRule(): Boolean = this in allStealthRuleTypes
fun Collection<MessagingRuleType>.normalizeStealthRules(): Set<MessagingRuleType> {
val normalizedRules = toMutableSet()
if (MessagingRuleType.STEALTH in normalizedRules) {
normalizedRules.removeAll(partialStealthRuleTypes)
return normalizedRules
}
if (partialStealthRuleTypes.all { it in normalizedRules }) {
normalizedRules.removeAll(partialStealthRuleTypes)
normalizedRules.add(MessagingRuleType.STEALTH)
}
return normalizedRules
}
fun Collection<MessagingRuleType>.withNormalizedRuleToggle(
ruleType: MessagingRuleType,
enabled: Boolean
): Set<MessagingRuleType> {
val updatedRules = toMutableSet().apply {
if (enabled) {
add(ruleType)
} else {
remove(ruleType)
}
}
if (!ruleType.isStealthRule()) {
return updatedRules
}
if (enabled) {
when (ruleType) {
MessagingRuleType.STEALTH -> updatedRules.removeAll(partialStealthRuleTypes)
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH -> updatedRules.remove(MessagingRuleType.STEALTH)
else -> Unit
}
}
return updatedRules.normalizeStealthRules()
}
@Parcelize
data class FriendStreaks(
val notify: Boolean = true,

View File

@@ -22,7 +22,7 @@ open class ScriptRuntime(
private val modules = mutableMapOf<String, JSModule>()
fun eachModule(f: JSModule.() -> Unit) {
open fun eachModule(f: JSModule.() -> Unit) {
modules.values.forEach { module ->
runCatching {
module.f()

View File

@@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi
val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])
coroutineScope.launch(Dispatchers.IO) {
try {
copyTo(fos)
} finally {
close()
fos.flush()
fos.close()
runCatching {
try {
copyTo(fos)
} finally {
close()
fos.flush()
fos.close()
}
}
}

View File

@@ -164,10 +164,10 @@ class ModContext(
disableMetrics = config.global.disableMetrics.get(),
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
customEmojiFontPath = getCustomEmojiFontPath(this)
)
)
}
customEmojiFontPath = getCustomEmojiFontPath(this),
debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get()
)
) }
fun getConfigLocale(): String {
return _config.locale

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.core
import me.eternal.purrfectsnap.common.scripting.JSModule
import android.app.Activity
import android.content.Context
import android.content.Intent

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.*
import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Semaphore
@@ -41,16 +42,111 @@ class BridgeClient(
private var continuation: Continuation<Boolean>? = null
private val connectSemaphore = Semaphore(permits = 1)
private val reconnectSemaphore = Semaphore(permits = 1)
private lateinit var service: BridgeInterface
private val serviceStateLock = Any()
@Volatile
private var service: BridgeInterface? = null
@Volatile
private var serviceBinder: IBinder? = null
@Volatile
private var isBound = false
@Volatile
private var isHandlingServiceConnection = false
private val connectionExecutor = Executors.newSingleThreadExecutor()
private val legacyBindThread = HandlerThread("BridgeClient").apply { start() }
private val legacyBindHandler by lazy { Handler(legacyBindThread.looper) }
private val onConnectedCallbacks = mutableListOf<suspend () -> Unit>()
private var cachePurrfectSnapApkPath: String? = null
private val serviceDeathRecipient = IBinder.DeathRecipient {
clearConnectedService()
}
private fun clearConnectedServiceLocked() {
serviceBinder?.let { binder ->
runCatching { binder.unlinkToDeath(serviceDeathRecipient, 0) }
}
serviceBinder = null
service = null
}
private fun clearConnectedService() {
synchronized(serviceStateLock) {
clearConnectedServiceLocked()
}
}
private fun attachConnectedService(binder: IBinder): Boolean {
synchronized(serviceStateLock) {
clearConnectedServiceLocked()
serviceBinder = binder
service = BridgeInterface.Stub.asInterface(binder)
return runCatching {
binder.linkToDeath(serviceDeathRecipient, 0)
true
}.getOrElse { throwable ->
Log.w("BridgeClient", "Failed to link bridge death recipient", throwable)
clearConnectedServiceLocked()
false
}
}
}
private fun isServiceAlive(): Boolean {
val binder = serviceBinder ?: service?.asBinder() ?: return false
return binder.isBinderAlive && binder.pingBinder()
}
private val connectedService: BridgeInterface
get() {
val currentService = service ?: throw DeadObjectException()
val binder = currentService.asBinder()
if (!binder.isBinderAlive || !binder.pingBinder()) throw DeadObjectException()
return currentService
}
private fun Context.unbindBridgeIfNeeded() {
if (!isBound) return
runCatching { unbindService(this@BridgeClient) }.onFailure { throwable ->
if (throwable !is IllegalArgumentException) throw throwable
}
isBound = false
}
private fun Context.bindBridge(intent: Intent): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
connectionExecutor,
this@BridgeClient
)
} else {
this::class.java.methods.firstOrNull {
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
}?.invoke(
this,
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
legacyBindHandler,
Process.myUserHandle()
) as? Boolean ?: false
}
}
private fun isRecoverableBinderFailure(throwable: Throwable): Boolean {
return throwable is DeadObjectException ||
throwable is RemoteException ||
throwable.cause is DeadObjectException ||
throwable.cause is RemoteException
}
fun addOnConnectedCallback(initNow: Boolean = false, callback: suspend () -> Unit) {
synchronized(onConnectedCallbacks) {
onConnectedCallbacks.add(callback)
}
initNow.takeIf { it && this::service.isInitialized }?.let {
initNow.takeIf { it && isServiceAlive() }?.let {
runBlocking {
callback()
}
@@ -67,7 +163,7 @@ class BridgeClient(
}
suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? {
if (this::service.isInitialized && service.asBinder().pingBinder()) {
if (isServiceAlive()) {
return true
}
@@ -75,10 +171,9 @@ class BridgeClient(
val retryDelay = 3000L
return withTimeoutOrNull(connectionTimeout) {
var result: Boolean? = null
for (retry in 0.. (connectionTimeout / retryDelay).toInt()) {
result = withTimeoutOrNull(retryDelay) {
val attempts = (connectionTimeout / retryDelay).toInt() + 1
repeat(attempts) { attempt ->
val result = withTimeoutOrNull(retryDelay) {
suspendCancellableCoroutine { cancellableContinuation ->
continuation = cancellableContinuation
with(context.androidContext) {
@@ -93,31 +188,12 @@ class BridgeClient(
runCatching {
val intent = Intent()
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
runCatching {
if (this@BridgeClient::service.isInitialized) {
unbindService(this@BridgeClient)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
Executors.newSingleThreadExecutor(),
this@BridgeClient
)
} else {
val handler = Handler(HandlerThread("BridgeClient").apply { start() }.looper)
this::class.java.methods.firstOrNull {
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
}?.invoke(
this,
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
handler,
Process.myUserHandle()
) ?: throw NoSuchMethodException("bindServiceAsUser")
unbindBridgeIfNeeded()
clearConnectedService()
if (!bindBridge(intent)) {
throw IllegalStateException("bindService returned false")
}
isBound = true
}.onFailure {
onFailure(it)
resumeContinuation(false)
@@ -125,54 +201,89 @@ class BridgeClient(
}
}
}
if (result != null) break
if (result == true) {
return@withTimeoutOrNull true
}
if (attempt + 1 < attempts) {
delay(250L)
}
}
result
false
}
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
this.service = BridgeInterface.Stub.asInterface(service)
runBlocking {
onConnectedCallbacks.forEach {
runCatching {
it()
}.onFailure {
context.log.error("Failed to run onConnectedCallback", it)
}
}
}
cachePurrfectSnapApkPath = this.service.applicationApkPath.also {
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
context.softRestartApp()
isHandlingServiceConnection = true
try {
if (!attachConnectedService(service)) {
resumeContinuation(false)
return
}
runBlocking {
onConnectedCallbacks.forEach {
runCatching {
it()
}.onFailure {
context.log.error("Failed to run onConnectedCallback", it)
}
}
}
val remoteApkPath = runCatching {
connectedService.applicationApkPath
}.getOrElse { throwable ->
if (isRecoverableBinderFailure(throwable)) {
Log.w("BridgeClient", "Bridge died during onServiceConnected initialization", throwable)
} else {
Log.e("BridgeClient", "Bridge initialization failed", throwable)
}
clearConnectedService()
resumeContinuation(false)
return
}
cachePurrfectSnapApkPath = remoteApkPath.also {
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
context.softRestartApp()
return
}
}
resumeContinuation(true)
} finally {
isHandlingServiceConnection = false
}
resumeContinuation(true)
}
override fun onNullBinding(name: ComponentName) {
clearConnectedService()
isBound = false
resumeContinuation(false)
}
override fun onServiceDisconnected(name: ComponentName) {
clearConnectedService()
isBound = false
continuation = null
}
override fun onBindingDied(name: ComponentName) {
clearConnectedService()
isBound = false
resumeContinuation(false)
}
private fun tryReconnect() {
runBlocking {
reconnectSemaphore.withPermit {
if (service.asBinder().pingBinder()) return@runBlocking
if (isServiceAlive()) return@withPermit
Log.d("BridgeClient", "service is dead, restarting")
val canLoad = connect {
Log.e("BridgeClient", "connection failed", it)
context.softRestartApp()
}
if (canLoad != true) {
Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad")
context.softRestartApp()
return@runBlocking
}
}
}
@@ -182,16 +293,16 @@ class BridgeClient(
return runCatching {
block()
}.getOrElse { throwable ->
if (throwable is DeadObjectException) {
tryReconnect()
return@getOrElse runCatching {
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
if (it is DeadObjectException) {
context.softRestartApp()
if (isRecoverableBinderFailure(throwable)) {
clearConnectedService()
if (!isHandlingServiceConnection) {
tryReconnect()
return@getOrElse runCatching {
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
throw it
}
throw it
}
}
throw throwable
@@ -201,15 +312,15 @@ class BridgeClient(
fun broadcastLog(tag: String, level: String, message: String) {
message.chunked(1024 * 256).forEach {
runCatching {
service.broadcastLog(tag, level, it)
connectedService.broadcastLog(tag, level, it)
}
}
}
fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath }
fun getApplicationApkPath(): String = safeServiceCall { connectedService.applicationApkPath }
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall {
service.enqueueDownload(intent, callback)
connectedService.enqueueDownload(intent, callback)
}
fun convertMedia(
@@ -219,40 +330,42 @@ class BridgeClient(
audioCodec: String?,
videoCodec: String?
): ParcelFileDescriptor? = safeServiceCall {
service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
connectedService.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
}
fun sync(callback: SyncCallback) {
if (!context.database.hasMain()) return
safeServiceCall {
service.sync(callback)
connectedService.sync(callback)
}
}
fun triggerSync(scope: SocialScope, id: String) = safeServiceCall {
service.triggerSync(scope.key, id)
connectedService.triggerSync(scope.key, id)
}
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
safeServiceCall {
val serializedGroups = groups.mapNotNull { it.toSerialized() }
val serializedFriends = friends.mapNotNull { it.toSerialized() }
// Binder transaction limit is 1MB. Use 128KB chunks to avoid TransactionTooLargeException.
val maxChunkBytes = 128 * 1024
fun chunkSerialized(values: List<String>): List<List<String>> {
fun calculateParts(values: List<String>): List<List<String>> {
if (values.isEmpty()) return listOf(emptyList())
val result = mutableListOf<List<String>>()
val currentChunk = mutableListOf<String>()
var currentChunk = mutableListOf<String>()
var currentSize = 0
values.forEach { value ->
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
val valueSize = value.toByteArray(Charsets.UTF_8).size + 32
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
result += currentChunk.toList()
currentChunk.clear()
currentChunk = mutableListOf()
currentSize = 0
}
currentChunk += value
currentChunk.add(value)
currentSize += valueSize
}
@@ -262,73 +375,71 @@ class BridgeClient(
return result
}
val groupChunks = chunkSerialized(serializedGroups)
val friendChunks = chunkSerialized(serializedFriends)
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
val groupParts = calculateParts(serializedGroups)
val friendParts = calculateParts(serializedFriends)
val totalParts = maxOf(groupParts.size, friendParts.size)
context.log.info(
"Sending social snapshot in $chunkCount chunk(s): " +
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
)
context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends")
repeat(chunkCount) { index ->
service.passGroupsAndFriends(
groupChunks.getOrElse(index) { emptyList() },
friendChunks.getOrElse(index) { emptyList() }
repeat(totalParts) { index ->
connectedService.passGroupsAndFriends(
groupParts.getOrElse(index) { emptyList() },
friendParts.getOrElse(index) { emptyList() },
index,
totalParts
)
}
}
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {
service.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
connectedService.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
}
fun getRuleIds(ruleType: MessagingRuleType): List<String> = safeServiceCall {
service.getRuleIds(ruleType.key)
connectedService.getRuleIds(ruleType.key)
}
fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = safeServiceCall {
service.setRule(targetUuid, type.key, state)
connectedService.setRule(targetUuid, type.key, state)
}
fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) }
fun getScopeNotes(id: String): String? = safeServiceCall { connectedService.getScopeNotes(id) }
fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) }
fun setScopeNotes(id: String, content: String?) = safeServiceCall { connectedService.setScopeNotes(id, content) }
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { service.getAllScopeNotes() }
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { connectedService.getAllScopeNotes() }
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { service.setAllScopeNotes(notes) }
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { connectedService.setAllScopeNotes(notes) }
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { service.scriptingInterface }
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { connectedService.scriptingInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { connectedService.e2eeInterface }
fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger }
fun getMessageLogger(): LoggerInterface = safeServiceCall { connectedService.logger }
fun getTracker(): TrackerInterface = safeServiceCall { service.tracker }
fun getTracker(): TrackerInterface = safeServiceCall { connectedService.tracker }
fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage }
fun getAccountStorage(): AccountStorage = safeServiceCall { connectedService.accountStorage }
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager }
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { connectedService.fileHandleManager }
fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager }
fun getLocationManager(): LocationManager = safeServiceCall { connectedService.locationManager }
fun getTaskInterface(): TaskInterface = safeServiceCall { service.taskInterface }
fun getTaskInterface(): TaskInterface = safeServiceCall { connectedService.taskInterface }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { connectedService.registerMessagingBridge(bridge) }
fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) }
fun closeOverlay() = safeServiceCall { service.closeOverlay() }
fun openOverlay(type: OverlayType) = safeServiceCall { connectedService.openOverlay(type.key) }
fun closeOverlay() = safeServiceCall { connectedService.closeOverlay() }
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { connectedService.registerConfigStateListener(listener) }
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { connectedService.getDebugProp(name, defaultValue) }
fun startCallDownload(
startTimestamp: Long,
author: String,
): CallDownloadSession {
return safeServiceCall { service.startCallDownload(startTimestamp, author) }
return safeServiceCall { connectedService.startCallDownload(startTimestamp, author) }
}
}

View File

@@ -47,6 +47,14 @@ class DatabaseAccess(
} == true
}
private val hasArroyoUserConversationTable by lazy {
useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_conversation'")?.use { query ->
query.moveToFirst() && query.getStringOrNull("name") == "user_conversation"
}
} == true
}
private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? {
// only cache read-only databases
if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) {
@@ -148,6 +156,10 @@ class DatabaseAccess(
}?.toMutableMap() ?: mutableMapOf()
}
if (!hasArroyoUserConversationTable) {
return@lazy mutableMapOf()
}
(useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery(
"SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?",
@@ -354,7 +366,7 @@ class DatabaseAccess(
}
fun getConversationType(conversationId: String): Int? {
if (hasArroyoConversationTable) {
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
return getFeedEntryByConversationId(conversationId)?.conversationType
}
@@ -372,7 +384,9 @@ class DatabaseAccess(
}
fun getDMConversationId(userId: String): String? {
if (hasArroyoConversationTable) {
friendDMsCache[userId]?.let { return it }
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
return friendDMsCache[userId]
}
@@ -408,6 +422,10 @@ class DatabaseAccess(
}
}
if (!hasArroyoUserConversationTable) {
return getFeedEntryByConversationId(conversationId)?.participants
}
return useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery(
"SELECT user_id FROM user_conversation WHERE client_conversation_id = ?",

View File

@@ -47,7 +47,7 @@ class EventDispatcher(
cacheHook(
methodParam.thisObject<Any>()::class.java
) {
hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param ->
hook(bindMethod.get().toString(), HookStage.AFTER) bindViewMethod@{ param ->
val instance = param.thisObject<Any>()
val view = instance::class.java.methods.firstOrNull {
it.name == getViewMethod.get().toString()
@@ -161,7 +161,6 @@ class EventDispatcher(
adapter = param
}
) {
if (canceled) param.setResult(null)
postHookEvent()
}
}

View File

@@ -80,6 +80,7 @@ class FeatureManager(
MessageLogger(),
ConvertMessageLocally(),
SnapchatPlus(),
AdBlockFix(),
DisableMetrics(),
EndpointsBlocker(),
PreventMessageSending(),
@@ -99,6 +100,7 @@ class FeatureManager(
MeoPasscodeBypass(),
AppLock(),
CameraTweaks(),
PerformanceMode(),
InfiniteStoryBoost(),
PinConversations(),
DeviceSpooferHook(),
@@ -166,6 +168,7 @@ class FeatureManager(
CustomTheming(),
HideTypingIndicator(),
FakeSnapScore(),
CompanionServer(),
)
features.values.toList().forEach { feature ->

View File

@@ -2,9 +2,11 @@ package me.eternal.purrfectsnap.core.features
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import java.util.concurrent.ConcurrentHashMap
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
private val ruleCache = ConcurrentHashMap<String, Boolean>()
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {
listeners.add(listener)
@@ -13,20 +15,24 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
open fun getRuleState() = context.config.rules.getRuleState(ruleType)
fun setState(conversationId: String, state: Boolean) {
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
context.bridgeClient.setRule(
context.database.getDMOtherParticipant(conversationId) ?: conversationId,
targetId,
ruleType,
state
)
ruleCache[targetId] = state
listeners.forEach { it(conversationId, state) }
}
fun getState(conversationId: String) =
context.bridgeClient.getRules(
context.database.getDMOtherParticipant(conversationId) ?: conversationId
).contains(ruleType) && getRuleState() != null
fun getState(conversationId: String): Boolean {
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
return ruleCache.getOrPut(targetId) {
context.bridgeClient.getRules(targetId).contains(ruleType)
} && getRuleState() != null
}
fun canUseRule(conversationId: String): Boolean {
open fun canUseRule(conversationId: String): Boolean {
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
return false
}

View File

@@ -1,6 +1,7 @@
package me.eternal.purrfectsnap.core.features.impl
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
@@ -37,18 +38,49 @@ class ConfigurationOverride : Feature("Configuration Override") {
}.getOrNull()
val propertyOverrides = mutableMapOf<String, ConfigFilter>()
val loggedOverrides = mutableSetOf<String>()
fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) {
propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment)
}
fun logPerformanceOverride(key: String, value: Any?) {
if (!key.contains("PRELOAD") &&
!key.contains("PERFORMANCE") &&
!key.contains("WARM") &&
!key.contains("PREFETCH") &&
!key.contains("LATENCY") &&
!key.contains("ANALYTICS") &&
!key.contains("THREAD_PRIORITY") &&
!key.contains("HD_MODE") &&
!key.contains("LENS") &&
!key.contains("THUMBNAIL")
) {
return
}
synchronized(loggedOverrides) {
if (!loggedOverrides.add(key)) return
}
context.log.info("Performance override applied: $key=$value", "PerformanceMode")
}
overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() },
{ true })
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() },
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
{ true }, isAppExperiment = true)
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
{ true })
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
}
run {
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
val level7Value = { _: ConfigKeyInfo -> 700 }
arrayOf(
"MY_STORY_UPLOAD_QUALITY_LEVEL",
@@ -72,10 +104,14 @@ class ConfigurationOverride : Feature("Configuration Override") {
isForceQuality, { true })
overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE",
isForceQuality, { 0.0f })
overrideProperty("CHAT_MEDIA_IMPORT_TRANSCODED_QUALITY",
isForceQuality, { 4 })
overrideProperty("POSTED_STORY_IMPORT_TRANSCODED_QUALITY",
isForceQuality, { 4 })
}
run {
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() }
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 })
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 })
}
@@ -84,6 +120,53 @@ class ConfigurationOverride : Feature("Configuration Override") {
{ true })
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
{ true })
overrideProperty("ENABLE_MESSAGE_WINDOW_MANAGER", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("ENABLE_SIMPLE_CONVERSATION_RESET", { context.config.global.performanceMode.profile.getNullable() == "max" },
{ false })
overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("CAMERA_THREAD_PRIORITY", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("HD_MODE_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() == "max" },
{ true })
arrayOf(
"FEATURE_PRELOADER",
"SERVER_PREFETCH",
"SERVER_PREFETCH_WITH_COF",
"DISCOVER_FEED_PERFORMANCE",
"LOGIN_PRELOAD",
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
"COMPUTE_FEED_CACHE_WITH_TTL",
"COMPUTE_FEED_NETWORK_WITH_CACHE",
"OPERA_WARMUP",
"SHOW_PREFETCH",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
}
arrayOf(
"USER_STORY_PRELOAD",
"STARTUP_LENS_ACTIVATOR",
"LENSES_PREVIEW_ACTIVATOR",
"THUMBNAIL_PRESENTER_ACTIVATOR",
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
"DISCOVER_FEED_STORY_PREFETCH",
"DISCOVER_FEED_THUMBNAILS",
"REFACTORED_WITH_WARMUP_LENS",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() == "max" }, { false })
}
overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("LOCK_SCREEN_ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() },
{ true })
@@ -93,18 +176,12 @@ class ConfigurationOverride : Feature("Configuration Override") {
overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() },
{ true }, isAppExperiment = true)
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() },
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", {
context.config.userInterface.disableSpotlight.get() &&
context.feature(Messaging::class).openedConversationUUID == null
},
{ false })
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
{ true })
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
}
overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false })
classReference.getAsClass()?.hook(
@@ -115,7 +192,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
propertyOverrides[propertyKey.name]?.let { (filter, value) ->
if (!filter(propertyKey)) return@let
param.setResult(value(propertyKey))
value(propertyKey).also {
logPerformanceOverride(propertyKey.name ?: return@also, it)
param.setResult(it)
}
}
}
@@ -135,7 +215,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
propertyOverrides[key]?.let { (filter, value) ->
val keyInfo = getConfigKeyInfo(enumData) ?: return@let
if (!filter(keyInfo)) return@let
setValue(value(keyInfo))
value(keyInfo).also {
logPerformanceOverride(key, it)
setValue(it)
}
}
}
@@ -151,7 +234,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
}
propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) ->
if (isAppExperiment != true || !filter(keyInfo)) return@let
param.setResult(value(keyInfo))
value(keyInfo).also {
logPerformanceOverride(keyInfo.name ?: return@also, it)
param.setResult(it)
}
}
}
@@ -174,7 +260,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
}
val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) }
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let {
logPerformanceOverride(keyInfo.name ?: return@let, it)
param.setResult(it)
}
}
}
}.onFailure {

View File

@@ -144,8 +144,24 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
}
private fun getRandomizedProfile(): RandomizedDeviceProfile {
val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable()
return randomizedProfile ?: RandomizedDeviceProfileStore
if (randomizedProfile != null) return randomizedProfile!!
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
val configProfileJson = spoofConfig.profileData.getNullable()
if (!configProfileJson.isNullOrBlank()) {
runCatching {
val profile = RandomizedDeviceProfile.fromJson(configProfileJson)
randomizedProfile = profile
context.log.verbose("Using restored randomized device profile from config")
return profile
}.onFailure {
context.log.warn("Failed to parse restored device profile from config, generating fresh one: ${it.message}")
}
}
val generationToken = spoofConfig.profileGenerationToken.getNullable()
return RandomizedDeviceProfileStore
.getOrCreate(context.androidContext, context.log, generationToken)
.also { profile ->
randomizedProfile = profile
@@ -155,18 +171,23 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) {
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
val profileJson = profile.toJson().toString()
val snapshot = profile.toJson().toString(2)
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot && spoofConfig.profileData.getNullable() == profileJson) return
spoofConfig.currentProfileSnapshot.set(snapshot)
spoofConfig.profileData.set(profileJson) // Synchronize raw profile data for multi-process persistence
runCatching {
val field = context.javaClass.getDeclaredField("_config\$delegate")
field.isAccessible = true
val lazyConfig = field.get(context) as Lazy<*>
val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching
modConfig.writeConfig(dispatchConfigListener = false)
context.log.verbose("Persisted randomized device profile snapshot to config")
context.log.verbose("Persisted randomized device profile data to config")
}.onFailure {
context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}")
context.log.warn("Failed to persist randomized device profile: ${it.message}")
}
}
@@ -296,9 +317,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
private fun getRandomizedSettingsToggleState(): RandomizedSettingsToggleState {
val config = context.config.experimental.spoof.randomizeDeviceProfile.settingsOptions
val secure = config.secure
val system = config.system
val global = config.global
val secure = config.secureSettings
val system = config.systemSettings
val global = config.globalSettings
return RandomizedSettingsToggleState(
secure = secure.globalState == true && (secure.base.get() || secure.tts.get()),
system = system.globalState == true && (system.base.get() || system.bluetooth.get()),
@@ -357,6 +378,10 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
supported32BitAbis: List<String>? = null,
supported64BitAbis: List<String>? = null
) {
val safeSupportedAbis = supportedAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_ABIS?.toList() ?: emptyList())
val safeSupported32BitAbis = supported32BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_32_BIT_ABIS?.toList() ?: emptyList())
val safeSupported64BitAbis = supported64BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_64_BIT_ABIS?.toList() ?: emptyList())
Build::class.java.fields.forEach { field ->
if (!field.isAccessible) field.isAccessible = true
runCatching {
@@ -377,9 +402,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
"DISPLAY" -> if (overrideDisplay) runCatching { field.set(null, display) }
"HOST" -> if (overrideHost) runCatching { field.set(null, host) }
"TIME" -> if (overrideBuildTime) runCatching { field.setLong(null, buildTime) }
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supportedAbis?.toTypedArray()) }
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported32BitAbis?.toTypedArray()) }
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported64BitAbis?.toTypedArray()) }
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupportedAbis.toTypedArray()) }
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported32BitAbis.toTypedArray()) }
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported64BitAbis.toTypedArray()) }
}
}

View File

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

View File

@@ -385,7 +385,7 @@ object RandomizedDeviceProfileStore {
bluetoothMacAddress = bluetoothMac,
ipAddress = ipAddress,
wifiSsid = region.randomWifiSsid(),
wifiRssi = random.nextInt(-72, -36),
wifiRssi = randomInt(-72, -36),
localeTag = locale.toLanguageTag(),
countryIso = region.countryIso,
timeZoneId = region.timeZoneId,
@@ -450,7 +450,7 @@ object RandomizedDeviceProfileStore {
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
generateSequence { random.nextInt(1, 224) }
generateSequence { randomInt(1, 224) }
.first { candidate ->
candidate != 10 &&
candidate != 127 &&
@@ -459,13 +459,18 @@ object RandomizedDeviceProfileStore {
candidate != 192
}
}
val secondOctet = random.nextInt(1, 255)
val thirdOctet = random.nextInt(1, 255)
val fourthOctet = random.nextInt(2, 255)
val secondOctet = randomInt(1, 255)
val thirdOctet = randomInt(1, 255)
val fourthOctet = randomInt(2, 255)
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
}
private fun randomInt(minInclusive: Int, maxExclusive: Int): Int {
require(maxExclusive > minInclusive)
return minInclusive + random.nextInt(maxExclusive - minInclusive)
}
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
require(maxExclusive > minInclusive)
val bound = maxExclusive - minInclusive

View File

@@ -0,0 +1,217 @@
package me.eternal.purrfectsnap.core.features.impl.global
import android.os.SystemClock
import android.view.View
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import java.util.ArrayList
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class AdBlockFix : Feature("AdBlockFix") {
private val adConversationIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
@Volatile
private var lastAutoSkippedOperaFingerprint: String? = null
@Volatile
private var lastAutoSkippedOperaAt = 0L
override fun init() {
if (!context.config.global.blockAds.get()) return
hookFeedEntryTracking()
hookMessagingFeedCallbacks()
hookOperaAutoSkip()
}
private fun hookFeedEntryTracking() {
findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param ->
val feedEntry = param.thisObject<Any>()
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
?: return@hookConstructor
if (isCampaignFeedEntry(feedEntry)) {
adConversationIds.add(conversationId)
}
}
}
private fun hookMessagingFeedCallbacks() {
context.mappings.useMapper(CallbackMapper::class) {
classLoader = context.androidContext.classLoader
val callbackMap = callbacks.getAsMap().orEmpty()
val hookedCallbacks = mutableSetOf<String>()
fun hookOnce(
callbackClassName: String,
methodName: String,
block: (param: me.eternal.purrfectsnap.core.util.hook.HookAdapter) -> Unit
) {
val hookKey = "$callbackClassName#$methodName"
if (!hookedCallbacks.add(hookKey)) return
runCatching {
findClass(callbackClassName).hook(methodName, HookStage.BEFORE) { param ->
block(param)
}
}.onFailure {
context.log.warn("Failed to hook $methodName on $callbackClassName")
}
}
callbackMap.entries.forEach { (callbackName, callbackClassName) ->
val className = callbackClassName ?: return@forEach
when {
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onFetchAndSyncFeedComplete") { param ->
val deletedEntries = param.argNullable<ArrayList<Any>>(2)
filterCampaignFeed(param.arg(0), deletedEntries)
if (deletedEntries?.isNotEmpty() == true) {
param.setArg(4, true)
}
}
}
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onSyncFeedComplete") { param ->
filterCampaignFeed(param.arg(0), param.argNullable(2))
}
}
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
hookOnce(className, "onFetchFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
hookOnce(className, "onFetchFeedEntriesComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
hookOnce(className, "onQueryFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FeedManagerDelegate" -> {
hookOnce(className, "onFeedEntriesUpdated") { param ->
filterCampaignFeed(param.arg(0))
}
hookOnce(className, "onInternalSyncFeed") { param ->
filterCampaignFeed(param.arg(0))
}
}
}
}
}
}
private fun hookOperaAutoSkip() {
onNextActivityCreate {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
val resolvedMethod = methodName.get() ?: return@forEach
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val viewState = runCatching {
instance::class.java.methods.firstOrNull { it.name.contains("ViewState") || it.name == "g" }?.invoke(instance)?.toString()
}.getOrNull() ?: return@hook
if (viewState != "FULLY_DISPLAYED") return@hook
val layerList = runCatching {
instance::class.java.methods.firstOrNull { it.name.contains("LayerList") || it.name == "l" }?.invoke(instance) as? ArrayList<*>
}.getOrNull() ?: return@hook
val paramMap = runCatching {
layerList.map { Layer(it).paramMap }.firstOrNull()
}.getOrNull() ?: return@hook
if (!isSpotlightCommercialPage(paramMap)) return@hook
val fingerprint = buildOperaFingerprint(paramMap)
val now = SystemClock.elapsedRealtime()
if (fingerprint == lastAutoSkippedOperaFingerprint && now - lastAutoSkippedOperaAt < 1_500L) {
return@hook
}
lastAutoSkippedOperaFingerprint = fingerprint
lastAutoSkippedOperaAt = now
runOnUiThread {
context.mainActivity?.window?.decorView?.postDelayed({
context.mainActivity?.window?.decorView?.let { decorView ->
val x = decorView.width * 0.88f
val y = decorView.height * 0.5f
decorView.dispatchSyntheticTap(x, y)
}
}, 70L)
}
}
}
}
}
}
private fun filterCampaignFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
entries.removeIf { feedEntry ->
if (!isCampaignFeedEntry(feedEntry)) return@removeIf false
val conversationIdInstance = feedEntry.getObjectFieldOrNull("mConversationId") ?: return@removeIf true
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance))
true
}
}
private fun createDeletedFeedEntry(conversationIdInstance: Any) =
findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)
}
set("mReason", "AD_CAMPAIGN_COMPLETE")
}!!
private fun isCampaignFeedEntry(feedEntry: Any?): Boolean {
if (feedEntry == null) return false
if (feedEntry.getObjectFieldOrNull("mConversationSubType")?.toString() == "CAMPAIGN") {
return true
}
return feedEntry.getObjectFieldOrNull("mConversationSubTypeMetadata")
?.getObjectFieldOrNull("mCampaignMetadata") != null
}
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {
return false
}
val adProductType = paramMap["ad_product_type"]?.toString()
if (!adProductType.isNullOrBlank() && adProductType != "UNKNOWN" && adProductType != "null") {
return true
}
val pageDump = paramMap.toString().uppercase()
return pageDump.contains("COMMERCIAL") || pageDump.contains("PROMOTED_STORY")
}
private fun buildOperaFingerprint(paramMap: ParamMap): String {
val storyId = paramMap["STORY_ID"]?.toString()
val snapId = paramMap["SNAP_ID"]?.toString()
?: paramMap["snap_id"]?.toString()
val index = paramMap["snap_index_in_story"]?.toString()
?: paramMap["SNAP_POSITION_IN_STORY"]?.toString()
return listOfNotNull(storyId, snapId, index, paramMap["ad_product_type"]?.toString()).joinToString("|")
}
}

View File

@@ -7,7 +7,7 @@ import me.eternal.purrfectsnap.core.features.Feature
class DisableMetrics : Feature("DisableMetrics") {
override fun init() {
if (!context.config.global.disableMetrics.get()) return
if (!context.config.global.disableMetrics.get() && context.config.global.performanceMode.profile.getNullable() == null) return
context.event.subscribe(NetworkApiRequestEvent::class) { param ->
val url = param.url

View File

@@ -8,57 +8,68 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.mapper.impl.PlusSubscriptionMapper
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class SnapchatPlus: Feature("SnapchatPlus") {
private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L)
private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L)
override fun init() {
val snapchatPlusTier = context.config.global.snapchatPlus.getNullable()
if (snapchatPlusTier == null || snapchatPlusTier == "not_subscribed") return
if (snapchatPlusTier != null) {
context.mappings.useMapper(PlusSubscriptionMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
//subscription tier
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> "NO_ACCESS"
"basic" -> "SNAPCHAT_PLUS"
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
else -> "SNAPCHAT_PLUS"
})
} else {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> 1
"basic" -> 2
"ad_free" -> 3
else -> 2
})
}
// Pre-calculate custom purchase date to eliminate main thread lag
val customPurchaseDateRaw = context.config.global.snapchatPlusPurchaseDate.get().trim()
val customPurchaseDateMillis = if (customPurchaseDateRaw.isNotEmpty()) {
runCatching {
LocalDate.parse(customPurchaseDateRaw, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}.getOrNull()
} else (System.currentTimeMillis() - 7776000000L) // 3 months fallback
//subscription status
set(statusField.getAsString()!!, 2)
set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime)
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
context.mappings.useMapper(PlusSubscriptionMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
//subscription tier
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> "NO_ACCESS"
"basic" -> "SNAPCHAT_PLUS"
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
else -> "SNAPCHAT_PLUS"
})
} else {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> 1
"basic" -> 2
"ad_free" -> 3
else -> 2
})
}
//subscription status
set(statusField.getAsString()!!, 2)
set(
originalSubscriptionTimeMillisField.getAsString()!!,
customPurchaseDateMillis
)
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
}
}
}
// Force enable all premium features in the catalog
if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) {
findClass("com.snap.plus.FeatureCatalog").methods.last {
!it.name.contains("init") &&
it.parameterTypes.isNotEmpty() &&
it.parameterTypes[0].name != "java.lang.Boolean"
}.hook(HookStage.BEFORE) { param ->
val instance = param.thisObject<Any>()
val firstArg = param.argNullable<Any>(0) ?: return@hook
instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName ->
instance.setObjectField(fieldName, firstArg)
runCatching {
val featureCatalogClass = findClass("com.snap.plus.FeatureCatalog")
featureCatalogClass.hook("isFeatureEnabled", HookStage.BEFORE) { param ->
param.setResult(true)
}
context.log.verbose("Successfully unlocked premium Snapchat features")
}
}
}

View File

@@ -257,8 +257,8 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
val snapManager = context.feature(Messaging::class).snapManager ?: return@hook
val stealthMode = context.feature(StealthMode::class)
// ignore non-stealth mode conversations
if (!stealthMode.canUseRule(conversationId.toString())) return@hook
// ignore conversations without snap stealth enabled
if (!stealthMode.canUseSnapStealth(conversationId.toString())) return@hook
stealthMode.addSnapInteractionException(clientMessageId)

View File

@@ -62,7 +62,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE)
if (messaging.openedConversationUUID?.toString() != targetConversationId) return false
}
if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false
if (context.feature(StealthMode::class).canUseChatStealth(targetConversationId)) return false
return canUseRule(targetConversationId)
}

View File

@@ -57,7 +57,7 @@ class Messaging : Feature("Messaging") {
private fun shouldHideBitmojiPresence(stealthMode: StealthMode): Boolean {
return context.config.messaging.hideBitmojiPresence.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
@@ -70,12 +70,12 @@ class Messaging : Feature("Messaging") {
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
return context.config.messaging.hideTypingNotifications.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) || hideTypingIndicator.canUseRule(it) } == true
}
private fun shouldHidePeek(stealthMode: StealthMode): Boolean {
return context.config.messaging.hidePeekAPeek.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun clearField(instance: Any, typeNamePart: String, shouldClear: Boolean) {
@@ -361,4 +361,3 @@ class Messaging : Feature("Messaging") {
return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList()
}
}

View File

@@ -118,7 +118,7 @@ class Notifications : Feature("Notifications") {
val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) {
putExtra("conversation_id", conversationId)
putExtra("notification_id", notificationData.id)
putExtra("client_message_id", message.messageDescriptor!!.messageId!!)
putExtra("client_message_id", message.messageDescriptor!!.messageId!!.toLong())
}
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
@@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") {
context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
val intent = event.intent ?: return@subscribe
val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe
val clientMessageId = intent.getLongExtra("client_message_id", -1)
val clientMessageId = intent.getLongExtra("client_message_id", -1L).takeIf { it != -1L }
?: intent.getStringExtra("client_message_id")?.toLongOrNull()
?: intent.getIntExtra("client_message_id", -1).toLong()
val notificationId = intent.getIntExtra("notification_id", -1)
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
@@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") {
})
}
ACTION_DOWNLOAD -> {
runCatching {
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
}.onFailure {
context.longToast(it)
context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...")
context.coroutineScope.launch(coroutineDispatcher) {
runCatching {
if (clientMessageId <= 0) throw Exception("Message not found or expired in database.")
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
}.onFailure {
val msg = if (it.message?.contains("not found", true) == true) "Message expired or already viewed." else it.message
context.longToast("Download failed: $msg")
}
}
}
ACTION_MARK_AS_READ -> {

View File

@@ -1,7 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -28,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
@@ -64,38 +72,126 @@ import kotlin.time.toDuration
class SendOverride : Feature("Send Override") {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status"
private const val STATUS_NOTIFICATION_ID = 54322
private const val COMPLETION_NOTIFICATION_ID = 54323
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME"
const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP"
private val internalMultipartSend = ThreadLocal.withInitial { false }
private var queuedOriginalItemRepeatCount = 0
private var queuedOriginalItemRepeatOverrideType: String? = null
private var queuedOriginalItemRepeatSnapDurationMs: Int? = null
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
// Notification & Loop Tracking
private var totalRepeatCount = 0
private var processedRepeatCount = 0
private var currentRecipientName: String = "Unknown"
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
queuedOriginalItemRepeatCount = repeatCount
queuedOriginalItemRepeatOverrideType = overrideType
MediaFilePicker.setQueuedOverrideType(overrideType)
queuedOriginalItemRepeatSnapDurationMs = snapDurationMs
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
}
private fun clearQueuedOriginalItemRepeats() {
queuedOriginalItemRepeatCount = 0
queuedOriginalItemRepeatOverrideType = null
queuedOriginalItemRepeatSnapDurationMs = null
}
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
if (queuedOriginalItemRepeatCount <= 0) {
private fun updateContinuousSendNotification(context: ModContext) {
if (!engineActive.get()) return
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val remaining = queuedOriginalItemRepeatCount
val processed = processedRepeatCount
val total = totalRepeatCount
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
if (!isWorking) {
notificationManager.cancel(STATUS_NOTIFICATION_ID)
showCompletionNotification(context, processed, total)
return
}
val progressPercent = if (total > 0) (processed * 100) / total else 0
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setColor(0xFF3498DB.toInt())
.setContentTitle("Sending Snaps to $currentRecipientName")
.setContentText("Progress: $processed / $total ($progressPercent%)")
.setSubText("$processed / $total")
.setProgress(total, processed, false)
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
}
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
val isError = sent < total && !isStopped.get()
val title = when {
isStopped.get() -> "Continuous Send Stopped"
isError -> "Continuous Send Failed"
else -> "Continuous Send Finished"
}
val content = "Sent $sent / $total snaps to $currentRecipientName"
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
.setContentTitle(title)
.setContentText(content)
.setAutoCancel(true)
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
}
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
val intent = Intent(action).setPackage(context.androidContext.packageName)
return PendingIntent.getBroadcast(
context.androidContext,
action.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
processedRepeatCount++
queuedOriginalItemRepeatCount--
MediaFilePicker.setQueuedOverrideType(overrideType)
updateContinuousSendNotification(context)
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
return result
}
@@ -112,6 +208,20 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun createContinuousNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CONTINUOUS_SEND_CHANNEL_ID,
"Continuous Send",
NotificationManager.IMPORTANCE_LOW
)
channel.description = "Progress status for continuous snap sending"
notificationManager.createNotificationChannel(channel)
}
}
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -192,7 +302,35 @@ class SendOverride : Feature("Send Override") {
@OptIn(ExperimentalLayoutApi::class)
override fun init() {
createNotificationChannel()
createContinuousNotificationChannel()
val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_PAUSE_RESUME -> {
isPaused.set(!isPaused.get())
updateContinuousSendNotification(context)
}
ACTION_STOP -> {
isStopped.set(true)
if (isPaused.get()) {
isPaused.set(false)
}
updateContinuousSendNotification(context)
}
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PAUSE_RESUME)
addAction(ACTION_STOP)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.androidContext.registerReceiver(actionReceiver, filter)
}
val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get()
var postSavePolicy: Int? = null
@@ -727,10 +865,10 @@ class SendOverride : Feature("Send Override") {
completionCallback: Any?
): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val mediaBuffers = mutableListOf<ByteArray>()
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
@@ -802,22 +940,54 @@ class SendOverride : Feature("Send Override") {
if (repeatCount <= 0) return false
fun sendIteration(index: Int) {
val callback = if (index == repeatCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
context.coroutineScope.launch {
while (isPaused.get() && !isStopped.get()) {
delay(500)
}
if (isStopped.get()) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return@launch
}
val callback = CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendIteration(index + 1)
processedRepeatCount++
queuedOriginalItemRepeatCount--
updateContinuousSendNotification(context)
if (index < repeatCount - 1) {
sendIteration(index + 1)
} else {
// Batch Finished: Trigger original Snapchat callback
originalCallback?.let { cb ->
runCatching {
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
if (method != null) {
if (method.parameterCount == 0) {
method.invoke(cb)
} else {
// Pass null for all required parameters to safely trigger the completion UI
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
}
}
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
}
}
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
.build()
}
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
if (index > 0) delay(1000) // Human-like delay
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
}
}
}
@@ -830,15 +1000,21 @@ class SendOverride : Feature("Send Override") {
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
}
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
val queuedOverrideType = MediaFilePicker.getQueuedOverrideType()
val resolvedOverrideType = queuedOverrideType
?: configOverrideType?.takeIf { it != "always_ask" }
val resolvedSnapDurationMs = if (queuedOverrideType != null) {
MediaFilePicker.getQueuedOverrideSnapDurationMs()
} else {
10000
}
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
sendEvent.addCallbackResult("onSuccess") {
context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess()
handleQueuedOriginalItemRepeatSuccess(context)
} else {
false
}
@@ -858,7 +1034,7 @@ class SendOverride : Feature("Send Override") {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
attachQueuedRepeatCallbacks(event)
}
if (sendMedia(resolvedOverrideType, 10000)) {
if (sendMedia(resolvedOverrideType, resolvedSnapDurationMs)) {
if (event.canceled) invokeOriginalAndRestoreResult(event)
}
return@subscribe
@@ -866,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
@@ -1274,6 +1449,11 @@ class SendOverride : Feature("Send Override") {
}
Button(onClick = {
val finalSelectedType = selectedType
val selectedSnapDurationMs = if (finalSelectedType != "SAVEABLE_SNAP") {
convertDuration(customDuration)
} else {
null
}
val repeatCount = if (continuousSendEnabled) {
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
} else {
@@ -1295,13 +1475,13 @@ class SendOverride : Feature("Send Override") {
}
alertDialog.dismiss()
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(null)
}
return@Button
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
@@ -1361,7 +1541,7 @@ class SendOverride : Feature("Send Override") {
if (sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
selectedSnapDurationMs
)) {
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast(
@@ -1410,22 +1590,32 @@ class SendOverride : Feature("Send Override") {
}
} else {
if (repeatCount == 1) {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
invokeOriginalAndRestoreResult(event)
}
} else if (MediaFilePicker.hasReusableOriginalItem()) {
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
totalRepeatCount = repeatCount
processedRepeatCount = 1
currentRecipientName = recipientNameForTask
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
attachQueuedRepeatCallbacks(event)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
invokeOriginalAndRestoreResult(event)
} else {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
} else {
totalRepeatCount = repeatCount
processedRepeatCount = 0
queuedOriginalItemRepeatCount = repeatCount
currentRecipientName = recipientNameForTask
sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
selectedSnapDurationMs
)
}
}

View File

@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
it.messageId = uniqueMessageIdentifier
it.conversationId = conversationId
it.userId = event.message.senderId.toString()
it.username = usernameCache.getOrPut(it.userId) {
context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId
}
it.username = usernameCache[it.userId]
?: context.database.getFriendInfo(it.userId)?.mutableUsername?.also { resolvedUsername ->
usernameCache[it.userId] = resolvedUsername
}
?: it.userId
it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
}
it.groupTitle = groupTitleCache[conversationId]
?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle ->
groupTitleCache[conversationId] = resolvedGroupTitle
}
?: conversationId
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
}
)

View File

@@ -1,16 +1,55 @@
package me.eternal.purrfectsnap.core.features.impl.spying
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ConcurrentHashMap
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) {
private val displayedMessageQueue = CopyOnWriteArraySet<Long>()
private val snapInteractionQueue = CopyOnWriteArraySet<Long>()
private val ruleSnapshotCache = ConcurrentHashMap<String, Pair<Long, Set<MessagingRuleType>>>()
private fun getTargetId(conversationId: String): String {
return context.database.getDMOtherParticipant(conversationId) ?: conversationId
}
private fun getRuleSnapshot(conversationId: String): Set<MessagingRuleType> {
val targetId = getTargetId(conversationId)
val now = System.currentTimeMillis()
ruleSnapshotCache[targetId]?.takeIf { now - it.first < 1_000L }?.let { return it.second }
return context.bridgeClient.getRules(targetId).toSet().also { rules ->
ruleSnapshotCache[targetId] = now to rules
}
}
private fun isRuleActive(
conversationId: String,
ruleType: MessagingRuleType
): Boolean {
val ruleState = context.config.rules.getRuleState(ruleType) ?: return false
val enabled = ruleType in getRuleSnapshot(conversationId)
return if (ruleState == RuleState.BLACKLIST) !enabled else enabled
}
fun canUseChatStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.CHAT_STEALTH)
}
fun canUseSnapStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.SNAP_STEALTH)
}
fun isAnyStealthEnabled(conversationId: String): Boolean {
return canUseChatStealth(conversationId) || canUseSnapStealth(conversationId)
}
fun addDisplayedMessageException(clientMessageId: Long) {
displayedMessageQueue.add(clientMessageId)
@@ -22,12 +61,10 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
override fun init() {
val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) }
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param ->
if (displayedMessageQueue.removeIf { param.arg<Long>(1) == it }) return@hook
if (isConversationInStealthMode(SnapUUID(param.arg(0)))) {
if (canUseChatStealth(SnapUUID(param.arg(0)).toString())) {
param.setResult(null)
}
}
@@ -35,7 +72,7 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
context.event.subscribe(OnSnapInteractionEvent::class) { event ->
if (snapInteractionQueue.removeIf { event.messageId == it }) return@subscribe
if (isConversationInStealthMode(event.conversationId)) {
if (canUseSnapStealth(event.conversationId.toString())) {
event.canceled = true
}
}

View File

@@ -12,6 +12,7 @@ import android.hardware.camera2.CameraCharacteristics.Key
import android.hardware.camera2.CameraManager
import android.media.Image
import android.media.ImageReader
import android.os.Build
import android.util.Range
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -28,6 +29,9 @@ class CameraTweaks : Feature("Camera Tweaks") {
@SuppressLint("MissingPermission", "DiscouragedApi")
override fun init() {
val config = context.config.camera
val skipUnstableStillCaptureTweaks = Build.MANUFACTURER.equals("samsung", ignoreCase = true) ||
Build.HARDWARE.contains("exynos", ignoreCase = true) ||
Build.BRAND.equals("samsung", ignoreCase = true)
// Toggle A: Audio & Video Optimizations (Bitrates)
if (config.audioVideoOptimizations.get()) {
@@ -44,7 +48,7 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
if (config.cameraOptimizations.get()) {
if (config.cameraOptimizations.get() && !skipUnstableStillCaptureTweaks) {
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
when (key) {

View File

@@ -0,0 +1,357 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.animation.ValueAnimator
import android.app.Activity
import android.app.Dialog
import android.database.sqlite.SQLiteDatabase
import android.hardware.camera2.CaptureRequest
import android.media.MediaRecorder
import android.os.Build
import android.os.HandlerThread
import android.os.Process
import android.transition.Transition
import android.util.Range
import android.view.View
import android.view.TextureView
import android.view.ViewPropertyAnimator
import android.view.WindowManager
import android.view.animation.Animation
import android.widget.OverScroller
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import okhttp3.Dispatcher
import java.lang.Thread
import java.lang.reflect.Method
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.atomic.AtomicBoolean
class PerformanceMode : Feature("Performance Mode") {
override fun init() {
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
val isMaxProfile = profile == "max"
val threadPriority = if (isMaxProfile) {
Process.THREAD_PRIORITY_DISPLAY
} else {
Process.THREAD_PRIORITY_MORE_FAVORABLE
}
val minimumFrameRate = if (isMaxProfile) 60 else 45
val durationScale = if (isMaxProfile) 0.35f else 0.55f
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
val maxRequests = if (isMaxProfile) 192 else 96
val maxRequestsPerHost = if (isMaxProfile) 32 else 16
val minimumCoreThreads = if (isMaxProfile) 16 else 8
val prefetchItemCount = if (isMaxProfile) 24 else 12
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
val maxScrollDurationMs = if (isMaxProfile) 72 else 180
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
context.log.info(
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
"PerformanceMode"
)
runCatching {
ValueAnimator.setFrameDelay(0L)
}
fun isPerformanceSensitiveThread(name: String?): Boolean {
val normalizedName = name?.lowercase() ?: return false
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
normalizedName.contains(it)
}
}
fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long {
if (durationMs <= 0L) return durationMs
return durationMs.coerceAtMost(maxDurationMs)
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!isMaxProfile) return@subscribe
val url = event.url
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
event.canceled = true
}
}
HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param ->
if (param.args().size < 2) return@hookConstructor
val threadName = param.argNullable<String>(0)
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
param.setArg(1, threadPriority)
}
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
val thread = param.nullableThisObject<Any>() as? HandlerThread ?: return@hook
if (!isPerformanceSensitiveThread(thread.name)) return@hook
runCatching {
val tid = thread.threadId
if (tid > 0) {
Process.setThreadPriority(tid, threadPriority)
}
}
}
Thread::class.java.hook("start", HookStage.AFTER) { param ->
val thread = param.thisObject<Thread>()
if (!isPerformanceSensitiveThread(thread.name)) return@hook
runCatching {
thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1
}
}
ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param ->
val executor = param.thisObject<ThreadPoolExecutor>()
runCatching {
val targetCorePoolSize = executor.maximumPoolSize.coerceAtLeast(1).coerceAtMost(minimumCoreThreads.coerceAtLeast(executor.corePoolSize))
if (executor.corePoolSize < targetCorePoolSize) {
executor.corePoolSize = targetCorePoolSize
}
executor.allowCoreThreadTimeOut(false)
executor.prestartAllCoreThreads()
}
}
Dispatcher::class.java.hookConstructor(HookStage.AFTER) { param ->
val dispatcher = param.thisObject<Dispatcher>()
runCatching {
dispatcher.maxRequests = maxRequests
dispatcher.maxRequestsPerHost = maxRequestsPerHost
}
}
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
param.setResult(durationScale)
}
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
Transition::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
Animation::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
if (isMaxProfile) {
recyclerView.itemAnimator = null
}
}
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
if (isMaxProfile) {
recyclerView.itemAnimator = null
}
}
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
val layoutManager = param.argNullable<Any>(0)
when (layoutManager) {
is LinearLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12)
}
is StaggeredGridLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
}
}
fun SQLiteDatabase.applyPerformancePragmas() {
runCatching { execSQL("PRAGMA synchronous = NORMAL") }
runCatching { execSQL("PRAGMA temp_store = MEMORY") }
runCatching { execSQL("PRAGMA cache_size = -32768") }
runCatching { execSQL("PRAGMA mmap_size = 268435456") }
runCatching { execSQL("PRAGMA journal_size_limit = 1048576") }
runCatching { execSQL("PRAGMA optimize") }
}
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
}
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
}
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
val currentRate = param.arg<Int>(0)
val applied = currentRate
.coerceAtLeast(if (isMaxProfile) 30 else 24)
.coerceAtMost(if (isMaxProfile) 60 else 45)
if (applied != currentRate) {
param.setArg(0, applied)
}
}
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
if (param.args().size >= 5) {
val original = param.arg<Int>(4)
val updated = original.coerceAtMost(maxScrollDurationMs)
if (updated != original) {
param.setArg(4, updated)
}
}
}
OverScroller::class.java.hook("fling", HookStage.BEFORE) { param ->
if (param.args().size >= 10) {
val overX = param.arg<Int>(8)
val overY = param.arg<Int>(9)
if (overX != 0) param.setArg(8, 0)
if (overY != 0) param.setArg(9, 0)
}
}
runCatching {
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply {
isAccessible = true
}
fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? {
return nativeMapViewClass.findRestrictedMethod { method ->
method.name == name && predicate(method)
}?.apply {
isAccessible = true
}
}
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
nativeMapViewClass.hookConstructor(HookStage.AFTER) { param ->
val nativeMapView = param.thisObject<Any>()
runCatching {
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6)
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
nativeSetTransitionDuration?.invoke(nativeMapView, 0L)
nativeSetTransitionOptions?.invoke(
nativeMapView,
transitionOptionsCtor.newInstance(0L, 0L, false)
)
nativeCancelTransitions?.invoke(nativeMapView)
}
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "g" &&
method.parameterCount == 6 &&
method.parameterTypes.last() == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(5)
val applied = original.coerceAtMost(16L)
if (applied != original) {
param.setArg(5, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "v" &&
method.parameterCount == 3 &&
method.parameterTypes[0] == Double::class.javaPrimitiveType &&
method.parameterTypes[1] == Double::class.javaPrimitiveType &&
method.parameterTypes[2] == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(2)
val applied = original.coerceAtMost(8L)
if (applied != original) {
param.setArg(2, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
}
}.onFailure {
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
}
fun applyActivityPerformanceTuning(activity: Activity) {
runCatching {
activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
val display = activity.display
val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate
?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate
activity.window.attributes = activity.window.attributes.apply {
this.preferredRefreshRate = targetRefreshRate
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
runCatching {
activity.window.setSustainedPerformanceMode(true)
}
}
}
onNextActivityCreate {
applyActivityPerformanceTuning(it)
}
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
val window = dialog.window ?: return@hook
runCatching {
window.setWindowAnimations(0)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
window.attributes = window.attributes.apply {
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
}
}
}
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
val textureView = param.thisObject<TextureView>()
runCatching {
// Universal Guard: Only accelerate views owned by Snapchat.
// This prevents crashes in native hardware providers across all devices.
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
}
}
}

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
import me.eternal.purrfectsnap.common.scripting.JSModule
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface

View File

@@ -0,0 +1,130 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.widget.TextView
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
class FakeFollowersCount : Feature("Fake Followers Count") {
override fun init() {
if (context.config.userInterface.spoofFollowersCount.globalState != true) return
val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() }
?: return
val digits = raw.replace(Regex("[^0-9]"), "")
if (digits.isEmpty()) return
val followerValue = try {
BigInteger(digits)
} catch (_: NumberFormatException) {
return
}
onNextActivityCreate {
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
val textView = param.thisObject() as? TextView ?: return@hook
if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook
val arg0 = param.argNullable<Any>(0) ?: return@hook
if (arg0 !is CharSequence) return@hook
val text = arg0.toString()
if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook
val display = formatFollowersDisplay(followerValue)
val replaced = FOLLOWERS_LINE.replace(text) { mr ->
"$display ${mr.groupValues[2]}"
}
param.setArg(0, replaced)
}
}
}
private fun formatFollowersDisplay(n: BigInteger): String {
val v = n.max(BigInteger.ZERO)
if (v < TEN_THOUSAND) {
return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong())
}
val (scaled, suffix) = scaleToTier(v)
return formatMantissaMaxFourDigits(scaled) + suffix
}
private fun scaleToTier(v: BigInteger): Pair<BigDecimal, String> {
var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0)
var floor = SCALE_TIERS[i].floor
var suffix = SCALE_TIERS[i].suffix
var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) {
i++
floor = SCALE_TIERS[i].floor
suffix = SCALE_TIERS[i].suffix
scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
}
return Pair(scaled, suffix)
}
private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String {
val x = scaled.abs().setScale(12, RoundingMode.HALF_UP)
if (x.compareTo(BigDecimal.ZERO) == 0) return "0"
if (x >= HUNDRED) {
val i = x.setScale(0, RoundingMode.HALF_UP)
var s = i.toPlainString()
if (digitCount(s) > 4) {
s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
if (x >= TEN) {
var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
if (digitCount(s) > 4) {
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP))
if (digitCount(s) > 4) {
s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
}
if (digitCount(s) > 4) {
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
private fun digitCount(s: String) = s.count { it.isDigit() }
private fun stripFrac(d: BigDecimal): String {
var s = d.stripTrailingZeros().toPlainString()
if ('.' in s) {
s = s.trimEnd('0').trimEnd('.')
}
return s
}
private data class ScaleTier(val floor: BigInteger, val suffix: String)
companion object {
private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE)
private val COMPOSER_SNAP_TEXT_VIEW = setOf(
"com.snap.valdi.views.ComposerSnapTextView",
"com.snap.composer.views.ComposerSnapTextView",
)
private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP)
private val TEN_THOUSAND = BigInteger("10000")
private val THOUSAND_BD = BigDecimal("1000")
private val HUNDRED = BigDecimal("100")
private val TEN = BigDecimal("10")
private val SCALE_TIERS = listOf(
ScaleTier(BigInteger("1000"), "K"),
ScaleTier(BigInteger("1000000"), "M"),
ScaleTier(BigInteger("1000000000"), "B"),
ScaleTier(BigInteger("1000000000000"), "T"),
ScaleTier(BigInteger("1000000000000000"), "Q"),
ScaleTier(BigInteger("1000000000000000000"), "E"),
ScaleTier(BigInteger("1000000000000000000000"), "Z"),
ScaleTier(BigInteger("1000000000000000000000000"), "Y"),
)
}
}

View File

@@ -9,6 +9,8 @@ import android.text.TextPaint
import android.view.View
import android.view.ViewGroup
import android.graphics.Typeface
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -78,9 +80,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
val ffSdlPrimaryTextStartMargin = 6 * density
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt()
val separatorHeight = (density * 2).toInt()
val safetyGap = (6 * density).toInt()
val textPaint = TextPaint().apply {
textSize = secondaryTextSize
isAntiAlias = true
}
context.event.subscribe(BuildMessageEvent::class) { param ->
@@ -105,14 +108,15 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
}
fetchMessages(conversationId) {
var maxTextHeight = 0
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
val rect = Rect()
textPaint.getTextBounds(msg, 0, msg.length, rect)
rect.height().also {
if (it > maxTextHeight) maxTextHeight = it
}.plus(separatorHeight)
} ?: run {
val universalTextSize = 12 * density
val fontMetrics = textPaint.apply { textSize = universalTextSize }.fontMetrics
val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt()
val spacing = (4 * density).toInt()
val messages = messageCache[conversationId]
val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing))
if (previewContainerHeight == 0) {
ffItem.layoutParams = ffItem.layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
@@ -120,22 +124,23 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
}
ffItem.layoutParams = ffItem.layoutParams.apply {
height = feedEntryHeight + previewContainerHeight + separatorHeight
height = feedEntryHeight + (safetyGap).toInt() + previewContainerHeight
}
cachedLayouts[conversationId] = frameLayout
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val offsetY = canvas.height.toFloat() - previewContainerHeight
paint.textSize = secondaryTextSize
paint.color = context.userInterface.colorPrimary
val startY = feedEntryHeight.toFloat() - (9 * density)
paint.textSize = universalTextSize
paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.85f).toArgb()
paint.typeface = Typeface.DEFAULT
paint.isAntiAlias = true
messageCache[conversationId]?.forEachIndexed { index, messageString ->
messages?.forEachIndexed { index, messageString ->
canvas.drawText(messageString,
feedEntryHeight + ffSdlPrimaryTextStartMargin,
offsetY + index * maxTextHeight,
ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 3),
startY + (index + 1) * lineHeight + (index * spacing),
paint
)
}

View File

@@ -1,17 +1,31 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.view.View
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.util.ArrayList
import java.util.concurrent.ConcurrentHashMap
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
@Volatile
private var cachedRuleIds: Set<String> = emptySet()
@Volatile
private var cachedRuleIdsAt = 0L
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
private var lastRuleIdsHash = 0
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)
@@ -19,10 +33,59 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
set("mReason", "CLEAR_CONVERSATION")
}
private fun filterFriendFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
private fun getRuleIdsSnapshot(): Set<String> {
val now = System.currentTimeMillis()
if (now - cachedRuleIdsAt <= 1_000L) return cachedRuleIds
return context.bridgeClient.getRuleIds(ruleType).toSet().also {
cachedRuleIds = it
cachedRuleIdsAt = now
}
}
private fun resolveRuleTargets(conversationId: String): Set<String> {
return conversationTargetsCache.getOrPut(conversationId) {
val targets = linkedSetOf(conversationId)
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
entry.friendUserId?.let { targets.add(it) }
entry.participants?.forEach { targets.add(it) }
}
targets
}
}
private fun shouldHideConversation(
conversationId: String,
ruleIds: Set<String>,
ruleState: RuleState?
): Boolean {
if (ruleState == null) return false
// Industrial Cache Gating: Clear decisions if the master rule list changed
val currentHash = ruleIds.hashCode()
if (currentHash != lastRuleIdsHash) {
hideDecisionCache.clear()
lastRuleIdsHash = currentHash
}
return hideDecisionCache.getOrPut(conversationId) {
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
}
}
private fun filterFriendFeed(
entries: ArrayList<Any>,
ruleIds: Set<String>,
ruleState: RuleState?,
deletedEntries: ArrayList<Any>? = null
) {
if (ruleState == null || entries.isEmpty()) return
entries.removeIf { feedEntry ->
val conversationIdInstance = feedEntry.getObjectField("mConversationId") ?: return@removeIf false
if (canUseRule(SnapUUID(conversationIdInstance).toString())) {
val conversationId = SnapUUID(conversationIdInstance).toString()
if (shouldHideConversation(conversationId, ruleIds, ruleState)) {
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)!!)
true
} else {
@@ -31,6 +94,20 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
}
}
private fun hideBoundChatFeedRow(view: View) {
var current: View? = view
repeat(4) {
val parent = current?.parent as? View
// Safety: Never hide the actual list container
if (parent?.javaClass?.name?.contains("RecyclerView") == true) {
current?.hideViewCompletely()
return
}
current?.hideViewCompletely()
current = parent
}
}
private fun hookCallbackMethod(
hookedCallbacks: MutableSet<String>,
callbackClassName: String,
@@ -51,6 +128,14 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
override fun init() {
if (!context.config.userInterface.hideFriendFeedEntry.get()) return
context.event.subscribe(BindViewEvent::class) { event ->
event.friendFeedItem { conversationId ->
if (shouldHideConversation(conversationId, getRuleIdsSnapshot(), getRuleState())) {
hideBoundChatFeedRow(event.view)
}
}
}
context.mappings.useMapper(CallbackMapper::class) {
classLoader = context.androidContext.classLoader
val hasFetchAndSyncCallback = callbacks.getAsMap()?.entries?.any {
@@ -69,13 +154,13 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
when {
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchAndSyncFeedComplete") { param ->
val deletedConversations: ArrayList<Any> = param.arg(2)
filterFriendFeed(param.arg(0), deletedConversations)
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
val deletedConversations = param.argNullable<ArrayList<Any>>(2)
val ruleIds = getRuleIdsSnapshot()
val ruleState = getRuleState()
filterFriendFeed(entries, ruleIds, ruleState, deletedConversations)
if (deletedConversations.any {
val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString()
context.database.getFeedEntryByConversationId(uuid) != null
}) {
if (deletedConversations?.isNotEmpty() == true) {
param.setArg(4, true)
}
}
@@ -83,34 +168,40 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onSyncFeedComplete") { param ->
filterFriendFeed(param.arg(0), param.argNullable(2))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState(), param.argNullable(2))
}
}
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedEntriesComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onQueryFeedComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "FeedManagerDelegate" -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFeedEntriesUpdated") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onInternalSyncFeed") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
}

View File

@@ -8,11 +8,18 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -25,6 +32,47 @@ import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.AppleLogo
import kotlin.random.Random
@Composable
private fun GradientIcon(
imageVector: ImageVector,
brush: Brush,
size: androidx.compose.ui.unit.Dp = 16.dp
) {
Image(
imageVector = imageVector,
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier
.size(size)
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
}
)
}
@Composable
private fun GradientText(
text: String,
brush: Brush,
fontWeight: FontWeight,
fontSize: androidx.compose.ui.unit.TextUnit
) {
Text(
text = text,
color = Color.White,
fontWeight = fontWeight,
fontSize = fontSize,
modifier = Modifier
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
}
)
}
class MessageIndicators : Feature("Message Indicators") {
override fun init() {
val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return
@@ -35,28 +83,43 @@ class MessageIndicators : Feature("Message Indicators") {
val appleLogo = AppleLogo
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, _ ->
event.chatMessage { conversationId, _ ->
val view = event.view as? ViewGroup ?: return@subscribe
view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) }
val message = event.databaseMessage ?: return@chatMessage
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
if (message.senderId == context.database.myUserId && messageIndicatorsConfig.contains("skip_own_indicators")) return@chatMessage
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2
if (isGroupConversation && messageIndicatorsConfig.contains("disable_indicators_in_groups")) return@chatMessage
createComposeView(event.view.context) {
val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9)))
val locationBrush = Brush.linearGradient(listOf(Color(0xFFFF4B5C), Color(0xFFFFB74D)))
val androidBrush = Brush.linearGradient(listOf(Color(0xFF3DDC84), Color(0xFFA5E635)))
val appleBrush = Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFF94A3B8)))
val webBrush = Brush.linearGradient(listOf(Color(0xFF4FC3F7), Color(0xFF00E5FF)))
val directorBrush = Brush.linearGradient(listOf(Color(0xFFFFB74D), Color(0xFFFF5E3A)))
val ovfBrush = Brush.linearGradient(listOf(Color(0xFFFF5CA8), Color(0xFFA64CFF)))
val memoriesBrush = Brush.linearGradient(listOf(Color(0xFFFFE066), Color(0xFFFFB300)))
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(top = 4.dp, end = 1.dp),
.padding(top = 6.dp, end = 6.dp),
contentAlignment = Alignment.TopEnd
) {
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
// Strictly detect Private Fidelius Wrap (1-on-1 private snaps)
reader.containsPath(4, 4, 1, 1) ||
reader.containsPath(4, 4, 1, 1, 1) ||
reader.getByteArray(4, 3, 3) != null ||
reader.containsPath(3, 99, 3)
if (reader.containsPath(4, 4, 1, 1)
|| reader.containsPath(4, 4, 1, 1, 1)
|| reader.getByteArray(4, 3, 3) != null
|| reader.containsPath(3, 99, 3)) {
return@rememberAsyncMutableState true
}
if (reader.containsPath(4, 5, 1, 3, 1)) return@rememberAsyncMutableState true
reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L)
}
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
@@ -75,52 +138,41 @@ class MessageIndicators : Feature("Message Indicators") {
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
}
val sentFromMemories by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 18) != null
|| reader.getString(4, 5, 1, 2)?.contains("/h/") == true
}
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
Image(
imageVector = Icons.Default.LocationOn,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.LocationOn, locationBrush)
}
if (messageIndicatorsConfig.contains("platform_indicator")) {
Image(
imageVector = when {
sentFromWebApp -> Icons.Default.Laptop
sentFromIosDevice -> appleLogo
else -> Icons.Default.Android
},
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val (platformIcon, platformBrush) = when {
sentFromWebApp -> Icons.Default.Laptop to webBrush
sentFromIosDevice -> appleLogo to appleBrush
else -> Icons.Default.Android to androidBrush
}
GradientIcon(platformIcon, platformBrush)
}
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
Image(
imageVector = Icons.Default.Lock,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.Lock, lockBrush)
}
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
Image(
imageVector = Icons.Default.Edit,
colorFilter = ColorFilter.tint(Color.Red),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.Edit, directorBrush)
}
if (sentFromMemories && messageIndicatorsConfig.contains("memories_indicator")) {
GradientIcon(Icons.Default.HistoryEdu, memoriesBrush)
}
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
Text(
GradientText(
text = "OVF",
color = Color.Red,
brush = ovfBrush,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
fontSize = 11.sp
)
}
}

Some files were not shown because too many files have changed in this diff Show More