192 Commits

Author SHA1 Message Date
ΞTΞRNAL
00c5d60b7b v1.5.8 2026-03-28 02:42:33 +05:30
ΞTΞRNAL
21cd306132 fix(PR): QOL fixes & Optimizations by Kaladin
QOL fixes & Optimizations.
2026-03-28 01:40:24 +05:30
DarkKnight2122
0d42aed0ff feat: Auto Open optimization 2026-03-28 00:22:36 +05:30
DarkKnight2122
f8fdd1893f feat: Task page layout revamp 2026-03-28 00:22:36 +05:30
DarkKnight2122
a8e2148b26 feat: Camera, Audio, Video & Network Optimizations 2026-03-28 00:22:35 +05:30
ΞTΞRNAL
1e9ad8eb2b Add v1.5.6 changelog entry 2026-03-27 01:06:17 +05:30
ΞTΞRNAL
182e7eefeb v1.5.6 2026-03-27 01:05:17 +05:30
ΞTΞRNAL
070a8ffaf7 v1.5.5 2026-03-26 21:24:28 +05:30
ΞTΞRNAL
95f98221e3 v1.5.4 2026-03-26 12:13:52 +05:30
ΞTΞRNAL
dbbc52b67f v1.5.3 2026-03-25 21:59:31 +05:30
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
ΞTΞRNAL
901e4f81b8 fix: Missing changes 2026-03-22 20:32:16 +05:30
ΞTΞRNAL
a4e86b5ab4 fix(PR): Aphelion task page layout optimization by Kaladin
Aphelion task page layout optimization
2026-03-22 20:31:25 +05:30
ΞTΞRNAL
bdc6d12739 fix: splitting issue for snaps sent through send override 2026-03-22 20:30:34 +05:30
DarkKnight2122
6c8d5297c8 Task page layout harmonization. 2026-03-22 19:43:20 +05:30
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
ΞTΞRNAL
565a8b8287 v1.4.9 2026-03-21 03:48:43 +05:30
ΞTΞRNAL
1c612ebc8e fix: force AMOLED theme for newer snap versions 2026-03-21 02:09:33 +05:30
ΞTΞRNAL
8db363a8f0 add missing changelog 2026-03-20 22:19:18 +05:30
ΞTΞRNAL
4fab5cc4ab fix(PR): Crash when using the theme button in android 11 & 12 by Kaladin
fix: Crash when using the theme button in android 11 & 12.
2026-03-20 22:18:12 +05:30
ΞTΞRNAL
a7a15702f3 v1.4.8 2026-03-20 22:16:37 +05:30
DarkKnight2122
04aaefc748 fix: optimize theme reveal fallbacks for Android 11 and 12 2026-03-20 19:22:55 +05:30
ΞTΞRNAL
58c4be44f2 v1.4.6 2026-03-20 05:05:25 +05:30
ΞTΞRNAL
3d83c3116c revert: accidental change 2026-03-20 03:00:24 +05:30
ΞTΞRNAL
2b00898355 v1.4.5 2026-03-20 02:58:58 +05:30
ΞTΞRNAL
975a9a101f fix: duplicate opera download & mark snaps as seen buttons 2026-03-20 01:40:12 +05:30
ΞTΞRNAL
793659c63b fix: friend feed menu button overlapping for group chats 2026-03-19 22:13:45 +05:30
ΞTΞRNAL
5bdc1d7f59 feat(ui): Implement theme reveal transition by Kaladin
feat(ui): Implement theme reveal transition using AGSL
2026-03-18 22:12:24 +05:30
DarkKnight2122
fc66453167 feat(ui): implement theme reveal transition using AGSL 2026-03-18 19:03:37 +05:30
ΞTΞRNAL
84a9d01d6a fix(PR): Multiple fixes by Kaladin
Aphelion theme fixes.
2026-03-18 16:56:06 +05:30
DarkKnight2122
a4b948bba3 UI: Aphelion Changelog and Setup Enhancements 2026-03-18 11:31:55 +05:30
DarkKnight2122
8c720992de UI Fixes for Aphelion and Optimizations 2026-03-18 11:31:55 +05:30
DarkKnight2122
d1fc97cee8 Aphelion Theme Harmonization across tasks page 2026-03-18 11:31:53 +05:30
ΞTΞRNAL
9df49f1bff Merge pull request #114 from RSR1337/RSR
fix: FFMpegProcessor copyright
2026-03-17 11:19:02 +05:30
RSR/
652062769e fix: FFMpegProcessor copyright 2026-03-15 03:14:59 +04:00
ΞTΞRNAL
f1eb833655 fix(PR): Multiple fixes and improvements by RSR
feat:  Video Recording Timer , Auto Skip . fix: Fake Snap Score
2026-03-15 00:28:56 +05:30
RSR/
a27753d8ae feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score 2026-03-14 20:05:59 +04:00
ΞTΞRNAL
da8a261202 feat(PR): Spoof SnapScore by RSR
RSR
2026-03-12 05:20:04 +05:30
ΞTΞRNAL
532ebfe0a7 Merge pull request #112 from particle-box/revert-110-revert-109-RSR
Revert "Revert "feat: Spoof Snap Score""
2026-03-12 05:19:12 +05:30
ΞTΞRNAL
bf7f371022 Revert "Revert "feat: Spoof Snap Score"" 2026-03-12 05:18:59 +05:30
RSR/
15c9a3fd5d Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-12 02:27:47 +04:00
RSR/
bb0ab20a5b fix 2026-03-12 02:27:33 +04:00
ΞTΞRNAL
9a507684ee Merge pull request #110 from particle-box/revert-109-RSR
Revert "feat: Spoof Snap Score"
2026-03-12 03:53:23 +05:30
ΞTΞRNAL
1fbf82b4fb Revert "feat: Spoof Snap Score" 2026-03-12 03:52:54 +05:30
ΞTΞRNAL
53696c26f4 feat(PR): Spoof SnapScore by RSR
feat: Spoof Snap Score
2026-03-12 03:47:24 +05:30
RSR/
381b190535 Merge branch 'particle-box:dev' into RSR 2026-03-12 02:12:51 +04:00
RSR/
50199b052f fix 2026-03-12 02:12:09 +04:00
particle-box
2135d66124 many fixes & improvements(v1.4.1) 2026-03-12 01:04:30 +05:30
RSR/
6948d86efc feat: Spoof Snap Score 2026-03-11 19:47:13 +04:00
ΞTΞRNAL
481e5840f0 fix overlay position by AhmedRaza 2026-03-11 11:57:12 +05:30
ΞTΞRNAL
216bb71fef feat(PR): Improved Device Spoof by RSR
Improved the spoof device feature
2026-03-11 04:57:16 +05:30
RSR/
181d19424f Improved the spoof device feature 2026-03-11 01:47:07 +04:00
ΞTΞRNAL
a4d2513138 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-10 21:02:59 +05:30
ΞTΞRNAL
9c0f14e797 fix some UI bugs 2026-03-10 21:02:25 +05:30
ΞTΞRNAL
800b40aaae add missing changelog 2026-03-10 20:40:36 +05:30
ΞTΞRNAL
9577dadd38 feat(PR): Aphelion Theme by Kaladin
Introducing Aphelion Theme
2026-03-10 19:36:31 +05:30
ΞTΞRNAL
d4a084f896 bump version to 1.4.0 2026-03-10 19:35:52 +05:30
ΞTΞRNAL
28df958197 Merge branch 'dev' into themes 2026-03-10 19:27:32 +05:30
Kaladin
9b0f336f91 Update RetroGameScreen.kt 2026-03-10 19:10:20 +05:30
DarkKnight2122
c52f60a29b Introducing Aphelion Theme 2026-03-10 18:49:31 +05:30
ΞTΞRNAL
a327786d9e feat(PR): Story Snap Batch Download by RSR
feat: Story Snap List Download
2026-03-10 18:46:54 +05:30
ΞTΞRNAL
2b0c6cae36 fix multiple issues 2026-03-10 18:43:57 +05:30
particle-box
05a383c823 fix: crash issues 2026-03-10 13:34:32 +05:30
RSR/
13cd3656fd Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-10 02:07:14 +04:00
RSR/
07a8d3041e feat: Download story snaps , Fix Translation 2026-03-10 02:00:20 +04:00
particle-box
5ece2ec408 fix: mark snaps as seen button for newer snap versions 2026-03-10 01:43:29 +05:30
ΞTΞRNAL
7f9b911f0b multiple fixes & improvements 2026-03-09 20:05:28 +05:30
DarkKnight2122
b1b6a4ae6f added high quality vectors 2026-03-09 02:16:53 +05:30
particle-box
eeee0c2e72 v1.3.9 2026-03-08 19:26:26 +05:30
particle-box
07d4a4f833 many fixes & improvements 2026-03-08 17:19:38 +05:30
particle-box
4adcff36e3 improve ads & metrics blocking 2026-03-08 04:47:58 +05:30
particle-box
26077a8c87 fix: translation 2026-03-07 04:41:46 +05:30
particle-box
28982aa655 v1.3.8 2026-03-07 04:33:21 +05:30
particle-box
89748cd261 multiple fixes & improvements 2026-03-07 00:00:36 +05:30
particle-box
97797127bd Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-06 01:49:04 +05:30
particle-box
23d8ff0370 fix: crash issue for some devices 2026-03-06 01:48:34 +05:30
ΞTΞRNAL
d986440f9f fix: add missing changelog 2026-03-04 15:19:44 +05:30
particle-box
fc3b1bcde1 prepare for v1.3.5 2026-03-04 15:18:25 +05:30
ΞTΞRNAL
0289f96019 Merge pull request #105 from particle-box/revert-102-dev
revert(PR): 102
2026-03-04 14:17:55 +05:30
ΞTΞRNAL
ec9ecac15c Revert "Feature: UI Modernization, Themed Icons, and Performance Optimization" 2026-03-04 14:15:32 +05:30
ΞTΞRNAL
5efccc9e78 many changes by DarkKnight2122
Feature: UI Modernization, Themed Icons, and Performance Optimization
2026-03-04 09:17:07 +05:30
ΞTΞRNAL
ae53cc1f72 many new features & fixes by RSR
- Story counter & Story Source
- fix: Media Quality Override
2026-03-04 09:13:04 +05:30
ΞTΞRNAL
5b170ebae6 Merge branch 'dev' into RSR 2026-03-04 09:11:37 +05:30
RSR/
87ca0eaa82 fix: Media Quality 2026-03-04 07:28:26 +04:00
RSR/
5496dc7eb5 feat: Story Counter , Story Source 2026-03-04 07:15:38 +04:00
particle-box
183164fd52 fix:snaps saving in chat & new look for msg edit history by AhmedRaza 2026-03-04 00:57:05 +05:30
particle-box
4987577001 fix: better location overlay position for newer snap versions 2026-03-04 00:17:32 +05:30
particle-box
c7a879fa62 fix: better location overlay crash 2026-03-03 23:55:50 +05:30
particle-box
d6e74cb207 fix: migration side effects 2026-03-03 23:37:30 +05:30
particle-box
1ccdaee879 kavaref migration 2026-03-03 21:28:18 +05:30
ΞTΞRNAL
956bdda6c0 migration from libxposed API to YukiHook 2026-03-03 19:16:14 +05:30
RSR/
1b1f68c6cf : ) 2026-02-26 06:44:29 +04:00
DarkKnight2122
b6f9c10b21 Implemented high-precision Vector Themed Icons and optimized system Updater logic, and improved the haptics manager wide. 2026-02-25 19:48:18 +05:30
RSR/
fb3f1f55c7 feat:StoryCounter
Display the number of snaps in the story
2026-02-25 05:08:14 +04:00
DarkKnight2122
f8e5d7370a Standardized sub-page layouts and enhanced system stability across all modules 2026-02-24 16:45:40 +05:30
DarkKnight2122
7623ec30c9 Optimized component animations with basicMarquee integration 2026-02-24 16:35:15 +05:30
DarkKnight2122
7be8e33cc6 Implemented morphing Infinity header 2026-02-24 16:35:15 +05:30
ΞTΞRNAL
bc252ea9a3 prepare for v1.3.2 2026-02-24 16:03:05 +05:30
particle-box
4aba9c815f multiple fixes 2026-02-24 12:51:25 +05:30
ΞTΞRNAL
05c71c7ef4 fix: checksum 2026-02-24 03:41:22 +05:30
ΞTΞRNAL
57ebadbc51 fix: checksum 2026-02-24 03:38:00 +05:30
particle-box
bdf3808526 fix: slow builds 2026-02-24 02:21:26 +05:30
particle-box
e8d1f62ff1 add translations back 2026-02-24 02:10:33 +05:30
particle-box
b64faf31ae Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-02-24 01:58:11 +05:30
particle-box
ed86916ed7 temporary removal 2026-02-24 01:57:47 +05:30
ΞTΞRNAL
5935afb6e1 fix(PR): Media file Picker and added mapper by RSR
fix MediaFilePicker on snap13.79 and added mapper
2026-02-18 11:43:03 +05:30
RSR/
1ac1542384 fix MediaFilePicker on snap13.79 and added mapper 2026-02-16 20:31:42 +04:00
ΞTΞRNAL
8eea50e745 fix: accidental change 2026-02-14 14:27:07 +05:30
ΞTΞRNAL
795bc1f96a address fdroid bot feedback
Added SHA256 checksum for Gradle distribution.
2026-02-14 14:26:06 +05:30
particle-box
0ab60d8b20 add icons and banner 2026-02-14 14:06:26 +05:30
particle-box
627abb3927 add support for frdroid 2026-02-14 14:04:17 +05:30
ΞTΞRNAL
f32a4c4a23 add buiding instructions 2026-02-14 13:39:53 +05:30
particle-box
4753d91315 update translations 2026-02-12 11:31:36 +05:30
particle-box
f1dc4429d8 revert profile downloader button 2026-02-12 09:48:54 +05:30
particle-box
e6135584d4 fix: several inconsistencies 2026-02-12 09:46:48 +05:30
ΞTΞRNAL
ea82a5d373 feat(PR): spotlight creator info by RSR
Spotlight Creator Info
2026-02-09 16:02:51 +05:30
William Boser
36aa0c52a2 Spotlight Creator Info 2026-02-09 12:10:18 +04:00
RSR/
e3008e0c9c fix 2026-02-07 19:20:39 +04:00
ΞTΞRNAL
c50327bfe9 fix(PR): story decryption by hazelnut27
Enhance media encryption handling with hybrid resolver and improved decryption methods
2026-02-07 19:14:50 +05:30
hazelnut27
a97449605a Refactor: Remove unused methods 2026-02-07 03:51:13 -06:00
hazelnut27
e53a7db9ed Enhance media encryption handling with hybrid resolver and improved decryption methods 2026-02-07 03:08:40 -06:00
particle-box
c16f1f130d feat: add website 2026-02-07 09:34:22 +05:30
particle-box
6eec014493 fix: translation issue 2026-02-07 01:26:52 +05:30
particle-box
a21b938be0 fix custom emoji & call recorder quality,add order feature for chat exports 2026-02-07 00:44:00 +05:30
particle-box
3744c5b422 fix: add support for more keys for translations 2026-02-06 23:29:57 +05:30
ΞTΞRNAL
4692987baf fix: Gemini Auto Reply By RSR 2026-02-06 21:39:35 +05:30
particle-box
869bf3f3b4 fix: improve display look 2026-02-06 10:43:57 +05:30
particle-box
ce67a4f2a7 fix: several bugs and inconsistencies 2026-02-06 10:41:31 +05:30
particle-box
6c1072171d Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-02-06 09:21:07 +05:30
particle-box
6559d4dcd2 fix: add more translations 2026-02-06 09:20:54 +05:30
ΞTΞRNAL
a1aef48b0c feat(PR): Google Maps Integration by Daplugg23 2026-02-06 09:17:28 +05:30
daplugg23
0779a496eb build: fix CLI build compatibility with Gradle 9.x/AGP 9.0
Add android.disallowKotlinSourceSets=false and android.sourceset.disallowProvider=false
to gradle.properties to resolve build failures caused by stricter checks in newer
Android Gradle Plugin versions. This allows the project to build successfully from CLI.
2026-02-05 20:25:04 -06:00
daplugg23
6227a078e4 fix(config): reorder location properties
Move locationSearchProvider and googleMapsApiKey properties in Global.kt
and en_US.json to be positioned between 'spoofLocation' and 'coordinates'.
This ensures they appear in the correct order in any auto-generated feature lists,
while maintaining their manual placement in BetterLocationRoot.kt.
2026-02-05 20:07:39 -06:00
daplugg23
dac42ea042 fix(location): force UI update on setting change
Use mutable state for locationSearchProvider and googleMapsApiKey display
values in BetterLocationRoot. Update these state variables immediately
when dialogs are dismissed/saved to ensure the UI reflects changes without
reopening the screen.

- Introduce currentProvider/currentApiKey state variables.
- Refresh state in onDismissRequest/callbacks.
2026-02-05 18:04:29 -06:00
daplugg23
0df57f7724 fix(location): ensure config persistence
Explicitly call context.config.writeConfig() when dismissing the
Location Search Provider or Google Maps API Key dialogs.
This ensures that the selected values are persisted immediately,
resolving issues where settings changes were lost or not applied.

Also wraps API key input dialog in explicit writeConfig logic.
2026-02-05 17:52:49 -06:00
daplugg23
8d6dcdb227 fix(location): correct UI layout and translation keys
Resolve vertical text rendering issue by using Modifier.weight(1f) in
ConfigSelector. Add proper translation keys to the 'better_location'
block in en_US.json so they are accessible to BetterLocationRoot.
Update key references to match the new structure.

- Add keys: location_search_provider_title, google_maps_api_key_title,
  option_osm, option_google_maps.
- Fix UI layout to handle text wrapping properly.
2026-02-05 17:40:13 -06:00
daplugg23
46e9b7e7f5 feat(location): implement Google Maps geocoding search
Add support for Google Maps Geocoding API in location search dialog.
- Update ChooseLocationDialog signature to accept API key.
- Switch search provider logic based on configuration.
- Use Google Maps Geocoding API when provider is 'google_maps'.
- Maintain OpenStreetMap Nominatim support as fallback/default.
- Pass configured API key from BetterLocationRoot UI.
2026-02-05 17:20:24 -06:00
daplugg23
d485718245 fix(location): remove disable option and add manual UI
- Add ConfigFlag.NO_DISABLE_KEY to locationSearchProvider config to prevent
  'Disable' (null) option which caused fallback to OSM.
- Add manual UI controls to BetterLocationRoot.kt for Location Search Provider
  and Google Maps API Key, as automatic UI generation was not present on this page.
- Add ConfigSelector and ConfigInput helpers to BetterLocationRoot.kt.
- Ensure API key input is hidden/masked in the UI list.
2026-02-05 17:13:44 -06:00
daplugg23
a4de81aaf6 fix(location): correct Google Maps tile source implementation
Replace XYTileSource with OnlineTileSourceBase anonymous object to correctly
handle Google Maps query parameter format (&x=...&y=...&z=...) instead of
path-based format (/z/x/y.png).

- Use correct tile servers (mt0-mt3)
- Override getTileURLString to construct valid Google Maps URLs
2026-02-05 16:52:41 -06:00
daplugg23
a8e0c53be0 feat(location): implement location search provider toggle
Add config properties for locationSearchProvider (osm/google_maps) and
googleMapsApiKey to BetterLocationConfig. Modify ChooseLocationDialog
to switch between OpenStreetMap (MAPNIK) and Google Maps tile sources
based on user preference.

- Add locationSearchProvider unique config property
- Add googleMapsApiKey string config property (SENSITIVE flag)
- Add XYTileSource for Google Maps tiles in AlertDialogs
- Pass locationSearchProvider from BetterLocationRoot to dialog

Translation keys already existed in all 28 language files.
2026-02-05 15:30:14 -06:00
daplugg23
a0728fa951 fix(i18n): remove duplicate ui_settings and update_settings keys 2026-02-05 14:59:50 -06:00
daplugg23
5c7b342195 feat(export): add saved locations export with toggle 2026-02-05 14:54:32 -06:00
daplugg23
67dcc16923 fix(i18n): merge duplicate JSON blocks in all 27 language files 2026-02-05 14:15:10 -06:00
daplugg23
214ec4ffdd fix(i18n): merge duplicate options/global blocks in en_US.json 2026-02-05 14:08:50 -06:00
particle-box
a821cdf85e feat: add confirmation dialogs 2026-02-05 23:14:30 +05:30
ΞTΞRNAL
1953397adf fix(PR): story decryption by hazelnut27
Fix story decryption
2026-02-05 12:46:06 +05:30
hazelnut27
c20c157c2b Fix story download and decryption 2026-02-04 23:53:48 -06:00
ΞTΞRNAL
60c1bf8569 fix(PR): multiple fixes by Daplugg23
Fix: OpenStreetMap User-Agent and Story Rewatch Indicator
2026-02-05 09:36:34 +05:30
daplugg23
08610ee398 Refactor: Move OpenStreetMap User-Agent to Constants 2026-02-04 20:35:06 -06:00
daplugg23
1e6e9ada62 Fix OpenStreetMap User-Agent and Story Rewatch Indicator 2026-02-04 20:22:58 -06:00
particle-box
528adc6255 Multiple fixes by RSR 2026-02-04 20:41:32 +05:30
ΞTΞRNAL
c4246e853c feat: Bulk Accept Friend Requests By RSR
Friend request
2026-02-04 20:35:10 +05:30
RSR/
ff398ccc9a Friend request 2026-02-04 11:52:32 +04:00
ΞTΞRNAL
3ce4617deb feat: add banner in README 2026-02-03 08:40:17 +05:30
ΞTΞRNAL
228d5e0b23 feat: add banner 2026-02-03 08:35:59 +05:30
particle-box
d65938ab69 prepare for v1.2.6 2026-02-02 10:20:22 +05:30
particle-box
e0ea732855 fix: translations not appearing properly 2026-02-02 09:43:43 +05:30
particle-box
58928f70e9 fix: build errors 2026-02-02 09:34:16 +05:30
particle-box
0d9267c13d final updation of translations 2026-02-02 08:47:04 +05:30
particle-box
9e41e8154a fix: translations 2026-02-02 00:10:38 +05:30
particle-box
c5f164ccc8 last commit before migration from GitHub 2026-01-31 07:55:39 +05:30
particle-box
c9052cba26 Complete Translations(pt.2) 2026-01-30 21:02:17 +05:30
particle-box
00ca6861f4 fix: rework translations(pt. 1) 2026-01-29 03:21:45 +05:30
particle-box
458e1dde0d Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-01-28 01:20:39 +05:30
particle-box
a3441e02a8 fix: send override dialog 2026-01-28 01:20:24 +05:30
ΞTΞRNAL
79f9715001 fix: Missing changelog 2026-01-28 00:52:31 +05:30
particle-box
ca35f68e88 fix: build warnings 2026-01-28 00:49:47 +05:30
particle-box
79d1f9098a update translation files 2026-01-28 00:33:57 +05:30
particle-box
f551daf3a4 restore 2026-01-28 00:15:24 +05:30
particle-box
01594b24b5 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-01-28 00:05:06 +05:30
particle-box
a92f577aec Multiple changes 2026-01-28 00:04:29 +05:30
ΞTΞRNAL
86370bd9d3 internal test 2026-01-27 23:41:57 +05:30
particle-box
296769af49 redesign: PurrAura Indicator(thanks to RSR) 2026-01-26 13:37:27 +05:30
ΞTΞRNAL
1401aa90cc Merge pull request #92 from syedinsaf/dev
docs: polish README wording and clarity
2026-01-26 13:26:01 +05:30
ΞTΞRNAL
6a752b68d9 fix: Revise README
Updated various sections for clarity and added new dependencies.
2026-01-26 13:24:30 +05:30
ΞTΞRNAL
fe6785f7f1 fix: unnecessary addition 2026-01-26 13:02:07 +05:30
particle-box
06046340c6 feat: wrong architecture detector 2026-01-26 12:45:31 +05:30
Syed Insaf
403c9df173 docs: polish README wording and clarity 2026-01-26 04:41:50 +05:30
Syed Insaf
67b493e6bb docs: polish README wording and clarity 2026-01-26 04:40:11 +05:30
particle-box
cd924b1b0d fix: no such directory auto patcher 2026-01-25 21:09:03 +05:30
particle-box
5d2a3dedb7 feat: Auto Mapper for Manage Friend List(thanks to RSR) 2026-01-25 20:30:16 +05:30
particle-box
7fe49f2fe4 fix: Prevent snap replay & fix remove audio note duration & transcription(thanks to RSR) 2026-01-25 14:28:03 +05:30
particle-box
327efafbe5 fixes 2026-01-23 20:22:47 +05:30
particle-box
de4d8dac39 more fixies 2026-01-23 01:22:10 +05:30
particle-box
d8d82cd098 fix: accidental change 2026-01-23 01:10:36 +05:30
particle-box
222b4885ba several fixes 2026-01-23 01:09:00 +05:30
particle-box
ef4f4c78cb fix: build errors 2026-01-23 00:48:55 +05:30
particle-box
ec05a12e6c fix: accidental changes 2026-01-23 00:42:33 +05:30
particle-box
2c5f54368c multiple fixes 2026-01-23 00:34:27 +05:30
ΞTΞRNAL
18f280cfd7 fix: guide 2026-01-22 00:14:38 +05:30
281 changed files with 116719 additions and 91099 deletions

View File

@@ -45,7 +45,7 @@ body:
attributes:
label: "SnapEnhance Version"
description: "On which SnapEnhance version is this happening?"
placeholder: "ex. 1.2.5"
placeholder: "ex. 1.3.6"
validations:
required: true
- type: checkboxes

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ cloudflare/**/allowed_codes.local.*
security/allowed-codes.local.*
security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

313
README.md
View File

