150 Commits
v1.5.9 ... 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
ΞTΞRNAL
a82db6a863 update changelog for v1.6.2 2026-04-01 15:33:17 +05:30
ΞTΞRNAL
9781008b2e fix(PR): Auto Open and Video Downloader Stability fixes by Kaladin
Auto Open and Video Downloader Stability fixes
2026-04-01 14:56:55 +05:30
ΞTΞRNAL
c71e06095d v1.6.2 2026-04-01 14:55:05 +05:30
DarkKnight2122
1fdaf6d55d Auto Open stabilization 2026-04-01 05:08:58 +05:30
DarkKnight2122
8a9258b318 Video Downloader Stability 2026-03-31 17:24:58 +05:30
ΞTΞRNAL
6491288513 v1.6.1 2026-03-29 12:35:34 +05:30
ΞTΞRNAL
33664c1112 v1.6.0 2026-03-29 00:40:08 +05:30
ΞTΞRNAL
bb8e644b4c fix(PR): Select highest quality media by comparing resolution by Issac XT
Fix: Select highest quality media by comparing resolution
2026-03-28 23:53:04 +05:30
Issac
9e5b318120 Fix: Select highest quality media by comparing resolution instead of assuming array order 2026-03-28 11:01:39 +05:30
135 changed files with 17686 additions and 6290 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

@@ -7,6 +7,8 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.core.app.CoreComponentFactory
@@ -30,12 +32,14 @@ import androidx.work.WorkManager
import me.eternal.purrfectsnap.bridge.BridgeService
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
import me.eternal.purrfectsnap.common.config.ModConfig
import me.eternal.purrfectsnap.common.logger.fatalCrash
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.common.util.constantLazyBridge
import me.eternal.purrfectsnap.common.util.getPurgeTime
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
@@ -275,6 +279,67 @@ class RemoteSideContext(
androidContext.startActivity(intent)
}
fun requestSocialSnapshotRefresh(
openSnapchatFirst: Boolean = true,
snapchatWarmupDelayMs: Long = 1200L,
returnDelayMs: Long = 1200L
) {
fun sendSocialSnapshotBroadcast() {
runCatching {
androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
log.error("Failed to request latest social snapshot", it)
}
}
if (!openSnapchatFirst) {
sendSocialSnapshotBroadcast()
return
}
val snapchatIntent = androidContext.packageManager
.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (snapchatIntent == null) {
shortToast(translation["toast_snapchat_not_installed"])
sendSocialSnapshotBroadcast()
return
}
val returnIntent = Intent(androidContext, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
}
val mainHandler = Handler(Looper.getMainLooper())
runCatching {
androidContext.startActivity(snapchatIntent)
mainHandler.postDelayed(
{
runCatching {
androidContext.startActivity(returnIntent)
}.onFailure {
log.error("Failed to return to PurrfectSnap after Snapchat handoff", it)
}
mainHandler.postDelayed(
{ sendSocialSnapshotBroadcast() },
returnDelayMs
)
},
snapchatWarmupDelayMs
)
}.onFailure {
log.error("Failed to launch Snapchat for social snapshot refresh", it)
sendSocialSnapshotBroadcast()
}
}
private fun scheduleAnnouncementCheck() {
val workManager = WorkManager.getInstance(androidContext)
val constraints = Constraints.Builder()

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++))
@@ -504,7 +505,10 @@ class DownloadProcessor (
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
val baseUrl = baseUrlNode.textContent
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
// FIX: Only add prefix if it's not already a full URL
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
}
}
val dashOptions = downloadRequest.dashOptions!!
@@ -612,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) {
@@ -645,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
@@ -93,34 +96,17 @@ fun AppDatabase.replaceMessagingData(
executeAsync {
database.beginTransaction()
try {
val friendIds = friends.map { it.userId }.toSet()
val groupIds = groups.map { it.conversationId }.toSet()
getFriends().forEach { friend ->
if (friend.userId !in friendIds) {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
}
}
getGroups().forEach { group ->
if (group.conversationId !in groupIds) {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
}
}
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
)
)
@@ -139,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
)
)
}
@@ -153,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)
}
}
@@ -172,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

@@ -9,6 +9,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -43,8 +46,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -72,6 +77,8 @@ 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
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -80,6 +87,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.DialogProperties
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import kotlin.math.max
import kotlin.math.min
@@ -162,6 +170,97 @@ class FeaturesRootSection : Routes.Route() {
}
}
internal fun isRandomizedProfileEnabled(): Boolean {
return context.config.root.experimental.spoof.randomizeDeviceProfile.globalState == true
}
internal fun requestFreshRandomizedProfile() {
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(UUID.randomUUID().toString())
randomizeConfig.currentProfileSnapshot.set("")
}
internal fun getRandomizedProfileSnapshot(): String {
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
?.takeIf { it.isNotBlank() }
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
?: "No generated profile is available yet. Enable the feature in Snapchat first.")
}
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
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() {
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
@@ -330,9 +429,17 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) {
internal fun PropertyAction(
property: PropertyPair<*>,
onConfigChanged: () -> Unit,
registerClickCallback: (() -> Unit) -> (() -> Unit)
) {
var showDialog by remember { mutableStateOf(false) }
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
var showRandomProfileProgressDialog by remember { mutableStateOf(false) }
var randomProfileStatus by remember { mutableStateOf("") }
var showCurrentRandomProfileDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
@@ -348,7 +455,64 @@ class FeaturesRootSection : Routes.Route() {
}
val propertyValue = property.value
fun persistConfig() = context.config.writeConfig()
val randomProfileEnabled = isRandomizedProfileEnabled()
val isRandomizedProfileContainer = property.name == "randomize_device_profile"
fun persistConfig() {
context.config.writeConfig()
onConfigChanged()
}
if (showRandomProfileProgressDialog) {
AestheticDialog(
onDismissRequest = {},
title = context.translation["manager.dialogs.randomize_device_profile.title"]
?: "Generating random device profile",
text = randomProfileStatus,
icon = Icons.Filled.AutoAwesome,
confirmButtonText = "",
onConfirm = {},
loading = true,
showIcon = false,
showCloseButton = false,
confirmEnabled = false
)
}
if (showCurrentRandomProfileDialog) {
val profileSnapshot = getRandomizedProfileSnapshot()
val clipboardManager = LocalClipboardManager.current
AestheticDialog(
onDismissRequest = { showCurrentRandomProfileDialog = false },
title = context.translation["manager.dialogs.randomize_device_profile.view_title"]
?: "Current randomized profile",
text = "",
icon = Icons.Filled.Visibility,
dismissButtonText = context.translation["button.copy"] ?: "Copy",
onDismiss = {
clipboardManager.setText(AnnotatedString(profileSnapshot))
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.copied"]
?: "Randomized profile copied"
)
},
confirmButtonText = context.translation["button.positive"],
onConfirm = { showCurrentRandomProfileDialog = false },
showCloseButton = false,
customContent = {
SelectionContainer {
Text(
text = profileSnapshot,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Start
)
}
}
)
}
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
registerDialogOnClickCallback()
@@ -518,12 +682,12 @@ class FeaturesRootSection : Routes.Route() {
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
onCheckedChange = { requestedState ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
propertyValue.setAny(state)
state = requestedState
propertyValue.setAny(requestedState)
persistConfig()
},
colors = purrfectSwitchColors()
@@ -566,6 +730,51 @@ class FeaturesRootSection : Routes.Route() {
}
DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
if (dataType == DataProcessors.Type.STRING && isRandomizedProfileActionProperty(property.name)) {
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(
onClick = {
if (property.name == "generate_fresh_profile_action") {
showRandomProfileProgressDialog = true
coroutineScope.launch {
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
?: "Allocating a randomized device fingerprint"
delay(260)
requestFreshRandomizedProfile()
persistConfig()
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.finalizing"]
?: "Finalizing the all-in-one profile and disabling manual overrides"
delay(260)
showRandomProfileProgressDialog = false
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.refresh_requested"]
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
)
}
} 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(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Text(actionLabel, maxLines = 1)
}
return
}
dialogComposable = {
when (dataType) {
DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
@@ -573,8 +782,15 @@ 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 }
}
@@ -592,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()
@@ -613,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)
@@ -660,12 +887,35 @@ class FeaturesRootSection : Routes.Route() {
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
onCheckedChange = { requestedState ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
container.globalState = state
if (isRandomizedProfileContainer && requestedState) {
showRandomProfileProgressDialog = true
coroutineScope.launch {
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
?: "Allocating a randomized device fingerprint"
delay(260)
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.network"]
?: "Preparing network, locale, and telephony values"
delay(260)
container.globalState = true
state = true
persistConfig()
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.done"]
?: "Randomized device profile generated"
delay(220)
showRandomProfileProgressDialog = false
context.log.info("Enabled randomized device profile mode from manager UI")
}
return@Switch
}
state = requestedState
container.globalState = requestedState
if (!requestedState && isRandomizedProfileContainer) {
context.log.info("Disabled randomized device profile mode from manager UI")
}
persistConfig()
},
colors = purrfectSwitchColors()
@@ -722,7 +972,12 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
internal fun PropertyCard(
property: PropertyPair<*>,
configRefreshNonce: Int,
onConfigChanged: () -> Unit,
onOpen: (() -> Unit)? = null
) {
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
val noticeColorMap = remember {
@@ -736,6 +991,7 @@ class FeaturesRootSection : Routes.Route() {
val versionCheck = remember { property.key.params.versionCheck }
val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)}
val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true }
val isInteractionEnabled = !isComponentDisabled
val cardShape = RoundedCornerShape(22.dp)
val interactionSource = remember { MutableInteractionSource() }
@@ -753,8 +1009,9 @@ class FeaturesRootSection : Routes.Route() {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 7.dp)
.graphicsLayer { if (isComponentDisabled) alpha = 0.5f }
.graphicsLayer { if (!isInteractionEnabled) alpha = 0.5f }
.clickable(
enabled = isInteractionEnabled,
interactionSource = interactionSource,
indication = null
) {
@@ -852,7 +1109,7 @@ class FeaturesRootSection : Routes.Route() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
PropertyAction(property, registerClickCallback = { callback ->
PropertyAction(property, onConfigChanged = onConfigChanged, registerClickCallback = { callback ->
if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
clickCallback = {
routes.manageRuleFeature.navigate {
@@ -1406,6 +1663,7 @@ class FeaturesRootSection : Routes.Route() {
) {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
var configRefreshNonce by rememberSaveable { mutableStateOf(0) }
val listState = rememberLazyListState()
@@ -1479,7 +1737,12 @@ class FeaturesRootSection : Routes.Route() {
upsertHistory(liveSearchQuery, sharedSearchHistory)
}
} else null
PropertyCard(item, onOpen = onOpen)
PropertyCard(
property = item,
configRefreshNonce = configRefreshNonce,
onConfigChanged = { configRefreshNonce++ },
onOpen = onOpen
)
}
}
item { Spacer(modifier = Modifier.height(12.dp)) }
@@ -1634,9 +1897,14 @@ class FeaturesRootSection : Routes.Route() {
onBack: (() -> Unit)? = null,
) {
PropertiesView(
properties = remember {
properties = remember(configContainer.globalState) {
configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair<Any> }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
!it.key.params.flags.contains(ConfigFlag.HIDDEN) &&
(
configContainer !== context.config.root.experimental.spoof.randomizeDeviceProfile ||
configContainer.globalState == true ||
!isRandomizedProfileActionProperty(it.key.name)
)
}
},
stateKey = stateKey,

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

@@ -24,11 +24,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.ReceiversConfig
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.common.util.snap.SnapWidgetBroadcastReceiverHelper
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
@@ -219,39 +221,59 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
}
cachedGroups = groups.run {
fun applySnapshot(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
coroutineScope.launch(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
val sortedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
} 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)
}
}
}
withContext(Dispatchers.Main) {
cachedFriends = sortedFriends
cachedGroups = sortedGroups
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
timeoutJob?.cancel()
hasFetchError = false
}
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
// Initial database load
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
context.database.getGroups()
)
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
context.androidContext.sendBroadcast(it)
}.onFailure {
context.log.error("Failed to send broadcast", it)
hasFetchError = true
// Real-time synchronization flow
context.requestSocialSnapshotRefresh()
coroutineScope.launch {
context.database.messagingDataFlow.collect { (friends, groups) ->
applySnapshot(friends, groups)
}
}
timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) {
delay(20000)
hasFetchError = true
delay(25000)
if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) {
hasFetchError = true
}
}
}
}
@@ -259,7 +281,6 @@ class AddFriendDialog(
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
@@ -319,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 ->
@@ -329,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(
@@ -338,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)
)
}
}
}
}
}
@@ -390,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(
@@ -410,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,16 +35,13 @@ 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.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
@@ -55,21 +52,37 @@ 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
}
}
}
}
}
internal fun requestLatestSnapshot() {
runCatching {
context.androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
context.log.error("Failed to request latest social snapshot", it)
}
context.requestSocialSnapshotRefresh()
}
@Composable
@@ -132,11 +145,6 @@ class SocialRootSection : Routes.Route() {
addFriendDialog?.Content {
addFriendDialog = null
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
FloatingActionButton(
@@ -166,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)
}
}
}
)
}
}
}
@@ -314,6 +1018,7 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button")
}
}
}

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,10 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
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