@@ -1,10 +1,14 @@
<div align="center">
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap" width="180"/>
<div align="center">
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/banner.jpg" alt="PurrfectSnap" width="100%"/>
</div>
# PurrfectSnap
### An Xposed module meant to redefine your Snapchat experience! Works on both non rooted and rooted devices!
### An Xposed module meant to redefine your Snapchat experience! Works on both non-rooted and rooted devices!
<br>
@@ -12,7 +16,7 @@
[![Downloads](https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/releases)
[![License](https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
[Installation](#installation) • [Features](#features) • [Changelog](#whats-new) • [Community](#community)
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
</div>
@@ -22,7 +26,7 @@
## Overview
PurrfectSnap is built on the foundation of SnapEnhance, pushing boundaries with innovative features and a refined user experience. This isn't just another fork, it's a complete reimagining of what's possible.
PurrfectSnap is built on the foundation of SnapEnhance, pushing boundaries with innovative features and a refined user experience. This isn't just another fork; it's a complete reimagining of what's possible.
We're committed to active development, bringing you powerful tools that actually matter. Every feature is designed with real users in mind, not just for the sake of adding to a feature list.
@@ -32,47 +36,14 @@ We're committed to active development, bringing you powerful tools that actually
This project exists because we believe in continuous innovation. We're grateful to the original SnapEnhance team for their groundbreaking work, and we're building on that legacy by exploring new possibilities and listening to what the community actually wants.
No aggressive donation requests. No minimal changes disguised as "major updates." Just genuine development driven by passion for creating something exceptional.
<br>
---
<br>
## 🔄 How It Works
```mermaid
graph TD
A[Install PurrfectSnap] --> B{Device Type?}
B -->|Non-Rooted| C[Auto Patcher Runs]
B -->|Rooted| D[Xposed Framework]
C --> E[Snapchat Patched Automatically]
D --> E
E --> F[Launch Snapchat]
F --> G[Access PurrfectSnap Settings]
G --> H[Configure Features]
H --> I{Enable Features}
I --> J[Media Downloader]
I --> K[Privacy Controls]
I --> L[UI Customization]
I --> M[Camera Enhancements]
I --> N[AI Intelligence Layer]
J --> O[Enhanced Snapchat Experience]
K --> O
L --> O
M --> O
N --> O
```
<br>
---
No aggressive donation requests. No minimal changes disguised as major updates. Just genuine development driven by a passion for creating something exceptional.
<br>
## Features
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
<table>
<tr>
<td width="50%">
@@ -125,7 +96,7 @@ Privacy-focused messaging features with advanced control over your conversations
- Screenshot bypass
- Anonymous story viewing
- Hide typing notifications
- Hide bitmoji presence
- Hide Bitmoji presence
- Prevent story rewatch indicators
**Enhanced Features**
@@ -240,149 +211,8 @@ Extensibility through custom scripts.
<br>
---
<br>
## What's New
Here's what makes PurrfectSnap different from upstream SnapEnhance.
<br>
### Aurora Design System
A complete visual language rebuilt from the ground up. Not just a theme, an entire design philosophy.
The interface now flows with purpose. Subtle animations guide your interactions. Every screen has been reconsidered, every transition refined. The result is an experience that feels premium without being ostentatious.
<br>
### PurrAura
The ban problem? Solved.
No more sacrificing features to stay safe. Add friends, block users, attach music; everything works as it should. No workarounds, no compromises, no disadvantages.
<br>
### Auto Patcher
Installation was complicated. Now it isn't.
We've eliminated 90% of the installation friction. No Shizuku. No LSPatch. Just a straightforward process that respects your time.
<br>
### Intelligence Layer
**Auto Reply**
Let AI handle routine responses while you focus on what matters. Configure automatic replies for chats, stories, and half-swipes. Essential for creators managing volume. Requires an API key from Google AI Studio, they offer free tier access.
**Scheduled Snaps**
Time-shifted communication. Set a snap to send at midnight for a birthday. Queue content for optimal timing. Your schedule, automated.
**Message Translator**
Language barriers, eliminated. Incoming messages automatically translate to your preferred language. Set it once, communicate globally.
<br>
### Privacy Enhancements
**Granular Controls**
Whitelist and blacklist modes for typing indicators and message logging. Choose exactly who sees what, friend by friend.
**Auto Delete Messages**
Set expiration times for your messages. They disappear on your schedule, not Snapchat's.
**Message Logger Viewer**
Review exported logs directly in-app. No external tools required.
<br>
### Data Persistence
**Friend Notes Backup**
Your notes are valuable. Now they're protected. Export and restore with ease. Never lose context again.
<br>
### Device Management
**ID Spoofing**
Device bans happen. Sometimes unjustly. We offer a solution for legitimate cases, approval required with proof of wrongful ban. This feature is restricted to prevent abuse.
**Model Spoofing**
Identify as a high-end device. Potential performance improvements, enhanced camera processing, better feature access.
**Network Spoofing**
Spoof your network status to Wi-Fi always to prevent several restrictions on mobile data.
<br>
### Restored Functionality
Features that disappeared? They're back.
Bulk messaging works again. Friend list management is fully operational. These aren't new features, they're restored capabilities that should never have been lost.
<br>
### Discovery
**Scripts Catalog**
Browse and install scripts without leaving the app. No more hunting for import links. One-tap installation. Always current.
**Friend Tracker Catalog**
Browse and install tracking rules without leaving the app. One-tap installation. Always current.
**Friend Tracker Import/Export**
Backup and restore your Friend Tracker configurations with ease.
<br>
### Advanced Tracking
**I Can See You Rule**
Track the exact time and duration when a friend enters your chat. Precision visibility tracking for Friend Tracker.
**Auto Open Snaps Enhancement**
More granular control with queue size configuration and customizable delays.
<br>
### Visual Refinements
**AMOLED Theme**
True black. Battery-saving. The most requested feature, delivered.
**Haptic Feedback**
Subtle tactile responses. Every interaction confirmed through touch.
**Customizable Bottom Bar**
Your navigation, your rules. Rearrange tabs, remove what you don't use, set your default view. Complete flexibility.
**Search History**
Recently searched features, instantly accessible. No more hunting through menus.
<br>
### Infrastructure
**In-App Updates**
New versions install directly from within the app. No GitHub hunting. No manual downloads. Just seamless updates.
**Update Notifications**
Stay current automatically. Get notified when new releases are available.
**Config Export/Import Summary**
See exactly which features a config will enable before importing or exporting. Know what you're getting.
<br>
### Quality
Hundreds of refinements you'll feel but might not see. Smoother animations. Faster response times. Eliminated edge cases. The kind of polish that separates good from exceptional.
> Some features are intended for educational, recovery, or accessibility purposes. Misuse may violate Snapchats terms of service.
> **We are not affiliated with Snap Inc. and are not responsible for any violations by the user. Our sole purpose is for education purposes only!**
<br>
@@ -392,9 +222,8 @@ Hundreds of refinements you'll feel but might not see. Smoother animations. Fast
## Installation
The guide is no longer needed!
For Non rooted users: Just Download & Install PurrfectSnap from [here](https://github.com/particle-box/PurrfectSnap/releases), and it'll do everything automatically for you!
For rooted users: Use Snapchat v13.64.0.52 only! Install PurrfectSnap from here: [Link](https://github.com/particle-box/PurrfectSnap/releases)
The guide is no longer needed!
Just download and install PurrfectSnap from [here](https://github.com/particle-box/PurrfectSnap/releases). The app automatically detects your device type and applies the appropriate setup.
<br>
@@ -406,7 +235,7 @@ For rooted users: Use Snapchat v13.64.0.52 only! Install PurrfectSnap from here:
Questions? Ideas? Found a bug? Our community is active and responsive.
**[Telegram Channel](https://t.me/purrfectsnap_official)** Announcements, discussions, and support
**[Telegram Channel](https://t.me/purrfectsnap_official)** : Announcements, discussions, and support
**For the discussions group link, refer to the description of the channel!**
<br>
@@ -415,28 +244,103 @@ Questions? Ideas? Found a bug? Our community is active and responsive.
<br>
## Contributing
## Build from Source
We welcome contributions from developers who share our vision. Whether it's code, documentation, or design—quality contributions are always appreciated.
We welcome contributions from developers who share our vision. Whether it's code, documentation, etc., contributions are always appreciated. Feel free to open a PR :)
<br>
### 1) Fork
---
1. Fork this repository on GitHub.
```
### 2) Generate a Release Keystore (Certificate)
Create a signing keystore (`.jks`) and keep it secure:
```bash
keytool -genkeypair -v \
-keystore purrfectsnap-release.keystore \
-storetype JKS \
-alias purrfectsnap \
-keyalg RSA \
-keysize 2048 \
-validity 10000
```
Optional local placement for Gradle signing:
```bash
mkdir -p ~/.android
cp purrfectsnap-release.keystore ~/.android/purrfectsnap-release.keystore
```
### 3) Convert Keystore to Base64
For GitHub Actions secret `PS_BASE_64`:
Linux/macOS:
```bash
base64 -w 0 purrfectsnap-release.keystore > keystore.base64
```
PowerShell (Windows):
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("purrfectsnap-release.keystore")) | Set-Content -NoNewline keystore.base64
```
### 4) Add Required GitHub Secrets (Your Fork)
Go to:
`Settings -> Secrets and variables -> Actions -> New repository secret`
Create these exact secrets (from `.github/workflows/release.yml`):
- `PS_BASE_64` = contents of `keystore.base64`
- `PS_RELEASE_KEY_ALIAS` = `purrfectsnap` (or your chosen alias)
- `PS_RELEASE_KEY_PASSWORD` = key password from `keytool`
- `PS_RELEASE_STORE_PASSWORD` = keystore password from `keytool`
### 5) Run Release Workflow
1. Open `Actions` in your fork.
2. Run `PurrfectSnap Release CI` (`workflow_dispatch`).
3. Workflow builds and signs:
- `armv8` release APK
- `armv7` release APK
4. Workflow creates a GitHub Release and uploads APK assets.
### Notes
- CI uses `JDK 21 (Temurin)`.
- Signing requires all four secrets above.
- Keep keystore and passwords private. Rotate immediately if exposed.
<br>
## Credits
PurrfectSnap is built with exceptional open source tools:
PurrfectSnap is built with exceptional open source tools. We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies.
**Core Dependencies**
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
- [libxposed](https://github.com/libxposed/api) — Framework integration
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
- [OMVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation
- [hiddenapibypass](https://github.com/LSPosed/HiddenApiBypass) — Hidden API access
- [dexlib2 (smali)](https://github.com/JesusFreke/smali) — Dex parsing
- [Bouncy Castle](https://www.bouncycastle.org/) — Cryptography
- [apksig](https://android.googlesource.com/platform/tools/apksig/) — APK signing tools
- [Rust Android Gradle Plugin](https://github.com/mozilla/rust-android-gradle) — Native build tooling
- [Android Gradle Plugin](https://developer.android.com/build) — Build system
- [Kotlin](https://kotlinlang.org/) — Language
- [Compose Compiler](https://developer.android.com/jetpack/compose/compiler) — Compose compiler
**Media Processing**
- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) — Media manipulation
- [coil](https://github.com/coil-kt/coil) — Image loading
- [smart-exception-java](https://github.com/arthenica/ffmpeg-kit) — ffmpeg-kit support lib
**Scripting Engine**
- [rhino](https://github.com/mozilla/rhino) — JavaScript runtime
@@ -446,6 +350,21 @@ PurrfectSnap is built with exceptional open source tools:
- [osmdroid](https://github.com/osmdroid/osmdroid) — Map functionality
- [libsu](https://github.com/topjohnwu/libsu) — Root operations
- [colorpicker-compose](https://github.com/skydoves/colorpicker-compose) — Color selection
- [OkHttp](https://github.com/square/okhttp) — Networking
- [Gson](https://github.com/google/gson) — JSON parsing
- [jsoup](https://github.com/jhy/jsoup) — HTML parsing
- [Fetch](https://github.com/tonyofrancis/Fetch) — Download manager
- [WorkManager](https://developer.android.com/jetpack/androidx/releases/work) — Background tasks
- [Accompanist](https://github.com/google/accompanist) — Compose utilities
- [Guava](https://github.com/google/guava) — Core utilities
- [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — Async
- [AndroidX DocumentFile](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) — File access
- [AndroidX RecyclerView](https://developer.android.com/jetpack/androidx/releases/recyclerview) — Lists
- [AndroidX Navigation Compose](https://developer.android.com/jetpack/androidx/releases/navigation) — Navigation
- [Jetpack Compose / Material3](https://developer.android.com/jetpack/compose) — UI toolkit
- [AndroidX Activity KTX](https://developer.android.com/jetpack/androidx/releases/activity) — Activity helpers
- [Compose BOM](https://developer.android.com/jetpack/compose/bom) — Compose version alignment
- [Material Icons (Compose)](https://developer.android.com/jetpack/compose/designsystems/material#icons) — Icons
<br>
@@ -455,8 +374,8 @@ PurrfectSnap is built with exceptional open source tools:
<br>
Built with care, maintained with passion
Built with care, maintained with passion ❤️
Open source under GPL-3.0, Apache-2.0
<br>
<br>

1
announcements.txt Normal file
View File

@@ -0,0 +1 @@
- Test

View File

@@ -100,6 +100,10 @@ fun computeKeystoreCertSha256(storeFile: File, storePass: String, keyAlias: Stri
}.getOrNull()
}
fun gradleOrEnv(name: String, providers: org.gradle.api.provider.ProviderFactory): String? {
return providers.gradleProperty(name).orNull ?: System.getenv(name)
}
android {
namespace = rootProject.ext["applicationId"].toString()
compileSdk = 36
@@ -113,18 +117,18 @@ android {
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
storePassword = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
keyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
keyPassword = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
storePassword = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
keyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
keyPassword = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
}
}
defaultConfig {
val autoCertSha = providers.provider {
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
val releaseKeyPass = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
val releaseKeyPass = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
if (!releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
computeKeystoreCertSha256(
releaseStore,
@@ -147,7 +151,7 @@ android {
applicationId = rootProject.ext["applicationId"].toString()
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
minSdk = 30
targetSdk = 36
multiDexEnabled = true
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
@@ -158,8 +162,8 @@ android {
isMinifyEnabled = true
proguardFiles += file("proguard-rules.pro")
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
}
@@ -263,6 +267,7 @@ dependencies {
}
implementation(project(":core"))
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(project(":common"))
implementation(project(":native"))
implementation(libs.androidx.documentfile)

View File

@@ -1,6 +1,6 @@
-dontwarn de.robv.android.xposed.**
-dontwarn org.mozilla.javascript.**
-dontwarn android.app.AndroidAppHelper
-dontwarn java.lang.reflect.AnnotatedType
-keep class com.tonyodev.fetch2.** { *; }
-keep class com.tonyodev.fetch2core.** { *; }

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -21,6 +21,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import me.eternal.purrfectsnap.bridge.BridgeService
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.Constants
@@ -45,12 +51,14 @@ import me.eternal.purrfectsnap.ui.manager.data.SnapchatAppInfo
import me.eternal.purrfectsnap.ui.overlay.RemoteOverlay
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.setup.SetupActivity
import me.eternal.purrfectsnap.task.AnnouncementCheckWorker
import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchConfiguration
import java.util.concurrent.TimeUnit
class RemoteSideContext(
@@ -123,6 +131,7 @@ class RemoteSideContext(
log.init()
log.verbose("Loading RemoteSideContext")
config.load()
ensureAutoUpdateCheckOnUpgrade()
launch {
mappings.apply {
init(androidContext)
@@ -132,6 +141,7 @@ class RemoteSideContext(
userLocale = config.locale
load()
}
scheduleAnnouncementCheck()
database.init()
streaksReminder.init()
scriptManager.init()
@@ -258,10 +268,48 @@ class RemoteSideContext(
Constants.SNAPCHAT_PACKAGE_NAME
)
if (intent == null) {
shortToast("Can't execute action: Snapchat is not installed")
shortToast(translation["toast_snapchat_not_installed"])
return
}
intent.putExtra(EnumAction.ACTION_PARAMETER, action.key)
androidContext.startActivity(intent)
}
private fun scheduleAnnouncementCheck() {
val workManager = WorkManager.getInstance(androidContext)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = Data.Builder()
.putString("announcements_url", "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt")
.putString("channel_name", "Announcements")
.putString("channel_description", "Notifications for PurrfectSnap announcements")
.putString("notification_title", "New announcement available")
.putString("notification_text", "Tap to open and read.")
.build()
val workRequest = PeriodicWorkRequestBuilder<AnnouncementCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()
workManager.enqueueUniquePeriodicWork(
"purrfectsnap_announcement_check",
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
}
private fun ensureAutoUpdateCheckOnUpgrade() {
val currentVersion = BuildConfig.VERSION_CODE.toLong()
val lastVersion = sharedPreferences.getLong("last_build_version_code", -1L)
val reenabledOnce = sharedPreferences.getBoolean("auto_update_reenabled_once", false)
if (lastVersion == currentVersion) return
if (!reenabledOnce) {
config.root.global.updateSettings.autoUpdateCheck.set(true)
config.writeConfig()
sharedPreferences.edit()
.putBoolean("auto_update_reenabled_once", true)
.apply()
}
sharedPreferences.edit().putLong("last_build_version_code", currentVersion).apply()
}
}

View File

@@ -95,6 +95,7 @@ class BiometricPromptActivity: ComponentActivity() {
setContent {}
}
@Deprecated("Overrides deprecated API onActivityResult")
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)

View File

@@ -4,6 +4,7 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -27,10 +28,25 @@ import kotlin.system.measureTimeMillis
class BridgeService : Service() {
private lateinit var remoteSideContext: RemoteSideContext
lateinit var syncCallback: SyncCallback
private var syncCallback: SyncCallback? = null
var messagingBridge: MessagingBridge? = null
@Volatile
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
private fun clearSyncCallback() {
syncCallback = null
}
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
pendingSocialSnapshotCallback = callback
}
fun clearEphemeralSocialSnapshotRequest() {
pendingSocialSnapshotCallback = null
}
override fun onDestroy() {
clearSyncCallback()
if (::remoteSideContext.isInitialized) {
remoteSideContext.bridgeService = null
}
@@ -47,36 +63,58 @@ class BridgeService : Service() {
}
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
val callback = syncCallback ?: return
runCatching {
if (!syncCallback.asBinder().pingBinder()) {
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
val database = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
callback.syncFriend(id)
}
SocialScope.GROUP -> {
if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
callback.syncGroup(id)
}
} ?: run {
if (updateOnly) {
when (scope) {
SocialScope.FRIEND -> database.deleteFriend(id)
SocialScope.GROUP -> database.deleteGroup(id)
}
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
when (scope) {
SocialScope.FRIEND -> {
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) } ?: run {
if (updateOnly) {
database.deleteFriend(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
SocialScope.GROUP -> {
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) } ?: run {
if (updateOnly) {
database.deleteGroup(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
}
}.onFailure {
if (it is RemoteException) {
clearSyncCallback()
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return@onFailure
}
remoteSideContext.log.error("Failed to sync $scope $id", it)
}
}
@@ -121,7 +159,7 @@ class BridgeService : Service() {
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Media conversion",
title = remoteSideContext.translation["task_media_conversion_title"],
author = null,
hash = taskId
)
@@ -162,6 +200,7 @@ class BridgeService : Service() {
}
override fun sync(callback: SyncCallback) {
clearSyncCallback()
syncCallback = callback
measureTimeMillis {
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
@@ -185,10 +224,15 @@ class BridgeService : Service() {
friends: List<String>
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
)
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
return
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}
override fun getScopeNotes(id: String): String? {

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.download
import android.content.ContentUris
import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
@@ -11,11 +12,9 @@ import android.provider.MediaStore
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.Constants
@@ -61,6 +60,14 @@ class DownloadProcessor (
private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback
) {
companion object {
private val downloadSemaphore = Semaphore(3)
}
private data class GallerySaveResult(
val uri: Uri,
val alreadyDownloaded: Boolean = false
)
private val translation by lazy {
remoteSideContext.translation.getCategory("download_processor")
@@ -118,78 +125,36 @@ class DownloadProcessor (
}
}
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
val fileName = buildOutputFileName(metadata.outputPath, fileType)
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
if (configuredFolder.isBlank()) {
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
?: throw Exception("Failed to save media (no output uri)")
pendingTask.task.extra = outputUri.toString()
pendingTask.success()
callbackOnSuccess(fileName)
return
}
val saveResult = if (configuredFolder.isBlank()) {
saveToSystemDefault(fileName, fileType, inputFile, metadata)
} else {
runCatching {
saveToConfiguredFolder(
configuredFolder = configuredFolder,
fileName = fileName,
fileType = fileType,
inputFile = inputFile,
metadata = metadata,
pendingTask = pendingTask
)
}.onFailure {
remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it)
}.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)
} ?: throw Exception("Failed to save media (no output uri)")
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
pendingTask.task.extra = saveResult.uri.toString()
pendingTask.success()
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!!
}
} else {
outputFolder
}
}
// checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file.
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
return@let
}
remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputFile.inputStream().use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = existingInputStream.read(buffer2)
if (read1 != read2 || !buffer1.contentEquals(buffer2)) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
}
}
pendingTask.task.extra = existingFile.uri.toString()
pendingTask.success()
if (saveResult.alreadyDownloaded) {
callbackOnFailure(translation["already_downloaded_toast"])
return
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
pendingTask.updateProgress("Saving media to gallery")
remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
pendingTask.task.extra = outputFile.uri.toString()
pendingTask.success()
runCatching {
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = outputFile.uri
data = saveResult.uri
})
}.onFailure {
remoteSideContext.log.error("Failed to scan media file", it)
@@ -205,15 +170,155 @@ class DownloadProcessor (
}
}
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
stream1.use { s1 ->
stream2.use { s2 ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
while (true) {
val read1 = s1.read(buffer1)
val read2 = s2.read(buffer2)
if (read1 != read2) return false
if (read1 == -1) return true
for (i in 0 until read1) {
if (buffer1[i] != buffer2[i]) return false
}
}
}
}
}
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?"
val selectionArgs = arrayOf(fileName, relativePath)
return remoteSideContext.androidContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
ContentUris.withAppendedId(collection, id)
}
}
private fun contentMatches(uri: Uri, inputFile: File): Boolean {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false
return streamsMatch(existingInputStream, inputFile.inputStream())
}
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
rawName.substringBeforeLast(".")
} else {
rawName
}
return "${sanitizeFileName(baseName)}.$targetExtension"
}
private fun sanitizeFileName(name: String): String {
return name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ")
.trim()
.trim('.')
.ifBlank { "media" }
}
private fun sanitizeRelativePath(path: String): String {
return path.trimEnd('/').split("/")
.mapNotNull { segment ->
segment.trim()
.takeIf { it.isNotBlank() }
?.let(::sanitizeFileName)
}
.joinToString("/")
}
private fun appendNameSuffix(fileName: String, index: Int): String {
val extension = fileName.substringAfterLast('.', "")
val baseName = fileName.substringBeforeLast(".", fileName)
return if (extension.isBlank()) {
"$baseName ($index)"
} else {
"$baseName ($index).$extension"
}
}
private fun saveToConfiguredFolder(
configuredFolder: String,
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
pendingTask: PendingTask,
): GallerySaveResult {
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name)
?: folder.createDirectory(name)
?: throw Exception("Failed to create output directory $name")
}
} else {
outputFolder
}
}
var finalFileName = fileName
var collisionCount = 0
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
collisionCount++
finalFileName = appendNameSuffix(fileName, collisionCount)
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
?: throw Exception("Failed to create output file $finalFileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $finalFileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun saveToSystemDefault(
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
): Uri? {
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
.trim('/')
): GallerySaveResult? {
val subPath = sanitizeRelativePath(
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
)
val baseRelative = when {
fileType.isImage -> Environment.DIRECTORY_PICTURES
fileType.isVideo -> Environment.DIRECTORY_MOVIES
@@ -222,40 +327,65 @@ class DownloadProcessor (
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
.joinToString("/") + "/"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val collection = when {
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
}
val resolver = remoteSideContext.androidContext.contentResolver
val uri = resolver.insert(collection, values) ?: return null
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: return null
uri
for (attempt in 0..100) {
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
return@let
} ?: run {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) }
return GallerySaveResult(uri)
}.onFailure {
runCatching { resolver.delete(uri, null, null) }
}
}
}
throw IllegalStateException("Failed to allocate unique filename")
} else {
@Suppress("DEPRECATION")
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
destDir.mkdirs()
val destFile = File(destDir, fileName)
FileOutputStream(destFile).use { out ->
inputFile.inputStream().use { it.copyTo(out) }
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
}
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
runCatching {
remoteSideContext.androidContext.sendBroadcast(
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = Uri.fromFile(destFile)
}
)
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
}
Uri.fromFile(destFile)
return GallerySaveResult(Uri.fromFile(destFile))
}
}
@@ -263,8 +393,7 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf<Job>()
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
val downloadedMedias = mutableMapOf<InputMedia, File>()
var totalSize = 1L
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
@@ -277,71 +406,72 @@ class DownloadProcessor (
)
}
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
coroutineScope {
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}
}.also { jobs.add(it) }
}
}
jobs.joinAll()
downloadedMedias
return downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
@@ -352,24 +482,24 @@ class DownloadProcessor (
val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.CONVERSION,
inputs = listOf(media.file.absolutePath),
inputs = listOf(media.absolutePath),
output = outputFile
))
media.file.delete()
media.delete()
saveMediaToGallery(pendingTask, outputFile, metadata)
outputFile.delete()
return
}
}
saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
saveMediaToGallery(pendingTask, media, metadata)
media.delete()
return
}
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
@@ -379,7 +509,7 @@ class DownloadProcessor (
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
dashPlaylistFile.outputStream().use {
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
}
@@ -404,7 +534,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -424,7 +554,7 @@ class DownloadProcessor (
// check if the media file has been deleted
if (task.type == TaskType.DOWNLOAD) {
val outputFile = runCatching {
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
}.getOrNull()
if (outputFile != null && !outputFile.exists()) {
@@ -439,113 +569,115 @@ class DownloadProcessor (
return@launch
}
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
downloadSemaphore.withPermit {
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
}
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to it.value
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
zipFile.file.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
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
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = DownloadedFile(it, FileType.fromFile(it))
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
shouldMergeOverlay = true
}
oldDownloadedMedias.forEach { (_, value) ->
value.file.delete()
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
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 mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
shouldMergeOverlay = true
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
}

View File

@@ -106,7 +106,18 @@ class FFMpegProcessor(
if (session.returnCode.isValueSuccess) {
Result.success(session)
} else {
Result.failure(Exception(session.output))
val output = session.output
val errorMsg = when {
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
else -> {
val lines = output.lines().filter { line ->
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
}
lines.lastOrNull()?.take(400)
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
}
}
Result.failure(Exception(errorMsg))
}
)
}, logFunction@{ log ->
@@ -119,11 +130,6 @@ class FFMpegProcessor(
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}
private fun isMediaCodecFailure(output: String): Boolean {
val lower = output.lowercase()
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
}
suspend fun execute(args: Request) {
// load ffmpeg native sync to avoid native crash
synchronized(this) { FFmpegKit.listSessions() }
@@ -140,7 +146,7 @@ class FFMpegProcessor(
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
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"
@@ -215,7 +221,7 @@ class FFMpegProcessor(
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
@@ -255,20 +261,6 @@ class FFMpegProcessor(
}
}
outputArguments += args.output.absolutePath
try {
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} catch (e: Exception) {
val output = e.message.orEmpty()
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
outputArguments -= "-c:v"
outputArguments += "-c:v" to "libx264"
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} else {
throw e
}
}
newFFMpegTask(globalArguments, inputArguments, outputArguments)
}
}

View File

@@ -17,6 +17,9 @@ import me.eternal.purrfectsnap.download.FFMpegProcessor
import me.eternal.purrfectsnap.task.PendingTaskListener
import me.eternal.purrfectsnap.task.Task
import me.eternal.purrfectsnap.task.TaskType
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.absoluteValue
@@ -43,7 +46,7 @@ class CallDownloadSessionImpl(
val job: Job
val writePfd: ParcelFileDescriptor
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
if (exists()) delete()
}
@@ -109,11 +112,15 @@ class CallDownloadSessionImpl(
return
}
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}_final.mp3")
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
val dateString = dateFormat.format(Date(callStartTimestamp))
val finalFileName = "Call_${author}_$dateString"
val outputFile = context.androidContext.cacheDir.resolve("${finalFileName}_final.mp3")
val pendingTask = context.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Call Recording $author",
title = context.translation.format("task_call_recording_title", "author" to author),
author = author,
hash = UUID.randomUUID().toString()
)
@@ -136,24 +143,30 @@ class CallDownloadSessionImpl(
}
val sortedStreams = streams.filter { it.outputFile.exists() }.sortedBy { it.startTimestampMillis }
if (sortedStreams.isEmpty()) {
pendingTask.fail("No recorded audio data")
return@launch
}
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_AUDIO_STREAMS,
inputs = sortedStreams.map { it.outputFile.absolutePath },
output = outputFile,
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp) }
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp).coerceAtLeast(0L) }
)
)
DownloadProcessor(context, object: DownloadCallback.Default() {
override fun onSuccess(outputPath: String) {
context.log.verbose("Downloaded call $outputPath")
context.shortToast(context.translation["features.properties.downloader.properties.call_recorder.properties.call_recording_saved_toast"])
}
}).saveMediaToGallery(pendingTask, outputFile, DownloadMetadata(
mediaIdentifier = UUID.randomUUID().toString(),
outputPath = createNewFilePath(
context.config.root,
UUID.randomUUID().toString().hashCode().absoluteValue.toString(16),
finalFileName,
downloadSource = MediaDownloadSource.VOICE_CALL,
mediaAuthor = author,
creationTimestamp = System.currentTimeMillis()
@@ -162,6 +175,9 @@ class CallDownloadSessionImpl(
downloadSource = MediaDownloadSource.VOICE_CALL.translate(context.translation),
iconUrl = null
))
} catch (e: Exception) {
context.log.error("Failed to merge call recording", e)
pendingTask.fail("Merge failed: ${e.message}")
} finally {
streams.forEach { stream ->
stream.outputFile.delete()

View File

@@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
}
}
fun AppDatabase.replaceMessagingData(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
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 ->
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
)
)
friend.streaks?.takeIf { it.length > 0 }?.also {
val streaks = getFriendStreaks(friend.userId)
database.execSQL(
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
streaks?.notify != false,
it.expirationTimestamp,
it.length
)
)
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
}
groups.forEach { group ->
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
}
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery(
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)

View File

@@ -0,0 +1,105 @@
package me.eternal.purrfectsnap.task
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.MainActivity
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
class AnnouncementCheckWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return runCatching {
val url = inputData.getString("announcements_url") ?: return Result.failure()
val body = fetchBody(url)?.trim().orEmpty()
if (body.isEmpty()) return Result.success()
val hash = sha256(body)
val prefs = appContext.getSharedPreferences("prefs", 0)
val lastHash = prefs.getString("announcements_last_hash", null)
if (lastHash == null) {
prefs.edit().putString("announcements_last_hash", hash).apply()
return Result.success()
}
if (lastHash != hash) {
prefs.edit().putString("announcements_last_hash", hash).apply()
showAnnouncementNotification()
}
Result.success()
}.getOrElse {
Result.failure()
}
}
private fun fetchBody(url: String): String? {
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body?.string()
}
}
private fun sha256(text: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { byte -> "%02x".format(byte) }
}
private fun showAnnouncementNotification() {
val channelId = "purrfectsnap_announcements"
val name = inputData.getString("channel_name") ?: "Announcements"
val descriptionText = inputData.getString("channel_description") ?: "Notifications for PurrfectSnap announcements"
val title = inputData.getString("notification_title") ?: "New announcement available"
val text = inputData.getString("notification_text") ?: "Tap to open and read."
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("route", "home")
putExtra("show_announcements", true)
}
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
}
with(NotificationManagerCompat.from(appContext)) {
notify(2, builder.build())
}
}
}

View File

@@ -46,7 +46,8 @@ data class Task(
val type: TaskType,
val title: String,
val author: String?,
val hash: String
val hash: String,
val isAutoOpen: Boolean = false
) {
var changeListener: () -> Unit = {}

View File

@@ -10,11 +10,13 @@ class RemoteTaskInterface(
private val activeTasks = context.taskManager.getActiveTasks()
override fun createTask(type: String, title: String, author: String, hash: String): String {
val taskType = TaskType.fromKey(type)
val task = Task(
type = TaskType.fromKey(type),
type = taskType,
title = title,
author = author.takeIf { it.isNotBlank() },
hash = hash
hash = hash,
isAutoOpen = taskType == TaskType.CHAT_ACTION
)
context.taskManager.createPendingTask(task)
return hash

View File

@@ -38,11 +38,13 @@ class TaskManager(
private val activeTasks = mutableMapOf<Long, PendingTask>()
private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!)
val task = Task(
type = TaskType.fromKey(cursor.getStringOrNull("type")!!),
type = taskType,
title = cursor.getStringOrNull("title")!!,
author = cursor.getStringOrNull("author"),
hash = cursor.getStringOrNull("hash")!!
hash = cursor.getStringOrNull("hash")!!,
isAutoOpen = taskType == TaskType.CHAT_ACTION
)
task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
task.extra = cursor.getStringOrNull("extra")
@@ -127,15 +129,26 @@ class TaskManager(
fun getTaskByHash(hash: String?): Task? {
if (hash == null) return null
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
return readTaskFromCursor(cursor)
return runBlocking {
suspendCoroutine { continuation ->
queueExecutor.execute {
runCatching {
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
} else {
continuation.resumeWith(Result.success(null))
}
}
}.onFailure {
continuation.resumeWith(Result.failure(it))
}
}
}
}
return null
}
fun getActiveTasks() = activeTasks
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
val tasks = mutableMapOf<Long, Task>()

View File

@@ -59,6 +59,8 @@ class UpdateCheckWorker(
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("show_changelog", true)
putExtra("changelog_version", versionName)
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -10,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -22,6 +24,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.foundation.background
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
@@ -33,13 +36,17 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.common.ui.ThemeMode
import me.eternal.purrfectsnap.common.ui.ThemePreferences
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
import android.content.IntentFilter
@@ -62,6 +69,8 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (::navController.isInitialized.not()) return
handleAnnouncementIntent(intent)
handleUpdateIntent(intent)
intent.getStringExtra("route")?.let { route ->
navController.popBackStack()
@@ -89,6 +98,8 @@ class MainActivity : ComponentActivity() {
activity = this@MainActivity
checkForRequirements()
}
handleAnnouncementIntent(intent)
handleUpdateIntent(intent)
val routes = Routes(managerContext)
routes.activityLauncher = ActivityLauncherHelper(this)
routes.getRoutes().forEach { it.init() }
@@ -113,6 +124,31 @@ class MainActivity : ComponentActivity() {
}
}
AppMaterialTheme(themeMode = themeMode) {
val shouldShowAbiWarning = remember {
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
val appIsArm64 = libDir.contains("arm64")
deviceIsArm64 && !appIsArm64
}
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
}
)
}
val background = MaterialTheme.colorScheme.background
val isLight = background.luminance() > 0.5f
val view = LocalView.current
@@ -177,6 +213,23 @@ class MainActivity : ComponentActivity() {
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay (Android 13+ only for stability)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
} else {
// Instantly clear reveal state on older versions
navigation.themeRevealState.pendingReveal?.let {
navigation.themeRevealState.clearReveal()
}
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)
@@ -225,4 +278,22 @@ class MainActivity : ComponentActivity() {
super.onDestroy()
unregisterReceiver(restartReceiver)
}
private fun handleAnnouncementIntent(intent: Intent) {
if (intent.getBooleanExtra("show_announcements", false)) {
applicationContext.getSharedPreferences("prefs", 0).edit()
.putBoolean("show_announcements_on_launch", true)
.apply()
}
}
private fun handleUpdateIntent(intent: Intent) {
if (intent.getBooleanExtra("show_changelog", false)) {
val version = intent.getStringExtra("changelog_version")
applicationContext.getSharedPreferences("prefs", 0).edit()
.putBoolean("show_changelog_on_launch", true)
.putString("changelog_version_on_launch", version)
.apply()
}
}
}

View File

@@ -0,0 +1,25 @@
package me.eternal.purrfectsnap.ui.manager
import me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion.AphelionTheme
import me.eternal.purrfectsnap.ui.manager.pages.themes.legacy.LegacyTheme
/**
* ManagerTheme is the registry of all available themes.
*
* Adding a new theme requires changes only in this file:
* - Add a new object entry to the sealed class
* - Add its ID string to fromId()
*
* No other file needs to change when a new theme is added.
*/
sealed class ManagerTheme(val theme: ThemeContract) {
object Legacy : ManagerTheme(LegacyTheme)
object Aphelion : ManagerTheme(AphelionTheme)
companion object {
fun fromId(id: String): ManagerTheme = when (id) {
"APHELION" -> Aphelion
else -> Legacy // default fallback is always Legacy
}
}
}

View File

@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -105,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
import kotlin.math.round
import kotlin.math.PI
import kotlin.math.sin
@@ -116,28 +116,44 @@ import kotlin.math.sin
androidx.compose.animation.ExperimentalAnimationApi::class
)
class Navigation(
private val context: RemoteSideContext,
internal val context: RemoteSideContext,
private val navController: NavHostController,
val routes: Routes = Routes(context).also { it.navController = navController }
) {
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
val themeRevealState = ThemeRevealState()
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
}
val haptic = LocalHapticFeedback.current
TopAppBar(
modifier = Modifier.height(headerHeight),
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer {
scaleX = 1f - (focusFactor * 0.05f)
scaleY = 1f - (focusFactor * 0.05f)
translationY = (-2 * focusFactor).dp.toPx()
}
)
}
}
@@ -146,11 +162,20 @@ class Navigation(
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
IconButton(onClick = {
if (canGoBack) {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
@@ -162,21 +187,34 @@ class Navigation(
actions = {
currentRoute?.topBarActions?.invoke(this)
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
IconButton(onClick = { openBottomBarCustomization = true }) {
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openBottomBarCustomization = true
}) {
Icon(Icons.Filled.Tune, contentDescription = null)
}
}
}
)
}
@Composable
fun FloatingBottomBar() {
val haptic = LocalHapticFeedback.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val availableRoutes = remember {
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
val barHeight = lerp(82.dp, 64.dp, focusFactor)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val iconTranslationY = (10 * focusFactor).dp
val prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> {
@@ -233,9 +271,17 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
color = Color.Transparent,
color = Color.White.copy(alpha = 0.08f),
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.drawBehind {
@@ -263,7 +309,7 @@ class Navigation(
Box(
Modifier
.fillMaxWidth()
.height(82.dp)
.height(barHeight)
.clip(barShape)
.background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape)
@@ -296,16 +342,16 @@ class Navigation(
)
}
)
Box(Modifier.fillMaxWidth().height(82.dp)) {
Box(Modifier.fillMaxWidth().height(barHeight)) {
var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
val selectedIndex = remember(currentRoute, selectedRoutes) {
val index = selectedRoutes.indexOf(currentRoute)
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
if (index >= 0) index else null
}
selectedIndex?.let { // Null check
selectedIndex?.let {
val itemWidthPx =
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
val offsetAnim = remember { Animatable(0f) }
@@ -369,7 +415,7 @@ class Navigation(
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = 10.dp, horizontal = 2.dp)
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) {
Box(
@@ -430,7 +476,10 @@ class Navigation(
contentDescription = null,
modifier = Modifier
.size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
.graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
)
},
label = {
@@ -441,11 +490,15 @@ class Navigation(
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
)
},
selected = isSelected,
@@ -456,7 +509,10 @@ class Navigation(
unselectedTextColor = Color.White.copy(alpha = 0.72f),
indicatorColor = Color.Transparent
),
onClick = { route.navigateReset() }
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
route.navigateReset()
}
)
}
}
@@ -545,7 +601,7 @@ class Navigation(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
} else {
val haptic = LocalHapticFeedback.current
val hapticCustom = LocalHapticFeedback.current
var draggingId by remember { mutableStateOf<String?>(null) }
var dragDelta by remember { mutableStateOf(0f) }
var dragStartIndex by remember { mutableStateOf(-1) }
@@ -577,7 +633,7 @@ class Navigation(
draggingId = id
dragStartIndex = selectedTabIds.indexOf(id)
dragDelta = 0f
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
},
onDrag = { _: PointerInputChange, dragAmount ->
dragDelta += dragAmount.y
@@ -591,7 +647,7 @@ class Navigation(
list.add(targetIndex, id)
selectedTabIds = list
saveSelected(selectedTabIds)
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
}
}
},
@@ -663,7 +719,7 @@ class Navigation(
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
) {
availableRoutes.forEach { route ->
val id = route.routeInfo.id
val already = selectedTabIds.contains(id)
@@ -794,7 +850,9 @@ class Navigation(
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
children.forEach { child ->
composable(child.routeInfo.id) { child.content.invoke(it) }
}
route.customComposables.invoke(this)
}
}

View File

@@ -0,0 +1,51 @@
package me.eternal.purrfectsnap.ui.manager
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@Composable
fun rememberRouteScrollState(key: String): ScrollState {
val scrollState = remember(key) {
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
}
LaunchedEffect(key, scrollState.value) {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
DisposableEffect(key, scrollState) {
onDispose {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
}
return scrollState
}
@Composable
fun rememberRouteLazyListState(key: String): LazyListState {
val savedState = RouteStateCache.lazyListOffsets[key]
val listState = remember(key) {
LazyListState(
firstVisibleItemIndex = savedState?.first ?: 0,
firstVisibleItemScrollOffset = savedState?.second ?: 0
)
}
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
DisposableEffect(key, listState) {
onDispose {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
}
return listState
}
private object RouteStateCache {
val scrollOffsets = mutableMapOf<String, Int>()
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
}

View File

@@ -54,7 +54,7 @@ class Routes(
) {
companion object {
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}&includeSavedLocations={includeSavedLocations}"
const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
const val VIEW_LOGGER_HISTORY_ROUTE = "view_logger_history/{uri}"

View File

@@ -0,0 +1,28 @@
package me.eternal.purrfectsnap.ui.manager
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
/**
* ThemeContract defines the visual layout contract every theme must fulfill.
*/
interface ThemeContract {
@Composable fun HomeRootSection.HomeScreen(nav: NavBackStackEntry)
@Composable fun HomeSettings.SettingsScreen(nav: NavBackStackEntry)
@Composable fun HomeAbout.AboutScreen(nav: NavBackStackEntry)
@Composable fun HomeLogs.LogsScreen(nav: NavBackStackEntry)
@Composable fun SocialRootSection.SocialScreen(nav: NavBackStackEntry)
@Composable fun TasksRootSection.TasksScreen(nav: NavBackStackEntry)
@Composable fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry)
@Composable fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry)
@Composable fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry)
}

View File

@@ -1,125 +1,284 @@
package me.eternal.purrfectsnap.ui.manager.components
import androidx.compose.foundation.BorderStroke
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.layout.statusBars
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.zIndex
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@Immutable
data class FloatingTopBarColors(
val container: Color,
val border: Brush
val borderStart: Color,
val borderEnd: Color
)
@Composable
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
return remember {
FloatingTopBarColors(
container = Color.White.copy(alpha = 0.07f),
border = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
container = Color.White.copy(alpha = 0.12f),
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
}
}
/**
* Unified Floating Top Bar for Aphelion.
* Handles the signature morphing animation and provides a "Bottom Content" slot.
*/
@Composable
fun FloatingTopBar(
title: String,
subtitle: String? = null,
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
scrollOffset: Int = 0,
enableMorph: Boolean = false,
containerAlpha: Float = 1f,
titleAlignment: Alignment.Horizontal = Alignment.Start,
actions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = colors.container,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, colors.border)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
val haptic = LocalHapticFeedback.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val focusFactor by remember(scrollOffset, enableMorph) {
derivedStateOf {
if (!enableMorph) 0f
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
}
}
val morphingParams by remember(focusFactor, statusBarHeight) {
derivedStateOf {
object {
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
val topCorners = lerp(26.dp, 0.dp, focusFactor)
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
val iconScale = 1f - (0.12f * focusFactor)
val horizontalShift = (6 * focusFactor).dp
}
}
}
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.5f) {
hasSnapped = false
}
}
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
RoundedCornerShape(
topStart = morphingParams.topCorners,
topEnd = morphingParams.topCorners,
bottomStart = morphingParams.bottomCorners,
bottomEnd = morphingParams.bottomCorners
)
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
val refractiveColor = remember { Color(0xFF241F52) }
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
1.0f to Color.Transparent
)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.graphicsLayer {
alpha = containerAlpha
},
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = (4 * focusFactor).dp
) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
)
)
)
}
} else {
Spacer(modifier = Modifier.height(0.dp))
}
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
val tr = morphingParams.topCorners.toPx()
val br = morphingParams.bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
uPath.apply {
moveTo(0f, 0f)
lineTo(0f, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
lineTo(size.width - br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
lineTo(size.width, 0f)
}
drawPath(uPath, brush, style = Stroke(strokeWidth))
} else {
borderPath.reset()
borderPath.apply {
moveTo(tr, 0f)
lineTo(size.width - tr, 0f)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
lineTo(size.width, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
lineTo(br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
lineTo(0f, tr)
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
}
drawPath(borderPath, brush, style = Stroke(strokeWidth))
}
}
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
}
)
}
}
Box(contentAlignment = Alignment.CenterEnd) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
content = actions
)
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
}
bottomContent(focusFactor)
}
}
}
}

View File

@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
object UpdateDownloader {
private const val TAG = "UpdateDownloader"
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private val fallbackHttpClient by lazy { OkHttpClient() }
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
@@ -84,6 +87,132 @@ object UpdateDownloader {
return downloadedFile
}
private fun scheduleReset(scope: CoroutineScope) {
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
downloadProgress.value = 0f
}
}
private fun installDownloadedFile(
remoteContext: RemoteSideContext,
downloadedFile: File,
scope: CoroutineScope
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.COMPLETED
runCatching {
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
scheduleReset(scope)
}
private fun failDownload(
remoteContext: RemoteSideContext,
scope: CoroutineScope,
errorMessage: String,
throwable: Throwable? = null
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to errorMessage),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
scheduleReset(scope)
}
private fun startHttpFallbackDownload(
remoteContext: RemoteSideContext,
downloadUrl: String,
filePath: String,
scope: CoroutineScope
) {
val partialFile = File("$filePath.part")
val outputFile = File(filePath)
scope.launch(Dispatchers.IO) {
runCatching {
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
partialFile.parentFile?.mkdirs()
if (partialFile.exists()) partialFile.delete()
if (outputFile.exists()) outputFile.delete()
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "PurrfectSnap-Updater")
.build()
fallbackHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("HTTP_${response.code}")
}
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
val contentLength = body.contentLength()
var downloadedBytes = 0L
body.byteStream().use { input ->
partialFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
}
}
}
}
}
if (!partialFile.renameTo(outputFile)) {
partialFile.copyTo(outputFile, overwrite = true)
partialFile.delete()
}
installDownloadedFile(remoteContext, outputFile, scope)
}.onFailure {
runCatching { partialFile.delete() }
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
}
}
}
fun downloadAndInstall(
remoteContext: RemoteSideContext,
downloadUrl: String,
@@ -91,6 +220,7 @@ object UpdateDownloader {
scope: CoroutineScope
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
val fetch = getInstance(remoteContext)
val filePath = File(context.externalCacheDir, fileName).path
remoteContext.log.info("Starting update download from $downloadUrl -> $filePath", TAG)
@@ -99,6 +229,7 @@ object UpdateDownloader {
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
var fallbackAttempted = false
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
@@ -107,7 +238,7 @@ object UpdateDownloader {
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
downloadState.value = DownloadState.DOWNLOADING
Toast.makeText(context, "Download started", Toast.LENGTH_SHORT).show()
Toast.makeText(context, translation["update_download_started_toast"], Toast.LENGTH_SHORT).show()
}
override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) {
@@ -115,56 +246,21 @@ object UpdateDownloader {
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
runCatching {
val downloadedFile = File(download.file)
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
installDownloadedFile(remoteContext, File(download.file), scope)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(context, "Download failed: $error", Toast.LENGTH_SHORT).show()
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
?: remoteContext.log.error("Update download failed: $error", TAG)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
fallbackAttempted = true
downloadState.value = DownloadState.DOWNLOADING
downloadProgress.value = 0f
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
return
}
failDownload(remoteContext, scope, error.toString(), throwable)
}
}
fetch.addListener(listener!!)

View File

@@ -143,7 +143,7 @@ class ManageReposSection: Routes.Route() {
addRepo(url)
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}

View File

@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray
import org.json.JSONObject
@@ -136,10 +137,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"]
val parser = remember { ConfigParser() }
val savedLocations = remember {
if (includeSavedLocations) context.database.getLocationCoordinates() else null
}
val featuresByCategory = remember {
parser.parse(context.config.exportToString(exportSensitiveData))
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
@@ -191,14 +196,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -210,7 +215,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {

View File

@@ -49,10 +49,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.abs
class ConfigImportConfirmationScreen : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
@@ -65,6 +69,44 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val indentation: Int
)
companion object {
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
}
/**
* Imports saved locations from JSON array into database with de-duplication.
* Only adds locations that don't already exist (within coordinate tolerance).
*/
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
val existingLocations = context.database.getLocationCoordinates()
for (i in 0 until locationsArray.size()) {
val locationObj = locationsArray.get(i).asJsonObject
val name = locationObj.get("name")?.asString ?: continue
val latitude = locationObj.get("latitude")?.asDouble ?: continue
val longitude = locationObj.get("longitude")?.asDouble ?: continue
val radius = locationObj.get("radius")?.asDouble ?: 100.0
// Check for existing location with similar coordinates (de-duplication)
val existingMatch = existingLocations.find { existing ->
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
}
if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply {
this.name = name
this.latitude = latitude
this.longitude = longitude
this.radius = radius
}
context.database.addOrUpdateLocationCoordinate(null, newLocation)
}
// If duplicate exists, skip (do not update or delete existing)
}
}
private inner class ConfigParser {
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
val featureList = mutableListOf<ImportedFeature>()
@@ -190,7 +232,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"] ?: "Import"
val importLabel = translation["confirm_button"]
Box(
modifier = Modifier
@@ -239,14 +281,14 @@ class ConfigImportConfirmationScreen : Routes.Route() {
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -256,12 +298,17 @@ class ConfigImportConfirmationScreen : Routes.Route() {
onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
context.config.loadFromString(json)
val savedLocationsJson = context.config.loadFromString(json)
// Import saved locations if present in the JSON
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: "Unknown error")
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}

View File

@@ -38,6 +38,7 @@ 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
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
title = translation["clear_list_button"],
text = translation["dialog_clear_confirmation_text"],
icon = Icons.Default.DeleteSweep,
confirmButtonText = "Clear",
dismissButtonText = context.translation["button.cancel"],
confirmButtonText = translation["dialog_clear_confirm_button"],
dismissButtonText = translation["dialog_clear_cancel_button"],
onDismiss = { confirmationDialog = false },
onConfirm = {
context.database.clearRuleIds(currentRuleType.key)
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
@@ -390,7 +391,7 @@ class ManageRuleFeature : Routes.Route() {
contentColor = Color.White
)
) {
Text(text = "Clear")
Text(text = translation["dialog_clear_confirm_button"])
}
}
}

View File

@@ -1,334 +1,126 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import android.os.SystemClock
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val scrollState = rememberScrollState()
val aboutStory = remember {
"""
PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, SUJΛL, Zain & scrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.
""".trimIndent()
}
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: "About",
onBack = { routes.navController.popBackStack() }
)
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
Spacer(modifier = Modifier.height(8.dp))
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
}
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.panelGradient)
.padding(horizontal = 22.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "PurrfectSnap",
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
fontFamily = avenirNext,
modifier = Modifier.clickable(
interactionSource = tapSource,
indication = null
) {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
}
)
Text(
text = "An Xposed Module meant to enhance your Snapchat experience!",
fontSize = 13.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Lead Developers",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(top = 10.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
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)
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Our Story",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = aboutStory,
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp
)
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "With love, PurrfectSnap Team",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
modifier = Modifier.weight(1f),
onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeAbout.AboutScreen(nav)
}
}
}
@Composable
private fun DeveloperCard(
internal fun DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
modifier: Modifier = Modifier
) {
val cardShape = RoundedCornerShape(20.dp)
val imageRing = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary,
PurrfectPalette.glowSecondary
)
)
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Surface(
modifier = modifier,
shape = cardShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
onClick = {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
},
modifier = modifier.scaleOnPress(tapSource),
interactionSource = tapSource,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 16.dp),
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(82.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape)
Surface(
modifier = Modifier.size(64.dp),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
Text(
PurrfectMarqueeText(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
)
}
}
}
}
}