@@ -11,6 +11,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -27,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
@@ -73,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
@@ -80,6 +86,146 @@ import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
private val translation: LocaleWrapper,
){
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName ?: "",
messageListJson = currentValue,
onSave = { newValue: String ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutoOpenScheduleDialog(
property: PropertyPair<String>,
onDismiss: () -> Unit
) {
val windowParts = (property.value.get() as String).split("-")
val startTime = windowParts.getOrNull(0)?.split(":") ?: listOf("23", "00")
val endTime = windowParts.getOrNull(1)?.split(":") ?: listOf("07", "00")
var isEditingEnd by remember { mutableStateOf(false) }
val startState = rememberTimePickerState(
initialHour = startTime.getOrNull(0)?.toIntOrNull() ?: 23,
initialMinute = startTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
val endState = rememberTimePickerState(
initialHour = endTime.getOrNull(0)?.toIntOrNull() ?: 7,
initialMinute = endTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
DefaultDialogCard {
Column(
modifier = Modifier.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = translation["auto_open_snaps.auto_open_schedule.title"] ?: "Auto Open Scheduler",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val activeColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
val inactiveColor = Color.Transparent
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (!isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = false }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.start"] ?: "Start"}: ${String.format("%02d:%02d", startState.hour, startState.minute)}",
color = if (!isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (!isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = true }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.end"] ?: "End"}: ${String.format("%02d:%02d", endState.hour, endState.minute)}",
color = if (isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
}
TimePicker(
state = if (isEditingEnd) endState else startState,
colors = TimePickerDefaults.colors(
clockDialColor = Color.White.copy(alpha = 0.05f),
clockDialSelectedContentColor = Color.White,
clockDialUnselectedContentColor = Color.White.copy(alpha = 0.7f),
selectorColor = PurrfectPalette.glowPrimary,
periodSelectorBorderColor = PurrfectPalette.glowPrimary,
periodSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
periodSelectorUnselectedContainerColor = Color.Transparent,
periodSelectorSelectedContentColor = Color.White,
periodSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f),
timeSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
timeSelectorUnselectedContainerColor = Color.White.copy(alpha = 0.05f),
timeSelectorSelectedContentColor = Color.White,
timeSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(text = translation["button.negative"], color = Color.White)
}
Button(
onClick = {
val startStr = String.format("%02d:%02d", startState.hour, startState.minute)
val endStr = String.format("%02d:%02d", endState.hour, endState.minute)
property.value.setAny("$startStr-$endStr")
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
contentColor = Color.White
)
) {
Text(text = translation["button.positive"])
}
}
}
}
}
@Composable
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
val scrollState = rememberScrollState()
@@ -285,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)
)
})
}
@@ -301,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,
@@ -370,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() }
@@ -1260,23 +1480,7 @@ class AlertDialogs(
}
}
}
}
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName,
messageListJson = currentValue,
onSave = { newValue ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
}
@Composable
fun MessageListManagerDialog(
@@ -1313,7 +1517,6 @@ class AlertDialogs(
textAlign = TextAlign.Center
)
// Message list
Card(
modifier = Modifier
.fillMaxWidth()
@@ -1329,7 +1532,7 @@ class AlertDialogs(
contentAlignment = Alignment.Center
) {
Text(
text = translation["auto_reply_messages.dialog.no_messages"],
text = translation["bulk_messaging_action.no_messages_found"] ?: "No messages",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -1371,24 +1574,14 @@ class AlertDialogs(
showAddDialog = true
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
tint = MaterialTheme.colorScheme.primary
)
Icon(Icons.Default.Edit, contentDescription = translation["common.edit"] ?: "Edit", tint = MaterialTheme.colorScheme.primary)
}
IconButton(
onClick = {
messageList = messageList.toMutableList().apply {
removeAt(index)
}
messageList = messageList.toMutableList().apply { removeAt(index) }
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
Icon(Icons.Default.Delete, contentDescription = translation["common.delete"] ?: "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
@@ -1398,7 +1591,6 @@ class AlertDialogs(
}
}
// Add button
Button(
onClick = {
editingIndex = -1
@@ -1408,20 +1600,13 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["auto_reply_messages.dialog.add_message"])
Text(text = translation["common.add"] ?: "Add Message")
}
// Dialog buttons
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1430,17 +1615,14 @@ class AlertDialogs(
) {
Button(
onClick = { onDismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val gson = com.google.gson.Gson()
val jsonString = gson.toJson(messageList)
onSave(jsonString)
onSave(gson.toJson(messageList))
onDismiss()
}
) {
@@ -1450,7 +1632,6 @@ class AlertDialogs(
}
}
// Add/Edit message dialog
if (showAddDialog) {
StandardDialog(
onDismissRequest = { showAddDialog = false },
@@ -1460,7 +1641,7 @@ class AlertDialogs(
) {
DefaultDialogCard {
Text(
text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["auto_reply_messages.dialog.edit_message"],
text = if (editingIndex == -1) translation["common.add"] ?: "Add Message" else translation["common.edit"] ?: "Edit Message",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
@@ -1472,13 +1653,13 @@ class AlertDialogs(
TextField(
value = editingText,
onValueChange = { editingText = it },
label = { Text(translation["auto_reply_messages.dialog.message_label"]) },
label = { Text(translation["common.message"] ?: "Message") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
minLines = 2,
maxLines = 4,
placeholder = { Text(translation["auto_reply_messages.dialog.message_placeholder"]) }
placeholder = { Text(translation["common.type_message"] ?: "Type message...") }
)
Row(
@@ -1489,32 +1670,22 @@ class AlertDialogs(
) {
Button(
onClick = { showAddDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
if (editingText.isNotBlank()) {
if (editingIndex == -1) {
// Add new message
messageList = messageList.toMutableList().apply {
add(editingText)
}
} else {
// Edit existing message
messageList = messageList.toMutableList().apply {
set(editingIndex, editingText)
}
messageList = messageList.toMutableList().apply {
if (editingIndex == -1) add(editingText) else set(editingIndex, editingText)
}
}
showAddDialog = false
},
enabled = editingText.isNotBlank()
) {
Text(text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["button.save"])
Text(text = if (editingIndex == -1) translation["common.add"] ?: "Add" else translation["button.save"])
}
}
}

View File

@@ -11,7 +11,10 @@ import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.addCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -47,6 +50,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
@@ -197,6 +202,21 @@ private fun InlineDialog(
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
val fallbackBackDispatcherOwner = remember(onDismissRequest) {
object : OnBackPressedDispatcherOwner {
private val lifecycleRegistry = LifecycleRegistry(this).apply {
currentState = Lifecycle.State.RESUMED
}
private val dispatcher = OnBackPressedDispatcher(onDismissRequest)
override val lifecycle: Lifecycle
get() = lifecycleRegistry
override val onBackPressedDispatcher: OnBackPressedDispatcher
get() = dispatcher
}
}
val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -212,43 +232,45 @@ private fun InlineDialog(
),
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
) {
content()
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}
@@ -444,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.5.9").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("308").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,211 @@
## 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
- Build Properties
- Device Identity
- Manufacturer + Model
- Brand + Product
- Hardware + Board
- ABI Lists
- Combined ABIs
- Split ABIs
- System Properties
- Build
- Locale
- Telephony
- Build Version
- Fingerprint
- Display
- Host
- Bootloader
- Build Time
- Locale Options
- Locale
- Language
- Region
- Time
- Time Zone ID
- Time Zone Display Name
- Auto Time
- Auto Time Zone
- Telephony Options
- MMS
- User Agent
- Network Identity
- Network Type
- Operator Numeric
- Operator Name
- Country ISO
- SIM Identity
- Country ISO
- Operator Numeric
- Operator Name
- SIM State
- Has ICC Card
- Phone Capabilities
- Phone Count
- Hearing Aid Support
- TTY Support
- World Phone
- Roaming
- SMS + Voice Capability
- Phone Type
- Settings Options
- Secure Settings
- Base
- TTS
- System Settings
- Base
- Bluetooth
- Global Settings
- Base
- Network Options
- Wi-Fi
- SSID
- RSSI
- DNS
- Servers
- Search Domains
- Private DNS
- Captive Portal
- Capability
- Identifier Options
- Android ID
- String Value
- Long Value
- Advertising ID
- Settings Value
- Play Services
- Hardware Addresses
- Wi-Fi MAC
- Bluetooth MAC
- Persistent App Language
- Randomize IP Address
- Generate Fresh Profile
- View Current Profile
- New: Spoof Viewing Gallery Presence
- New: Spoof Reply Camera Presence
- New: I Can See you 2 Friend Tracker Rule
- New: I Can See you 3 Friend Tracker Rule
- New: Disable "Can't Login?" overlay feature
- Fix: Social Tab showing Failed to fetch Data for some accounts
- Fix: Pick a Location Dialog Crash
- Fix: Auto Open Notification duplication when snapchat is killed or force closed(tq to Kaladin)
- Fix: Fixed a logic error where AutoOpenSnaps would ignore per-conversation rules(tq to Kaladin)
- Fix: Auto Open engine stuck in "Monitoring" even when there are snaps in the queue(tq to Kaladin)
- Fix: Auto Open session toggles not working as expected(tq to Kaladin)
- Fix: Auto Open engine is now optimmized to work with the lower end devices to reduce the overheating and excessive lag(tq to Kaladin)
- New: Auto Open Scheduler, a new toggle and a new clock picker to schedule time period for your auto open engine to process(tq to Kaladin)
- New: Auto Open Notification session statistics toggles, now you can choose which stats to show in the notification(tq to Kaladin)
- New: Updated Auto Open notification stats which now includes a tiered "Processing Speed" and "Estimated Finish" stats(tq to Kaladin)
- Fix: Video Downloader is saving the video files from spotlight and stories with extension as ".dat file"(tq to Kaladin)
## v1.6.1
- Fix: Custom Frame Rate
- Fix: Conversation Sound Style
- Fix: Auto Reactions when downloading stories(Removed story thumbnail feature)
## v1.6.0
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
- New: Call Metadata Notifier!
- New: Pin Messages!(Locally)
- Fix: Select highest quality media by comparing resolution(tq to Issac XT)
## v1.5.9
- New: Block Calls Feature!
- New: Unlock Zoom Limit Feature!

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",
@@ -532,6 +552,18 @@
"title": "Export Sensitive Data?",
"content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)"
},
"randomize_device_profile": {
"title": "Generating random device profile",
"done": "Randomized device profile generated",
"view_title": "Current randomized profile",
"empty": "No generated profile is available yet. Enable the feature in Snapchat first.",
"refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.",
"phase": {
"allocating": "Allocating a randomized device fingerprint",
"network": "Preparing network, locale, and telephony values",
"finalizing": "Finalizing the all-in-one profile and disabling manual overrides"
}
},
"messaging_action": {
"title": "Choose content types to process",
"select_all_button": "Select All"
@@ -767,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": {
@@ -898,7 +946,8 @@
"notices": {
"unstable": "\u26a0 Unstable",
"ban_risk": "\u26a0 This feature may cause bans",
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour"
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour",
"randomize_device_profile_override": "Controlled by Randomized Device Profile"
},
"properties": {
"downloader": {
@@ -1076,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"
@@ -1174,6 +1227,14 @@
"name": "Hide Bitmoji Presence",
"description": "Prevents your Bitmoji from popping up while in Chat"
},
"spoof_viewing_gallery_presence": {
"name": "Spoof Viewing Gallery Presence",
"description": "Keeps your Bitmoji visible in Chat while viewing chat media"
},
"spoof_reply_camera_presence": {
"name": "Spoof Reply Camera Presence",
"description": "Keeps your Bitmoji visible in Chat while using the reply camera"
},
"hide_typing_notifications": {
"name": "Hide Typing Notifications",
"description": "Prevents anyone from knowing you're typing a message"
@@ -1864,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"
@@ -2013,6 +2080,40 @@
"name": "Force Wi-Fi Transport Flag",
"description": "Force network transport to report Wi-Fi instead of mobile data"
},
"randomize_device_profile": {
"name": "Randomized Device Profile",
"description": "Generate and apply a full randomized device, network, locale, and settings profile in one restart-safe profile",
"properties": {
"show_activation_overlay": {
"name": "Show Activation Overlay",
"description": "Show the in-app toast when the randomized profile becomes active"
},
"randomize_ip_address": {
"name": "Randomize IP Address",
"description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created"
},
"persistent_app_language": {
"name": "Persistent App Language",
"description": "Force Snapchat to stay on a specific supported app language"
},
"generate_fresh_profile_action": {
"name": "Generate Fresh Profile",
"description": "Request a newly generated randomized profile"
},
"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"
}
}
},
"spoof_device_id": {
"name": "Spoof Device ID",
"description": "Override the Android ID sent to Snapchat",
@@ -2236,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",
@@ -2330,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",
@@ -2348,7 +2461,10 @@
"custom_android_id": {
"null": "Use real Android ID"
},
"add_friend_source_spoof": {
"persistent_app_language": {
"system_default": "System Default"
},
"add_friend_source_spoof": {
"added_by_username": "By Username",
"added_by_mention": "By Mention",
"added_by_group_chat": "By Group Chat",
@@ -2551,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",
@@ -2965,6 +3084,10 @@
"stopped_speaking": "Stopped Speaking",
"started_peeking": "Started Peeking",
"stopped_peeking": "Stopped Peeking",
"started_using_reply_camera": "Started Using Reply Camera",
"stopped_using_reply_camera": "Stopped Using Reply Camera",
"started_viewing_chat_media": "Started Viewing Chat Media",
"stopped_viewing_chat_media": "Stopped Viewing Chat Media",
"message_read": "Message Read",
"message_deleted": "Message Deleted",
"message_saved": "Message Saved",
@@ -2977,7 +3100,9 @@
"snap_replayed_twice": "Snap Replayed Twice",
"snap_screenshot": "Snap Screenshot",
"snap_screen_record": "Snap Screen Record",
"i_can_see_you": "I Can See You"
"i_can_see_you": "I Can See You",
"i_can_see_you_2": "I Can See You 2",
"i_can_see_you_3": "I Can See You 3"
},
"cleared_from_feed": "Cleared from feed",
"tracker_actions": {
@@ -3186,6 +3311,10 @@
"stopped_speaking": "{friend} stopped speaking in {conversation}",
"started_peeking": "{friend} started peeking in {conversation}",
"stopped_peeking": "{friend} stopped peeking in {conversation}",
"started_using_reply_camera": "{friend} opened the reply camera in {conversation}",
"stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}",
"started_viewing_chat_media": "{friend} started viewing chat media in {conversation}",
"stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}",
"message_read": "{friend} read a message in {conversation}",
"message_deleted": "{friend} deleted a message in {conversation}",
"message_saved": "{friend} saved a message in {conversation}",
@@ -3198,7 +3327,9 @@
"snap_replayed_twice": "{friend} replayed a snap twice in {conversation}",
"snap_screenshot": "{friend} took a screenshot in {conversation}",
"snap_screen_record": "{friend} screen recorded in {conversation}",
"i_can_see_you": "{friend} activity in {conversation}: {details}"
"i_can_see_you": "{friend} activity in {conversation}: {details}",
"i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}",
"i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}"
},
"friend_mutation_observer": {
"notification_channel_name": "Friend Mutation Observer",
@@ -3351,6 +3482,10 @@
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"started_using_reply_camera": "Opened reply camera",
"stopped_using_reply_camera": "Closed reply camera",
"started_viewing_chat_media": "Started viewing chat media",
"stopped_viewing_chat_media": "Stopped viewing chat media",
"message_read": "Read message",
"message_deleted": "Deleted message",
"message_saved": "Saved message",
@@ -3402,6 +3537,10 @@
"stopped_speaking": "stopped speaking",
"started_peeking": "started peeking",
"stopped_peeking": "stopped peeking",
"started_using_reply_camera": "opened the reply camera",
"stopped_using_reply_camera": "closed the reply camera",
"started_viewing_chat_media": "started viewing chat media",
"stopped_viewing_chat_media": "stopped viewing chat media",
"message_read": "read a message",
"message_deleted": "deleted a message",
"message_saved": "saved a message",
@@ -3414,7 +3553,9 @@
"snap_replayed_twice": "replayed a snap twice",
"snap_screenshot": "took a screenshot",
"snap_screen_record": "screen recorded",
"i_can_see_you": "was active"
"i_can_see_you": "was active",
"i_can_see_you_2": "was viewing gallery",
"i_can_see_you_3": "was using the reply camera"
}
}
},
@@ -3474,6 +3615,7 @@
"disable_feature_loading_label": "Disable Feature Loading",
"disable_auto_mapper_label": "Disable Auto Mapper",
"disable_bypass_indicator_label": "Disable Bypass Indicator",
"disable_cant_login_button_label": "Disable Can't Login Button",
"friend_list": {
"manage_title": "Manage Friend List",
"export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.",

File diff suppressed because it is too large Load Diff

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)
@@ -63,7 +64,7 @@ class Experimental : ConfigContainer() {
}
val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }

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,22 +166,33 @@ 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 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 onlyWhenIdle = boolean("only_when_idle", 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") {
addFlags(ConfigFlag.NO_DISABLE_KEY)
inputCheck = { it.matches(Regex("^([01]\\d|2[0-3]):([0-5]\\d)-([01]\\d|2[0-3]):([0-5]\\d)$")) }
}
}
class AutoDeleteSentMessagesConfig : ConfigContainer(hasGlobalState = true) {
@@ -202,11 +213,45 @@ 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() }
val hidePeekAPeek = boolean("hide_peek_a_peek")
val hideBitmojiPresence = boolean("hide_bitmoji_presence")
val spoofViewingGalleryPresence = boolean("spoof_viewing_gallery_presence")
val spoofReplyCameraPresence = boolean("spoof_reply_camera_presence")
val hideTypingNotifications = boolean("hide_typing_notifications")
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
@@ -223,6 +268,13 @@ class MessagingTweaks : ConfigContainer() {
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
addFlags(ConfigFlag.NO_DISABLE_KEY)
}.apply { set("disabled") }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",
@@ -280,41 +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

@@ -4,10 +4,198 @@ import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
class Spoof : ConfigContainer(hasGlobalState = true) {
companion object {
val supportedSnapchatLanguages = listOf(
"ar", "bn", "bn-BD", "bn-IN", "da", "de", "el", "en-GB", "es", "es-AR", "es-ES", "es-MX",
"fi", "fil", "fil-PH", "fr", "gu", "gu-IN", "hi", "hi-IN", "in", "it", "ja", "kn", "kn-IN",
"ko", "ml", "ml-IN", "mr", "mr-IN", "ms", "ms-MY", "nb", "nl", "pa", "pa-IN", "pl", "pt",
"pt-PT", "ro", "ru", "sv", "ta", "ta-IN", "te", "te-IN", "th", "th-TH", "tr", "ur", "ur-PK",
"vi", "vi-VN", "zh", "zh-CN", "zh-TW"
)
}
inner class RandomizedDeviceProfileConfig : ConfigContainer(hasGlobalState = true) {
inner class RandomizedBuildVersionConfig : ConfigContainer(hasGlobalState = true) {
val fingerprint = boolean("fingerprint", defaultValue = true) { requireRestart() }
val display = boolean("display", defaultValue = true) { requireRestart() }
val host = boolean("host", defaultValue = true) { requireRestart() }
val bootloader = boolean("bootloader", defaultValue = true) { requireRestart() }
val buildTime = boolean("build_time", defaultValue = true) { requireRestart() }
}
inner class RandomizedDeviceIdentityConfig : ConfigContainer(hasGlobalState = true) {
val manufacturerModel = boolean("manufacturer_model", defaultValue = true) { requireRestart() }
val brandProduct = boolean("brand_product", defaultValue = true) { requireRestart() }
val hardwareBoard = boolean("hardware_board", defaultValue = true) { requireRestart() }
}
inner class RandomizedAbiListsConfig : ConfigContainer(hasGlobalState = true) {
val combinedAbis = boolean("combined_abis", defaultValue = true) { requireRestart() }
val splitAbis = boolean("split_abis", defaultValue = true) { requireRestart() }
}
inner class RandomizedSystemPropertiesConfig : ConfigContainer(hasGlobalState = true) {
val build = boolean("build", defaultValue = true) { requireRestart() }
val locale = boolean("locale", defaultValue = true) { requireRestart() }
val telephony = boolean("telephony", defaultValue = true) { requireRestart() }
}
inner class RandomizedBuildPropertiesConfig : ConfigContainer(hasGlobalState = true) {
val deviceIdentity = container("device_identity", RandomizedDeviceIdentityConfig().apply { globalState = true })
val abiLists = container("abi_lists", RandomizedAbiListsConfig().apply { globalState = true })
val systemProperties = container("system_properties", RandomizedSystemPropertiesConfig().apply { globalState = true })
val buildVersion = container("build_version", RandomizedBuildVersionConfig().apply { globalState = true })
}
inner class RandomizedLocaleValueConfig : ConfigContainer(hasGlobalState = true) {
val language = boolean("language", defaultValue = true) { requireRestart() }
val region = boolean("region", defaultValue = true) { requireRestart() }
}
inner class RandomizedTimeConfig : ConfigContainer(hasGlobalState = true) {
val timeZoneId = boolean("time_zone_id", defaultValue = false) { requireRestart() }
val timeZoneDisplayName = boolean("time_zone_display_name", defaultValue = false) { requireRestart() }
val autoTime = boolean("auto_time", defaultValue = false) { requireRestart() }
val autoTimeZone = boolean("auto_time_zone", defaultValue = false) { requireRestart() }
}
inner class RandomizedLocaleConfig : ConfigContainer(hasGlobalState = true) {
val locale = container("locale", RandomizedLocaleValueConfig().apply { globalState = true })
val time = container("time", RandomizedTimeConfig().apply { globalState = true })
}
inner class RandomizedMmsConfig : ConfigContainer(hasGlobalState = true) {
val userAgent = boolean("user_agent", defaultValue = true) { requireRestart() }
}
inner class RandomizedNetworkIdentityConfig : ConfigContainer(hasGlobalState = true) {
val networkType = boolean("network_type", defaultValue = true) { requireRestart() }
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
}
inner class RandomizedSimIdentityConfig : ConfigContainer(hasGlobalState = true) {
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
val simState = boolean("sim_state", defaultValue = true) { requireRestart() }
val hasIccCard = boolean("has_icc_card", defaultValue = true) { requireRestart() }
}
inner class RandomizedPhoneCapabilitiesConfig : ConfigContainer(hasGlobalState = true) {
val phoneCount = boolean("phone_count", defaultValue = true) { requireRestart() }
val hearingAid = boolean("hearing_aid", defaultValue = true) { requireRestart() }
val tty = boolean("tty", defaultValue = true) { requireRestart() }
val worldPhone = boolean("world_phone", defaultValue = true) { requireRestart() }
val roaming = boolean("roaming", defaultValue = true) { requireRestart() }
val smsVoice = boolean("sms_voice", defaultValue = true) { requireRestart() }
val phoneType = boolean("phone_type", defaultValue = true) { requireRestart() }
}
inner class RandomizedTelephonyConfig : ConfigContainer(hasGlobalState = true) {
val mms = container("mms", RandomizedMmsConfig().apply { globalState = true })
val networkIdentity = container("network_identity", RandomizedNetworkIdentityConfig().apply { globalState = true })
val simIdentity = container("sim_identity", RandomizedSimIdentityConfig().apply { globalState = true })
val phoneCapabilities = container("phone_capabilities", RandomizedPhoneCapabilitiesConfig().apply { globalState = true })
}
inner class RandomizedSecureSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
val tts = boolean("tts", defaultValue = true) { requireRestart() }
}
inner class RandomizedSystemSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
val bluetooth = boolean("bluetooth", defaultValue = true) { requireRestart() }
}
inner class RandomizedGlobalSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
}
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = 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) {
val ssid = boolean("ssid", defaultValue = true) { requireRestart() }
val rssi = boolean("rssi", defaultValue = true) { requireRestart() }
}
inner class RandomizedDnsConfig : ConfigContainer(hasGlobalState = true) {
val servers = boolean("servers", defaultValue = true) { requireRestart() }
val searchDomains = boolean("search_domains", defaultValue = true) { requireRestart() }
val privateDns = boolean("private_dns", defaultValue = true) { requireRestart() }
}
inner class RandomizedCaptivePortalConfig : ConfigContainer(hasGlobalState = true) {
val capability = boolean("capability", defaultValue = true) { requireRestart() }
}
inner class RandomizedNetworkConfig : ConfigContainer(hasGlobalState = true) {
val wifi = container("wifi", RandomizedWifiConfig().apply { globalState = true })
val dns = container("dns", RandomizedDnsConfig().apply { globalState = true })
val captivePortal = container("captive_portal", RandomizedCaptivePortalConfig().apply { globalState = true })
}
inner class RandomizedAndroidIdConfig : ConfigContainer(hasGlobalState = true) {
val stringValue = boolean("string_value", defaultValue = true) { requireRestart() }
val longValue = boolean("long_value", defaultValue = true) { requireRestart() }
}
inner class RandomizedAdvertisingIdConfig : ConfigContainer(hasGlobalState = true) {
val settingsValue = boolean("settings_value", defaultValue = true) { requireRestart() }
val playServices = boolean("play_services", defaultValue = true) { requireRestart() }
}
inner class RandomizedHardwareAddressesConfig : ConfigContainer(hasGlobalState = true) {
val wifiMac = boolean("wifi_mac", defaultValue = true) { requireRestart() }
val bluetoothMac = boolean("bluetooth_mac", defaultValue = true) { requireRestart() }
}
inner class RandomizedIdentifiersConfig : ConfigContainer(hasGlobalState = true) {
val androidId = container("android_id", RandomizedAndroidIdConfig().apply { globalState = true })
val advertisingId = container("advertising_id", RandomizedAdvertisingIdConfig().apply { globalState = true })
val hardwareAddresses = container("hardware_addresses", RandomizedHardwareAddressesConfig().apply { globalState = true })
}
val showActivationOverlay = boolean("show_activation_overlay", defaultValue = false)
val randomizeIpAddress = boolean("randomize_ip_address", defaultValue = false) { requireRestart() }
val buildProperties = container("build_properties", RandomizedBuildPropertiesConfig().apply { globalState = true })
val localeOptions = container("locale_options", RandomizedLocaleConfig().apply { globalState = true })
val telephonyOptions = container("telephony_options", RandomizedTelephonyConfig().apply { globalState = true })
val settingsOptions = container("settings_options", RandomizedSettingsConfig().apply { globalState = true })
val networkOptions = container("network_options", RandomizedNetworkConfig().apply { globalState = true })
val identifierOptions = container("identifier_options", RandomizedIdentifiersConfig().apply { globalState = true })
val persistentAppLanguage = unique("persistent_app_language", *supportedSnapchatLanguages.toTypedArray()) {
requireRestart()
addFlags(ConfigFlag.NO_TRANSLATE)
disabledKey = "system_default"
customOptionTranslationPath = "features.options.persistent_app_language"
}
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() {
val spoofAndroidId = boolean("spoof_android_id") { requireRestart() }
val spoofAndroidId = boolean("spoof_android_id") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val customAndroidId = string("custom_android_id") {
requireRestart()
addFlags(ConfigFlag.HIDDEN)
inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) }
}
}
@@ -16,8 +204,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() }
val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() }
val forceWifiTransportFlag = boolean("force_wifi_transport_flag") { requireRestart() }
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
val spoofDevice = boolean("spoof_device") { requireRestart() }
val randomizeDeviceProfile = container("randomize_device_profile", RandomizedDeviceProfileConfig()) { requireRestart() }
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val spoofDevice = boolean("spoof_device") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val deviceModel = unique("device_model",
"none",
"random",
@@ -38,6 +227,7 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
"realme GT 6"
) {
requireRestart()
addFlags(ConfigFlag.HIDDEN)
customOptionTranslationPath = "features.options.device_model"
}
}

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

@@ -21,6 +21,8 @@ enum class FileType(
JPG("jpg", "image/jpg",false, true, false),
ZIP("zip", "application/zip", false, false, false),
WEBP("webp", "image/webp", false, true, false),
HEIC("heic", "image/heic", false, true, false),
HEIF("heif", "image/heif", false, true, false),
MPD("mpd", "text/xml", false, false, false),
UNKNOWN("dat", "application/octet-stream", false, false, false);
@@ -64,25 +66,16 @@ enum class FileType(
}
val majorBrand = String(array, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
// Explicitly exclude known IMAGE-only brands to prevent false positives (HEIC/HEIF)
val imageBrands = setOf("heic", "heix", "hevc", "hevx", "mif1", "msf1")
if (majorBrand in imageBrands) return false
return majorBrand in setOf(
"mp41",
"mp42",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"mif1",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
)
"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 {
@@ -97,8 +90,24 @@ enum class FileType(
val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16)
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value
?: if (looksLikeIsoBmffVideo(headerBytes)) MP4 else UNKNOWN
// 1. Check strict signatures
fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value?.let { return it }
// 2. Check ISO BMFF container type
val majorBrand = if (headerBytes.size >= 12 &&
headerBytes[4] == 'f'.code.toByte() && headerBytes[5] == 't'.code.toByte() &&
headerBytes[6] == 'y'.code.toByte() && headerBytes[7] == 'p'.code.toByte()) {
String(headerBytes, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
} else null
if (majorBrand != null) {
if (majorBrand in setOf("heic", "heix")) return HEIC
if (majorBrand in setOf("mif1", "msf1")) return HEIF
if (looksLikeIsoBmffVideo(headerBytes)) return MP4
}
return UNKNOWN
}
fun fromInputStream(inputStream: InputStream): 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

@@ -9,7 +9,9 @@ data class FriendPresenceState(
val typing: Boolean,
val wasTyping: Boolean,
val speaking: Boolean,
val peeking: Boolean
val peeking: Boolean,
val usingReplyCamera: Boolean,
val viewingChatMedia: Boolean
)
open class SessionEvent(
@@ -44,6 +46,8 @@ enum class SessionEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
I_CAN_SEE_YOU("i_can_see_you"),
I_CAN_SEE_YOU_2("i_can_see_you_2"),
I_CAN_SEE_YOU_3("i_can_see_you_3"),
}
enum class TrackerEventType(
@@ -58,6 +62,10 @@ enum class TrackerEventType(
STOPPED_SPEAKING("stopped_speaking"),
STARTED_PEEKING("started_peeking"),
STOPPED_PEEKING("stopped_peeking"),
STARTED_USING_REPLY_CAMERA("started_using_reply_camera"),
STOPPED_USING_REPLY_CAMERA("stopped_using_reply_camera"),
STARTED_VIEWING_CHAT_MEDIA("started_viewing_chat_media"),
STOPPED_VIEWING_CHAT_MEDIA("stopped_viewing_chat_media"),
// mcs events
MESSAGE_READ("message_read"),
@@ -73,6 +81,8 @@ enum class TrackerEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
I_CAN_SEE_YOU("i_can_see_you"),
I_CAN_SEE_YOU_2("i_can_see_you_2"),
I_CAN_SEE_YOU_3("i_can_see_you_3"),
}

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

@@ -319,6 +319,7 @@ class SecurityFeatures(
loginHelpComposable = {
var showDialog by remember { mutableStateOf(false) }
var isLoginScreen by remember { mutableStateOf(false) }
val disableHelpButton = context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true"
LaunchedEffect(Unit) {
while (true) {
@@ -329,13 +330,13 @@ class SecurityFeatures(
}
}
if (isLoginScreen) {
if (isLoginScreen && !disableHelpButton) {
LoginSignupHelpButton(
onClick = { showDialog = true }
)
}
if (isLoginScreen && showDialog) {
if (isLoginScreen && !disableHelpButton && showDialog) {
LoginSignupHelpDialog(
onDismiss = { showDialog = false }
)
@@ -433,6 +434,9 @@ class SecurityFeatures(
context.features.addActivityCreateListener { activity ->
if (!activity.javaClass.name.endsWith("LoginSignupActivity")) return@addActivityCreateListener
if (context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true") {
return@addActivityCreateListener
}
activity.findViewById<ViewGroup>(android.R.id.content).apply {
visibility = ViewGroup.INVISIBLE

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
@@ -30,6 +31,7 @@ import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.common.util.toSerialized
import me.eternal.purrfectsnap.core.ModContext
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executors
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -40,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()
}
@@ -66,7 +163,7 @@ class BridgeClient(
}
suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? {
if (this::service.isInitialized && service.asBinder().pingBinder()) {
if (isServiceAlive()) {
return true
}
@@ -74,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) {
@@ -92,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)
@@ -124,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
}
}
}
@@ -181,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
@@ -200,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(
@@ -218,78 +330,116 @@ 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 {
service.passGroupsAndFriends(
groups.mapNotNull { it.toSerialized() },
friends.mapNotNull { it.toSerialized() }
)
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 calculateParts(values: List<String>): List<List<String>> {
if (values.isEmpty()) return listOf(emptyList())
val result = mutableListOf<List<String>>()
var currentChunk = mutableListOf<String>()
var currentSize = 0
values.forEach { value ->
val valueSize = value.toByteArray(Charsets.UTF_8).size + 32
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
result += currentChunk.toList()
currentChunk = mutableListOf()
currentSize = 0
}
currentChunk.add(value)
currentSize += valueSize
}
if (currentChunk.isNotEmpty()) {
result += currentChunk.toList()
}
return result
}
val groupParts = calculateParts(serializedGroups)
val friendParts = calculateParts(serializedFriends)
val totalParts = maxOf(groupParts.size, friendParts.size)
context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends")
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(),
@@ -114,7 +116,10 @@ class FeatureManager(
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
LocalPinnedMessages(),
BlockCalls(),
CallMetadataNotifier(),
ConversationSoundEffects(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),
@@ -163,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

@@ -15,193 +15,358 @@ data class DeviceInfo(
val host: String
)
data class DeviceBuildProfile(
val androidRelease: String,
val display: String,
val buildId: String,
val incremental: String,
val host: String,
val bootloader: String? = null
)
data class DeviceCapabilityProfile(
val supportedAbis: List<String>,
val supported32BitAbis: List<String>,
val supported64BitAbis: List<String>,
val phoneCount: Int,
val isHearingAidCompatibilitySupported: Boolean,
val isTtySupported: Boolean,
val isWorldPhone: Boolean,
val isSmsCapable: Boolean,
val isVoiceCapable: Boolean,
val phoneType: Int,
val phoneTypeString: String
)
data class DeviceTemplate(
val marketingName: String,
val deviceInfo: DeviceInfo,
val builds: List<DeviceBuildProfile>,
val capabilities: DeviceCapabilityProfile
)
object DeviceSpoofer {
private val defaultCapabilities = DeviceCapabilityProfile(
supportedAbis = listOf("arm64-v8a", "armeabi-v7a", "armeabi"),
supported32BitAbis = listOf("armeabi-v7a", "armeabi"),
supported64BitAbis = listOf("arm64-v8a"),
phoneCount = 2,
isHearingAidCompatibilitySupported = true,
isTtySupported = false,
isWorldPhone = true,
isSmsCapable = true,
isVoiceCapable = true,
phoneType = 1,
phoneTypeString = "PHONE_TYPE_GSM"
)
private val singleSimCapabilities = defaultCapabilities.copy(phoneCount = 1)
private val devices = mapOf(
"Pixel 8 Pro" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 8 Pro",
brand = "google",
device = "husky",
product = "husky",
hardware = "husky",
board = "husky",
bootloader = "husky-1.0-11003666",
display = "UQ1A.231205.015",
host = "abfarm-release-rbe-64-00163"
"Pixel 8 Pro" to DeviceTemplate(
marketingName = "Pixel 8 Pro",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 8 Pro",
brand = "google",
device = "husky",
product = "husky",
hardware = "husky",
board = "husky",
bootloader = "husky-1.0-11003666",
display = "UQ1A.231205.015",
host = "abfarm-release-rbe-64-00163"
),
builds = listOf(
DeviceBuildProfile("14", "UQ1A.231205.015", "UQ1A.231205.015", "11003666", "abfarm-release-rbe-64-00163", "husky-1.0-11003666"),
DeviceBuildProfile("15", "AP4A.250205.002", "AP4A.250205.002", "12141234", "abfarm-release-rbe-64-00171", "husky-1.0-12141234")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 9 Pro XL" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 9 Pro XL",
brand = "google",
device = "komodo",
product = "komodo",
hardware = "komodo",
board = "komodo",
bootloader = "komodo-1.0-12110753",
display = "AP3A.241105.008",
host = "abfarm-release-rbe-64-00163"
"Pixel 9 Pro XL" to DeviceTemplate(
marketingName = "Pixel 9 Pro XL",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 9 Pro XL",
brand = "google",
device = "komodo",
product = "komodo",
hardware = "komodo",
board = "komodo",
bootloader = "komodo-1.0-12110753",
display = "AP3A.241105.008",
host = "abfarm-release-rbe-64-00163"
),
builds = listOf(
DeviceBuildProfile("14", "AP3A.241105.008", "AP3A.241105.008", "12110753", "abfarm-release-rbe-64-00163", "komodo-1.0-12110753"),
DeviceBuildProfile("15", "BP1A.250105.006", "BP1A.250105.006", "13120567", "abfarm-release-rbe-65-00088", "komodo-1.0-13120567")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10",
brand = "google",
device = "frankel",
product = "frankel",
hardware = "tensor_g5",
board = "frankel",
bootloader = "frankel-1.0-12345678",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00200"
"Pixel 10" to DeviceTemplate(
marketingName = "Pixel 10",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10",
brand = "google",
device = "frankel",
product = "frankel",
hardware = "tensor_g5",
board = "frankel",
bootloader = "frankel-1.0-12345678",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00200"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345678", "abfarm-release-rbe-65-00200", "frankel-1.0-12345678")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro",
brand = "google",
device = "blazer",
product = "blazer",
hardware = "tensor_g5",
board = "blazer",
bootloader = "blazer-1.0-12345679",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00201"
"Pixel 10 Pro" to DeviceTemplate(
marketingName = "Pixel 10 Pro",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro",
brand = "google",
device = "blazer",
product = "blazer",
hardware = "tensor_g5",
board = "blazer",
bootloader = "blazer-1.0-12345679",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00201"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345679", "abfarm-release-rbe-65-00201", "blazer-1.0-12345679")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro XL" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro XL",
brand = "google",
device = "mustang",
product = "mustang",
hardware = "tensor_g5",
board = "mustang",
bootloader = "mustang-1.0-12345680",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00202"
"Pixel 10 Pro XL" to DeviceTemplate(
marketingName = "Pixel 10 Pro XL",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro XL",
brand = "google",
device = "mustang",
product = "mustang",
hardware = "tensor_g5",
board = "mustang",
bootloader = "mustang-1.0-12345680",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00202"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345680", "abfarm-release-rbe-65-00202", "mustang-1.0-12345680")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro Fold" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro Fold",
brand = "google",
device = "rango",
product = "rango",
hardware = "tensor_g5",
board = "rango",
bootloader = "rango-1.0-12345681",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00203"
"Pixel 10 Pro Fold" to DeviceTemplate(
marketingName = "Pixel 10 Pro Fold",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro Fold",
brand = "google",
device = "rango",
product = "rango",
hardware = "tensor_g5",
board = "rango",
bootloader = "rango-1.0-12345681",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00203"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345681", "abfarm-release-rbe-65-00203", "rango-1.0-12345681")
),
capabilities = singleSimCapabilities.copy(isWorldPhone = false)
),
"Galaxy S23 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S918B",
brand = "samsung",
device = "dm3q",
product = "dm3qxx",
hardware = "qcom",
board = "kalama",
bootloader = "S918BXXU3BWJM",
display = "UP1A.231005.007.S918BXXU3BWJM",
host = "21DH7R2P"
"Galaxy S23 Ultra" to DeviceTemplate(
marketingName = "Galaxy S23 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S918B",
brand = "samsung",
device = "dm3q",
product = "dm3qxx",
hardware = "qcom",
board = "kalama",
bootloader = "S918BXXU3BWJM",
display = "UP1A.231005.007.S918BXXU3BWJM",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("14", "UP1A.231005.007.S918BXXU3BWJM", "UP1A.231005.007", "S918BXXU3BWJM", "21DH7R2P", "S918BXXU3BWJM"),
DeviceBuildProfile("15", "AP3A.240905.015.S918BXXU4CXA1", "AP3A.240905.015", "S918BXXU4CXA1", "21DH7R2P", "S918BXXU4CXA1")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Galaxy S24 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S928B",
brand = "samsung",
device = "e9q",
product = "e9qxx",
hardware = "qcom",
board = "pineapple",
bootloader = "S928BXXU1AXB5",
display = "UP1A.231005.007.S928BXXU1AXB5",
host = "21DH7R2P"
"Galaxy S24 Ultra" to DeviceTemplate(
marketingName = "Galaxy S24 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S928B",
brand = "samsung",
device = "e9q",
product = "e9qxx",
hardware = "qcom",
board = "pineapple",
bootloader = "S928BXXU1AXB5",
display = "UP1A.231005.007.S928BXXU1AXB5",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("14", "UP1A.231005.007.S928BXXU1AXB5", "UP1A.231005.007", "S928BXXU1AXB5", "21DH7R2P", "S928BXXU1AXB5"),
DeviceBuildProfile("15", "AP3A.240905.015.S928BXXU2BYD6", "AP3A.240905.015", "S928BXXU2BYD6", "21DH7R2P", "S928BXXU2BYD6")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Galaxy S25 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S938B",
brand = "samsung",
device = "e3q",
product = "e3qxx",
hardware = "qcom",
board = "s5e9945",
bootloader = "S938BXXU1AXL2",
display = "UP1A.231005.007.S938BXXU1AXL2",
host = "21DH7R2P"
"Galaxy S25 Ultra" to DeviceTemplate(
marketingName = "Galaxy S25 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S938B",
brand = "samsung",
device = "e3q",
product = "e3qxx",
hardware = "qcom",
board = "s5e9945",
bootloader = "S938BXXU1AXL2",
display = "UP1A.231005.007.S938BXXU1AXL2",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("15", "AP3A.241005.019.S938BXXU1AXL2", "AP3A.241005.019", "S938BXXU1AXL2", "21DH7R2P", "S938BXXU1AXL2")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OnePlus 15" to DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2651",
brand = "OnePlus",
device = "OP5929L1",
product = "OP5929L1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2651_15.0.0.503(EX01)",
host = "ubuntu-build"
"OnePlus 15" to DeviceTemplate(
marketingName = "OnePlus 15",
deviceInfo = DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2651",
brand = "OnePlus",
device = "OP5929L1",
product = "OP5929L1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2651_15.0.0.503(EX01)",
host = "ubuntu-build"
),
builds = listOf(
DeviceBuildProfile("15", "CPH2651_15.0.0.503(EX01)", "CPH2651_15.0.0.503(EX01)", "15.0.0.503", "ubuntu-build"),
DeviceBuildProfile("15", "CPH2651_15.0.0.601(EX01)", "CPH2651_15.0.0.601(EX01)", "15.0.0.601", "ubuntu-build")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OnePlus Open" to DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2551",
brand = "OnePlus",
device = "OP594DL1",
product = "OP594DL1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2551_14.0.0.600(EX01)",
host = "ubuntu-build"
"OnePlus Open" to DeviceTemplate(
marketingName = "OnePlus Open",
deviceInfo = DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2551",
brand = "OnePlus",
device = "OP594DL1",
product = "OP594DL1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2551_14.0.0.600(EX01)",
host = "ubuntu-build"
),
builds = listOf(
DeviceBuildProfile("14", "CPH2551_14.0.0.600(EX01)", "CPH2551_14.0.0.600(EX01)", "14.0.0.600", "ubuntu-build"),
DeviceBuildProfile("15", "CPH2551_15.0.0.305(EX01)", "CPH2551_15.0.0.305(EX01)", "15.0.0.305", "ubuntu-build")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Xiaomi 15 Ultra" to DeviceInfo(
manufacturer = "Xiaomi",
model = "25010PN30G",
brand = "Xiaomi",
device = "xuanyuan",
product = "xuanyuan_global",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "VK.15.0.3.0.VNGMIXM",
host = "c3-miui-ota-bd164.bj"
"Xiaomi 15 Ultra" to DeviceTemplate(
marketingName = "Xiaomi 15 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Xiaomi",
model = "25010PN30G",
brand = "Xiaomi",
device = "xuanyuan",
product = "xuanyuan_global",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "VK.15.0.3.0.VNGMIXM",
host = "c3-miui-ota-bd164.bj"
),
builds = listOf(
DeviceBuildProfile("15", "VK.15.0.3.0.VNGMIXM", "VK.15.0.3.0.VNGMIXM", "15.0.3.0", "c3-miui-ota-bd164.bj"),
DeviceBuildProfile("15", "VK.15.0.6.0.VNGMIXM", "VK.15.0.6.0.VNGMIXM", "15.0.6.0", "c3-miui-ota-bd164.bj")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OPPO Find X9 Pro" to DeviceInfo(
manufacturer = "OPPO",
model = "PHY110",
brand = "OPPO",
device = "OP595DL1",
product = "OP595DL1_EEA",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PHY110_15.0.0.100(EX01)",
host = "ubuntu-build-server"
"OPPO Find X9 Pro" to DeviceTemplate(
marketingName = "OPPO Find X9 Pro",
deviceInfo = DeviceInfo(
manufacturer = "OPPO",
model = "PHY110",
brand = "OPPO",
device = "OP595DL1",
product = "OP595DL1_EEA",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PHY110_15.0.0.100(EX01)",
host = "ubuntu-build-server"
),
builds = listOf(
DeviceBuildProfile("15", "PHY110_15.0.0.100(EX01)", "PHY110_15.0.0.100(EX01)", "15.0.0.100", "ubuntu-build-server"),
DeviceBuildProfile("15", "PHY110_15.0.0.202(EX01)", "PHY110_15.0.0.202(EX01)", "15.0.0.202", "ubuntu-build-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"vivo X100 Pro" to DeviceInfo(
manufacturer = "vivo",
model = "V2309A",
brand = "vivo",
device = "V2309A",
product = "PD2309",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "OP557L.PD2309.14.0.0.100",
host = "compiler-server"
"vivo X100 Pro" to DeviceTemplate(
marketingName = "vivo X100 Pro",
deviceInfo = DeviceInfo(
manufacturer = "vivo",
model = "V2309A",
brand = "vivo",
device = "V2309A",
product = "PD2309",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PD2309F_EX_A_14.0.13.2.W30",
host = "compiler-server"
),
builds = listOf(
DeviceBuildProfile("14", "PD2309F_EX_A_14.0.13.2.W30", "PD2309F_EX_A_14.0.13.2.W30", "14.0.13.2", "compiler-server"),
DeviceBuildProfile("15", "PD2309F_EX_A_15.0.8.5.W30", "PD2309F_EX_A_15.0.8.5.W30", "15.0.8.5", "compiler-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"realme GT 6" to DeviceInfo(
manufacturer = "realme",
model = "RMX3851",
brand = "realme",
device = "RMX3851",
product = "RMX3851_11_A.13",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "RMX3851_14.0.0.700(EX01)",
host = "ubuntu-server"
"realme GT 6" to DeviceTemplate(
marketingName = "realme GT 6",
deviceInfo = DeviceInfo(
manufacturer = "realme",
model = "RMX3851",
brand = "realme",
device = "RMX3851",
product = "RMX3851_11_A.13",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "RMX3851_14.0.0.700(EX01)",
host = "ubuntu-server"
),
builds = listOf(
DeviceBuildProfile("14", "RMX3851_14.0.0.700(EX01)", "RMX3851_14.0.0.700(EX01)", "14.0.0.700", "ubuntu-server"),
DeviceBuildProfile("15", "RMX3851_15.0.0.205(EX01)", "RMX3851_15.0.0.205(EX01)", "15.0.0.205", "ubuntu-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
)
)
fun getAvailableDevices(): List<String> = devices.keys.toList()
fun getDeviceInfo(modelName: String): DeviceInfo? {
return devices[modelName]?.deviceInfo
}
fun getDeviceTemplate(modelName: String): DeviceTemplate? {
return devices[modelName]
}
@@ -211,6 +376,10 @@ object DeviceSpoofer {
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys"
}
fun generateFingerprint(deviceInfo: DeviceInfo, buildProfile: DeviceBuildProfile): String {
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:${buildProfile.androidRelease}/${buildProfile.buildId}/${buildProfile.incremental}:user/release-keys"
}
fun generateAndroidId(): String {
val random = SecureRandom()
val bytes = ByteArray(8)

View File

@@ -70,34 +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
@@ -111,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
@@ -152,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(
@@ -302,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> {
@@ -424,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

@@ -0,0 +1,578 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.content.Context
import me.eternal.purrfectsnap.common.logger.AbstractLogger
import org.json.JSONArray
import org.json.JSONObject
import java.net.InetAddress
import java.security.SecureRandom
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
data class RandomizedDeviceProfile(
val schemaVersion: Int,
val profileId: String,
val deviceInfo: DeviceInfo,
val androidRelease: String,
val buildIncremental: String,
val buildDisplayId: String,
val buildFingerprint: String,
val buildHost: String,
val buildTime: Long,
val supportedAbis: List<String>,
val supported32BitAbis: List<String>,
val supported64BitAbis: List<String>,
val androidId: String,
val gsfId: String,
val advertisingId: String,
val wifiMacAddress: String,
val bluetoothMacAddress: String,
val ipAddress: String,
val wifiSsid: String,
val wifiRssi: Int,
val localeTag: String,
val countryIso: String,
val timeZoneId: String,
val timeZoneDisplayName: String,
val networkType: Int,
val networkOperator: String,
val networkOperatorName: String,
val networkCountryIso: String,
val simCountryIso: String,
val simOperator: String,
val simOperatorName: String,
val simState: Int,
val hasIccCard: Boolean,
val phoneCount: Int,
val isHearingAidCompatibilitySupported: Boolean,
val isTtySupported: Boolean,
val isWorldPhone: Boolean,
val isNetworkRoaming: Boolean,
val isSmsCapable: Boolean,
val isVoiceCapable: Boolean,
val phoneType: Int,
val phoneTypeString: String,
val mmsUaProfUrl: String,
val mmsUserAgent: String,
val dnsServers: List<String>,
val dnsSearchDomains: String,
val privateDnsServerName: String,
val privateDnsActive: Boolean,
val hasCaptivePortal: Boolean,
val secureStringSettings: Map<String, String>,
val secureIntSettings: Map<String, Int>,
val systemStringSettings: Map<String, String>,
val systemIntSettings: Map<String, Int>,
val globalStringSettings: Map<String, String>,
val globalIntSettings: Map<String, Int>
) {
fun locale(): Locale = Locale.forLanguageTag(localeTag)
fun timeZone(): TimeZone = TimeZone.getTimeZone(timeZoneId)
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", schemaVersion)
put("profileId", profileId)
put("deviceInfo", JSONObject().apply {
put("manufacturer", deviceInfo.manufacturer)
put("model", deviceInfo.model)
put("brand", deviceInfo.brand)
put("device", deviceInfo.device)
put("product", deviceInfo.product)
put("hardware", deviceInfo.hardware)
put("board", deviceInfo.board)
put("bootloader", deviceInfo.bootloader)
put("display", deviceInfo.display)
put("host", deviceInfo.host)
})
put("androidRelease", androidRelease)
put("buildIncremental", buildIncremental)
put("buildDisplayId", buildDisplayId)
put("buildFingerprint", buildFingerprint)
put("buildHost", buildHost)
put("buildTime", buildTime)
put("supportedAbis", JSONArray(supportedAbis))
put("supported32BitAbis", JSONArray(supported32BitAbis))
put("supported64BitAbis", JSONArray(supported64BitAbis))
put("androidId", androidId)
put("gsfId", gsfId)
put("advertisingId", advertisingId)
put("wifiMacAddress", wifiMacAddress)
put("bluetoothMacAddress", bluetoothMacAddress)
put("ipAddress", ipAddress)
put("wifiSsid", wifiSsid)
put("wifiRssi", wifiRssi)
put("localeTag", localeTag)
put("countryIso", countryIso)
put("timeZoneId", timeZoneId)
put("timeZoneDisplayName", timeZoneDisplayName)
put("networkType", networkType)
put("networkOperator", networkOperator)
put("networkOperatorName", networkOperatorName)
put("networkCountryIso", networkCountryIso)
put("simCountryIso", simCountryIso)
put("simOperator", simOperator)
put("simOperatorName", simOperatorName)
put("simState", simState)
put("hasIccCard", hasIccCard)
put("phoneCount", phoneCount)
put("isHearingAidCompatibilitySupported", isHearingAidCompatibilitySupported)
put("isTtySupported", isTtySupported)
put("isWorldPhone", isWorldPhone)
put("isNetworkRoaming", isNetworkRoaming)
put("isSmsCapable", isSmsCapable)
put("isVoiceCapable", isVoiceCapable)
put("phoneType", phoneType)
put("phoneTypeString", phoneTypeString)
put("mmsUaProfUrl", mmsUaProfUrl)
put("mmsUserAgent", mmsUserAgent)
put("dnsServers", JSONArray(dnsServers))
put("dnsSearchDomains", dnsSearchDomains)
put("privateDnsServerName", privateDnsServerName)
put("privateDnsActive", privateDnsActive)
put("hasCaptivePortal", hasCaptivePortal)
put("secureStringSettings", JSONObject(secureStringSettings))
put("secureIntSettings", JSONObject(secureIntSettings))
put("systemStringSettings", JSONObject(systemStringSettings))
put("systemIntSettings", JSONObject(systemIntSettings))
put("globalStringSettings", JSONObject(globalStringSettings))
put("globalIntSettings", JSONObject(globalIntSettings))
}
companion object {
fun fromJson(json: String): RandomizedDeviceProfile {
val root = JSONObject(json)
val deviceInfoJson = root.getJSONObject("deviceInfo")
return RandomizedDeviceProfile(
schemaVersion = root.getInt("schemaVersion"),
profileId = root.getString("profileId"),
deviceInfo = DeviceInfo(
manufacturer = deviceInfoJson.getString("manufacturer"),
model = deviceInfoJson.getString("model"),
brand = deviceInfoJson.getString("brand"),
device = deviceInfoJson.getString("device"),
product = deviceInfoJson.getString("product"),
hardware = deviceInfoJson.getString("hardware"),
board = deviceInfoJson.getString("board"),
bootloader = deviceInfoJson.getString("bootloader"),
display = deviceInfoJson.getString("display"),
host = deviceInfoJson.getString("host")
),
androidRelease = root.getString("androidRelease"),
buildIncremental = root.getString("buildIncremental"),
buildDisplayId = root.getString("buildDisplayId"),
buildFingerprint = root.getString("buildFingerprint"),
buildHost = root.getString("buildHost"),
buildTime = root.getLong("buildTime"),
supportedAbis = jsonArrayToStringList(root.getJSONArray("supportedAbis")),
supported32BitAbis = jsonArrayToStringList(root.getJSONArray("supported32BitAbis")),
supported64BitAbis = jsonArrayToStringList(root.getJSONArray("supported64BitAbis")),
androidId = root.getString("androidId"),
gsfId = root.getString("gsfId"),
advertisingId = root.getString("advertisingId"),
wifiMacAddress = root.getString("wifiMacAddress"),
bluetoothMacAddress = root.getString("bluetoothMacAddress"),
ipAddress = root.optString("ipAddress").ifBlank { defaultFallbackIpAddress() },
wifiSsid = root.getString("wifiSsid"),
wifiRssi = root.getInt("wifiRssi"),
localeTag = root.getString("localeTag"),
countryIso = root.getString("countryIso"),
timeZoneId = root.getString("timeZoneId"),
timeZoneDisplayName = root.getString("timeZoneDisplayName"),
networkType = root.getInt("networkType"),
networkOperator = root.getString("networkOperator"),
networkOperatorName = root.getString("networkOperatorName"),
networkCountryIso = root.getString("networkCountryIso"),
simCountryIso = root.getString("simCountryIso"),
simOperator = root.getString("simOperator"),
simOperatorName = root.getString("simOperatorName"),
simState = root.getInt("simState"),
hasIccCard = root.getBoolean("hasIccCard"),
phoneCount = root.getInt("phoneCount"),
isHearingAidCompatibilitySupported = root.getBoolean("isHearingAidCompatibilitySupported"),
isTtySupported = root.getBoolean("isTtySupported"),
isWorldPhone = root.getBoolean("isWorldPhone"),
isNetworkRoaming = root.getBoolean("isNetworkRoaming"),
isSmsCapable = root.getBoolean("isSmsCapable"),
isVoiceCapable = root.getBoolean("isVoiceCapable"),
phoneType = root.getInt("phoneType"),
phoneTypeString = root.getString("phoneTypeString"),
mmsUaProfUrl = root.getString("mmsUaProfUrl"),
mmsUserAgent = root.getString("mmsUserAgent"),
dnsServers = jsonArrayToStringList(root.getJSONArray("dnsServers")),
dnsSearchDomains = root.getString("dnsSearchDomains"),
privateDnsServerName = root.getString("privateDnsServerName"),
privateDnsActive = root.getBoolean("privateDnsActive"),
hasCaptivePortal = root.getBoolean("hasCaptivePortal"),
secureStringSettings = jsonObjectToStringMap(root.getJSONObject("secureStringSettings")),
secureIntSettings = jsonObjectToIntMap(root.getJSONObject("secureIntSettings")),
systemStringSettings = jsonObjectToStringMap(root.getJSONObject("systemStringSettings")),
systemIntSettings = jsonObjectToIntMap(root.getJSONObject("systemIntSettings")),
globalStringSettings = jsonObjectToStringMap(root.getJSONObject("globalStringSettings")),
globalIntSettings = jsonObjectToIntMap(root.getJSONObject("globalIntSettings"))
)
}
private fun jsonArrayToStringList(array: JSONArray): List<String> = buildList {
for (index in 0 until array.length()) {
add(array.getString(index))
}
}
private fun jsonObjectToStringMap(jsonObject: JSONObject): Map<String, String> {
return jsonObject.keys().asSequence().associateWith { jsonObject.getString(it) }
}
private fun jsonObjectToIntMap(jsonObject: JSONObject): Map<String, Int> {
return jsonObject.keys().asSequence().associateWith { jsonObject.getInt(it) }
}
private fun defaultFallbackIpAddress(): String = "23.42.18.101"
}
}
object RandomizedDeviceProfileStore {
private const val prefsName = "purrfectsnap_spoof"
private const val schemaVersion = 5
private const val profileKey = "randomized_device_profile"
private val random = SecureRandom()
fun getOrCreate(context: Context, logger: AbstractLogger, generationToken: String?): RandomizedDeviceProfile {
val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val requestedToken = generationToken.orEmpty()
prefs.getString(profileKey, null)?.let { raw ->
runCatching {
RandomizedDeviceProfile.fromJson(raw)
}.onSuccess { profile ->
val storedToken = prefs.getString("${profileKey}_token", "") ?: ""
if (profile.schemaVersion == schemaVersion && storedToken == requestedToken) {
logger.info("Loaded randomized device profile ${profile.profileId} (${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model})")
return profile
}
}.onFailure {
logger.warn("Failed to parse saved randomized device profile, regenerating: ${it.message}")
}
}
val previousProfile = prefs.getString(profileKey, null)?.let { raw ->
runCatching { RandomizedDeviceProfile.fromJson(raw) }.getOrNull()
}
val profile = generateProfile(previousProfile)
prefs.edit()
.putString(profileKey, profile.toJson().toString())
.putString("${profileKey}_token", requestedToken)
.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()
logger.info(
"Generated randomized device profile ${profile.profileId}: " +
"${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model}, " +
"androidId=${profile.androidId}, ip=${profile.ipAddress}, locale=${profile.localeTag}, tz=${profile.timeZoneId}, " +
"carrier=${profile.simOperatorName}"
)
return profile
}
private fun generateProfile(previousProfile: RandomizedDeviceProfile?): RandomizedDeviceProfile {
val eligibleDevices = DeviceSpoofer.getAvailableDevices().filter {
DeviceSpoofer.getDeviceTemplate(it)?.capabilities?.let { capabilities ->
capabilities.isSmsCapable && capabilities.isVoiceCapable && capabilities.isWorldPhone
} == true
}.ifEmpty { DeviceSpoofer.getAvailableDevices() }
val deviceCandidates = eligibleDevices.filterNot {
previousProfile != null &&
DeviceSpoofer.getDeviceTemplate(it)?.deviceInfo?.model == previousProfile.deviceInfo.model
}.ifEmpty { eligibleDevices }
val deviceName = pick(deviceCandidates)
val deviceTemplate = DeviceSpoofer.getDeviceTemplate(deviceName) ?: error("Missing device template for $deviceName")
val regionCandidates = regionProfiles.filterNot {
previousProfile != null &&
it.localeTag == previousProfile.localeTag &&
it.simOperatorName == previousProfile.simOperatorName
}.ifEmpty { regionProfiles }
val region = pick(regionCandidates)
val buildCandidates = deviceTemplate.builds.filter { it.androidRelease in region.androidReleaseOptions }
.ifEmpty { deviceTemplate.builds }
val buildProfile = pick(buildCandidates)
val deviceInfo = deviceTemplate.deviceInfo.copy(
display = buildProfile.display,
host = buildProfile.host,
bootloader = buildProfile.bootloader ?: deviceTemplate.deviceInfo.bootloader
)
val androidRelease = buildProfile.androidRelease
val buildIncremental = buildProfile.incremental
val buildDisplayId = buildProfile.display
val buildFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildProfile)
val buildHost = buildProfile.host
val buildTime = System.currentTimeMillis() - randomLong(45L, 220L) * 24L * 60L * 60L * 1000L
val wifiMac = randomMacAddress()
val bluetoothMac = randomMacAddress()
val ipAddress = region.randomPublicIpAddress()
val locale = Locale.forLanguageTag(region.localeTag)
val timeZone = TimeZone.getTimeZone(region.timeZoneId)
val capabilities = deviceTemplate.capabilities
val secureStringSettings = mapOf(
"accessibility_enabled" to "0",
"speak_password" to "0",
"allowed_geolocation_origins" to "",
"install_non_market_apps" to "0",
"device_provisioned" to "1",
"enabled_notification_listeners" to ""
)
val secureIntSettings = mapOf(
"input_method_selector_visibility" to 0,
"accessibility_display_inversion_enabled" to 0,
"enabled_accessibility_services" to 0,
"skip_first_use_hints" to 0,
"tts_default_synth" to 0
)
val systemStringSettings = mapOf(
"dtmf_tone_type" to "normal",
"mode_ringer_streams_affected" to "166",
"mute_streams_affected" to "46",
"show_password" to "1",
"user_rotation" to "0"
)
val systemIntSettings = mapOf(
"bluetooth_discoverability" to 0,
"bluetooth_discoverability_timeout" to 120,
"date_format" to 0,
"end_button_behavior" to 2
)
val globalStringSettings = mapOf(
"adb_enabled" to "0",
"auto_time" to "1",
"auto_time_zone" to "1",
"development_settings_enabled" to "0",
"stay_on_while_plugged_in" to "0",
"usb_mass_storage_enabled" to "0",
"wifi_networks_available_notification_on" to "0",
"data_roaming" to "1"
)
val globalIntSettings = mapOf(
"always_finish_activities" to 0,
"animator_duration_scale" to 1,
"http_proxy" to 0,
"network_preference" to 1,
"transition_animation_scale" to 1,
"use_google_mail" to 1,
"wait_for_debugger" to 0
)
return RandomizedDeviceProfile(
schemaVersion = schemaVersion,
profileId = UUID.randomUUID().toString().substring(0, 8),
deviceInfo = deviceInfo,
androidRelease = androidRelease,
buildIncremental = buildIncremental,
buildDisplayId = buildDisplayId,
buildFingerprint = buildFingerprint,
buildHost = buildHost,
buildTime = buildTime,
supportedAbis = capabilities.supportedAbis,
supported32BitAbis = capabilities.supported32BitAbis,
supported64BitAbis = capabilities.supported64BitAbis,
androidId = randomHex(16),
gsfId = randomHex(16),
advertisingId = UUID.randomUUID().toString(),
wifiMacAddress = wifiMac,
bluetoothMacAddress = bluetoothMac,
ipAddress = ipAddress,
wifiSsid = region.randomWifiSsid(),
wifiRssi = randomInt(-72, -36),
localeTag = locale.toLanguageTag(),
countryIso = region.countryIso,
timeZoneId = region.timeZoneId,
timeZoneDisplayName = timeZone.getDisplayName(false, TimeZone.SHORT, locale),
networkType = 13,
networkOperator = region.networkOperator,
networkOperatorName = region.networkOperatorName,
networkCountryIso = region.countryIso.lowercase(Locale.US),
simCountryIso = region.countryIso.lowercase(Locale.US),
simOperator = region.simOperator,
simOperatorName = region.simOperatorName,
simState = 5,
hasIccCard = true,
phoneCount = capabilities.phoneCount,
isHearingAidCompatibilitySupported = capabilities.isHearingAidCompatibilitySupported,
isTtySupported = capabilities.isTtySupported,
isWorldPhone = capabilities.isWorldPhone,
isNetworkRoaming = false,
isSmsCapable = capabilities.isSmsCapable,
isVoiceCapable = capabilities.isVoiceCapable,
phoneType = capabilities.phoneType,
phoneTypeString = capabilities.phoneTypeString,
mmsUaProfUrl = "",
mmsUserAgent = region.mmsUserAgent(deviceTemplate.marketingName, androidRelease),
dnsServers = region.dnsServers.sortedBy { random.nextInt() }.take(2),
dnsSearchDomains = region.dnsSearchDomains,
privateDnsServerName = region.privateDnsServerName,
privateDnsActive = true,
hasCaptivePortal = false,
secureStringSettings = secureStringSettings,
secureIntSettings = secureIntSettings,
systemStringSettings = systemStringSettings,
systemIntSettings = systemIntSettings,
globalStringSettings = globalStringSettings,
globalIntSettings = globalIntSettings
)
}
private fun randomHex(length: Int): String {
val chars = CharArray(length)
val alphabet = "0123456789abcdef"
for (index in chars.indices) {
chars[index] = alphabet[random.nextInt(alphabet.length)]
}
return String(chars)
}
private fun randomDigits(length: Int): String {
val chars = CharArray(length)
for (index in chars.indices) {
chars[index] = ('0'.code + random.nextInt(10)).toChar()
}
return String(chars)
}
private fun randomMacAddress(): String {
val bytes = ByteArray(6)
random.nextBytes(bytes)
bytes[0] = (bytes[0].toInt() and 0xFE or 0x02).toByte()
return bytes.joinToString(":") { "%02x".format(it.toInt() and 0xFF) }
}
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
generateSequence { randomInt(1, 224) }
.first { candidate ->
candidate != 10 &&
candidate != 127 &&
candidate != 169 &&
candidate != 172 &&
candidate != 192
}
}
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
var bits: Long
var candidate: Long
do {
bits = random.nextLong() ushr 1
candidate = bits % bound
} while (bits - candidate + (bound - 1) < 0L)
return minInclusive + candidate
}
private fun <T> pick(values: List<T>): T = values[random.nextInt(values.size)]
private data class RegionProfile(
val localeTag: String,
val countryIso: String,
val timeZoneId: String,
val networkOperator: String,
val networkOperatorName: String,
val simOperator: String,
val simOperatorName: String,
val dnsServers: List<String>,
val dnsSearchDomains: String,
val privateDnsServerName: String,
val timeFormat: String,
val androidReleaseOptions: List<String>,
val wifiPrefixes: List<String>,
val ipPrefixes: List<Int>
) {
fun randomWifiSsid(): String = "${pick(wifiPrefixes)}-${randomDigits(4)}"
fun randomPublicIpAddress(): String = randomPublicIpv4(ipPrefixes)
fun mmsUserAgent(model: String, androidRelease: String): String {
return "$model/$androidRelease"
}
}
private val regionProfiles = listOf(
RegionProfile(
localeTag = "en-US",
countryIso = "US",
timeZoneId = "America/New_York",
networkOperator = "310260",
networkOperatorName = "T-Mobile",
simOperator = "310260",
simOperatorName = "T-Mobile",
dnsServers = listOf("8.8.8.8", "8.8.4.4", "1.1.1.1"),
dnsSearchDomains = "hsd1.ny.comcast.net",
privateDnsServerName = "dns.google",
timeFormat = "12",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("TP-Link", "NETGEAR", "XFINITY", "HomeWiFi"),
ipPrefixes = listOf(23, 24, 45, 47, 66, 67, 68, 69, 72, 73, 98, 104, 107, 108, 162, 184, 198, 199)
),
RegionProfile(
localeTag = "en-GB",
countryIso = "GB",
timeZoneId = "Europe/London",
networkOperator = "23430",
networkOperatorName = "EE",
simOperator = "23430",
simOperatorName = "EE",
dnsServers = listOf("1.1.1.1", "1.0.0.1", "8.8.8.8"),
dnsSearchDomains = "bb.sky.com",
privateDnsServerName = "one.one.one.one",
timeFormat = "24",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("Sky", "BT-Hub", "VirginMedia", "Linksys"),
ipPrefixes = listOf(51, 62, 77, 81, 86, 87, 88, 89, 90, 91, 92, 109, 141, 176, 185, 188)
),
RegionProfile(
localeTag = "de-DE",
countryIso = "DE",
timeZoneId = "Europe/Berlin",
networkOperator = "26202",
networkOperatorName = "Vodafone DE",
simOperator = "26202",
simOperatorName = "Vodafone DE",
dnsServers = listOf("9.9.9.9", "149.112.112.112", "1.1.1.1"),
dnsSearchDomains = "fritz.box",
privateDnsServerName = "dns.quad9.net",
timeFormat = "24",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("FRITZBox", "Vodafone", "Telekom", "WLAN"),
ipPrefixes = listOf(2, 5, 31, 37, 46, 79, 80, 84, 85, 87, 91, 93, 95, 109, 134, 176, 178, 188)
),
RegionProfile(
localeTag = "en-IN",
countryIso = "IN",
timeZoneId = "Asia/Kolkata",
networkOperator = "405874",
networkOperatorName = "Jio",
simOperator = "405874",
simOperatorName = "Jio",
dnsServers = listOf("1.1.1.1", "8.8.8.8", "9.9.9.9"),
dnsSearchDomains = "airtelbroadband.in",
privateDnsServerName = "dns.google",
timeFormat = "12",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("JioFiber", "Airtel", "ACTFibernet", "HomeNet"),
ipPrefixes = listOf(14, 27, 42, 49, 59, 61, 101, 103, 106, 117, 122, 125, 157, 182)
)
)
}

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

@@ -0,0 +1,274 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.Feature
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.getObjectFieldOrNull
import java.nio.ByteBuffer
import java.text.DateFormat
import java.util.Date
import java.util.LinkedHashSet
import java.util.Locale
class CallMetadataNotifier : Feature("Call Metadata Notifier") {
private data class CapturedCallMetadata(
var callUuid: String? = null,
var attemptId: String? = null,
var media: String? = null,
var isGroup: String? = null,
var scopeId: String? = null,
var ipv4Address: String? = null,
var startTimestamp: Long? = null,
var endedTimestamp: Long? = null,
val messageTypes: LinkedHashSet<String> = linkedSetOf(),
val rawPayloads: LinkedHashSet<String> = linkedSetOf()
) {
fun reset() {
callUuid = null
attemptId = null
media = null
isGroup = null
scopeId = null
ipv4Address = null
startTimestamp = null
endedTimestamp = null
messageTypes.clear()
rawPayloads.clear()
}
fun hasData(): Boolean {
return callUuid != null ||
attemptId != null ||
media != null ||
isGroup != null ||
scopeId != null ||
ipv4Address != null ||
messageTypes.isNotEmpty() ||
rawPayloads.isNotEmpty()
}
}
private val notificationChannelId = "call_metadata_notifier"
private val metadata = CapturedCallMetadata()
private var wasInCall = false
private val translation by lazy { context.translation.getCategory("call_metadata_notifier") }
private val notificationManager by lazy {
context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(
NotificationChannel(
notificationChannelId,
translation["notification_channel_name"],
NotificationManager.IMPORTANCE_HIGH
)
)
}
}
private fun isAscii(bytes: ByteArray): Boolean {
return bytes.all { it in 0x20..0x7E || it == 0x0A.toByte() || it == 0x0D.toByte() }
}
private fun collectStrings(reader: ProtoReader, depth: Int = 0, output: MutableList<String>) {
if (depth > 5) return
reader.eachBuffer { _, buffer ->
if (buffer.isEmpty()) return@eachBuffer
if (isAscii(buffer)) {
output.add(buffer.toString(Charsets.UTF_8))
}
runCatching { ProtoReader(buffer) }.getOrNull()?.let {
collectStrings(it, depth + 1, output)
}
}
}
private fun parseObjectPayload(payload: String): Map<String, String> {
val regex = Regex("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|true|false|-?\\d+(?:\\.\\d+)?)")
return regex.findAll(payload).associate { match ->
val key = match.groupValues[1]
val rawValue = match.groupValues[2]
key to rawValue.trim('"')
}
}
@Synchronized
private fun updateFromPayload(payload: String) {
if (!payload.startsWith("{") || !payload.endsWith("}")) return
val parsed = parseObjectPayload(payload)
if (parsed.isEmpty()) return
parsed["callUuid"]?.let { newCallUuid ->
if (metadata.callUuid != null && metadata.callUuid != newCallUuid && metadata.hasData()) {
notifyCallEnded("new_call_boundary")
}
metadata.callUuid = newCallUuid
}
parsed["attemptId"]?.let { metadata.attemptId = it }
parsed["media"]?.let { metadata.media = it }
parsed["isGroup"]?.let { metadata.isGroup = it }
parsed["scopeId"]?.let { metadata.scopeId = it }
parsed["ipv4Address"]?.let { metadata.ipv4Address = it }
parsed["messageType"]?.let { metadata.messageTypes.add(it) }
parsed["callAction"]?.let {
metadata.messageTypes.add("callAction:$it")
if (it.equals("START", ignoreCase = true) && metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
wasInCall = true
}
if (it.equals("END", ignoreCase = true) || it.equals("HANGUP", ignoreCase = true)) {
handleCallEnded("call_action:$it")
}
}
metadata.rawPayloads.add(payload)
}
@Synchronized
private fun handleVolatileEvent(reader: ProtoReader) {
val payloads = mutableListOf<String>()
collectStrings(reader, output = payloads)
payloads.forEach { payload ->
updateFromPayload(payload)
val normalized = payload.lowercase(Locale.ROOT)
if ("caller_hangup" in normalized || "\"messagetype\":\"call_end\"" in normalized) {
handleCallEnded("volatile_end")
}
}
}
@Synchronized
private fun handleCallStarted(reason: String) {
if (!wasInCall) {
wasInCall = true
if (metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
}
}
metadata.messageTypes.add("state:$reason")
}
@Synchronized
private fun handleCallEnded(reason: String) {
val shouldNotify = wasInCall || metadata.hasData()
wasInCall = false
if (!shouldNotify) return
notifyCallEnded(reason)
}
@Synchronized
private fun notifyCallEnded(reason: String) {
if (!metadata.hasData()) return
metadata.endedTimestamp = System.currentTimeMillis()
val lines = buildList {
metadata.ipv4Address?.let { add("IP: $it") }
metadata.scopeId?.let { add("Scope ID: $it") }
metadata.callUuid?.let { add("Call UUID: $it") }
metadata.attemptId?.let { add("Attempt ID: $it") }
metadata.media?.let { add("Media: $it") }
metadata.isGroup?.let { add("Group Call: $it") }
if (metadata.startTimestamp != null) {
add("Started: ${DateFormat.getDateTimeInstance().format(Date(metadata.startTimestamp!!))}")
}
if (metadata.endedTimestamp != null) {
add("Ended: ${DateFormat.getDateTimeInstance().format(Date(metadata.endedTimestamp!!))}")
}
if (metadata.messageTypes.isNotEmpty()) {
add("Events: ${metadata.messageTypes.joinToString(", ")}")
}
add("Reason: $reason")
}
val notification = Notification.Builder(context.androidContext, notificationChannelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(translation["notification_title"])
.setContentText(lines.firstOrNull() ?: translation["notification_empty"])
.setStyle(Notification.BigTextStyle().bigText(lines.joinToString("\n")))
.setAutoCancel(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build()
notificationManager.notify((metadata.callUuid ?: metadata.attemptId ?: reason).hashCode(), notification)
metadata.reset()
wasInCall = false
}
override fun init() {
if (!context.config.messaging.callMetadataNotifier.get()) return
runCatching {
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
val duplicate = buffer.duplicate().apply { position(0) }
val bytes = ByteArray(duplicate.limit())
duplicate.get(bytes)
val reader = ProtoReader(bytes)
if (reader.getString(1, 1) != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
handleVolatileEvent(eventData)
}
}
runCatching {
findClass("com.google.firebase.messaging.FirebaseMessagingService")
.methods
.first {
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
it.returnType == Void::class.javaPrimitiveType &&
it.parameterCount == 1 &&
it.parameterTypes[0] == Intent::class.java
}
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
when (intent.getStringExtra("type")?.lowercase(Locale.ROOT)) {
"abandon_audio", "abandon_video" -> handleCallEnded("firebase:${intent.getStringExtra("type")}")
}
}
}
listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
).forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0) ?: return@hook
val inCall = params.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hook
if (inCall) handleCallStarted("talkcore:update_true")
else handleCallEnded("talkcore:update_false")
}
hook("disposeTSCallingSession", HookStage.BEFORE) {
handleCallEnded("talkcore:dispose")
}
}
}
}
listOf(
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
"com.snapchat.talkcore.TSCallingStateUpdateParams"
).forEach { className ->
runCatching {
findClass(className).hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
if (inCall) handleCallStarted("params_ctor:true")
else handleCallEnded("params_ctor:false")
}
}
}
}
}

View File

@@ -0,0 +1,281 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
private val seenIncomingMessageIds = LinkedHashSet<Long>()
private val maxTrackedMessages = 512
private data class BubbleSpec(
val durationMs: Int,
val startFreqHz: Double,
val endFreqHz: Double,
val overtoneFreqHz: Double,
val amplitude: Double
)
private data class BubbleStep(
val spec: BubbleSpec,
val pauseAfterMs: Long = 0L
)
private val iMessageSendBubble = BubbleSpec(
durationMs = 78,
startFreqHz = 1160.0,
endFreqHz = 690.0,
overtoneFreqHz = 1820.0,
amplitude = 0.50
)
private val iMessageReceiveBubble = BubbleSpec(
durationMs = 92,
startFreqHz = 1040.0,
endFreqHz = 640.0,
overtoneFreqHz = 1680.0,
amplitude = 0.46
)
private val whatsappSendBubble = BubbleSpec(
durationMs = 86,
startFreqHz = 860.0,
endFreqHz = 520.0,
overtoneFreqHz = 1410.0,
amplitude = 0.52
)
private val whatsappReceiveBubble = BubbleSpec(
durationMs = 94,
startFreqHz = 920.0,
endFreqHz = 560.0,
overtoneFreqHz = 1520.0,
amplitude = 0.50
)
private val telegramSendPrimary = BubbleSpec(
durationMs = 58,
startFreqHz = 1110.0,
endFreqHz = 820.0,
overtoneFreqHz = 1710.0,
amplitude = 0.42
)
private val telegramSendAccent = BubbleSpec(
durationMs = 34,
startFreqHz = 1360.0,
endFreqHz = 980.0,
overtoneFreqHz = 2060.0,
amplitude = 0.22
)
private val telegramReceivePrimary = BubbleSpec(
durationMs = 72,
startFreqHz = 1080.0,
endFreqHz = 780.0,
overtoneFreqHz = 1680.0,
amplitude = 0.44
)
private val telegramReceiveAccent = BubbleSpec(
durationMs = 42,
startFreqHz = 1280.0,
endFreqHz = 940.0,
overtoneFreqHz = 1940.0,
amplitude = 0.18
)
private val subtleSendBubble = BubbleSpec(
durationMs = 60,
startFreqHz = 760.0,
endFreqHz = 520.0,
overtoneFreqHz = 1180.0,
amplitude = 0.26
)
private val subtleReceiveBubble = BubbleSpec(
durationMs = 66,
startFreqHz = 800.0,
endFreqHz = 560.0,
overtoneFreqHz = 1260.0,
amplitude = 0.24
)
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
private fun buildBubblePcm(spec: BubbleSpec, sampleRate: Int = 44_100): ByteArray {
val sampleCount = (sampleRate * (spec.durationMs / 1000.0)).toInt().coerceAtLeast(1)
val pcm = ByteArray(sampleCount * 2)
for (i in 0 until sampleCount) {
val progress = i.toDouble() / sampleCount.toDouble()
val envelope = exp(-4.8 * progress) * (1.0 - exp(-20.0 * progress))
val freq = spec.startFreqHz + (spec.endFreqHz - spec.startFreqHz) * progress
val t = i.toDouble() / sampleRate.toDouble()
val fundamental = sin(2.0 * PI * freq * t)
val overtone = 0.18 * sin(2.0 * PI * spec.overtoneFreqHz * t)
val airyTail = 0.08 * sin(2.0 * PI * (freq * 0.48) * t)
val warmth = 0.14 * sin(2.0 * PI * (freq * 0.24) * t)
val sample = ((fundamental + overtone + airyTail + warmth) * envelope * spec.amplitude)
.coerceIn(-1.0, 1.0)
val shortValue = (sample * Short.MAX_VALUE).toInt().toShort()
pcm[i * 2] = (shortValue.toInt() and 0xFF).toByte()
pcm[i * 2 + 1] = ((shortValue.toInt() shr 8) and 0xFF).toByte()
}
return pcm
}
private fun playBubble(spec: BubbleSpec) {
if (context.isMainActivityPaused) return
context.executeAsync {
val sampleRate = 44_100
val pcm = buildBubblePcm(spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(spec.durationMs.toLong() + 24L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
private fun playBubbleSequence(steps: List<BubbleStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
steps.forEach { step ->
val sampleRate = 44_100
val pcm = buildBubblePcm(step.spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(step.spec.durationMs.toLong() + step.pauseAfterMs + 18L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
}
private fun playStyledSend() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageSendBubble)
"whatsapp" -> playBubble(whatsappSendBubble)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramSendPrimary, pauseAfterMs = 16L),
BubbleStep(telegramSendAccent)
)
)
else -> playBubble(subtleSendBubble)
}
}
private fun playStyledReceive() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageReceiveBubble)
"whatsapp" -> playBubbleSequence(
listOf(
BubbleStep(whatsappReceiveBubble, pauseAfterMs = 12L),
BubbleStep(
whatsappReceiveBubble.copy(
durationMs = 42,
startFreqHz = 1210.0,
endFreqHz = 860.0,
overtoneFreqHz = 1980.0,
amplitude = 0.20
)
)
)
)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramReceivePrimary, pauseAfterMs = 14L),
BubbleStep(telegramReceiveAccent)
)
)
else -> playBubble(subtleReceiveBubble)
}
}
private fun markSeen(messageId: Long): Boolean {
synchronized(seenIncomingMessageIds) {
val added = seenIncomingMessageIds.add(messageId)
while (seenIncomingMessageIds.size > maxTrackedMessages) {
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
}
return added
}
}
override fun init() {
if (context.config.messaging.conversationSoundEffectsStyle.get() == "disabled") return
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
event.addCallbackResult("onSuccess") {
playStyledSend()
}
}
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
event.messages
.asSequence()
.filter { it.senderId?.toString() != myUserId }
.mapNotNull { it.messageDescriptor?.messageId }
.filter { markSeen(it) }
.firstOrNull()
?.let {
playStyledReceive()
}
}
}
}

View File

@@ -57,17 +57,25 @@ 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 {
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofViewingGalleryPresence.get()
}
private fun shouldSpoofReplyCameraPresence(stealthMode: StealthMode): Boolean {
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofReplyCameraPresence.get()
}
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) {
@@ -156,6 +164,8 @@ class Messaging : Feature("Messaging") {
classReference.getAsClass()?.let { wrapperClass ->
val bitmojiMethodNames = mutableSetOf<String>()
val viewingGalleryMethodNames = mutableSetOf<String>()
val replyCameraMethodNames = mutableSetOf<String>()
val typingMethodNames = mutableSetOf<String>()
val peekingMethodNames = mutableSetOf<String>()
@@ -165,14 +175,24 @@ class Messaging : Feature("Messaging") {
if (parameterTypes.any { parameterType ->
listOf(
"PlatformChatVisibleAction",
"PlatformChatHiddenAction",
"PlatformViewingChatMediaAction",
"PlatformUsingReplyCameraAction"
"PlatformChatHiddenAction"
).any { parameterType.name.contains(it) }
}) {
bitmojiMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformViewingChatMediaAction")
}) {
viewingGalleryMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformUsingReplyCameraAction")
}) {
replyCameraMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformTypingAction")
}) {
@@ -194,6 +214,22 @@ class Messaging : Feature("Messaging") {
}
}
viewingGalleryMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldSpoofViewingGalleryPresence(stealthMode)
}) {
it.setResult(null)
}
}
replyCameraMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldSpoofReplyCameraPresence(stealthMode)
}) {
it.setResult(null)
}
}
typingMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldHideTyping(stealthMode, hideTypingIndicator)
@@ -214,8 +250,8 @@ class Messaging : Feature("Messaging") {
val instance = param.thisObject<Any>()
clearField(instance, "PlatformChatVisibleAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformChatHiddenAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformViewingChatMediaAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformUsingReplyCameraAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformViewingChatMediaAction", shouldSpoofViewingGalleryPresence(stealthMode))
clearField(instance, "PlatformUsingReplyCameraAction", shouldSpoofReplyCameraPresence(stealthMode))
clearField(instance, "PlatformTypingAction", shouldHideTyping(stealthMode, hideTypingIndicator))
clearField(instance, "PlatformStartPeekingAction", shouldHidePeek(stealthMode))
}
@@ -325,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

@@ -25,6 +25,12 @@ import java.text.DateFormat
import java.util.Date
class FriendTracker : Feature("Friend Tracker") {
companion object {
private const val PRESENCE_PEEKING_BIT = 8
private const val PRESENCE_REPLY_CAMERA_BIT = 9
private const val PRESENCE_CHAT_MEDIA_BIT = 10
}
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val tracker by lazyBridge { context.bridgeClient.getTracker() }
private val translation by lazy { context.translation.getCategory("friend_tracker_notifications") }
@@ -37,6 +43,8 @@ class FriendTracker : Feature("Friend Tracker") {
))
} }
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
private val galleryEntries = mutableMapOf<Pair<String, String>, Long>()
private val replyCameraEntries = mutableMapOf<Pair<String, String>, Long>()
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
@@ -104,7 +112,12 @@ class FriendTracker : Feature("Friend Tracker") {
context.log.verbose("dispatching $action for $eventType in $conversationName")
val iCanSeeYouDetails = if (eventType == TrackerEventType.I_CAN_SEE_YOU) buildICanSeeYouDetails(extras) else ""
val iCanSeeYouDetails = when (eventType) {
TrackerEventType.I_CAN_SEE_YOU,
TrackerEventType.I_CAN_SEE_YOU_2,
TrackerEventType.I_CAN_SEE_YOU_3 -> buildICanSeeYouDetails(extras)
else -> ""
}
val notificationText = translation[eventType.key]
.replace("{friend}", authorName)
.replace("{conversation}", conversationName)
@@ -133,7 +146,7 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
private fun buildICanSeeYouExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
private fun buildTimedActivityExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
entry ?: -1,
exit ?: -1,
duration ?: -1
@@ -189,10 +202,22 @@ class FriendTracker : Feature("Friend Tracker") {
(currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT
oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING
oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING
(oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING
oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING
(oldState == null || !oldState.usingReplyCamera) && currentState?.usingReplyCamera == true -> TrackerEventType.STARTED_USING_REPLY_CAMERA
oldState?.usingReplyCamera == true && (currentState == null || !currentState.usingReplyCamera) -> TrackerEventType.STOPPED_USING_REPLY_CAMERA
(oldState == null || !oldState.viewingChatMedia) && currentState?.viewingChatMedia == true -> TrackerEventType.STARTED_VIEWING_CHAT_MEDIA
oldState?.viewingChatMedia == true && (currentState == null || !currentState.viewingChatMedia) -> TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA
(oldState == null || !oldState.peeking) &&
currentState?.peeking == true &&
currentState.usingReplyCamera != true &&
oldState?.usingReplyCamera != true -> TrackerEventType.STARTED_PEEKING
oldState?.peeking == true &&
(currentState == null || !currentState.peeking) &&
currentState?.usingReplyCamera != true &&
oldState.usingReplyCamera != true -> TrackerEventType.STOPPED_PEEKING
else -> null
} ?: return
}
eventType ?: return
when (eventType) {
TrackerEventType.CONVERSATION_ENTER -> {
@@ -206,7 +231,35 @@ class FriendTracker : Feature("Friend Tracker") {
TrackerEventType.I_CAN_SEE_YOU,
conversationId,
userId,
buildICanSeeYouExtras(entry, exit, entry?.let { exit - it })
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
TrackerEventType.STARTED_VIEWING_CHAT_MEDIA -> {
galleryEntries[conversationId to userId] = System.currentTimeMillis()
}
TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA -> {
val key = conversationId to userId
val exit = System.currentTimeMillis()
val entry = galleryEntries.remove(key)
dispatchEvents(
TrackerEventType.I_CAN_SEE_YOU_2,
conversationId,
userId,
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
TrackerEventType.STARTED_USING_REPLY_CAMERA -> {
replyCameraEntries[conversationId to userId] = System.currentTimeMillis()
}
TrackerEventType.STOPPED_USING_REPLY_CAMERA -> {
val key = conversationId to userId
val exit = System.currentTimeMillis()
val entry = replyCameraEntries.remove(key)
dispatchEvents(
TrackerEventType.I_CAN_SEE_YOU_3,
conversationId,
userId,
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
else -> {}
@@ -266,14 +319,20 @@ class FriendTracker : Feature("Friend Tracker") {
userIds.add(participantUserId)
if (participantUserId == context.database.myUserId) return@eachBuffer
val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer
val usingReplyCamera = stateMap.getOrElse(PRESENCE_REPLY_CAMERA_BIT) { false }
val viewingChatMedia = stateMap.getOrElse(PRESENCE_CHAT_MEDIA_BIT) { false }
val peeking = stateMap.getOrElse(PRESENCE_PEEKING_BIT) { false }
presenceMap[participantUserId] = FriendPresenceState(
bitmojiPresent = stateMap[0],
typing = stateMap[4],
wasTyping = stateMap[5],
speaking = stateMap[6] && stateMap[4],
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
// Snapchat moved peeking by one bit on newer builds and added
// dedicated chat-presence flags for reply camera and chat media viewing.
peeking = peeking,
usingReplyCamera = usingReplyCamera,
viewingChatMedia = viewingChatMedia
)
}

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

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