View File

@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
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.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0)
internal val logListState = LazyListState()
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
internal val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
private fun clearLogsAndReload() {
internal fun clearLogsAndReload() {
context.coroutineScope.launch {
context.log.clearLogs()
withContext(Dispatchers.Main) {
@@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() {
}
}
private fun exportLogs() {
internal fun exportLogs() {
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"])
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
visibleLogs.add(line)
}
}
}
}
readerResult.onFailure {
context.longToast("Failed to read logs!")
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
val filteredLogs = withContext(Dispatchers.IO) {
(0 until reader.lineCount).mapNotNull { index ->
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
}
}
visibleLogs.clear()
visibleLogs.addAll(filteredLogs)
}
delay(220)
if (visibleLogs.isNotEmpty()) {
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
logListState.scrollToItem(targetIndex)
}
isRefreshing = false
}
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
isRefreshing = true
refreshLogs()
}
}
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
isRefreshing = true
refreshLogs()
})
LaunchedEffect(Unit) {
isRefreshing = true
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
LogsFloatingBar(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
refreshLogs()
},
onExport = { exportLogs() },
onClear = { clearLogsAndReload() }
)
Spacer(modifier = Modifier.height(12.dp))
Surface(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 12.dp,
bottom = routes.bottomPadding + 22.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp)
)
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
}
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Column(
@@ -271,20 +167,19 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogsFloatingBar(
internal fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
var showDropDown by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var showMenu by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding),
shape = RoundedCornerShape(28.dp),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
@@ -292,87 +187,93 @@ class HomeLogs : Routes.Route() {
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["common.back"],
tint = Color.White
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectPalette.glowSecondary
Text(
text = translation["manager.routes.home_logs"] ?: "Logs",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = translation["refresh_button_description"],
tint = Color.White
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = Color.White
)
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
onExport()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
},
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
onClick = {
onExport()
showMenu = false
}
)
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = Color(0xFFFF9CAB)
)
)
}
},
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
onClick = {
onClear()
showMenu = false
}
)
}
}
}
@@ -381,7 +282,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun EmptyLogsState() {
internal fun EmptyLogsState() {
Column(
modifier = Modifier
.fillMaxSize()
@@ -412,7 +313,7 @@ class HomeLogs : Routes.Route() {
textAlign = TextAlign.Center
)
Text(
text = "Pull to refresh or trigger an action to see new entries.",
text = translation["refresh_hint"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp,
textAlign = TextAlign.Center,
@@ -422,8 +323,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
// Normalize overly fragmented log text (some entries were rendered with one character per line)
internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
val normalizedMessage = remember(line.message) {
val cleaned = line.message.replace("\r", "")
val fragments = cleaned.lines()
@@ -534,14 +434,14 @@ class HomeLogs : Routes.Route() {
}
}
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
LogLevel.WARN -> Color(0xFFFFD782)
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
}
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
LogLevel.DEBUG -> "Debug"
LogLevel.INFO -> "Info"
LogLevel.VERBOSE -> "Verbose"
@@ -550,14 +450,14 @@ class HomeLogs : Routes.Route() {
LogLevel.ASSERT -> "Assert"
}
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
}
private fun shouldHideLog(line: LogLine): Boolean {
internal fun shouldHideLog(line: LogLine): Boolean {
val message = line.message.lowercase()
val tag = line.tag.lowercase()
return message.startsWith("blocked ep") ||

View File

@@ -46,14 +46,15 @@ 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.Help
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Widgets
@@ -81,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -91,6 +94,8 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
@@ -121,6 +126,7 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -136,6 +142,7 @@ class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
companion object {
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
val cardMargin = 10.dp
val pageBackgroundGradient = Brush.verticalGradient(
listOf(
@@ -146,22 +153,23 @@ class HomeRootSection : Routes.Route() {
)
}
private val changelogClient by lazy { OkHttpClient() }
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
internal val changelogClient by lazy { OkHttpClient() }
internal val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
internal val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
internal val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
private val heroGradientColors = listOf(
internal val heroGradientColors = listOf(
Color(0xFF5C4B99),
Color(0xFF322B5E),
Color(0xFF1B1836)
)
private val quickActionsGradientColors = listOf(
internal val quickActionsGradientColors = listOf(
Color(0xFF241C3E),
Color(0xFF151127)
)
private lateinit var activityLauncherHelper: ActivityLauncherHelper
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
private val cardEntries by lazy {
internal val cardEntries by lazy {
val list = mutableListOf<QaCard>()
EnumQuickActions.entries.forEach { q ->
val name = context.translation["actions.${q.key}.name"]
@@ -173,7 +181,7 @@ class HomeRootSection : Routes.Route() {
}
list
}
private val cards by lazy {
internal val cards by lazy {
EnumQuickActions.entries.map {
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
}.associate {
@@ -188,7 +196,7 @@ class HomeRootSection : Routes.Route() {
}
@Composable
private fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
internal fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
val prefs = remember { context.sharedPreferences }
val state = remember { mutableStateOf(prefs.getBoolean(key, default)) }
DisposableEffect(prefs, key) {
@@ -211,13 +219,17 @@ class HomeRootSection : Routes.Route() {
onClick: (() -> Unit)? = null,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
haptic: HapticFeedback? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val clickModifier = if (onClick != null) {
Modifier.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) { onClick() }
) {
haptic?.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
} else {
Modifier
}
@@ -241,7 +253,7 @@ class HomeRootSection : Routes.Route() {
}
@Composable
private fun HeroBadge(text: String) {
internal fun HeroBadge(text: String) {
Text(
text = text,
color = Color.White,
@@ -257,7 +269,8 @@ class HomeRootSection : Routes.Route() {
@Composable
private fun TopBarActionChip(
icon: ImageVector,
label: String,
label: String? = null,
contentDescription: String? = label,
onClick: () -> Unit,
) {
Surface(
@@ -273,13 +286,17 @@ class HomeRootSection : Routes.Route() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(icon, contentDescription = label, tint = Color.White)
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
Icon(icon, contentDescription = contentDescription, tint = Color.White)
label?.let {
Text(
text = it,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@@ -311,9 +328,9 @@ class HomeRootSection : Routes.Route() {
label = context.translation["manager.routes.home_logs"]
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"]
) { routes.settings.navigate() }
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"]
) { routes.about.navigate() }
}
@@ -401,7 +418,7 @@ class HomeRootSection : Routes.Route() {
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onWikiClick: () -> Unit,
onWebsiteClick: () -> Unit,
onTelegramClick: () -> Unit,
onGithubClick: () -> Unit,
authorName: String,
@@ -446,7 +463,7 @@ class HomeRootSection : Routes.Route() {
fontFamily = avenirNext
)
Text(
text = "An Xposed Module meant to enhance your Snapchat experience",
text = translation["hero_tagline"],
color = Color.White.copy(alpha = 0.9f),
fontSize = 15.sp,
lineHeight = 20.sp,
@@ -458,9 +475,9 @@ class HomeRootSection : Routes.Route() {
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge("Version: $versionName - $channelLabel")
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge("Build: $it")
HeroBadge(translation.format("hero_build_label", "build" to it))
}
}
@@ -559,7 +576,7 @@ class HomeRootSection : Routes.Route() {
tint = Color(0xFFA3F0C2)
)
Text(
text = "Ready to install",
text = translation["update_ready_label"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -604,7 +621,7 @@ class HomeRootSection : Routes.Route() {
.background(if (isPurrAuraActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3))
)
Text(
text = if (isPurrAuraActive) "PurrAura Active!" else "PurrAura Inactive",
text = if (isPurrAuraActive) translation["purr_aura_active_label"] else translation["purr_aura_inactive_label"],
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
@@ -616,11 +633,11 @@ class HomeRootSection : Routes.Route() {
onClick = onManageClick,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
modifier = Modifier.align(Alignment.CenterHorizontally)
modifier = Alignment.CenterHorizontally.let { Modifier.align(it) }
) {
Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(6.dp))
Text("Open Settings")
Text(translation["open_settings_button"])
}
}
}
@@ -642,15 +659,15 @@ class HomeRootSection : Routes.Route() {
) {
Button(
modifier = Modifier.weight(1f),
onClick = onWikiClick,
onClick = onWebsiteClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Wiki", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
@@ -665,7 +682,7 @@ class HomeRootSection : Routes.Route() {
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = translation["github_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
}
ExternalLinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
@@ -678,7 +695,7 @@ class HomeRootSection : Routes.Route() {
}
}
}
private fun resolveTileKey(name: String): String {
internal fun resolveTileKey(name: String): String {
val entry = cardEntries.firstOrNull { it.name == name }
return entry?.id ?: name
}
@@ -696,13 +713,13 @@ class HomeRootSection : Routes.Route() {
val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
}
private fun clearTileSpan(name: String) {
internal fun clearTileSpan(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_size_$key").apply()
}
private fun clearTileOffset(name: String) {
internal fun clearTileOffset(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_offset_$key").apply()
@@ -724,449 +741,23 @@ class HomeRootSection : Routes.Route() {
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles().filter { it.isNotBlank() }
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") "Pre-release" else "Stable"
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
Updater.getLatestRelease(channel)
}
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val coroutineScope = rememberCoroutineScope()
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
var showChangelogDialog by remember { mutableStateOf(false) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
val handleUpdateAction: () -> Unit = {
latestUpdate?.let { latest ->
val supportedAbis = android.os.Build.SUPPORTED_ABIS
var abiName: String? = null
for (abi in supportedAbis) {
when (abi) {
"arm64-v8a" -> {
abiName = "arm64"
break
}
"armeabi-v7a" -> {
abiName = "armv7"
break
}
}
}
context.log.info(
"Update request: device ABIs=${supportedAbis.joinToString()} resolvedArch=${abiName ?: "unknown"}",
"HomeRoot"
)
if (latest.workflowId != null) {
if (abiName == null) {
android.widget.Toast.makeText(
context.androidContext,
"Your device architecture is not supported for automatic updates.",
android.widget.Toast.LENGTH_LONG
).show()
} else {
val artifactName = "purrfectsnap-${abiName}-debug"
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
context.log.info("Debug update -> downloading $artifactName from $downloadUrl", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope)
}
return@let
}
val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] }
if (releaseDownload != null) {
val fileName = releaseDownload.substringAfterLast('/')
context.log.info("Release update -> arch=$abiName url=$releaseDownload file=$fileName", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope)
} else {
context.log.warn(
"No matching update asset for arch=$abiName (available: ${latest.assetDownloads.keys})",
"HomeRoot"
)
context.androidContext.openLink(latest.releaseUrl)
}
}
}
fun loadChangelog(targetVersion: String, url: String) {
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
val body = response.body?.string() ?: throw IllegalStateException("Empty changelog body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
changelogError = error.message ?: "Failed to load changelog"
changelogLoading = false
}
}
}
}
val onUpdateButtonClick: () -> Unit = {
latestUpdate?.let {
showChangelogDialog = true
loadChangelog(it.versionName, changelogUrl)
}
}
var showQuickActionsMenu by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val contentBottomPadding = routes.bottomPadding + navigationBarPadding + 96.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(pageBackgroundGradient)
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = contentBottomPadding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(WindowInsets.statusBars.asPaddingValues())
.padding(horizontal = cardMargin, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
Spacer(modifier = Modifier.height(12.dp))
HeroSection(
versionName = BuildConfig.VERSION_NAME,
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = onUpdateButtonClick,
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onWikiClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/wiki") },
onTelegramClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
onGithubClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
authorName = "ETERNAL",
onManageClick = { routes.settings.navigate() },
avenirNext = avenirNext,
)
Spacer(modifier = Modifier.height(12.dp))
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsAnim") { hasQuickActions ->
val quickCardShape = RoundedCornerShape(34.dp)
Surface(
modifier = Modifier
.padding(horizontal = cardMargin, vertical = 10.dp),
shape = quickCardShape,
tonalElevation = 0.dp,
shadowElevation = 24.dp,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Brush.linearGradient(quickActionsGradientColors))
.padding(horizontal = 24.dp, vertical = 28.dp)
.padding(bottom = navigationBarPadding + 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!hasQuickActions) {
Text(
translation["quick_actions_title"],
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Outlined.Widgets,
contentDescription = translation["quick_actions_icon_description"],
modifier = Modifier.size(72.dp),
tint = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No quick tiles yet",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Design your dream grid with the actions you use the most.",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = { showQuickActionsMenu = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = translation["add_quick_action_description"],
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Add tile")
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
translation["quick_actions_title"],
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White,
maxLines = 3,
overflow = TextOverflow.Clip
)
Text(
text = "${selectedTiles.size} curated shortcuts",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { showQuickActionsMenu = true },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage),
contentDescription = translation["manage_quick_actions_description"],
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Manage")
}
}
}
val spacing = 12.dp
val gridPadding = 8.dp
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val preferredTileWidth = 100.dp
val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing))
.toInt()
.coerceAtLeast(2)
.coerceAtMost(4)
val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns
val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(all = gridPadding),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.spacedBy(spacing),
maxItemsInEachRow = columns
) {
selectedTiles.forEach { tileName ->
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach
val (card, action) = cardEntry
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.width(tileWidth)
.aspectRatio(1.05f)
.scaleOnPress(interactionSource)
.clickable { action(routes) },
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.22f)
)
)
)
.clipToBounds()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = Color.White,
modifier = Modifier.size(44.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
if (showChangelogDialog && latestUpdate != null) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = "Changelog",
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Update",
onConfirm = {
showChangelogDialog = false
handleUpdateAction()
},
dismissButtonText = "Cancel",
onDismiss = { showChangelogDialog = false },
confirmEnabled = !changelogLoading,
customContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 340.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
when {
changelogLoading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = Color.White
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading changelog…",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
changelogError != null -> {
Text(
text = changelogError ?: "Failed to load changelog",
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = changelogText ?: "Changelog not available",
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
selectedQuickActions = selectedTiles,
onDismiss = { showQuickActionsMenu = false },
onSave = { newList ->
val previous = selectedTiles.toList()
val removed = previous.filter { it !in newList }
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
newList.forEach { clearTileOffset(it) }
selectedTiles.clear()
selectedTiles.addAll(newList)
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}
showQuickActionsMenu = false
},
translation = translation
)
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@HomeRootSection.HomeScreen(nav)
}
}
}
}
private fun extractChangelogForVersion(raw: String, version: String): String {
internal fun extractChangelogForVersion(raw: String, version: String): String {
val lines = raw.lines()
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
val collected = mutableListOf<String>()
@@ -1185,3 +776,7 @@ private fun extractChangelogForVersion(raw: String, version: String): String {
}
}

View File

@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -32,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
@@ -43,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -51,6 +53,8 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
@@ -65,10 +69,10 @@ import java.util.concurrent.TimeUnit
class HomeSettings : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
private val dialogs by lazy { AlertDialogs(context.translation) }
private fun scheduleUpdateCheck() {
internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
@@ -125,11 +129,31 @@ class HomeSettings : Routes.Route() {
workManager.cancelUniqueWork("purrfectsnap_update_check")
}
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeSettings.SettingsScreen(nav)
}
}
}
@Composable
private fun RowTitle(title: String) {
internal fun RowTitle(title: String) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
@@ -138,16 +162,22 @@ class HomeSettings : Routes.Route() {
color = Color.White
)
}
@Composable
private fun PremiumPreferenceToggle(
internal fun PremiumPreferenceToggle(
sharedPreferences: SharedPreferences,
key: String,
text: String,
defaultValue: Boolean = false
defaultValue: Boolean = false,
confirmDisableTitle: String? = null,
confirmDisableText: String? = null
) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
var showDisableDialog by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
LaunchedEffect(realKey) {
if (!sharedPreferences.contains(realKey)) {
@@ -156,6 +186,24 @@ class HomeSettings : Routes.Route() {
}
}
if (showDisableDialog) {
AestheticDialog(
onDismissRequest = { showDisableDialog = false },
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
text = confirmDisableText.orEmpty(),
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
value = false
sharedPreferences.edit().putBoolean(realKey, false).apply()
showDisableDialog = false
},
onDismiss = { showDisableDialog = false },
showCloseButton = false
)
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -164,11 +212,15 @@ class HomeSettings : Routes.Route() {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = !value
sharedPreferences
.edit() {
val nextValue = !value
if (!nextValue && confirmDisableTitle != null) {
showDisableDialog = true
} else {
value = nextValue
sharedPreferences.edit() {
putBoolean(realKey, value)
}
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -184,7 +236,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
val hapticFeedback = LocalHapticFeedback.current
@@ -216,7 +268,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
var confirmationDialog by remember {
mutableStateOf(false)
}
@@ -264,8 +316,9 @@ class HomeSettings : Routes.Route() {
}
}
}
@Composable
private fun ShiftedRow(
internal fun ShiftedRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
@@ -278,628 +331,52 @@ class HomeSettings : Routes.Route() {
) { content(this) }
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val sharedButtonColors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.12f),
@Composable
internal fun GlassCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = modifier,
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.08f)),
contentColor = Color.White
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = modifier,
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.08f)),
contentColor = Color.White
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
content()
}
}
}
@Composable
fun AestheticDropdownField(
value: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.clip(shape)
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = value, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
contentColor = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Text(
text = translation["manager.routes.home_settings"],
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
}
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"])
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
}
GlassCard {
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"])
val hapticFeedback = LocalHapticFeedback.current
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) {
val spacingModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 26.dp)
ExposedDropdownMenuBox(
expanded = channelMenuExpanded,
onExpandedChange = { channelMenuExpanded = it },
modifier = spacingModifier
) {
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()
}
)
}
}
}
}
}
}
GlassCard {
RowTitle(title = "Reset PurrfectSnap")
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
// Clear setup progress and route back to SetupActivity
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
// Clear config to defaults
context.config.reset()
context.config.writeConfig()
// Launch setup activity fresh
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
// Close current manager activity
routes.navController.popBackStack()
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Reset and restart setup",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Reset",
modifier = Modifier.padding(end = 14.dp)
)
}
}
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredMessageCount()
}
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredStoriesCount()
}
var showImportDialog by remember { mutableStateOf(false) }
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()
.align(Alignment.CenterHorizontally),
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 { outputStream ->
context.messageLogger.databaseFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
}.onFailure {
context.log.error("Failed to export database", it)
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
}
},
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_message_logger.db")
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
routes.viewLoggerHistory.navigate {
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
}
}
}.onFailure {
context.log.error("Failed to open file", it)
context.longToast("Failed to open file! ${it.localizedMessage}")
}
},
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
}.onFailure {
context.log.error("Failed to clear messages", it)
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
}.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 = "Import")
}
}
}
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) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import message logger") },
text = { Text("Importing will override your current message logger database. Continue?") },
confirmButton = {
TextButton(onClick = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
runCatching {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
context.messageLogger.databaseFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Unable to open selected file")
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
context.shortToast(translation["success_toast"])
context.log.info("Imported message logger from $uri", "MessageLogger")
}.onFailure {
context.log.error("Failed to import message logger", it)
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
}
}
}.onFailure {
context.log.error("Failed to launch import picker", it)
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
}
}) {
Text(translation["button.import"] ?: "Import")
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text(translation["button.cancel"])
}
}
)
}
}
}
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = translation["friend_notes_description"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp),
color = Color.White,
textAlign = TextAlign.Center
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = {
runCatching {
val notes = context.database.getAllScopeNotes()
if (notes.isEmpty()) {
context.shortToast(translation["friend_notes_no_notes_to_backup"])
return@runCatching
}
val json = context.gson.toJson(notes)
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
it.write(json.toByteArray())
}
context.shortToast(translation["friend_notes_backup_success"])
}
}.onFailure {
context.log.error("Failed to backup notes", it)
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["backup_button"])
}
Button(onClick = {
runCatching {
activityLauncherHelper.openFile("application/json") { uri ->
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
val json = it.reader().readText()
val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
context.database.setAllScopeNotes(notes)
context.shortToast(translation["friend_notes_restore_success"])
}
}
}.onFailure {
context.log.error("Failed to restore notes", it)
context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: "")))
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["restore_button"])
}
}
}
}
}
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)
) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
Box(modifier = Modifier.weight(1f)) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
AestheticDropdownField(
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
expanded = expanded,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
onClick = { expanded = true }
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType ->
DropdownMenuItem(onClick = {
expanded = false
selectedFileType = fileType
}, text = {
Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName)
})
}
}
}
}
Button(
onClick = {
runCatching {
context.coroutineScope.launch {
selectedFileType.resolve(context.androidContext).delete()
}
}.onFailure {
context.log.error("Failed to clear file", it)
context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: "")))
}.onSuccess {
context.shortToast(translation["success_toast"])
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.1f),
contentColor = Color.White
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
shape = RoundedCornerShape(14.dp)
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(translation["clear_button"])
}
}
ShiftedRow {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true
)
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"])
}
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
}
content()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AestheticDropdownField(
value: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.clip(shape)
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = value, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.text.font.FontFamily
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.input.pointer.pointerInteropFilter
@@ -58,6 +53,8 @@ import kotlin.math.roundToInt
import kotlin.random.Random
class RetroGameScreen : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.retro_flight") }
override val content: @Composable (NavBackStackEntry) -> Unit = {
val gridWidth = 120
val gridHeight = 160
@@ -194,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
Box(
modifier = Modifier
.fillMaxSize()
@@ -205,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = "Retro Flight",
onBack = { routes.navController.popBackStack() }
)
if (isAphelion) {
FloatingTopBar(
title = translation["title"] ?: "Retro Flight",
onBack = { routes.navController.popBackStack() }
)
} else {
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["title"] ?: "",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
@@ -243,7 +282,7 @@ class RetroGameScreen : Routes.Route() {
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "GAME OVER",
text = translation["game_over_label"],
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -258,7 +297,7 @@ class RetroGameScreen : Routes.Route() {
),
shape = RoundedCornerShape(6.dp)
) {
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
}
}
}
@@ -293,7 +332,7 @@ class RetroGameScreen : Routes.Route() {
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "LEFT",
text = translation["left_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
@@ -324,7 +363,7 @@ class RetroGameScreen : Routes.Route() {
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "RIGHT",
text = translation["right_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,

View File

@@ -1,13 +1,20 @@
package me.eternal.purrfectsnap.ui.manager.pages.location
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
alertDialogs.DefaultDialogCard {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
val fieldColors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedLabelColor = PurrfectPalette.textSecondary,
unfocusedLabelColor = PurrfectPalette.textSecondary,
cursorColor = PurrfectPalette.glowSecondary,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
Surface(
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp
) {
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(
Column(
modifier = Modifier
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) }
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) }
)
OutlinedTextField(
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) }
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
Color(0xFF1A143A).copy(alpha = 0.94f)
)
),
RoundedCornerShape(24.dp)
)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
Text(
text = translation["save_coordinates_dialog_title"],
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
horizontalArrangement = Arrangement.End
) {
Text(translation["save_dialog_button"])
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
contentColor = Color.White,
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
disabledContentColor = Color.White.copy(alpha = 0.6f)
)
) {
Text(translation["save_dialog_button"])
}
}
}
}
}
}
}

View File

@@ -6,7 +6,6 @@ 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.isSystemInDarkTheme
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -127,7 +126,11 @@ class BetterLocationRoot : Routes.Route() {
overflow = TextOverflow.Ellipsis
)
Text(
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
text = translation.format(
"spoofed_coordinates_title",
"latitude" to friendLocation.latitude.toFloat().toString(),
"longitude" to friendLocation.longitude.toFloat().toString()
),
fontSize = 11.sp,
fontWeight = FontWeight.Light,
color = Color.White.copy(alpha = 0.8f)
@@ -217,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
}
@Composable
private fun ThemedEditLocationButton(onClick: () -> Unit) {
private fun CoordinateActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
accent: Color,
onClick: () -> Unit
) {
FilledIconButton(
modifier = Modifier.size(40.dp),
onClick = onClick,
modifier = Modifier.size(42.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
),
onClick = onClick
containerColor = accent.copy(alpha = 0.22f),
contentColor = Color.White
)
) {
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
Icon(icon, contentDescription = description)
}
}
@@ -234,6 +242,8 @@ class BetterLocationRoot : Routes.Route() {
val coordinatesProperty = remember {
context.config.root.global.betterLocation.getPropertyPair("coordinates")
}
val providerProperty = remember { context.config.root.global.betterLocation.getPropertyPair("location_search_provider") }
val apiKeyProperty = remember { context.config.root.global.betterLocation.getPropertyPair("google_maps_api_key") }
val updateDispatcher = rememberAsyncUpdateDispatcher()
val savedCoordinates = rememberAsyncMutableStateList(
@@ -245,10 +255,17 @@ class BetterLocationRoot : Routes.Route() {
var showMap by remember { mutableStateOf(false) }
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
var showTeleportDialog by remember { mutableStateOf(false) }
var showProviderDialog by remember { mutableStateOf(false) }
var showApiKeyDialog by remember { mutableStateOf(false) }
val marker = remember { mutableStateOf<Marker?>(null) }
val mapView = remember { mutableStateOf<MapView?>(null) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) {
mutableStateOf(
(coordinatesProperty.value.getNullable() as? Pair<*, *>)
?: (0.0 to 0.0)
)
}
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
context.coroutineScope.launch {
@@ -271,6 +288,36 @@ class BetterLocationRoot : Routes.Route() {
)
}
var currentProvider by remember {
mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm")
}
var currentApiKey by remember {
mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "")
}
if (showProviderDialog) {
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
showProviderDialog = false
context.config.writeConfig()
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
}) {
alertDialogs.UniqueSelectionDialog(providerProperty)
}
}
if (showApiKeyDialog) {
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}) {
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -337,9 +384,16 @@ class BetterLocationRoot : Routes.Route() {
)
) {
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
addSavedCoordinateDialog = true
}) {
alertDialogs.ChooseLocationDialog(
property = coordinatesProperty,
marker = marker,
mapView = mapView,
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
saveCoordinates = {
addSavedCoordinateDialog = true
}
) {
showMap = false
context.config.writeConfig()
}
@@ -395,6 +449,42 @@ class BetterLocationRoot : Routes.Route() {
) {
context.config.root.global.betterLocation.suspendLocationUpdates.set(it)
}
@Composable
fun ConfigSelector(text: String, value: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.weight(1f))
Text(
text = value,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp,
modifier = Modifier.padding(start = 8.dp)
)
}
}
@Composable
fun ConfigInput(text: String, value: String, onClick: () -> Unit) {
ConfigSelector(text, if (value.isNotEmpty()) "********" else translation["options.empty"], onClick)
}
ConfigSelector(
text = translation["location_search_provider_title"],
value = translation["option_$currentProvider"]
) { showProviderDialog = true }
if (currentProvider == "google_maps") {
ConfigInput(
text = translation["google_maps_api_key_title"],
value = currentApiKey
) { showApiKeyDialog = true }
}
}
item {
GlassPanel(
@@ -588,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
color = PurrfectPalette.textSecondary
)
}
FilledIconButton(onClick = {
CoordinateActionButton(
icon = Icons.Default.Edit,
description = translation["edit_icon_description"],
accent = PurrfectPalette.glowPrimary
) {
showEditDialog = true
}) {
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
}
FilledIconButton(onClick = {
CoordinateActionButton(
icon = Icons.Default.DeleteOutline,
description = translation["delete_icon_description"],
accent = PurrfectPalette.glowSecondary
) {
showDeleteDialog = true
}) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
}
}
}

View File

@@ -255,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -292,7 +292,7 @@ class ManageScriptReposSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
}
}
}
@@ -183,7 +183,8 @@ fun ScriptCatalog(root: ScriptingRootSection) {
Button(
onClick = {
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md"
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md",
context.translation["toast_open_link_failed"]
)
},
colors = ButtonDefaults.buttonColors(
@@ -205,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
) {
item {
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
)
)
)
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
Text(
text = translation["loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
}
}
}
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
Box(
@@ -310,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = translation.format("version", "version" to (entry.version ?: "N/A")),
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
fontWeight = FontWeight.Medium,
fontSize = 11.sp,

View File

@@ -30,7 +30,9 @@ 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.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
@@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.storage.isScriptEnabled
import me.eternal.purrfectsnap.storage.setScriptEnabled
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.chooseFolder
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
class ScriptingRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.scripting") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0)
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
internal var selectedTab by mutableStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
return try {
val installedScripts = context.scriptManager.getSyncedModules()
installedScripts.any { module ->
@@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() {
}
}
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
context.coroutineScope.launch {
if (isScriptInstalledByUrl(scriptUrl)) {
context.shortToast(translation["script_already_installed"])
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ImportRemoteScript(
internal fun ImportRemoteScript(
dismiss: () -> Unit
) {
var url by remember { mutableStateOf("") }
@@ -140,7 +142,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
isLoading = false
}
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ModuleActions(
internal fun ModuleActions(
script: ModuleInfo,
canUpdate: Boolean,
dismiss: () -> Unit
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
fun ModuleItem(script: ModuleInfo) {
internal fun ModuleItem(script: ModuleInfo) {
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
context.database.isScriptEnabled(script.name)
}
@@ -430,8 +432,8 @@ class ScriptingRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {}
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
internal fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"]
Box(
modifier = Modifier
.fillMaxWidth()
@@ -439,13 +441,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(68.dp),
modifier = Modifier.size(78.dp),
shape = CircleShape,
color = Color.White.copy(alpha = 0.1f),
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
shadowElevation = 14.dp,
border = BorderStroke(
1.dp,
1.5.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,17 +458,18 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(6.dp)
.size(66.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
)
)
)
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
@@ -474,7 +477,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(34.dp)
)
}
}
@@ -482,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
fun ScriptSettings(script: ModuleInfo) {
internal fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember {
val module =
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
@@ -499,70 +502,23 @@ class ScriptingRootSection : Routes.Route() {
}
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) { context.scriptManager.getScriptsFolder() }
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
if (showImportDialog) {
ImportRemoteScript { showImportDialog = false }
}
if (showToast) {
LaunchedEffect(showToast) {
context.shortToast(translation["select_scripts_folder_toast"])
showToast = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
}
},
onImport = {
if (scriptingFolder == null) showToast = true else showImportDialog = true
},
onOpenFolder = {
if (scriptingFolder == null) showToast = true else scriptingFolder?.let { context.androidContext.openLink(it.uri.toString()) }
},
onManageRepos = { routes.manageScriptRepos.navigate() },
onDocs = { context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs") },
folderSelected = scriptingFolder != null
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
)
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@ScriptingRootSection.ScriptingScreen(nav)
}
}
}
@Composable
private fun InstalledTabContent(
internal fun InstalledTabContent(
scriptingFolder: DocumentFile?
) {
val scriptModules by rememberAsyncMutableState(
@@ -613,7 +569,7 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
Surface(
@@ -729,7 +685,7 @@ class ScriptingRootSection : Routes.Route() {
title = context.translation["manager.dialogs.scripting_warning.title"],
text = context.translation["manager.dialogs.scripting_warning.content"],
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"] ?: "OK",
confirmButtonText = translation["button.ok"],
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
@@ -785,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun CatalogTabContent(
internal fun CatalogTabContent(
scriptingFolder: DocumentFile?
) {
val coroutineScope = rememberCoroutineScope()
@@ -818,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingHeader(
internal fun ScriptingHeader(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit,
@@ -861,7 +817,7 @@ class ScriptingRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.scripts"] ?: "Scripts",
text = translation["manager.routes.scripts"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -896,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingTabSwitcher(
internal fun ScriptingTabSwitcher(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit
@@ -943,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -219,19 +219,26 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
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 {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
@@ -248,6 +255,13 @@ class AddFriendDialog(
}
}
}
DisposableEffect(Unit) {
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
me.eternal.purrfectsnap.ui.util.Dialog(
onDismissRequest = {
@@ -375,8 +389,7 @@ class AddFriendDialog(
enabled = !allFriendsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
?: "Select All",
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {

View File

@@ -192,7 +192,7 @@ class LoggedStories : Routes.Route() {
content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use {
it.data.toFile().absolutePath
} ?: run {
context.shortToast("Failed to get file")
context.shortToast(translation["failed_to_get_file"])
return@Button
},
type = DownloadMediaType.LOCAL_MEDIA,

View File

@@ -91,7 +91,7 @@ class ManageScope: Routes.Route() {
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
text = "",
icon = Icons.Rounded.DeleteForever,
confirmButtonText = translation["delete_button"] ?: "Delete",
confirmButtonText = translation["delete_button"],
dismissButtonText = context.translation["button.cancel"],
onDismiss = { deleteConfirmDialog = false },
onConfirm = {
@@ -109,7 +109,7 @@ class ManageScope: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = titleText ?: "Manage",
title = titleText ?: translation["manage_scope_title"],
onBack = { routes.navController.popBackStack() },
actions = {
IconButton(onClick = { deleteConfirmDialog = true }) {

View File

@@ -63,7 +63,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
class MessagingPreview: Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview") }
private lateinit var coroutineScope: CoroutineScope
private lateinit var previewScrollState: LazyListState
@@ -79,21 +79,6 @@ class MessagingPreview: Routes.Route() {
else selectedMessages.add(messageId)
}
private fun tr(key: String, fallback: String? = null): String {
fun normalizeCandidate(candidate: String?): String? {
if (candidate.isNullOrBlank()) return null
if (candidate == key) return null
if (candidate.endsWith(".$key")) return null
return candidate
}
return normalizeCandidate(translation.getOrNull(key))
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
?: fallback
?: key
}
@Composable
private fun ActionsSheetItem(
title: String,
@@ -338,20 +323,20 @@ class MessagingPreview: Routes.Route() {
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
when {
senderId == null -> "Unknown"
senderId == myUserId -> "You"
senderId == null -> translation["sender_unknown"]
senderId == myUserId -> translation["sender_you"]
scope == SocialScope.FRIEND -> friendDisplayName
?: context.database.getFriendInfo(scopeId)?.displayName
?: context.database.getFriendInfo(scopeId)?.mutableUsername
?: "Friend"
?: translation["sender_friend"]
else -> context.database.getFriendInfo(senderId)?.displayName
?: context.database.getFriendInfo(senderId)?.mutableUsername
?: "Unknown"
?: translation["sender_unknown"]
}
}
val contentTypeLabel = remember(contentType) {
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: translation["sender_unknown"]
}
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
@@ -408,7 +393,7 @@ class MessagingPreview: Routes.Route() {
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Text(
text = senderDisplayName ?: "Unknown",
text = senderDisplayName ?: translation["sender_unknown"],
color = Color.White.copy(alpha = 0.82f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
@@ -487,7 +472,7 @@ class MessagingPreview: Routes.Route() {
.padding(40.dp),
horizontalArrangement = Arrangement.Center
) {
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
Text(translation["no_message_hint"], color = PurrfectPalette.textSecondary)
}
}
Spacer(modifier = Modifier.height(20.dp))
@@ -564,7 +549,7 @@ class MessagingPreview: Routes.Route() {
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
} ?: translation.getOrNull("processed_messages_toast")?.let {
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
} ?: "Processed ${processMessageCount.intValue} messages"
} ?: translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
context.longToast(toastText)
}
}
@@ -579,7 +564,7 @@ class MessagingPreview: Routes.Route() {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return
}
@@ -640,7 +625,7 @@ class MessagingPreview: Routes.Route() {
) {
val processedText = translation.getOrNull("processed_messages_text")?.let {
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
} ?: "Processed ${processMessageCount.intValue}"
} ?: translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
Text(processedText)
if (activeTask?.hasFixedGoal() == true) {
LinearProgressIndicator(
@@ -679,7 +664,7 @@ class MessagingPreview: Routes.Route() {
}.onFailure {
context.log.error("Failed to fetch messages", it)
context.shortToast(
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
translation.getOrNull("message_fetch_failed") ?: translation["message_fetch_failed"]
)
}
}
@@ -710,7 +695,7 @@ class MessagingPreview: Routes.Route() {
fetchNewMessages()
}.onFailure {
context.longToast(
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
translation.getOrNull("bridge_init_failed") ?: translation["bridge_init_failed"]
)
context.log.error("Failed to initialize messaging bridge", it)
}
@@ -751,11 +736,11 @@ class MessagingPreview: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = titleText ?: translation["title"] ?: "Preview",
title = titleText ?: translation["title"],
subtitle = if (selectedMessages.isNotEmpty()) {
"${selectedMessages.size} selected"
} else {
tr("subtitle", "Hold to select")
translation["subtitle"]
.substringBefore("")
.substringBefore("·")
.substringBefore("|")
@@ -798,7 +783,7 @@ class MessagingPreview: Routes.Route() {
Text(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge",
?: translation["bridge_connection_error"],
modifier = Modifier.padding(16.dp),
color = Color.White
)
@@ -831,7 +816,7 @@ class MessagingPreview: Routes.Route() {
val selectionSubtitle = if (hasSelection) {
"${selectedMessages.size} selected"
} else {
"Choose message types"
translation["choose_message_types_subtitle"]
}
Column(
@@ -862,7 +847,7 @@ class MessagingPreview: Routes.Route() {
)
Spacer(Modifier.height(14.dp))
Text(
text = tr("actions_title", "Conversation Actions"),
text = translation["actions_title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
@@ -886,31 +871,31 @@ class MessagingPreview: Routes.Route() {
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
ActionsSheetItem(
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
title = translation[saveKey],
subtitle = if (hasSelection) translation["save_selected_messages_subtitle"] else translation["save_by_content_type_subtitle"],
icon = Icons.Rounded.BookmarkAdded
) {
launchMessagingTask(MessagingTaskType.SAVE)
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
}
ActionsSheetItem(
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
title = translation[unsaveKey],
subtitle = if (hasSelection) translation["unsave_selected_messages_subtitle"] else translation["unsave_by_content_type_subtitle"],
icon = Icons.Rounded.BookmarkBorder
) {
launchMessagingTask(MessagingTaskType.UNSAVE)
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
}
ActionsSheetItem(
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
subtitle = "Marks snaps as seen",
title = translation[markKey],
subtitle = translation["mark_as_seen_subtitle"],
icon = Icons.Rounded.RemoveRedEye
) {
if (messagingBridge == null) {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return@ActionsSheetItem
}
@@ -924,8 +909,8 @@ class MessagingPreview: Routes.Route() {
runCurrentTask()
}
ActionsSheetItem(
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
title = translation[deleteKey],
subtitle = if (hasSelection) translation["delete_selected_messages_subtitle"] else translation["delete_by_content_type_subtitle"],
icon = Icons.Rounded.DeleteForever,
danger = true
) {
@@ -933,7 +918,7 @@ class MessagingPreview: Routes.Route() {
context.longToast(
translation.getOrNull("bridge_connection_error")
?: translation.getOrNull("bridge_connection_failed")
?: "Failed to connect to bridge"
?: translation["bridge_connection_error"]
)
return@ActionsSheetItem
}

View File

@@ -35,31 +35,45 @@ 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.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
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
class SocialRootSection : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
private fun updateScopeLists() {
internal fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
}
}
internal fun requestLatestSnapshot() {
runCatching {
context.androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
context.log.error("Failed to request latest social snapshot", it)
}
}
@Composable
private fun ScopeList(
internal fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
@@ -76,7 +90,6 @@ class SocialRootSection : Routes.Route() {
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
//check if scope list is empty
val listSize = list.size
if (listSize == 0) {
@@ -185,138 +198,23 @@ class SocialRootSection : Routes.Route() {
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList
} else {
friendList.filter {
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
val filteredGroups = remember(groupList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
groupList
} else {
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
SocialHeader(
titles = titles,
pagerState = pagerState,
onTabSelected = { index ->
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
friendCount = friendList.size,
groupCount = groupList.size,
searchActive = searchActive,
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = searchShape,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, searchBorder),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, searchShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = searchHint,
tint = PurrfectPalette.textSecondary
)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontSize = 15.sp
),
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
modifier = Modifier.weight(1f)
) { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = searchHint,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
innerTextField()
}
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = context.translation["close_button_description"]
?: "Clear search",
tint = Color.White
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@SocialRootSection.SocialScreen(nav)
}
}
}
@Composable
private fun SocialCard(
internal fun SocialCard(
scope: SocialScope,
friend: MessagingFriendInfo?,
group: MessagingGroupInfo?,
@@ -479,7 +377,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialHeader(
internal fun SocialHeader(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit,
@@ -521,7 +419,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.social"] ?: "Social",
text = translation["manager.routes.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -532,12 +430,12 @@ class SocialRootSection : Routes.Route() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
StatPill(label = "Friends", value = friendCount)
StatPill(label = "Groups", value = groupCount)
StatPill(label = translation["friends_tab"], value = friendCount)
StatPill(label = translation["groups_tab"], value = groupCount)
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) "Close search" else "Search",
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
tint = Color.White
)
}
@@ -553,7 +451,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialTabSwitcher(
internal fun SocialTabSwitcher(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit
@@ -599,7 +497,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun EmptyState(scope: SocialScope) {
internal fun EmptyState(scope: SocialScope) {
val title = when (scope) {
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
@@ -626,13 +524,13 @@ class SocialRootSection : Routes.Route() {
modifier = Modifier.size(26.dp)
)
Text(
text = title,
text = title ?: "",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
text = translation["social_empty_hint"] ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -641,7 +539,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun StatPill(label: String, value: Int) {
internal fun StatPill(label: String, value: Int) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.08f),
@@ -670,3 +568,7 @@ class SocialRootSection : Routes.Route() {
}
}
}

View File

@@ -0,0 +1,237 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
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.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.scaleOnPress
@Composable
fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
val horizontalPadding = 24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding + 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Box(modifier = Modifier.fillMaxWidth().background(PurrfectPalette.panelGradient)) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = translation["about_title"] ?: "About",
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
fontFamily = avenirNext,
modifier = Modifier.clickable(
interactionSource = tapSource,
indication = null,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
}
)
)
Text(
text = translation["about_tagline"] ?: "",
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = translation["about_lead_developers_title"] ?: "Lead Developers",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
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))
}
}
}
}
// LITERAL OUR STORY VERBATIM RESTORATION (Justified & Indented)
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
PurrfectMarqueeText(
text = translation["about_story_title"] ?: "About Us",
color = Color.White,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(0.7f).padding(vertical = 4.dp),
thickness = 1.2.dp,
color = Color.White.copy(alpha = 0.3f)
)
Text(
text = aboutStory,
fontSize = 15.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Justify,
style = LocalTextStyle.current.copy(
lineHeight = 22.sp,
textIndent = TextIndent(firstLine = 24.sp)
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
)
}
}
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = translation["about_thanks_title"] ?: "Special Thanks", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}

View File

@@ -0,0 +1,56 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.runtime.*
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.PropertyPair
import me.eternal.purrfectsnap.common.config.toPropertyPair
@Composable
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
when (currentDestination?.route) {
FEATURE_CONTAINER_ROUTE -> {
val containerName = navBackStackEntry?.arguments?.getString("name")!!
val propertyPair = allContainers[containerName]!!
Container(
configContainer = propertyPair.value.get() as ConfigContainer,
stateKey = "${routeInfo.id}:container:$containerName",
sectionTitle = context.translation[propertyPair.key.propertyName()],
sectionSubtitle = context.translation[propertyPair.key.propertyDescription()],
onBack = { routes.navController.popBackStack() }
)
}
SEARCH_FEATURE_ROUTE -> {
val keyword = navBackStackEntry?.arguments?.getString("keyword").orEmpty()
val properties = allProperties.filter {
isSearchVisibleProperty(it.key) && (
it.key.name.contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { (it.key to it.value).toPropertyPair() }
PropertiesView(
properties = properties,
stateKey = "${routeInfo.id}:search:$keyword",
isSearchResults = true,
searchKeyword = keyword,
enableGlobalSearch = true,
onBack = { navigateToMainRoot() }
)
}
else -> {
Container(
configContainer = context.config.root,
stateKey = "${routeInfo.id}:container:root"
)
}
}
}

View File

@@ -0,0 +1,884 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
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.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
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.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import okhttp3.OkHttpClient
import okhttp3.Request
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
@Composable
fun LivingPurrAura(isActive: Boolean, haptic: HapticFeedback) {
val infiniteTransition = rememberInfiniteTransition(label = "aura")
val pulseScale by infiniteTransition.animateFloat(
initialValue = 0.88f, targetValue = 1.12f,
animationSpec = infiniteRepeatable(tween(1600, easing = EaseInOutSine), RepeatMode.Reverse),
label = "pulse"
)
val glow1 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, easing = LinearEasing), RepeatMode.Restart),
label = "g1"
)
val glow2 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 1100, easing = LinearEasing), RepeatMode.Restart),
label = "g2"
)
val glow3 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 2200, easing = LinearEasing), RepeatMode.Restart),
label = "g3"
)
val coreColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3),
animationSpec = tween(800), label = "coreColor"
)
val secondaryColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowSecondary else Color(0xFF6B6B7A),
animationSpec = tween(800), label = "secondaryColor"
)
Canvas(modifier = Modifier.size(44.dp)) {
val center = Offset(size.width / 2, size.height / 2)
val baseRadius = 6.dp.toPx()
fun drawAuroraGlow(progress: Float, alphaMultiplier: Float) {
if (!isActive || progress <= 0f) return
val auroraRadius = baseRadius * (1.2f + 4.5f * progress)
drawCircle(
brush = Brush.radialGradient(
0.0f to coreColor.copy(alpha = 0.25f * (1f - progress) * alphaMultiplier),
0.6f to secondaryColor.copy(alpha = 0.12f * (1f - progress) * alphaMultiplier),
1.0f to Color.Transparent,
center = center, radius = auroraRadius
),
radius = auroraRadius, center = center
)
}
drawAuroraGlow(glow1, 0.8f)
drawAuroraGlow(glow2, 0.5f)
drawAuroraGlow(glow3, 0.3f)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(coreColor, secondaryColor),
center = center, radius = baseRadius * pulseScale
),
radius = baseRadius * pulseScale, center = center
)
drawCircle(
color = Color.White.copy(alpha = 0.5f),
radius = (baseRadius * pulseScale) * 0.25f,
center = Offset(center.x - (baseRadius * pulseScale) * 0.3f, center.y - (baseRadius * pulseScale) * 0.3f)
)
}
}
@Composable
fun AphelionTopBarActionChip(
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
haptic: HapticFeedback,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(20.dp).graphicsLayer {
val s = 0.82f + (0.18f * shrinkFactor); scaleX = s; scaleY = s
}
)
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)
)
}
}
}
}
@Composable
fun RowScope.AphelionHomeActionChips(
scrollState: androidx.compose.foundation.ScrollState,
haptic: HapticFeedback
) {
val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
AphelionTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() }
AphelionTopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() }
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AphelionHeroSection(
versionName: String,
latestUpdate: Updater.LatestRelease?,
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
scrollOffset: () -> Int,
haptic: HapticFeedback
) {
val heroShape = RoundedCornerShape(36.dp)
val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) }
Box(
modifier = Modifier
.padding(horizontal = HomeRootSection.cardMargin, vertical = 6.dp)
.clip(heroShape)
.background(Brush.linearGradient(heroGradientColors))
.border(1.dp, Color.White.copy(alpha = 0.1f), heroShape)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "PurrfectSnap",
color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, fontFamily = avenirNext,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 250f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.06f)
}
)
Text(
text = "By \u039eT\u039eRNAL",
color = Color.White.copy(alpha = 0.75f), fontSize = 14.sp, fontFamily = avenirNext,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 300f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.04f)
}
)
Text(
text = translation["hero_tagline"] ?: "",
color = Color.White.copy(alpha = 0.9f), fontSize = 15.sp, lineHeight = 20.sp, textAlign = TextAlign.Center,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 350f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.02f)
}
)
}
FlowRow(
modifier = Modifier.fillMaxWidth().graphicsLayer {
alpha = (1f - ((scrollOffset() - 400f) / 120f)).coerceIn(0f, 1f)
},
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge(translation.format("hero_build_label", "build" to it))
}
}
if (latestUpdate != null) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(translation["update_title"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Text(translation.format("update_content", "version" to latestUpdate.versionName), color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp)
}
AnimatedContent(targetState = downloadState, label = "UpdateDownloadHero") { state ->
when (state) {
UpdateDownloader.DownloadState.IDLE,
UpdateDownloader.DownloadState.FAILED -> {
Button(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onUpdateAction() },
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
) { Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) }
}
UpdateDownloader.DownloadState.DOWNLOADING -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(progress = { downloadProgress }, modifier = Modifier.size(28.dp), color = Color.White)
Text("${(downloadProgress * 100).toInt()}%", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
UpdateDownloader.DownloadState.COMPLETED -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFFA3F0C2))
Text(translation["update_ready_label"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)),
tonalElevation = 0.dp, shadowElevation = 0.dp
) {
val unifiedButtonWidth = 180.dp
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.12f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier.width(unifiedButtonWidth).height(46.dp),
tonalElevation = 0.dp, shadowElevation = 0.dp
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) {
LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isPurrAuraActive) translation["purr_aura_active_label"] ?: "" else translation["purr_aura_inactive_label"] ?: "",
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp
)
}
}
OutlinedButton(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onAboutClick() },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(containerColor = Color.White.copy(alpha = 0.06f), contentColor = Color.White),
modifier = Modifier.width(unifiedButtonWidth).height(46.dp)
) {
Icon(Icons.Filled.Info, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation.getOrNull("about_meet_team_button") ?: "About Us", fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val androidContext = context.androidContext
Button(
modifier = Modifier.weight(1f).height(44.dp),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://purrfectsnap.me", context.translation["toast_open_link_failed"]) },
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
PurrfectMarqueeText(text = "Site", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color(0xFF1B152E))
}
}
OutlinedButton(
modifier = Modifier.weight(1f).height(44.dp),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
PurrfectMarqueeText(text = translation["github_button"] ?: "", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color.White)
}
}
ExternalLinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) },
tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f),
haptic = haptic
)
}
}
}
}
}
val haptic = LocalHapticFeedback.current
val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) }
val prefs = remember { context.sharedPreferences }
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
val hasInitialized = prefs.getBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, false)
when {
storedTiles.isNotEmpty() -> {
if (!hasInitialized) prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
storedTiles
}
hasInitialized -> storedTiles
else -> {
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 downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
val scrollState = rememberScrollState()
var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) }
var showChangelogDialog by rememberSaveable { mutableStateOf(false) }
var changelogText by rememberSaveable { mutableStateOf<String?>(null) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
var fullChangelogLoading by remember { mutableStateOf(false) }
var fullChangelogError by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
var announcementsLoading by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.value) { routes.navigation?.globalScrollOffset = scrollState.value }
val handleUpdateAction: () -> Unit = {
latestUpdate?.let { latest ->
val abiName = android.os.Build.SUPPORTED_ABIS.firstNotNullOfOrNull {
when (it) { "arm64-v8a" -> "arm64"; "armeabi-v7a" -> "armv7"; else -> null }
}
if (latest.workflowId != null) {
if (abiName != null) {
val artifactName = "purrfectsnap-${if (abiName == "arm64") "armv8" else "armv7"}-debug"
UpdateDownloader.downloadAndInstall(context, "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip", "$artifactName.zip", coroutineScope)
}
} else {
abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url ->
UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope)
}
}
}
}
fun loadChangelog() {
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
changelogError = e.message ?: "Failed to fetch"
changelogLoading = false
}
}
}
}
fun loadAnnouncements() {
if (announcementsText != null) return
announcementsLoading = true
coroutineScope.launch(Dispatchers.IO) {
runCatching {
OkHttpClient().newCall(Request.Builder().url(announcementsUrl).build()).execute().use { it.body?.string() ?: "" }
}
.onSuccess { withContext(Dispatchers.Main) { announcementsText = it; announcementsLoading = false } }
.onFailure { withContext(Dispatchers.Main) { announcementsLoading = false } }
}
}
fun loadFullChangelog() {
if (fullChangelogText != null) return
fullChangelogLoading = true
fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
body.trim()
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
fullChangelogText = text
fullChangelogLoading = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
fullChangelogError = e.message ?: "Failed to fetch"
fullChangelogLoading = false
}
}
}
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
Box(modifier = Modifier.fillMaxSize().background(HomeRootSection.pageBackgroundGradient)) {
val focusFactor by remember(scrollState.value) {
derivedStateOf { (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
}
val stickyBrandingAlpha by remember(scrollState.value) {
derivedStateOf { ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f) }
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val headerHeight = lerp(54.dp, 56.dp, focusFactor)
val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
val topCorners = lerp(26.dp, 0.dp, focusFactor)
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) {
val refractiveColor = remember { Color(0xFF241F52) }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = containerTopPadding)
.height(internalTopPadding + headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
1.0f to Color.Transparent
)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = containerTopPadding)
.headerHeightTracker { controlsHeight = it }
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = focusFactor * 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = focusFactor * 0.4f)
)
)
val tr = topCorners.toPx()
val br = bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
uPath.apply {
moveTo(0f, 0f)
lineTo(0f, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 180f, -90f, false)
lineTo(size.width - br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 90f, -90f, false)
lineTo(size.width, 0f)
}
drawPath(uPath, brush, style = Stroke(strokeWidth))
} else if (focusFactor > 0.01f) {
borderPath.reset()
borderPath.apply {
moveTo(tr, 0f)
lineTo(size.width - tr, 0f)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * tr, 0f, size.width, 2 * tr), 270f, 90f, false)
lineTo(size.width, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 0f, 90f, false)
lineTo(br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 90f, 90f, false)
lineTo(0f, tr)
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2 * tr, 2 * tr), 180f, 90f, false)
}
drawPath(borderPath, brush, style = Stroke(strokeWidth))
}
},
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = bottomCorners, bottomEnd = bottomCorners),
color = Color(0xFF1B152E).copy(alpha = focusFactor * 0.95f)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = internalTopPadding)
.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() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null,
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)
}
}
}
}
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = routes.bottomPadding + 4.dp)) {
Spacer(Modifier.height(controlsHeight + containerTopPadding))
AphelionHeroSection(
versionName = BuildConfig.VERSION_NAME,
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,
scrollOffset = { scrollState.value },
haptic = haptic
)
Spacer(Modifier.height(12.dp))
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActions") { hasQuickActions ->
Surface(
modifier = Modifier.padding(horizontal = HomeRootSection.cardMargin, vertical = 10.dp),
shape = RoundedCornerShape(34.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(quickActionsGradientColors)).padding(horizontal = 24.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!hasQuickActions) {
Text(translation["quick_actions_title"] ?: "", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.85f))
Spacer(Modifier.height(24.dp))
Icon(Icons.Outlined.Widgets, contentDescription = null, modifier = Modifier.size(72.dp), tint = Color.White)
Spacer(Modifier.height(16.dp))
Text(translation["quick_actions_empty_title"] ?: "", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(Modifier.height(20.dp))
Button(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text(translation["quick_actions_add_tile_button"] ?: "")
}
} else {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(translation["quick_actions_title"] ?: "", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()), fontSize = 13.sp, color = Color.White.copy(alpha = 0.75f))
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(translation["quick_actions_manage_button"] ?: "")
}
}
var gridIsVisible by remember { mutableStateOf(false) }
var animationPhase by remember { mutableIntStateOf(1) }
LaunchedEffect(gridIsVisible) {
if (gridIsVisible) {
delay(600); animationPhase = 2
delay(1200); animationPhase = 3
}
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords ->
val windowHeight = context.androidContext.resources.displayMetrics.heightPixels
val posY = coords.localToWindow(Offset.Zero).y
if (posY > 0 && posY < windowHeight * 0.95f) gridIsVisible = true
}
) {
val columns = (maxWidth / 110.dp).toInt().coerceIn(2, 4)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.spacedBy(12.dp),
maxItemsInEachRow = columns
) {
selectedTiles.forEach { name ->
val cardEntry = cards.entries.find { it.key.first == name } ?: return@forEach
val interactionSource = remember { MutableInteractionSource() }
val animatedIconSize by animateDpAsState(
targetValue = if (animationPhase >= 2) 28.dp else 44.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
label = "iconShrink"
)
Surface(
modifier = Modifier.width(100.dp).aspectRatio(1.05f).scaleOnPress(interactionSource)
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); cardEntry.value(routes) },
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.3f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f)))).clipToBounds()) {
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(cardEntry.key.second, contentDescription = null, tint = Color.White, modifier = Modifier.size(animatedIconSize))
Spacer(Modifier.height(8.dp))
PurrfectMarqueeText(
text = cardEntry.key.first,
style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
color = Color.White,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
if (showAnnouncementsDialog) {
AestheticDialog(
onDismissRequest = { showAnnouncementsDialog = false },
title = translation["announcements_dialog_title"] ?: "Announcements",
text = "", icon = Icons.Filled.Notifications,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showAnnouncementsDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showChangelogDialog) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "", icon = Icons.Filled.Info,
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
onDismiss = { showChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (changelogLoading) CircularProgressIndicator(color = Color.White)
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showFullChangelogDialog) {
AestheticDialog(
onDismissRequest = { showFullChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "",
icon = Icons.Filled.Description,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showFullChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
selectedQuickActions = selectedTiles,
onDismiss = { showQuickActionsMenu = false },
onSave = { newList ->
val removed = selectedTiles.filter { it !in newList }
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
selectedTiles.clear(); selectedTiles.addAll(newList)
context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) }
showQuickActionsMenu = false
},
translation = translation
)
}
}

View File

@@ -0,0 +1,180 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
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.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing 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)
}
}
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
val filteredLogs = (0 until reader.lineCount).mapNotNull { index ->
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
}
withContext(Dispatchers.Main) {
visibleLogs.clear()
visibleLogs.addAll(filteredLogs)
if (visibleLogs.isNotEmpty()) {
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
}
isRefreshing = false
}
}
}
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
refreshLogs()
}
}
LaunchedEffect(Unit) {
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var showDropDown by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 12.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
clearLogsAndReload()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"] ?: "Clear", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
exportLogs()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"] ?: "Export", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
)
}
}

View File

@@ -0,0 +1,92 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import androidx.documentfile.provider.DocumentFile
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.common.util.ktx.openLink
import kotlinx.coroutines.launch
@Composable
fun ScriptingRootSection.AphelionScriptingScreen(nav: NavBackStackEntry) {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) { context.scriptManager.getScriptsFolder() }
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
}
}
if (showImportDialog) {
ImportRemoteScript { showImportDialog = false }
}
if (showToast) {
LaunchedEffect(showToast) {
context.shortToast(translation["select_scripts_folder_toast"])
showToast = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
}
},
onImport = {
if (scriptingFolder == null) showToast = true else showImportDialog = true
},
onOpenFolder = {
if (scriptingFolder == null) {
showToast = true
} else {
scriptingFolder?.let {
context.androidContext.openLink(
it.uri.toString(),
context.translation["toast_open_link_failed"]
)
}
}
},
onManageRepos = { routes.manageScriptRepos.navigate() },
onDocs = {
context.androidContext.openLink(
"https://github.com/SnapEnhance/scripting-docs",
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
)
}
}
}

View File

@@ -0,0 +1,346 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.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.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.ui.util.openFile
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var controlsHeight by remember { mutableStateOf(100.dp) }
var showResetSetupDialog by remember { mutableStateOf(false) }
val sharedButtonColors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.07f),
contentColor = Color.White
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
LaunchedEffect(computedScrollOffset) {
routes.navigation?.globalScrollOffset = computedScrollOffset
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = context.translation["button.positive"],
dismissButtonText = context.translation["button.negative"],
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
) {
item {
Spacer(Modifier.height(controlsHeight))
}
item {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch(
checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
val newId = if (isAphelion) "APHELION" else "LEGACY"
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Ensure it finishes its work before scope potentially closes
}
},
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}
}
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
// UPDATES
GlassCard {
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() }) }
}
}
}
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
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 = { 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"]) }
}
}
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)
}
}
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
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"])
}
}
}
}
}
}
}
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = computedScrollOffset,
enableMorph = true,
titleAlignment = Alignment.CenterHorizontally,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
routes.navigation?.openBottomBarCustomization = true
}) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
)
}
}

View File

@@ -0,0 +1,176 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.navigation.NavBackStackEntry
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.theme.PurrfectPalette
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList
} else {
friendList.filter {
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
val filteredGroups = remember(groupList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
groupList
} else {
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
SocialHeader(
titles = titles,
pagerState = pagerState,
onTabSelected = { index ->
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
friendCount = friendList.size,
groupCount = groupList.size,
searchActive = searchActive,
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = searchShape,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, searchBorder),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, searchShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = searchHint,
tint = PurrfectPalette.textSecondary
)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontSize = 15.sp
),
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
modifier = Modifier.weight(1f)
) { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = searchHint,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
innerTextField()
}
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["clear_search_button_description"] ?: "Clear",
tint = Color.White
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
}
}
}

View File

@@ -0,0 +1,755 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.draw.clipToBounds
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.task.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
val scrollState = rememberLazyListState()
val haptic = LocalHapticFeedback.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
val scope = rememberCoroutineScope()
var showConfirmDialog by remember { mutableStateOf(false) }
var alsoDeleteFiles by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchActiveTasks(this)
}
DisposableEffect(Unit) {
onDispose {
taskSelection.clear()
}
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
fetchActiveTasks(scope)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = containerTopPadding),
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) {
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TaskTab.entries.forEach { tab ->
val isSelected = selectedTab == tab
val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg")
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.White.copy(alpha = backgroundAlpha))
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
selectedTab = tab
},
contentAlignment = Alignment.Center
) {
Text(
text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"),
color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f),
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND }
val recentList = recentTasks.filter { task ->
task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash }
}
val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND }
val scheduledRecent = recentTasks.filter { task ->
task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash }
}
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = 8.dp,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (selectedTab == TaskTab.ACTIVE) {
item(key = "auto_open_card") {
var queueItems by remember { mutableStateOf(listOf<Any>()) }
var processedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
runCatching {
val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface
processedCount = autoOpen?.processedCount ?: 0
val items = autoOpen?.queueItems ?: emptyList()
queueItems = items.mapNotNull {
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
}
}
delay(2000)
}
}
val queueSize = queueItems.size
if (queueSize > 0 || processedCount > 0) {
var isExpanded by remember { mutableStateOf(false) }
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
isExpanded = !isExpanded
}
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(10.dp))
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White)
}
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
null,
tint = Color.White.copy(alpha = 0.5f)
)
}
Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) {
Text(
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.6f)
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
queueItems.forEach { rawItem ->
val item = rawItem as? Map<String, String> ?: return@forEach
Row(
modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f))
}
Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f))
}
}
if (processedCount > 0 || queueSize > 0) {
OutlinedButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() }
},
modifier = Modifier.fillMaxWidth().height(36.dp),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f))
) {
Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp)
}
}
}
}
}
}
}
}
if (activeList.isEmpty() && recentList.isEmpty()) {
item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") }
}
val groupedActiveTasks = activeList.distinctBy { it.task.hash }
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
val isAutoOpenTask = pendingTask.task.isAutoOpen
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
val pulseAlpha by pulseAnimation.animateFloat(
initialValue = 0.15f,
targetValue = 0.45f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
AphelionTaskCard(
modifier = Modifier
.fillMaxWidth()
.let {
if (isAutoOpenTask) {
it.border(
width = 1.5.dp,
brush = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
)
),
shape = RoundedCornerShape(22.dp)
)
} else it
},
task = pendingTask.task,
pendingTask = pendingTask
)
}
items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
}
} else {
if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) {
item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") }
}
items(scheduledActive, key = { it.taskId }) { pendingTask ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(scheduledRecent, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Filled.PlaylistAddCheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = activeTasks.size.toString(),
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 12.sp
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
}
}
)
}
if (showConfirmDialog) {
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["tasks_remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, scope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
@Composable
internal fun TasksRootSection.AphelionTasksEmptyState(text: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Box(
modifier = Modifier
.size(58.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = text,
tint = Color.White
)
}
}
Text(
text = text,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White
)
}
}
@Composable
internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
var taskStatus by remember { mutableStateOf(task.status) }
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(
defaultValue = null as DocumentFile?,
keys = arrayOf(taskStatus.name)
) {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
}
val listener = remember { PendingTaskListener(
onStateChange = { taskStatus = it },
onProgress = { label, progress ->
taskProgressLabel = label
taskProgress = progress
}
) }
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
val haptic = LocalHapticFeedback.current
val isActive = pendingTask != null && !taskStatus.isFinalStage()
fun toggleSelection() {
if (isSelected) {
taskSelection.removeIf { it.first == task }
return
}
taskSelection.add(task to documentFile)
}
fun openFile() {
if (!isDocumentFileReadable || documentFile == null) return
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile!!.uri, documentFile!!.type)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
})
}.onFailure {
context.log.error("Failed to open file ${documentFile?.uri}", it)
context.shortToast(translation["failed_to_open_file"] ?: "Failed to open file")
}
}
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
)
}
.let {
if (isSelected) {
it.border(2.dp, PurrfectPalette.glowSecondary, RoundedCornerShape(22.dp)).clip(RoundedCornerShape(22.dp))
} else it
}
val chipLabel = when {
isActive -> translation.getOrNull("task_sending") ?: "Sending"
taskStatus == TaskStatus.SUCCESS -> null
taskStatus == TaskStatus.FAILURE -> translation.getOrNull("task_failed") ?: "Failed"
taskStatus == TaskStatus.CANCELLED -> translation.getOrNull("task_cancelled") ?: "Cancelled"
else -> taskStatus.name.lowercase().replaceFirstChar { it.titlecase() }
}
val chipIcon = when {
isActive -> null
taskStatus == TaskStatus.SUCCESS -> Icons.Filled.Check
taskStatus == TaskStatus.FAILURE -> Icons.Filled.WarningAmber
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
else -> Icons.Filled.Info
}
val chipColors = when {
isActive -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.08f),
labelColor = Color.White
)
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
labelColor = Color.White
)
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.06f),
labelColor = PurrfectPalette.textSecondary
)
else -> AssistChipDefaults.assistChipColors()
}
val countdownText = if (isActive) {
taskProgressLabel?.let { label ->
Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label
}
} else null
val cardShape = RoundedCornerShape(22.dp)
Surface(
modifier = cardModifier,
shape = cardShape,
color = Color.Transparent,
border = BorderStroke(1.dp, if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) else SolidColor(Color.White.copy(alpha = 0.1f)))
) {
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
var loadFailed by remember { mutableStateOf(false) }
val doc = documentFile
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
}
Column(modifier = Modifier.weight(1f)) {
if (task.type == TaskType.SCHEDULED_SEND) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", style = MaterialTheme.typography.labelMedium, color = PurrfectPalette.textSecondary)
Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White)
task.author?.takeIf { it != "null" }?.let { recipients ->
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Filled.People, contentDescription = null, modifier = Modifier.size(16.dp).padding(top = 2.dp), tint = PurrfectPalette.textSecondary)
recipients.split(", ").let { list ->
Text(list.joinToString(", "), style = MaterialTheme.typography.bodyMedium, color = Color.White, lineHeight = 20.sp)
}
}
}
}
} else {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White)
task.author?.takeIf { it != "null" }?.let {
Spacer(modifier = Modifier.width(5.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
}
}
Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary)
}
Column(modifier = Modifier.padding(top = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
chipLabel?.let { label ->
val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) {
{ Icon(Icons.Filled.Timer, contentDescription = null, modifier = Modifier.size(16.dp)) }
} else if (isActive) {
countdownText?.let { countdown -> { Text(countdown, style = MaterialTheme.typography.labelSmall) } }
} else chipIcon?.let { icon -> { Icon(icon, contentDescription = null) } }
val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) {
translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText"
} else label
AssistChip(onClick = {}, enabled = false, leadingIcon = leadingIcon, label = { Text(displayLabel) }, colors = chipColors)
}
if (!taskStatus.isFinalStage()) {
if (isActive) {
taskProgressLabel?.let {
val labelText = if (task.isAutoOpen) {
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
"$it$speed snaps/min"
} else it
Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White)
}
} else {
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
}
if (taskProgress != -1 && (taskProgressLabel == null || isActive)) {
LinearProgressIndicator(
progress = { taskProgress.toFloat() / 100f },
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),
color = PurrfectPalette.glowSecondary, trackColor = Color.White.copy(alpha = 0.12f)
)
}
if (!isActive) {
task.extra?.takeIf { it.isNotEmpty() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) }
}
}
}
}
Column {
if (isActive) {
FilledIconButton(
onClick = {
runCatching { pendingTask?.cancel() }.onFailure { throwable ->
context.log.error("Failed to cancel task $pendingTask", throwable)
}
},
colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), contentColor = Color.White)
) { Icon(Icons.Filled.Close, contentDescription = "Cancel") }
} else if (taskStatus == TaskStatus.SUCCESS) {
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(250)) + scaleIn(tween(300)),
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150))
) {
Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White)
}
}
} else {
when (taskStatus) {
TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B))
TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B))
else -> {}
}
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.ThemeContract
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
object AphelionTheme : ThemeContract {
@Composable
override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) {
AphelionHomeScreen(nav)
}
@Composable
override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) {
AphelionSettingsScreen(nav)
}
@Composable
override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) {
AphelionAboutScreen(nav)
}
@Composable
override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
AphelionLogsScreen(nav)
}
@Composable
override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) {
AphelionSocialScreen(nav)
}
@Composable
override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
AphelionTasksScreen(nav)
}
@Composable
override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) {
AphelionFeaturesScreen(nav)
}
@Composable
override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) {
AphelionScriptingScreen(nav)
}
@Composable
override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) {
TrackerScreenContent(nav)
}
}

View File

@@ -148,7 +148,7 @@ class FriendTrackerCatalog : Routes.Route() {
contentColor = Color.White
)
) {
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
Text(translation["manage_repos_description"] ?: context.translation["manager.routes.manage_friend_tracker_repos"])
}
}
)
@@ -176,7 +176,7 @@ class FriendTrackerCatalog : Routes.Route() {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
}
}
}
@@ -341,7 +341,7 @@ class FriendTrackerCatalog : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
title = translation["title"],
onBack = { routes.navController.popBackStack() },
actions = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {

View File

@@ -115,7 +115,7 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
}
}
}.onFailure {
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
}
}) {

View File

@@ -78,7 +78,7 @@ class FriendTrackerConfigImportScreen : Routes.Route() {
routes.onRuleImported?.invoke()
routes.navController.popBackStack()
}.onFailure {
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: "Unknown")))
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
}) {
Text(translation["confirm_button"])

View File

@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Store
import androidx.compose.ui.window.Dialog
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
@@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
@@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
private val titles by lazy {
internal val titles by lazy {
listOf(
translation["rules_tab"],
translation["logs_tab"]
)
}
private var currentPage by mutableIntStateOf(0)
private lateinit var logDeleteAction : () -> Unit
private lateinit var exportAction : () -> Unit
internal var currentPage by mutableIntStateOf(0)
internal lateinit var logDeleteAction : () -> Unit
internal lateinit var exportAction : () -> Unit
@Composable
private fun TrackerIconButton(
internal fun TrackerIconButton(
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun TrackerActionButton(
internal fun TrackerActionButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun TrackerPillButton(
internal fun TrackerPillButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
@@ -248,7 +244,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
}.onFailure {
context.longToast("Failed to read file: ${it.message}")
context.longToast(
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
)
}
}
}
@@ -302,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -345,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun ConfigRulesTab() {
internal fun ConfigRulesTab() {
val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc()
@@ -566,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
@Composable
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage
@@ -591,7 +589,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
}.onFailure {
context.longToast("Failed to read file: ${it.message}")
context.longToast(
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
)
}
}
}
@@ -621,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier
@@ -792,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() {
)
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@FriendTrackerManagerRoot.FriendTrackerScreen(nav)
}
}
}
}
@Composable

View File

@@ -245,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -282,7 +282,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

@@ -16,10 +16,14 @@ class TrackerConfigParser(private val context: Routes.Route) {
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
val featureMap = mutableMapOf<String, MutableList<ImportedFeature>>()
val exportedData = context.context.gson.fromJson(configJson, ExportedTrackerData::class.java)
val authorLabel = context.translation["tracker_author_label"]
val enabledLabel = context.translation["tracker_enabled_label"]
val enabledValue = context.translation["tracker_enabled_value"]
val disabledValue = context.translation["tracker_disabled_value"]
exportedData.rules.forEach { rule ->
val features = mutableListOf<ImportedFeature>()
features.add(ImportedFeature(rule.name, "Author", "author", rule.author ?: "Unknown", 0))
features.add(ImportedFeature(rule.name, "Enabled", "enabled", rule.enabled, 0))
features.add(ImportedFeature(rule.name, authorLabel, "author", rule.author ?: context.context.translation["common.unknown"], 0))
features.add(ImportedFeature(rule.name, enabledLabel, "enabled", if (rule.enabled) enabledValue else disabledValue, 0))
rule.events?.forEach { event ->
features.add(ImportedFeature(rule.name, context.context.translation["tracker_events.${event.eventType}"], event.eventType, event.actions.joinToString(", ") { context.context.translation["tracker_actions.${it.key}"] }, 1))
}
@@ -30,7 +34,7 @@ class TrackerConfigParser(private val context: Routes.Route) {
fun parseValue(featureKey: String, value: Any): Any {
return when (value) {
is Boolean -> if (value) "Enabled" else "Disabled"
is Boolean -> if (value) context.translation["tracker_enabled_value"] else context.translation["tracker_disabled_value"]
is JSONArray -> {
val list = mutableListOf<String>()
for (i in 0 until value.length()) {

View File

@@ -0,0 +1,60 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import me.eternal.purrfectsnap.RemoteSideContext
/**
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
* Provides more nuanced feedback than standard Compose haptics.
*/
object AphelionHaptics {
/**
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
*/
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
// Fallback for older APIs
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}.onFailure { it.printStackTrace() }
}
/**
* Triggers a "soft" impact feel, good for glass interactions.
*/
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}.onFailure { it.printStackTrace() }
}
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
}
}

View File

@@ -0,0 +1,111 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.graphics.BitmapShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlin.math.sqrt
import androidx.compose.ui.platform.LocalHapticFeedback
import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
onComplete: () -> Unit
) {
// Safety check: if bitmap was recycled or is null, skip.
val bitmap = request.oldThemeBitmap ?: run {
LaunchedEffect(request.id) { onComplete() }
return
}
if (bitmap.isRecycled) {
LaunchedEffect(request.id) { onComplete() }
return
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
// Ensure the reveal is cleared even if navigation happens mid-animation
DisposableEffect(request.id) {
onDispose { onComplete() }
}
val maxRadius = remember(configuration) {
with(density) {
val w = configuration.screenWidthDp.dp.toPx()
val h = configuration.screenHeightDp.dp.toPx()
sqrt(w * w + h * h)
}
}
val animatedRadius = remember(request.id) { Animatable(0f) }
LaunchedEffect(request.id) {
AphelionHaptics.themeRevealTick(context, hapticFeedback)
animatedRadius.animateTo(
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
)
onComplete()
}
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
val timeValue by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 10f,
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
label = "wave_time_value"
)
// --- AGSL SHADER LOGIC (Android 13+) ---
val runtimeShader = remember(bitmap) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
}
val shaderPaint = remember(runtimeShader) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = animatedRadius.value
val center = request.originCenter
drawIntoCanvas { canvas ->
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
}
}
}

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
/**
* Carries all data needed to execute one theme reveal transition.
*/
data class ThemeRevealRequest(
val newThemeId: String,
val originCenter: Offset,
val oldThemeBitmap: android.graphics.Bitmap?,
val id: Long = System.currentTimeMillis()
)
/**
* Observable state that lives on the [Navigation] instance.
* Optimized for stability during rapid toggle events.
*/
class ThemeRevealState {
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
private set
/**
* Requests a new theme reveal animation.
* Always starts a new reveal immediately, even if one is already in progress.
*/
fun requestReveal(
newThemeId: String,
originCenter: Offset,
bitmap: android.graphics.Bitmap?
) {
// Clean up the old one first to prevent memory leaks and "dead periods"
val oldBitmap = pendingReveal?.oldThemeBitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
// Immediately update with the new request ID to force a fresh animation
pendingReveal = ThemeRevealRequest(
newThemeId = newThemeId,
originCenter = originCenter,
oldThemeBitmap = bitmap,
id = System.currentTimeMillis() // Unique ID ensures fresh start
)
}
/** Called by the overlay composable once the animation has fully completed. */
fun clearReveal() {
val oldBitmap = pendingReveal?.oldThemeBitmap
pendingReveal = null
// Manual memory management for the heavy screenshot bitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
}
}

View File

@@ -0,0 +1,86 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
/**
* Finalized "Perfect Optic" AGSL Shader.
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
*/
object WaveEdgeShader {
const val AGSL = """
uniform shader content;
uniform float revealRadius;
uniform float2 revealCenter;
uniform float bandWidth;
uniform float time;
uniform float uProgress;
float hash(float2 p) {
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
}
float valueNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
u.y
);
}
half4 main(float2 pos) {
float d = distance(pos, revealCenter);
float energy = (1.0 - uProgress);
// Slower, more majestic large noise
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
float totalNoise = noiseLarge * energy;
float dist = d - (revealRadius + totalNoise);
if (dist > 0.0) {
return content.eval(pos);
}
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
float effectStart = revealRadius - dynamicBand;
if (dist < -dynamicBand) {
return half4(0.0);
}
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
// Asymmetric crest: Sharp start, long slow tail
float waveShape = pow(bandProgress, 2.0);
float2 dir = normalize(pos - revealCenter + 0.001);
// Refraction: Impactful but clear
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
float2 refractedPos = pos + dir * refractionAmt;
// Chromatic Aberration: Deep prism split
float aberration = waveShape * (35.0 * energy + 10.0);
half r = content.eval(refractedPos + dir * aberration).r;
half g = content.eval(refractedPos).g;
half b = content.eval(refractedPos - dir * aberration).b;
// Sharp highlight at the front edge
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
// Leading edge softening (minor)
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
return half4(
r + half(highlight),
g + half(highlight),
b + half(highlight),
half(alpha)
);
}
"""
}

View File

@@ -78,6 +78,7 @@ class RemoteOverlay(
fun close() {
if (!::dialog.isInitialized || !dialog.isShowing) return
dismissCallback = null
context.sharedPreferences.edit().putBoolean("overlay_active", false).apply()
context.androidContext.mainExecutor.execute {
dialog.dismiss()
}
@@ -92,6 +93,7 @@ class RemoteOverlay(
return
}
context.sharedPreferences.edit().putBoolean("overlay_active", true).apply()
context.androidContext.mainExecutor.execute {
dialog = object: Dialog(context.androidContext, R.style.FullscreenOverlayDialog) {
override fun dismiss() {
@@ -99,6 +101,9 @@ class RemoteOverlay(
if (it()) return
}
super.dismiss()
this@RemoteOverlay.context.sharedPreferences.edit()
.putBoolean("overlay_active", false)
.apply()
this@RemoteOverlay.context.config.writeConfig()
}
}

View File

@@ -3,6 +3,7 @@
package me.eternal.purrfectsnap.ui.setup
import android.app.Activity
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
@@ -78,6 +79,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -203,6 +205,8 @@ class SetupActivity : ComponentActivity() {
}
setContent {
val context = LocalContext.current
val translation = setupContext.translation
val navController = rememberNavController()
var canGoNext by remember { mutableStateOf(false) }
var lastRoute by rememberSaveable { mutableStateOf("") }
@@ -214,6 +218,31 @@ class SetupActivity : ComponentActivity() {
}
val skipPatch by rememberSaveable { skipPatchChoice }
val installMode by installModeChoice
val shouldShowAbiWarning = remember {
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
val appIsArm64 = libDir.contains("arm64")
deviceIsArm64 && !appIsArm64
}
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = translation["setup.activity.close_button"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
}
)
}
val visibleScreens = remember(skipPatch, installMode) {
requiredScreens.filterNot { screen ->
if (skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
@@ -316,7 +345,14 @@ class SetupActivity : ComponentActivity() {
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) "I understand (${importantTimeout}s)" else "I understand"
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) {
@@ -324,7 +360,7 @@ class SetupActivity : ComponentActivity() {
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = "Important!",
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
@@ -338,7 +374,7 @@ class SetupActivity : ComponentActivity() {
showCloseButton = false,
customContent = {
Text(
text = "If you have used SnapEnhance or any other mod besides PurrfectSnap, we recommend uninstalling everything and staying on stock Snapchat for one week. Then switch to PurrfectSnap after next Friday.",
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
@@ -443,50 +479,50 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
return when (this) {
is PickLanguageScreen -> SetupStepMeta(
route = route,
title = translation["setup.dialogs.select_language"] ?: "Choose your language",
subtitle = "Tune PurrfectSnap to speak your voice before anything else.",
title = translation["setup.dialogs.select_language"],
subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language
)
is InstallModeScreen -> SetupStepMeta(
route = route,
title = "Choose your device",
subtitle = "Pick the path that matches how you'll install PurrfectSnap.",
title = translation["setup.activity.install_mode_title"],
subtitle = translation["setup.activity.install_mode_subtitle"],
icon = Icons.Filled.VerifiedUser
)
is PermissionsScreen -> SetupStepMeta(
route = route,
title = translation["setup.permissions.dialog"] ?: "Essential permissions",
subtitle = "Grant the essentials so overlays, downloads, and alerts stay reliable.",
title = translation["setup.permissions.dialog"],
subtitle = translation["setup.activity.permissions_subtitle"],
icon = Icons.Filled.VerifiedUser
)
is PatchSnapchatScreen -> SetupStepMeta(
route = route,
title = "Auto Patcher",
subtitle = "Streamlined download, patch, and install with a single flow.",
title = translation["setup.activity.patch_title"],
subtitle = translation["setup.activity.patch_subtitle"],
icon = Icons.Filled.Download
)
is RootInstallSnapchatScreen -> SetupStepMeta(
route = route,
title = "Snapchat Installer",
subtitle = "Download and install the recommended Snapchat build.",
title = translation["setup.activity.root_install_title"],
subtitle = translation["setup.activity.root_install_subtitle"],
icon = Icons.Filled.Download
)
is SaveFolderScreen -> SetupStepMeta(
route = route,
title = translation["setup.dialogs.save_folder"] ?: "Where should we save?",
subtitle = "Pick your personal vault so snaps land exactly where you expect.",
title = translation["setup.dialogs.save_folder"],
subtitle = translation["setup.activity.save_folder_subtitle"],
icon = Icons.Filled.Folder
)
is MappingsScreen -> SetupStepMeta(
route = route,
title = translation["setup.mappings.dialog"] ?: "Mapping your Snapchat",
subtitle = "We calibrate everything to your install so the magic works flawlessly.",
title = translation["setup.mappings.dialog"],
subtitle = translation["setup.activity.mappings_subtitle"],
icon = Icons.Filled.AutoAwesome
)
@@ -595,6 +631,7 @@ private fun SetupHeader(
currentIndex: Int,
total: Int
) {
val translation = SharedContextHolder.remote(LocalContext.current).translation
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth(),
@@ -620,7 +657,11 @@ private fun SetupHeader(
tint = Color.White
)
Text(
text = "Step ${currentIndex + 1} of $total",
text = translation.format(
"setup.activity.step_counter",
"current" to (currentIndex + 1).toString(),
"total" to total.toString()
),
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
@@ -695,6 +736,7 @@ private fun StepBadgesRow(steps: List<SetupStepMeta>, currentStep: Int) {
@Composable
private fun StepBadge(step: SetupStepMeta, state: StepState) {
val translation = SharedContextHolder.remote(LocalContext.current).translation
val baseColor = when (state) {
StepState.COMPLETE -> PurrfectPalette.glowSecondary
StepState.ACTIVE -> PurrfectPalette.glowPrimary
@@ -746,9 +788,9 @@ private fun StepBadge(step: SetupStepMeta, state: StepState) {
overflow = TextOverflow.Ellipsis
)
val hint = when (state) {
StepState.COMPLETE -> "Checked off"
StepState.ACTIVE -> "In progress"
StepState.UPCOMING -> "Ready next"
StepState.COMPLETE -> translation["setup.activity.step_complete"]
StepState.ACTIVE -> translation["setup.activity.step_active"]
StepState.UPCOMING -> translation["setup.activity.step_upcoming"]
}
Text(
text = hint,
@@ -840,6 +882,7 @@ private fun NextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val translation = SharedContextHolder.remote(LocalContext.current).translation
val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.6f, label = "NextButtonAlpha")
val gradient = Brush.horizontalGradient(
listOf(
@@ -875,7 +918,11 @@ private fun NextButton(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = if (isFinalStep) "Finish setup" else "Continue",
text = if (isFinalStep) {
translation["setup.activity.finish_button"]
} else {
translation["setup.activity.continue_button"]
},
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp

View File

@@ -98,10 +98,14 @@ class InstallModeScreen(
}
if (showGuides) {
val confirmLabel = if (timeout > 0) "I understand (${timeout}s)" else "I understand"
val confirmLabel = if (timeout > 0) {
context.translation.format("setup.install_mode.confirm_timeout", "seconds" to timeout.toString())
} else {
context.translation["setup.install_mode.confirm"]
}
AestheticDialog(
onDismissRequest = { if (timeout == 0) showGuides = false },
title = "Please note!",
title = context.translation["setup.install_mode.notice_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
@@ -138,39 +142,39 @@ class InstallModeScreen(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Select the type of device you have: rooted or non-rooted. If you are unsure, choose Non-root and continue.",
text = context.translation["setup.install_mode.notice_intro"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Non-rooted devices",
text = context.translation["setup.install_mode.notice_non_root_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = "Select Non-root and the app will handle everything. Tap Install Patched Snapchat when it appears. After it installs, do not open Snapchat yet. Continue the PurrfectSnap setup; once it finishes, you can open Snapchat and enjoy.",
text = context.translation["setup.install_mode.notice_non_root_body"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Rooted devices",
text = context.translation["setup.install_mode.notice_root_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = "Make sure you have flashed LSPosed first. We recommend JingMatrix LSPosed or LSPosed Irena. After you select Root, the app will install the recommended Snapchat version. Do not open it yet; continue the PurrfectSnap setup. When setup finishes, enable PurrfectSnap in LSPosed and reboot your phone. Then start using Snapchat. We highly recommend detaching Snapchat from the Play Store with the Zygisk Detach module to prevent auto-updates.",
text = context.translation["setup.install_mode.notice_root_body"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "If you run into any installation issues, the solution will appear here. Please read it carefully.",
text = context.translation["setup.install_mode.notice_issues_hint"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
@@ -178,9 +182,9 @@ class InstallModeScreen(
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.White)) {
append("Note: ")
append(context.translation["setup.install_mode.notice_note_prefix"])
}
append("New Accounts easily get locked! It is recommended to use an older account with PurrfectSnap.")
append(context.translation["setup.install_mode.notice_note_body"])
},
style = bodyStyle,
textAlign = TextAlign.Start,
@@ -194,8 +198,8 @@ class InstallModeScreen(
SetupCard {
StepTitle(
title = "Choose your device",
subtitle = "If you don't know, select Non-rooted device and proceed.",
title = context.translation["setup.install_mode.step_title"],
subtitle = context.translation["setup.install_mode.step_subtitle"],
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Column(
@@ -203,8 +207,8 @@ class InstallModeScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ModeOption(
title = "Rooted device",
subtitle = "Use Lsposed and skip auto patching.",
title = context.translation["setup.install_mode.root_option_title"],
subtitle = context.translation["setup.install_mode.root_option_subtitle"],
icon = Icons.Filled.VerifiedUser,
accent = Brush.horizontalGradient(
listOf(
@@ -219,8 +223,8 @@ class InstallModeScreen(
}
)
ModeOption(
title = "Non-rooted device",
subtitle = "Use included auto patcher to install patched Snapchat.",
title = context.translation["setup.install_mode.non_root_option_title"],
subtitle = context.translation["setup.install_mode.non_root_option_subtitle"],
icon = Icons.Filled.Shield,
accent = Brush.horizontalGradient(
listOf(
@@ -262,7 +266,7 @@ class InstallModeScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Skip Auto Setup",
text = context.translation["setup.install_mode.skip_auto_setup"],
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,

View File

@@ -11,21 +11,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -34,15 +27,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -52,18 +41,10 @@ class MappingsScreen : SetupScreen() {
@Composable
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val translation = context.translation
var infoText by remember { mutableStateOf(null as String?) }
var isGenerating by remember { mutableStateOf(false) }
var showCompletionNotice by remember { mutableStateOf(false) }
var completionCountdown by remember { mutableIntStateOf(10) }
fun finishMappings() {
if (isFirstRunFlow) {
showCompletionNotice = true
} else {
goNext()
}
}
fun finishMappings() = goNext()
if (infoText != null) {
fun dismiss() {
@@ -86,100 +67,6 @@ class MappingsScreen : SetupScreen() {
}
}
LaunchedEffect(showCompletionNotice) {
if (showCompletionNotice) {
completionCountdown = 10
while (completionCountdown > 0) {
delay(1000)
completionCountdown--
}
}
}
if (showCompletionNotice) {
val confirmLabel = if (completionCountdown > 0) {
"I understand (${completionCountdown}s)"
} else {
"I understand"
}
AestheticDialog(
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
title = "Please note!",
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
confirmEnabled = completionCountdown == 0,
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "If you see the \"Account temporarily disabled\" error while logging in, do not worry. Follow these steps in order:",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "1. Reopen Snapchat and log in. This fixes it most of the time.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "2. If it still fails, tap the login button repeatedly. This usually covers the next chunk.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "3. If it still fails, clear Snapchat's data, disable any VPN, and log in again.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "For rooted users:",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold,
lineHeight = 18.sp
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Reopen Snapchat and log in. If it still fails, disable PurrfectSnap in LSPosed, log in, then re-enable PurrfectSnap.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isGenerating) return@launch
@@ -193,7 +80,11 @@ class MappingsScreen : SetupScreen() {
if (warnings.isNotEmpty()) {
isGenerating = false
infoText = "${warnings.size} warning(s) occurred while generating mappings:\n\n${warnings.joinToString("\n")}".also {
infoText = translation.format(
"setup.mappings.warnings_info",
"count" to warnings.size.toString(),
"warnings" to warnings.joinToString("\n")
).also {
context.log.warn(it)
}
return@launch
@@ -216,7 +107,7 @@ class MappingsScreen : SetupScreen() {
subtitle = null
)
DialogText(
text = "This only takes a moment. Keep the app open while magic happens!"
text = translation["setup.mappings.progress_hint"]
)
Spacer(modifier = Modifier.height(12.dp))
Surface(

View File

@@ -71,6 +71,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.components.AestheticDialog
@@ -93,7 +94,8 @@ class PatchSnapchatScreen : SetupScreen() {
@Composable
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf("Auto Patcher is ready.") }
val translation = context.translation
val logs = remember { mutableStateListOf(translation["setup.patch.ready_log"]) }
@Suppress("DEPRECATION")
val clipboard = LocalClipboardManager.current
var progress by remember { mutableFloatStateOf(-1f) }
@@ -146,7 +148,7 @@ class PatchSnapchatScreen : SetupScreen() {
repeat(80) {
if (isSnapchatInstalledAfter(patchStartedAt)) {
installVerified = true
pushLog("Snapchat install confirmed. You're cleared to continue.")
pushLog(translation["setup.patch.install_confirmed_log"])
return@launch
}
delay(1200)
@@ -179,13 +181,24 @@ class PatchSnapchatScreen : SetupScreen() {
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
pushStatus(
translation.format(
"setup.patch.download_recommended_status",
"version" to latestApk.tagName
)
)
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
?: context.androidContext.cacheDir
val cacheDir = listOfNotNull(
context.activity?.externalCacheDir,
context.androidContext.externalCacheDir,
context.androidContext.cacheDir
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
val outputFile = File(cacheDir, latestApk.apkName)
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
return@withContext null
}
outputFile.outputStream().use { output ->
response.body?.byteStream()?.use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -217,22 +230,27 @@ class PatchSnapchatScreen : SetupScreen() {
downloadFinished = false
patchStartedAt = System.currentTimeMillis()
logs.clear()
pushLog("Starting Auto Patcher for recommended Snapchat version.")
pushLog(translation["setup.patch.starting_log"])
runCatching {
if (isSnapchatInstalled()) {
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then start Auto Patcher again.")
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
pushStatus(translation["setup.patch.uninstall_prompt_status"])
throw IllegalStateException(translation["setup.patch.uninstall_prompt_error"])
}
val modulePath = context.androidContext.packageManager.getPackageInfo(
context.androidContext.packageName, 0
).applicationInfo?.sourceDir ?: throw IllegalStateException("Module apk not found")
pushStatus("Fetching recommended Snapchat APK...")
).applicationInfo?.sourceDir ?: throw IllegalStateException(translation["setup.patch.module_apk_not_found_error"])
pushStatus(translation["setup.patch.fetching_apk_status"])
val downloaded = downloadSnapchatFromAutoPatchServer()
?: throw IllegalStateException("Download failed")
?: throw IllegalStateException(translation["setup.patch.download_failed_error"])
downloadedApkPath = downloaded.absolutePath
pushStatus("Download completed: ${downloaded.name}")
pushStatus(
translation.format(
"setup.patch.download_completed_status",
"fileName" to downloaded.name
)
)
downloadFinished = true
pushStatus("Starting patch powered by Jingmatrix Lspatch")
pushStatus(translation["setup.patch.starting_patch_status"])
val lsPatch = LSPatch(
context.androidContext,
mapOf(context.androidContext.packageName to File(modulePath)),
@@ -241,16 +259,22 @@ class PatchSnapchatScreen : SetupScreen() {
)
val outputs = withContext(Dispatchers.IO) { lsPatch.patchSplits(listOf(downloaded)) }
val patched = outputs["base.apk"] ?: outputs.values.firstOrNull()
?: throw IllegalStateException("Patched apk not produced")
?: throw IllegalStateException(translation["setup.patch.patched_not_produced_error"])
patchedApkPath = patched.absolutePath
pushStatus("Patched build ready. Install to finish.")
pushStatus(translation["setup.patch.patched_ready_status"])
}.onFailure {
val message = it.message ?: it.toString()
error = it.message ?: it.toString()
it.stackTraceToString()
.lineSequence()
.filter { line -> line.isNotBlank() }
.forEach { line -> pushLog(line) }
pushStatus("Failed: ${it.message}")
pushStatus(
translation.format(
"setup.patch.failed_status",
"message" to message
)
)
}
isRunning = false
progress = -1f
@@ -276,7 +300,7 @@ class PatchSnapchatScreen : SetupScreen() {
fun markAlreadyInstalled() {
installRequested = false
installVerified = true
pushLog("Marked as installed manually. You're cleared to continue.")
pushLog(translation["setup.patch.mark_installed_log"])
}
val accent = remember {
@@ -291,10 +315,10 @@ class PatchSnapchatScreen : SetupScreen() {
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = "Facing issues?",
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Got it",
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
@@ -310,36 +334,36 @@ class PatchSnapchatScreen : SetupScreen() {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "How to fix installation errors",
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Issue: App cannot be installed because it conflicts with an existing package.",
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "Fix: Download Snapchat from the Play Store and uninstall it without keeping data. Run Auto Patcher again. If it still does not work, run:",
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "adb uninstall com.snapchat.android",
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = "Issue: App not installed because the package appears to be invalid.",
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "Fix: Download and install JingMatrix LSPatch, then patch Snapchat 13.64.0.52 in Integrated mode. Select Embed Modules and embed the PurrfectSnap APK. Then choose Skip auto setup during PurrfectSnap setup to skip Auto Patcher.",
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
@@ -350,12 +374,12 @@ class PatchSnapchatScreen : SetupScreen() {
SetupCard {
StepTitle(
title = "Auto Patcher",
title = translation["setup.patch.title"],
subtitle = null,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
JingmatrixBadge(accent)
JingmatrixBadge(accent, translation)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
@@ -386,9 +410,12 @@ class PatchSnapchatScreen : SetupScreen() {
val isPatching = isRunning && downloadFinished && !isDownloading
Text(
text = when {
isDownloading -> "Downloading Snapchat ${(progress * 100).toInt()}%"
isPatching -> "Patching..."
else -> "Initializing..."
isDownloading -> translation.format(
"setup.patch.status_downloading",
"percent" to (progress * 100).toInt().toString()
)
isPatching -> translation["setup.patch.status_patching"]
else -> translation["setup.patch.status_initializing"]
},
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.Medium
@@ -420,9 +447,10 @@ class PatchSnapchatScreen : SetupScreen() {
logs = logs,
pulse = logPulse,
accent = accent,
translation = translation,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog("Logs copied to clipboard.")
pushLog(translation["setup.patch.logs_copied"])
}
)
@@ -457,7 +485,7 @@ class PatchSnapchatScreen : SetupScreen() {
tint = Color.White
)
Text(
text = "Patched APK installed",
text = translation["setup.patch.install_success"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -466,7 +494,7 @@ class PatchSnapchatScreen : SetupScreen() {
} else {
if (patchedApk == null) {
GradientActionButton(
label = "Start auto patch",
label = translation["setup.patch.start_button"],
icon = Icons.Filled.Download,
onClick = { startPatch() },
enabled = !isRunning
@@ -474,7 +502,7 @@ class PatchSnapchatScreen : SetupScreen() {
}
if (patchedApk != null) {
GradientActionButton(
label = "Install patched Snapchat",
label = translation["setup.patch.install_button"],
icon = Icons.Filled.Verified,
onClick = { installPatchedApk() },
enabled = true
@@ -503,7 +531,7 @@ class PatchSnapchatScreen : SetupScreen() {
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Facing issues?",
text = translation["setup.patch.issues_title"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -533,7 +561,7 @@ class PatchSnapchatScreen : SetupScreen() {
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Already Installed?",
text = translation["setup.patch.already_installed_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -549,7 +577,10 @@ class PatchSnapchatScreen : SetupScreen() {
}
@Composable
private fun JingmatrixBadge(accent: Brush) {
private fun JingmatrixBadge(
accent: Brush,
translation: LocaleWrapper
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -575,7 +606,7 @@ private fun JingmatrixBadge(accent: Brush) {
)
}
Text(
text = "Powered by Jingmatrix Lspatch",
text = translation["setup.patch.powered_by_label"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -641,6 +672,7 @@ private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush,
translation: LocaleWrapper,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
@@ -679,7 +711,7 @@ private fun LogsPanel(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Logs",
text = translation["setup.patch.logs_title"],
color = Color.White,
fontWeight = FontWeight.Bold
)
@@ -709,7 +741,7 @@ private fun LogsPanel(
modifier = Modifier.size(14.dp)
)
Text(
text = "Copy",
text = translation["setup.patch.copy_button"],
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
@@ -721,7 +753,10 @@ private fun LogsPanel(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
logs.forEach { line ->
Text(
text = "- $line",
text = translation.format(
"setup.patch.log_line_prefix",
"line" to line
),
color = PurrfectPalette.textPrimary,
fontSize = 13.sp,
lineHeight = 16.sp

View File

@@ -73,9 +73,9 @@ class PermissionsScreen : SetupScreen() {
private fun descriptionFor(key: String): String {
return when (key) {
"notification_access" -> "Alerts you the second downloads finish."
"battery_optimization" -> "Keeps background tasks alive without being killed."
"display_over_other_apps" -> "Enables floating overlays while you are in Snapchat."
"notification_access" -> context.translation["setup.permissions.notification_access_description"]
"battery_optimization" -> context.translation["setup.permissions.battery_optimization_description"]
"display_over_other_apps" -> context.translation["setup.permissions.display_over_other_apps_description"]
else -> ""
}
}
@@ -178,7 +178,7 @@ class PermissionsScreen : SetupScreen() {
modifier = Modifier.padding(end = 6.dp)
)
Text(
text = "Granted",
text = context.translation["setup.permissions.granted_label"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)

View File

@@ -151,7 +151,7 @@ class PickLanguageScreen : SetupScreen() {
modifier = Modifier.weight(1f)
) {
Text(
text = "Current selection",
text = context.translation["setup.pick_language.current_selection"],
fontSize = 14.sp,
color = PurrfectPalette.textSecondary
)
@@ -180,9 +180,9 @@ class PickLanguageScreen : SetupScreen() {
contentColor = Color.White
)
) {
Text(text = "Browse languages")
Text(text = context.translation["setup.pick_language.browse_languages"])
}
DialogText(text = "You can change this anytime from PurrfectSnap settings.")
DialogText(text = context.translation["setup.pick_language.change_anytime_hint"])
}
if (isDialog) {
@@ -196,7 +196,7 @@ class PickLanguageScreen : SetupScreen() {
modifier = Modifier.fillMaxWidth()
) {
StepTitle(
title = "Available Languages",
title = context.translation["setup.pick_language.available_languages"],
subtitle = null,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center

View File

@@ -66,6 +66,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -86,7 +87,8 @@ class RootInstallSnapchatScreen : SetupScreen() {
@Composable
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf("Snapchat installer is ready.") }
val translation = context.translation
val logs = remember { mutableStateListOf(translation["setup.root_install.ready_log"]) }
@Suppress("DEPRECATION")
val clipboard = LocalClipboardManager.current
var progress by remember { mutableFloatStateOf(-1f) }
@@ -136,7 +138,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
repeat(80) {
if (isSnapchatInstalledAfter(downloadStartedAt)) {
installVerified = true
pushLog("Snapchat install confirmed. You're cleared to continue.")
pushLog(translation["setup.root_install.install_confirmed_log"])
return@launch
}
delay(1200)
@@ -168,13 +170,24 @@ class RootInstallSnapchatScreen : SetupScreen() {
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
pushStatus(
translation.format(
"setup.root_install.download_recommended_status",
"version" to latestApk.tagName
)
)
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
?: context.androidContext.cacheDir
val cacheDir = listOfNotNull(
context.activity?.externalCacheDir,
context.androidContext.externalCacheDir,
context.androidContext.cacheDir
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
val outputFile = File(cacheDir, latestApk.apkName)
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
return@withContext null
}
outputFile.outputStream().use { output ->
response.body?.byteStream()?.use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -212,7 +225,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
fun markAlreadyInstalled() {
installRequested = false
installVerified = true
pushLog("Marked as installed manually. You're cleared to continue.")
pushLog(translation["setup.root_install.mark_installed_log"])
}
fun startDownloadAndInstall() {
@@ -227,27 +240,38 @@ class RootInstallSnapchatScreen : SetupScreen() {
downloadFinished = false
downloadStartedAt = System.currentTimeMillis()
logs.clear()
pushLog("Starting Snapchat download for rooted install.")
pushLog(translation["setup.root_install.start_download_log"])
runCatching {
if (isSnapchatInstalled()) {
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then try again.")
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
pushStatus(translation["setup.root_install.uninstall_prompt_status"])
throw IllegalStateException(translation["setup.root_install.uninstall_prompt_error"])
}
pushStatus("Fetching recommended Snapchat APK...")
pushStatus(translation["setup.root_install.fetching_apk_status"])
val downloaded = downloadSnapchatFromAutoPatchServer()
?: throw IllegalStateException("Download failed")
?: throw IllegalStateException(translation["setup.root_install.download_failed_error"])
downloadedApkPath = downloaded.absolutePath
pushStatus("Download completed: ${downloaded.name}")
pushStatus(
translation.format(
"setup.root_install.download_completed_status",
"fileName" to downloaded.name
)
)
downloadFinished = true
pushStatus("Launching installer...")
pushStatus(translation["setup.root_install.launching_installer_status"])
installDownloadedApk()
}.onFailure {
val message = it.message ?: it.toString()
error = it.message ?: it.toString()
it.stackTraceToString()
.lineSequence()
.filter { line -> line.isNotBlank() }
.forEach { line -> pushLog(line) }
pushStatus("Failed: ${it.message}")
pushStatus(
translation.format(
"setup.root_install.failed_status",
"message" to message
)
)
}
isRunning = false
progress = -1f
@@ -265,7 +289,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
SetupCard {
StepTitle(
title = "Snapchat Installer",
title = translation["setup.root_install.title"],
subtitle = null,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
@@ -299,9 +323,12 @@ class RootInstallSnapchatScreen : SetupScreen() {
val isDownloading = progress >= 0f
Text(
text = if (isDownloading) {
"Downloading Snapchat ${(progress * 100).toInt()}%"
translation.format(
"setup.root_install.status_downloading",
"percent" to (progress * 100).toInt().toString()
)
} else {
"Preparing installer..."
translation["setup.root_install.status_preparing"]
},
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.Medium
@@ -333,9 +360,10 @@ class RootInstallSnapchatScreen : SetupScreen() {
logs = logs,
pulse = logPulse,
accent = accent,
translation = translation,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog("Logs copied to clipboard.")
pushLog(translation["setup.root_install.logs_copied"])
}
)
@@ -370,7 +398,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
tint = Color.White
)
Text(
text = "Snapchat installed",
text = translation["setup.root_install.install_success"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -379,7 +407,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
} else {
if (downloadedApk == null) {
GradientActionButton(
label = "Download Snapchat",
label = translation["setup.root_install.download_button"],
icon = Icons.Filled.Download,
onClick = { startDownloadAndInstall() },
enabled = !isRunning
@@ -387,7 +415,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
}
if (downloadedApk != null) {
GradientActionButton(
label = "Install Snapchat",
label = translation["setup.root_install.install_button"],
icon = Icons.Filled.Verified,
onClick = { installDownloadedApk() },
enabled = true
@@ -416,7 +444,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Already Installed?",
text = translation["setup.root_install.already_installed_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -489,6 +517,7 @@ private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush,
translation: LocaleWrapper,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
@@ -527,7 +556,7 @@ private fun LogsPanel(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Logs",
text = translation["setup.root_install.logs_title"],
color = Color.White,
fontWeight = FontWeight.Bold
)
@@ -557,7 +586,7 @@ private fun LogsPanel(
modifier = Modifier.size(14.dp)
)
Text(
text = "Copy",
text = translation["setup.root_install.copy_button"],
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
@@ -569,7 +598,10 @@ private fun LogsPanel(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
logs.forEach { line ->
Text(
text = "- $line",
text = translation.format(
"setup.root_install.log_line_prefix",
"line" to line
),
color = PurrfectPalette.textPrimary,
fontSize = 13.sp,
lineHeight = 16.sp

View File

@@ -52,13 +52,23 @@ class SaveFolderScreen : SetupScreen() {
var currentFolder by remember {
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
}
val readablePath = remember(currentFolder) {
if (currentFolder.isBlank()) null
else runCatching {
val uri = android.net.Uri.parse(currentFolder)
val path = uri.path ?: return@runCatching currentFolder
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else currentFolder
}.getOrElse { currentFolder }
}
var showNoPickerDialog by remember { mutableStateOf(false) }
SetupCard {
StepTitle(
title = context.translation["setup.dialogs.save_folder"],
subtitle = null
)
DialogText(text = "Please choose the location where media should be downloaded to.")
DialogText(text = context.translation["setup.save_folder.description"])
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
@@ -99,12 +109,12 @@ class SaveFolderScreen : SetupScreen() {
modifier = Modifier.weight(1f)
) {
Text(
text = "Destination",
text = context.translation["setup.save_folder.destination_label"],
fontSize = 13.sp,
color = PurrfectPalette.textSecondary
)
Text(
text = if (currentFolder.isBlank()) "System default" else currentFolder,
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
@@ -162,7 +172,7 @@ class SaveFolderScreen : SetupScreen() {
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Text(text = "Use default location")
Text(text = context.translation["setup.save_folder.use_default_location_button"])
}
if (showNoPickerDialog) {
@@ -204,13 +214,13 @@ class SaveFolderScreen : SetupScreen() {
}
}
Text(
text = "Folder picker unavailable",
text = context.translation["setup.save_folder.no_picker_title"],
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Text(
text = "Some cloned/dual-app environments block the system folder picker. You can continue using the system default save location, or open the app outside clone mode to select a custom folder.",
text = context.translation["setup.save_folder.no_picker_message"],
fontSize = 14.sp,
lineHeight = 18.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -230,7 +240,7 @@ class SaveFolderScreen : SetupScreen() {
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Text("Cancel")
Text(context.translation["button.cancel"])
}
Button(
onClick = {
@@ -248,7 +258,7 @@ class SaveFolderScreen : SetupScreen() {
contentColor = Color.White
)
) {
Text("Use default")
Text(context.translation["setup.save_folder.use_default_button"])
}
}
}
@@ -256,7 +266,7 @@ class SaveFolderScreen : SetupScreen() {
}
}
DialogText(
text = "PurrfectSnap requires Storage permissions to download and Save Media from Snapchat."
text = context.translation["setup.save_folder.permission_hint"]
)
}
}

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
@@ -29,6 +30,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -38,8 +41,8 @@ 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 me.eternal.purrfectsnap.common.config.ConfigFlag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog as StandardDialog
import androidx.core.net.toUri
import com.github.skydoves.colorpicker.compose.*
import com.google.gson.JsonParser
@@ -56,14 +59,22 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
@@ -208,29 +219,40 @@ class AlertDialogs(
@Suppress("UNCHECKED_CAST")
fun UniqueSelectionDialog(property: PropertyPair<*>) {
val disabledKey = property.key.params.disabledKey
val noDisable = property.key.params.flags.contains(ConfigFlag.NO_DISABLE_KEY)
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
val disabledEntry = disabledKey ?: "null"
remove(disabledEntry)
add(0, disabledEntry)
if (disabledKey == null) {
if (noDisable) {
disabledKey?.let { remove(it) }
remove("null")
add(0, "null")
} else {
val disabledEntry = disabledKey ?: "null"
remove(disabledEntry)
add(0, disabledEntry)
if (disabledKey == null) {
remove("null")
add(0, "null")
}
}
}
var selectedValue by remember {
mutableStateOf(property.value.getNullable()?.toString() ?: (disabledKey ?: "null"))
val currentValue = property.value.getNullable()?.toString()
mutableStateOf(currentValue ?: if (noDisable) (keys.firstOrNull().orEmpty()) else (disabledKey ?: "null"))
}
DefaultDialogCard {
keys.forEachIndexed { index, item ->
fun select() {
selectedValue = item
if (disabledKey != null && item == disabledKey) {
if (!noDisable && disabledKey != null && item == disabledKey) {
property.value.setAny(disabledKey)
return
}
property.value.setAny(if (disabledKey == null && index == 0) null else item)
if (!noDisable && disabledKey == null && index == 0) {
property.value.setAny(null)
return
}
property.value.setAny(item)
}
Row(
@@ -314,7 +336,7 @@ class AlertDialogs(
Button(
onClick = {
if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) {
Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
return@Button
}
@@ -485,7 +507,7 @@ class AlertDialogs(
currentColor = null
}
},
label = { Text(text = "Hex Color") },
label = { Text(text = translation["dialogs.hex_color_label"]) },
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
@@ -580,6 +602,8 @@ class AlertDialogs(
property: PropertyPair<*>,
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
locationSearchProvider: String = "osm",
googleMapsApiKey: String = "",
saveCoordinates: (() -> Unit)? = null,
dismiss: () -> Unit = {}
) {
@@ -590,6 +614,13 @@ class AlertDialogs(
}
}
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
fun dismissKeyboard() {
keyboardController?.hide()
focusManager.clearFocus(force = true)
}
mapView.value = remember {
Configuration.getInstance().apply {
@@ -599,7 +630,20 @@ class AlertDialogs(
MapView(context).apply {
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setTileSource(TileSourceFactory.MAPNIK)
val tileSource = if (locationSearchProvider == "google_maps") {
object : OnlineTileSourceBase(
"GoogleMaps",
0, 19, 256, ".png",
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
}
}
} else {
TileSourceFactory.MAPNIK
}
setTileSource(tileSource)
val startPoint = GeoPoint(coordinates.first, coordinates.second)
controller.setZoom(10.0)
@@ -613,12 +657,37 @@ class AlertDialogs(
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
dismissKeyboard()
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
mapView.invalidate()
return true
}
})
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
override fun longPressHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
}))
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
dismissKeyboard()
return false
}
override fun onZoom(event: ZoomEvent?): Boolean {
dismissKeyboard()
return false
}
})
overlays.add(marker.value)
}
}
@@ -673,29 +742,70 @@ class AlertDialogs(
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val resultsScrollState = rememberScrollState()
suspend fun search() {
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
.header("User-Agent", Constants.USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) {
return@use
}
BackHandler {
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
if (shouldDismissKeyboard) {
dismissKeyboard()
locationName = ""
addressResults = emptyList()
searchJob?.cancel()
searchJob = null
} else {
dismiss()
}
}
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
suspend fun search() {
if (locationSearchProvider == "google_maps") {
// Google Maps Search
okHttpClient.newCall(Request.Builder()
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
.appendQueryParameter("address", locationName)
.appendQueryParameter("key", googleMapsApiKey)
.build().toString())
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
if (jsonResponse.has("results")) {
val results = jsonResponse.getAsJsonArray("results")
addressResults = results.take(5).map { jsonElement ->
val result = jsonElement.asJsonObject
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
Triple(
result.get("formatted_address").asString,
geometry.get("lat").asString,
geometry.get("lng").asString
)
}
}
}
}
} else {
// OSM Nominatim Search (Existing Logic)
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
.appendQueryParameter("q", locationName)
.appendQueryParameter("format", "jsonv2")
.build().toString())
.header("User-Agent", Constants.OSM_USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
}
}
}
}
searchJob = null
}
@@ -759,7 +869,7 @@ class AlertDialogs(
color = Color.White
)
Text(
text = "Search or tap on the map",
text = betterLocationTranslation["search_or_tap_map_hint"],
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
@@ -813,11 +923,11 @@ class AlertDialogs(
search()
}
},
placeholder = { Text(text = "Search location...") },
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search",
contentDescription = betterLocationTranslation["search_icon_description"],
tint = Color.White
)
},
@@ -889,6 +999,7 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.clickable {
dismissKeyboard()
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
@@ -937,7 +1048,7 @@ class AlertDialogs(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Searching...",
text = betterLocationTranslation["searching_label"],
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
@@ -1036,7 +1147,7 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(
StandardDialog(
onDismissRequest = {
customCoordinatesDialog = false
},
@@ -1085,7 +1196,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lat.value,
onValueChange = { lat.value = it },
label = { Text(text = "Latitude") },
label = { Text(text = translation["latitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
@@ -1105,7 +1216,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lon.value,
onValueChange = { lon.value = it },
label = { Text(text = "Longitude") },
label = { Text(text = translation["longitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
@@ -1341,7 +1452,7 @@ class AlertDialogs(
// Add/Edit message dialog
if (showAddDialog) {
Dialog(
StandardDialog(
onDismissRequest = { showAddDialog = false },
properties = DialogProperties(
usePlatformDefaultWidth = false

View File

@@ -4,12 +4,28 @@ package me.eternal.purrfectsnap.ui.util
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Outline
import android.os.Build
import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.addCallback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -19,9 +35,12 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.*
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.core.view.WindowCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
@@ -33,6 +52,12 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
import kotlin.math.roundToInt
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext?.findActivity()
else -> null
}
class DialogProperties constructor(
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
@@ -83,6 +108,17 @@ fun Dialog(
content: @Composable () -> Unit
) {
val view = LocalView.current
var forceInline by remember(view) { mutableStateOf(false) }
val hostActivity = remember(view) { view.context.findActivity() }
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
if (shouldUseInline) {
InlineDialog(
onDismissRequest = onDismissRequest,
dismissOnClickOutside = properties.dismissOnClickOutside,
content = content
)
return
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val composition = rememberCompositionContext()
@@ -110,11 +146,30 @@ fun Dialog(
}
DisposableEffect(dialog) {
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
val showDialog = {
try {
dialog.prepareWindowForHost()
if (!dialog.isShowing) {
dialog.show()
}
} catch (_: WindowManager.BadTokenException) {
forceInline = true
}
}
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
showDialog()
} else {
val listener = object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
showDialog()
}
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(listener)
}
dialog.show()
onDispose {
dialog.dismiss()
@@ -131,6 +186,75 @@ fun Dialog(
}
}
@Composable
private fun InlineDialog(
onDismissRequest: () -> Unit,
dismissOnClickOutside: Boolean,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val displayMetrics = LocalContext.current.resources.displayMetrics
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
Popup(
alignment = androidx.compose.ui.Alignment.Center,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = dismissOnClickOutside
),
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)
)
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}
}
interface DialogWindowProvider {
val window: Window
}
@@ -279,6 +403,26 @@ private class DialogWrapper(
}
}
fun prepareWindowForHost() {
val hostActivity = composeView.context.findActivity()
val hostToken = composeView.applicationWindowToken
?: composeView.windowToken
?: composeView.rootView?.applicationWindowToken
?: composeView.rootView?.windowToken
if (hostActivity == null) {
when {
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
}
}
if (hostToken != null) {
window?.attributes = window?.attributes?.apply {
token = hostToken
}
}
hostActivity?.let { setOwnerActivity(it) }
}
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
dialogLayout.layoutDirection = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR

View File

@@ -2,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.provider.Settings
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
/**
* Returns true if the user has disabled animator duration scale at the system level.
* This respects Accessibility/Developer settings where motion is reduced or disabled.
*/
fun prefersReducedMotion(context: Context): Boolean {
return runCatching {
@@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean {
@Composable
fun rememberPrefersReducedMotion(): Boolean {
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
// One-shot read; if you need to observe changes live, add a ContentObserver.
LaunchedEffect(Unit) {
state.value = prefersReducedMotion(context)
}
@@ -47,18 +35,34 @@ fun rememberPrefersReducedMotion(): Boolean {
}
object Motion {
/**
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
*/
const val HEADER_MORPH_THRESHOLD = 300f
/**
* Milliseconds per pixel for marquee scrolling speed.
*/
const val MARQUEE_CADENCE = 20
// 1. High-end Spring Physics for "Alive" UI
val springDynamic = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
@Composable
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Int> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Int>(durationMillis = d, easing = easing)
return tween(durationMillis = d, easing = easing)
}
@Composable
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Float> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Float>(durationMillis = d, easing = easing)
return tween(durationMillis = d, easing = easing)
}
@Composable
@@ -68,23 +72,23 @@ object Motion {
}
/**
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
* Pass the same [interactionSource] into the clickable component to synchronize state.
* Kinetic scale-down on press using spring physics.
*/
@Composable
fun Modifier.scaleOnPress(
interactionSource: InteractionSource,
enabled: Boolean = true,
scaleDown: Float = 0.98f
scaleDown: Float = 0.96f
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val target = if (pressed) scaleDown else 1f
val spec = Motion.tweenFloatSpec(150)
val animated by androidx.compose.animation.core.animateFloatAsState(
val animated by animateFloatAsState(
targetValue = target,
animationSpec = spec,
animationSpec = Motion.springDynamic,
label = "pressScale"
)
return this.then(Modifier.graphicsLayer {
scaleX = animated
scaleY = animated

View File

@@ -0,0 +1,26 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A specialized modifier that tracks the height of the header (FloatingTopBar)
* and provides it to the caller. This eliminates repetitive onGloballyPositioned
* logic across all sub-pages.
*
* @param onHeightChanged Callback triggered when the header height is measured.
*/
fun Modifier.headerHeightTracker(
onHeightChanged: (Dp) -> Unit
): Modifier = composed {
val density = LocalDensity.current
this.onGloballyPositioned { coordinates ->
val heightDp = with(density) { coordinates.size.height.toDp() }
onHeightChanged(heightDp)
}
}

View File

@@ -0,0 +1,58 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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.foundation.basicMarquee
import androidx.compose.ui.text.rememberTextMeasurer
/**
* A premium marquee text component that supports continuous looping.
* Optimized using TextMeasurer for robust width calculation.
*/
@Composable
fun PurrfectMarqueeText(
text: String,
style: TextStyle = TextStyle.Default,
modifier: Modifier = Modifier,
color: Color = Color.White,
textAlign: TextAlign = TextAlign.Center,
contentAlignment: Alignment = Alignment.Center,
delayMillis: Int = 1500,
velocity: Dp = 30.dp,
maxLines: Int = 1,
enabled: Boolean = true
) {
Box(
modifier = modifier.clipToBounds(),
contentAlignment = contentAlignment
) {
Text(
text = text,
style = style,
color = color,
textAlign = textAlign,
maxLines = maxLines,
softWrap = true,
overflow = TextOverflow.Ellipsis,
modifier = if (enabled) {
Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = delayMillis,
initialDelayMillis = delayMillis,
velocity = velocity
)
} else Modifier
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 27 KiB

BIN
banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -33,13 +33,15 @@ 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.1.2").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("252").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("306").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.
rootProject.ext.set(
"buildHash",
properties["debug_build_hash"]
?: java.security.SecureRandom()
(properties["debug_build_hash"] as String?)
?: "${rootProject.ext["appVersionCode"]}_${java.security.SecureRandom()
.nextLong(Long.MAX_VALUE / 1000L, Long.MAX_VALUE)
.toString(16)
.toString(16)}"
)

View File

@@ -1,3 +1,186 @@
## v1.5.8
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)
- Fix: Optimized 1MB socket buffers for high-speed throughput which should upload the video/snap faster then before(tq to Kaladin)
- New: Aphelion Task page went full layout overhaul with segmented "Active" and "Scheduled" tab views(tq to Kaladin)
- FixL Auto-Open Status Card: Restored real-time visibility of processed snaps and queue status(tq to Kaladin)
- New: Smart trickle which has background processing with resource awareness (Paused/Trickle/Normal states)(tq to Kaladin)
- New: Four different states for auto open processing so that the device can be usable with auto open in the background(tq to Kaladin)
- Fix: memory hogging of the auto open queues, now the device should run much faster without any lag or stuttures(tq to Kaladin)
- Fix: "dot in username issue" with the implementation of unique incrementing ((1).jpg), where previously it was overwriting the previous file(tq to Kaladin)
- Fix: Lock Indicator for the snaps, which should now accurately be working on all versions(tq to Kaladin)
- Fix: Pressing back while keyboard is open in spoofed coordinates would close the dialog
- Fix: Not able to scroll in the "Can't Login?" dialog
## v1.5.6
- New: Add "Can't login" hint directly in the login screen
## v1.5.5
- Fix: Custom Emoji now works for all devices!
- New: Story preview in story batch download dialog
- Fix: Auto Skip Stories getting stuck
- Fix: Stories ending up saving in .dat format
## v1.5.4
- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap
- Fix: Spoof Coordinates Title
- New: Changelogs feature
## v1.5.3
- New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue)
- Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay
- New: Translucent Dialogs & refreshed animation
- Fix: Adjusted Snap Preview Location
## v1.5.2
- New: Continuous snap sender feature!
- Fix: Snap send failure for E2E Chats
## v1.5.1
- Fix: Splitting issue for video snaps sent through gallery media send override!
- New: Toggle to turn off/on splitting for video snaps sent through send override
- Fix: Aphelion task page layout optimization(tq to Kaladin)
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI
## v1.4.9
- Fix: Force AMOLED Theme for newer versions of Snapchat
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)
## v1.4.8
- Fix: Crash Issues for some devices
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
- Fix: Both side call recording for newer versions of snapchat
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
## v1.4.6
- Fix: Half Swipe Notifications for newer versions of snapchat
- Fix: No Config import/export button if Aphelion theme is turned off
## v1.4.5
- New: Spoof Snap Score Locally(tq to RSR)
- New: Video Recording Timer(tq to RSR)
- New: Auto Skip(tq to RSR)
- Fix: Processing failed to save snaps(tq to RSR)
- Fix: Story counter & story source for newer versions of snapchat(tq to RSR)
- Fix: Features page using Aphelion scroll even when Aphelion is not in use(tq to Kaladin)
- Fix: Aphelion Task page not showing the thumbnails of the completed tasks(tq to Kaladin)
- Fix: Tasks page not showing the merge button when Aphelion is not in use(tq to Kaladin)
- Fix: Features and its sub pages scroll state is remembered infintely, causing the pages to load where they were exited even after the app is closed(tq to Kaladin)
- New: Expanded Friend Mutation Observer to include the new layout when Aphelion is in use(tq to Kaladin)
- New: Theme Transition Animation(tq to Kaladin)
- Fix: Friend feed menu button overlapping for group chats
- Fix: Duplicate opera download & mark snaps as seen buttons
## v1.4.1
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
- Fix: Story Counter/Story Source position(tq to AhmedRaza)
- Fix: Better Location Overlay Icon for newer Snapchat versions
- Fix: Double tap to mark chat as read for newer Snapchat versions
- New: Mark Chat as Read(Friend feed menu)
- Fix: Opera Download button duplicating
- Fix: All friends getting automatically added even after unselecting in the social tab
- Fix: Spotlight Creator Info
## v1.4.0
- Fix: Download profile picture button showing in all pages
- Fix: Opera Download button & Mark Snaps as seen for newer snapchat versions
- Fix: Crash issues
- Fix: Sync Binder died
- Fix: Random friends showing up in Social tab
- Fix: Untranslated strings in the message preview section(en_US only for now)
- Fix: Show First Created Username
- Fix: Call recording starting automatically when taking snaps/videos
- Fix: Snap Downloading issue for some devices
- New: Story Snap Batch Download(tq to RSR)
- New: Aphelion Theme!(tq to Kaladin)
- Fix: Monochrome icon of PurrfectSnap(tq to Kaladin)
## v1.3.9
- Fix: Improve ads & metrics blocking
- Fix: Hide Typing Notifications & Hide Bitmoji Presence
- Fix: Many fixes & improvements
## v1.3.8
- Fix: Download profile picture button shape
- New: Auto Patcher(Jingmatrix Lspatch) updated to v0.8
- Fix: Video playback rate slider mobility
- New: Redesign Convert Message dialog
- Fix: Media File Picker issues
- Fix: REQUEST_NOT_SUCCESSFUL issues while updating PurrfectSnap for some devices
## v1.3.6
- Fix: Crash issues for some devices
- Fix: Scroll state
- New: Redesign profile picture downloader & spotlight comments username dialog
- New: Change the profile picture download button to an icon
- New: Redesign playback slider & saved coordinates buttons
- Fix: Add missing translations(en_US only for now, all languages to be supported in the next update)
- Fix: Deadosobjectexception
## v1.3.5
- New: Added support for Snap versions upto v13.81(server side update, requires v1.3.2 and above)
- New: Migration from libxposed API to YukiHook
- New: Switch to kavaref
- Fix: better location overlay crash
- Fix: better location icon position for newer snap versions
- New: Story counter(tq to RSR)
- New: Story Source(tq to RSR)
- Fix: Snaps getting saved even if the option is unchecked(tq to AhmedRaza)
- Fix: Chat edit history UI(tq to AhmedRaza)
- Fix: Media quality(tq to RSR)
- New: Remove support for Android 9 and 10
## v1.3.2
- ⚠️ ALL USERS ARE REQUESTED TO UPDATE TO THIS VERSION AS SOON AS POSSIBLE!⚠️
- Fix: Crucial fixes
- Fix: Media File Picker and added mapper(tq to RSR)
## v1.3.0
- New: Bulk Manage Friend Requests(tq to RSR)
- Fix: Bulk Action Progress Dialogs(tq to RSR)
- Fix: Audio note send override(tq to RSR)
- Fix: Improve PurrAura Active indicator(tq to RSR)
- Fix: Prevent Story Rewatch(tq to Brandon)
- Fix: Location search(tq to Brandon)
- Fix: story download & decryption(tq to Hazelnut)
- New: Add confirmation dialogs for Reset PurrfectSnap option
- New: Google Maps Integration(tq to Brandon)
- Fix: support more translation keys
- Fix: several inconsistencies
- Fix: add proper support for scripts
- New: Swap wiki with new website
- Fix: Custom emoji is now stable!
- Fix: Poor Call recorder quality
- New: order feature for chat exports
- Fix: Gemini Auto Reply(tq to RSR)
- New: Spotlight Creator Info(tq to RSR)
## v1.2.6
- Fix: All translations are now high quality and up to date
## v1.2.5
- Fix: Prevent snap replay/missed audio voice call(tq to RSR)
- New: Call Recording UI(tq to RSR)
- Fix: weird Search bar behaviour in overlay
- New: wrong architecture detector
- Fix: Arabic Translation(tq to RSR)
- New: Fresh look of PurrAura Indicator(tq to RSR)
- Fix: no such directory auto patcher
- New: Auto Mapper for Manage Friend List(tq to RSR)
- Fix: remove audio note duration & transcription(tq to RSR)
- Fix: Missing translations
- New: Chat Wallpaper download button UI
## v1.2.0
- Fix: Prevent snap replay/missed audio voice call(thanks to RSR)
- Fix: Various messaging fixes for newer snap versions(thanks to RSR)
- New: Announcements feature(directly view important announcements right through the app!)
- Fix: Missing about page
- Fix: incorrect snap version instructions
## v1.1.2
- New: Chat Wallpaper Download(upstream commit)
- Fix: mappings error

View File

@@ -46,6 +46,8 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.androidx.documentfile)
implementation(libs.rhino)
implementation(libs.kavaref.core)
implementation(libs.kavaref.extension)
implementation(libs.rhino.android) {
exclude(group = "org.mozilla", module = "rhino-runtime")
}

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