205 Commits

Author SHA1 Message Date
Ξ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
ΞTΞRNAL
d8a7af7614 fix: revise changelog 2026-01-19 23:58:26 +05:30
ΞTΞRNAL
c221beaa30 add: attention! 2026-01-19 23:37:26 +05:30
particle-box
c3cee0c911 fix: versioning and changelog parsing 2026-01-19 23:34:37 +05:30
particle-box
835dacd491 fix: release apk naming 2026-01-19 23:26:59 +05:30
particle-box
77b0a8e02a Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-01-19 23:04:58 +05:30
particle-box
e49146504a fix: checkout changelog for release 2026-01-19 23:04:33 +05:30
ΞTΞRNAL
113f47e3c1 feat: chat wallpaper download(upstream commit) 2026-01-19 22:46:07 +05:30
particle-box
2912d211a5 feat: chat wallpaper downloader 2026-01-19 22:44:24 +05:30
particle-box
705fcca5ee fix: ci signing and warnings 2026-01-19 22:36:28 +05:30
particle-box
204fde4857 fix: clear hook throwable 2026-01-19 22:16:00 +05:30
particle-box
caf57bfb5e fix: restore valid ffmpeg-kit aar 2026-01-19 22:06:15 +05:30
particle-box
d765767299 fix: restore ffmpeg-kit aar 2026-01-19 21:54:36 +05:30
particle-box
7028fc5189 fix: accidental removal 2026-01-19 21:45:57 +05:30
particle-box
fdc5bf4f17 fix: workflows 2026-01-19 21:43:26 +05:30
ΞTΞRNAL
c84b269601 fix: possible fix 2026-01-19 04:55:02 +05:30
ΞTΞRNAL
d4fde5d5b9 fix: TypeScript 2026-01-19 04:46:04 +05:30
ΞTΞRNAL
c311f7714a feat: release.yml 2026-01-19 02:11:14 +05:30
ΞTΞRNAL
4e3deb33fc feat: Update changelogs for version 1.1.2 2026-01-19 01:58:21 +05:30
particle-box
9fa733f98d many changes
new feature to record both sides while call recording(upstream commit), fix armv7 failed to initialize error & fix a9 issues
2026-01-19 01:52:26 +05:30
ΞTΞRNAL
1ba7e411ca fix: Mappings
fix an issue where generating mappings showed snapchat not installed even though its installed
2026-01-17 20:08:52 +05:30
particle-box
d3b7ddda40 fix: build warnings 2026-01-16 23:49:27 +05:30
ΞTΞRNAL
4961a36c63 fix: use sdk root for signing tools 2026-01-16 22:50:56 +05:30
ΞTΞRNAL
af7b231561 fix: signing 2026-01-16 21:33:49 +05:30
ΞTΞRNAL
e4c6c5fde8 fix: signing 2026-01-16 13:09:20 +05:30
ΞTΞRNAL
b4f95c2fd4 fix: remove outdated jobs 2026-01-16 12:42:12 +05:30
ΞTΞRNAL
15b35f85b2 (bot): fix github workflows 2026-01-16 12:35:01 +05:30
ΞTΞRNAL
3f11e09a00 ci: update debug signing secrets 2026-01-16 12:31:17 +05:30
ΞTΞRNAL
7889fb2ac8 fix: crash 2026-01-14 01:39:02 +05:30
ΞTΞRNAL
488d107d99 Update changelog for version 1.1.1 2026-01-14 01:06:43 +05:30
particle-box
1bf6335ca2 v1.1.0: Stable! 2026-01-13 23:41:29 +05:30
particle-box
dd27ed9450 bulk updation of all translation files 2026-01-13 03:00:54 +05:30
particle-box
f6ac584991 prepare for bulk translation 2026-01-12 20:54:43 +05:30
particle-box
8a91ab87a2 update source translation file 2026-01-12 20:36:54 +05:30
particle-box
eea3d9ea53 test new feature 2026-01-12 19:17:37 +05:30
ΞTΞRNAL
e9de1a5744 fix: LICENSE link 2026-01-11 16:05:21 +05:30
Aditya Akolkar
701e17aa21 feat: redesign README 2026-01-01 19:20:35 +05:30
ΞTΞRNAL
91db54a141 feat: redesign README
Updated the README to enhance clarity and detail about PurrfectSnap's features and installation process.
2025-12-31 18:43:01 +05:30
496 changed files with 114837 additions and 472042 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

View File

@@ -28,22 +28,68 @@ jobs:
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv8Debug
- name: Determine the latest Build Tools version installed
shell: bash
run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
id: sign_app
uses: SnapEnhance/sign-android-release@master
with:
releaseDirectory: app/build/outputs/apk/armv8/debug/
signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv8/debug"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv8Debug-aligned.apk"
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv8Debug-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv8Debug-aligned.apk"
- name: Get current build version
id: version-env
run: |
@@ -52,7 +98,7 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
@@ -80,22 +126,68 @@ jobs:
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Determine the latest Build Tools version installed
shell: bash
run: echo "BUILD_TOOL_VERSION=$(ls \"$ANDROID_HOME/build-tools/\" | tail -n 1)" >> $GITHUB_ENV
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
id: sign_app
uses: SnapEnhance/sign-android-release@master
with:
releaseDirectory: app/build/outputs/apk/armv7/debug/
signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/debug"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Debug-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
- name: Get current build version
id: version-env
run: |
@@ -104,85 +196,10 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
job_manager:
runs-on: ubuntu-latest
outputs:
manager_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build
run: ./gradlew --configuration-cache manager:assembleDebug
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: manager
path: manager/build/outputs/apk/debug/*.apk
job_core:
runs-on: ubuntu-latest
outputs:
core_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleCoreDebug
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: core
path: app/build/outputs/apk/core/debug/*.apk
make_prerelease:
needs: [job_armv7, job_armv8, job_manager, job_core]
runs-on: ubuntu-latest
steps:
- name: Download all build artifacts
uses: actions/download-artifact@v4
with:
path: ./all-apks
- name: Display all files for debug
run: find ./all-apks
- name: Set up GitHub CLI
run: |
sudo apt-get install -y gh
- name: Create Debug Prerelease with all APKs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="debug-${{ github.run_id }}-${{ github.sha }}"
TITLE="Debug CI $VERSION"
NOTE="Auto-generated debug prerelease including all APKs from jobs."
APKLIST=$(find ./all-apks -type f -name '*.apk' | tr '\n' ' ')
echo "APKS: $APKLIST"
gh release create "$VERSION" $APKLIST --repo ${{ github.repository }} --title "$TITLE" --notes "$NOTE" --prerelease

236
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,236 @@
name: PurrfectSnap Release CI
on:
workflow_dispatch:
inputs:
ci_upload:
description: 'Upload to CI channel'
required: false
type: boolean
jobs:
job_armv8:
runs-on: ubuntu-latest
outputs:
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv8Release
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv8/release"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv8Release-aligned.apk"
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv8Release-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv8Release-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv8/release/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv8/release/purrfectsnap-armv8Release-signed.apk app/build/outputs/apk/armv8/release/purrfectsnap_${{ env.version }}-armv8Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv8-release
path: app/build/outputs/apk/armv8/release/*.apk
job_armv7:
runs-on: ubuntu-latest
outputs:
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Release
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/release"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Release-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/release/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/release/purrfectsnap-armv7Release-signed.apk app/build/outputs/apk/armv7/release/purrfectsnap_${{ env.version }}-armv7Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-release
path: app/build/outputs/apk/armv7/release/*.apk
make_release:
needs: [job_armv7, job_armv8]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Download all build artifacts
uses: actions/download-artifact@v4
with:
path: ./all-apks
- name: Display all files for debug
run: find ./all-apks
- name: Set up GitHub CLI
run: |
sudo apt-get install -y gh
- name: Create Release with all APKs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(awk 'NR==1 && $1=="##" {print $2; exit}' changelogs-stable.txt)
NOTE=$(awk 'NR==1 {next} /^## / {exit} {sub(/^[[:space:]]+/, "", $0); print}' changelogs-stable.txt)
if [ -z "${VERSION}" ] || [ -z "${NOTE}" ]; then
echo "Failed to parse changelogs-stable.txt for release title/body." >&2
exit 1
fi
TITLE="${VERSION}"
APKLIST=$(find ./all-apks -type f -name '*.apk' | tr '\n' ' ')
echo "APKS: $APKLIST"
gh release create "$VERSION" $APKLIST --repo ${{ github.repository }} --title "$TITLE" --notes "$NOTE"

3
.gitignore vendored
View File

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

391
README.md
View File

@@ -1,79 +1,382 @@
<br>
<div align="center">
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap Logo" width="150"/>
<h1>PurrfectSnap 🐾✨</h1>
<h3>A fork of Snapenhance made to <em>meow</em> your Snapchat experience. 😉</h3>
<p>
<a href="https://github.com/particle-box/PurrfectSnap/releases/latest"><img src="https://img.shields.io/github/v/release/particle-box/PurrfectSnap?style=for-the-badge&color=cba6f7&labelColor=302d41&label=Release" alt="Latest Release"></a>
<a href="https://github.com/particle-box/PurrfectSnap/releases"><img src="https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=302d41&label=Downloads" alt="Downloads"></a>
<a href="https://github.com/particle-box/PurrfectSnap/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=302d41" alt="License"></a>
</p>
<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!
<br>
[![Release](https://img.shields.io/github/v/release/particle-box/PurrfectSnap?include_prereleases&style=for-the-badge&color=cba6f7&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/releases/latest)
[![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) • [Build](#build-from-source) • [Community](#community)
</div>
<br>
---
## 🐈 What is PurrfectSnap?
## Overview
**PurrfectSnap** enhances your Snapchat experience by building upon the incredible foundation of the original Snapenhance project. Our goal is to actively develop and integrate powerful, modern features that give you unparalleled control and a truly unique aesthetic.
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.
<br>
## 🙏 Acknowledgements
## Philosophy
This project would not be possible without the monumental effort and dedication of the **original Snapenhance team**. We are immensely grateful for their work. PurrfectSnap is our way of contributing to their vision by exploring new features and ideas that we believe the community will love.
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 a passion for creating something exceptional.
<br>
## 🚀 What's New in the Litterbox?
## Features
PurrfectSnap isn't just a maintenance fork. It's a complete evolution. Heres what sets us apart from the upstream project:
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
***Revamped Theming Engine**: Theming is back, and it's better than ever! Includes a gorgeous, battery-saving **AMOLED theme** for the Snapenhance menu.
***Dynamic Bottom Bar**: Total control over your navigation. Add or remove tabs to create a layout that works for you, and even set your own default tab.
***Effortless Configuration**: A brand-new interface for importing and exporting your Snapenhance settings.
***Advanced Friend Tracker**: Seamlessly import and export your tracked friends list.
***Built-in Catalogs**: Discover new customizations with integrated catalogs for both **Friend Tracker** and **Scripts**.
***Refined Quick Actions**: Weve updated the quick actions menu for a smoother, more intuitive workflow.
***Haptic Feedback**: Feel the interface with optional haptic feedback for a more tactile experience.
***Intelligent Auto-Updater**: Get notified about new updates and install them **directly from within the app**. No more hunting for nightly links.
* **Fresh Coat of Paint**: Restored icons (like the beloved gear ⚙️) and a polished UI.
* **Modern Internals**: All dependencies have been updated to their latest versions for maximum performance and stability.
<table>
<tr>
<td width="50%">
### Media Downloader
Advanced media management with extensive customization options for downloading and organizing content.
**Core Capabilities**
- Custom save locations and path formatting
- Automatic downloads from selected sources
- Profile picture downloads
- Voice note capture with format control
- FFmpeg integration for advanced processing
**Smart Features**
- Duplicate prevention with override option
- Overlay merging for combined content
- Custom logging for tracking downloads
- Context menu integration
</td>
<td width="50%">
### User Interface
Complete control over your interface with deep customization options.
**Visual Customization**
- Custom themes including AMOLED mode
- Configurable icon styles
- Message preview customization
- Bootstrap override for default tabs
**Interface Control**
- Hide unwanted UI components
- Enhanced friend map nametags
- Snap preview options
- Streak expiration info display
- Vertical story viewer
- Message indicators and stealth mode display
</td>
</tr>
<tr>
<td width="50%">
### Messaging
Privacy-focused messaging features with advanced control over your conversations.
**Privacy Tools**
- Screenshot bypass
- Anonymous story viewing
- Hide typing notifications
- Hide Bitmoji presence
- Prevent story rewatch indicators
**Enhanced Features**
- Unlimited snap view time
- Auto mark as read
- Conversation pinning (unlimited)
- Message logger with whitelist/blacklist
- Better notifications with blacklist support
- Double tap actions and reactions
- Message retention policy bypass
</td>
<td width="50%">
### Global Settings
System-wide enhancements that improve your overall experience.
**Performance**
- Better location handling
- Media upload quality control
- Custom video playback rates
- Default volume controls
**Optimization**
- Ad blocking
- Metrics disabling
- Story section control
- Video length restriction bypass
- Snap splitting disable
- Telecom framework control
</td>
</tr>
<tr>
<td width="50%">
### Camera
Professional-grade camera controls for content creation.
**Recording Options**
- Custom frame rates (front/back)
- HEVC recording support
- Custom resolution override
- Force camera source encoding
**Creative Control**
- Immersive preview mode
- Black photo option
- Startup default camera selection
</td>
<td width="50%">
### Rules Engine
Automation system for complex workflows.
- Stealth mode rules
- Auto download conditions
- Auto save parameters
- Auto open snap rules
- Unsaveable message settings
</td>
</tr>
<tr>
<td width="50%">
### Experimental
Cutting-edge features for power users.
**Advanced Tools**
- Native hooks for deep customization
- Spoofing capabilities
- Story logger
- Call recorder
- Account switcher
- App lock
- End-to-end encryption
- My Eyes Only passcode bypass
**Developer Features**
- Better transcript
- Friend notes
- COF experiments
- Custom streaks format
- Prevent forced logout
</td>
<td width="50%">
### Scripting
Extensibility through custom scripts.
- Developer mode
- Module folder management
- Auto reload capability
- Integrated UI
- Log control options
- Optimization toggles
</td>
</tr>
</table>
<br>
## 🥊 Claws Out: PurrfectSnap vs. Other Forks
**Additional Features**
Not all forks are created equal. Some forks offer minimal changes—like basic translation or dependency updates—while aggressively seeking donations. Heres how PurrfectSnap stacks up.
> **Streaks Reminder** — Configurable interval notifications with remaining time display and group notification support
| Feature & Philosophy | **PurrfectSnap** 👑 | Other "Feature-Rich" Forks |
| :---------------------------------- | :----------------------------------------------------------: | :------------------------: |
| **Active Feature Development** | ✅ Constant innovation & new user-facing features | ❌ Stagnant |
| **UI/UX Innovation** | ✅ Customizable bottom bar, new screens, AMOLED themes | ❌ Stale, outdated UI |
| **User-Centric Tools** | ✅ Built-in catalogs, in-app updater, advanced trackers | ❌ None |
| **Core Development Focus** | Pushing the boundaries of what's possible | Basic dependency bumps |
| **Community Respect** | Driven by passion, features are free for everyone | Aggressive donation appeals for minor updates |
> **Friend Tracker** — Event recording with background operation and automatic purge management
<br>
## 🔮 Future Paws-ibilities (Roadmap)
We're just getting started. Here are some of the exciting features planned for future releases:
* 💡 **Auto-Reply**: Automatically respond to Snaps and messages with custom rules.
* 💡 **Auto-Snap Sender**: Schedule and automate sending Snaps to friends.
* 💡 **Recommended Version Indicator**: Get clear guidance on the best Snapchat version to use with PurrfectSnap for optimal stability.
> 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>
## 🛠️ Installation
---
**Please** refer to the **[Telegram Channel](https://t.me/purrfectsnap_official)** .
<br>
## Installation
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>
---
<br>
## Community
Questions? Ideas? Found a bug? Our community is active and responsive.
**[Telegram Channel](https://t.me/purrfectsnap_official)** : Announcements, discussions, and support
**For the discussions group link, refer to the description of the channel!**
<br>
---
<br>
## Build from Source
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 :)
### 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.
## Credits
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
- [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
- [rhino-android](https://github.com/F43nd1r/rhino-android) — Android integration
**Utilities**
- [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>
---
<div align="center">
<p>Made with ❤️ and a lot of catnip.</p>
<br>
Built with care, maintained with passion ❤️
Open source under GPL-3.0, Apache-2.0
<br>
<br>
</div>

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
@@ -110,13 +114,35 @@ android {
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
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 {
computeKeystoreCertSha256(
File(System.getProperty("user.home"), ".android/debug.keystore"),
storePass = "android",
keyAlias = "androiddebugkey"
).orEmpty()
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
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,
storePass = releaseStorePass,
keyAlias = releaseKeyAlias,
keyPass = releaseKeyPass ?: releaseStorePass
)
} else {
computeKeystoreCertSha256(
File(System.getProperty("user.home"), ".android/debug.keystore"),
storePass = "android",
keyAlias = "androiddebugkey"
)
}.orEmpty()
}
val expectedCertSha256 = providers.gradleProperty("EXPECTED_CERT_SHA256")
.orElse(providers.gradleProperty("psExpectedCertSha256"))
@@ -125,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()}\"")
@@ -135,6 +161,12 @@ android {
release {
isMinifyEnabled = true
proguardFiles += file("proguard-rules.pro")
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
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")
}
}
debug {
(properties["debug_flavor"] == null).also {
@@ -235,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

@@ -16,8 +16,8 @@
android:usesCleartextTraffic="true"
android:label="@string/app_name"
tools:targetApi="34"
android:allowBackup="true"
android:hasFragileUserData="true"
android:allowBackup="false"
android:hasFragileUserData="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
@@ -58,11 +58,12 @@
<activity android:name=".bridge.ForceStartActivity"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:exported="false" />
android:exported="true" />
<activity android:name=".bridge.BiometricPromptActivity"
android:theme="@style/BiometricPromptTheme"
android:excludeFromRecents="true"
android:exported="false" />
android:exported="true"
android:permission="com.snapchat.android.permission.UPDATE_STICKER_INDEX" />
<receiver android:name=".StreaksReminder" />
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -104,10 +104,10 @@ class RemoteFileHandleManager(
File(userImportFolder, name.substringAfterLast("/"))
)
}
FileHandleScope.COMPOSER -> {
FileHandleScope.VALDI -> {
AssetFileHandle(
context,
"composer/${name.substringAfterLast("/")}"
"valdi/${name.substringAfterLast("/")}"
)
}
}

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

@@ -1,17 +1,23 @@
@file:Suppress("DEPRECATION")
package me.eternal.purrfectsnap.bridge
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.hardware.biometrics.BiometricPrompt
import android.hardware.fingerprint.FingerprintManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.app.KeyguardManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import me.eternal.purrfectsnap.SharedContextHolder
import java.util.concurrent.Executors
class BiometricPromptActivity: ComponentActivity() {
private val deviceCredentialRequestCode = 2201
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -21,16 +27,50 @@ class BiometricPromptActivity: ComponentActivity() {
}
val remoteSideContext = SharedContextHolder.remote(this)
val executor = Executors.newSingleThreadExecutor()
val negativeText = remoteSideContext.translation.getOrNull("biometric_auth.cancel")
?.takeIf { it.isNotBlank() }
?: "Cancel"
val titleText = remoteSideContext.translation.getOrNull("biometric_auth.title")
?.takeIf { it.isNotBlank() }
?: "Unlock"
val subtitleText = remoteSideContext.translation.getOrNull("biometric_auth.subtitle")
?.takeIf { it.isNotBlank() }
?: "Confirm your screen lock"
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val fingerprintManager = getSystemService(FingerprintManager::class.java)
val hasFingerprintSupport = runCatching {
fingerprintManager?.isHardwareDetected == true && fingerprintManager.hasEnrolledFingerprints()
}.getOrDefault(false)
if (!hasFingerprintSupport) {
val keyguardManager = getSystemService(KeyguardManager::class.java)
val canUseDeviceCredential = keyguardManager?.isKeyguardSecure == true
if (canUseDeviceCredential) {
val intent = keyguardManager.createConfirmDeviceCredentialIntent(titleText, subtitleText)
if (intent != null) {
startActivityForResult(intent, deviceCredentialRequestCode)
setContent {}
return
}
}
cancel()
setContent {}
return
}
}
BiometricPrompt.Builder(this@BiometricPromptActivity)
.setTitle(remoteSideContext.translation["biometric_auth.title"])
.setSubtitle(remoteSideContext.translation["biometric_auth.subtitle"])
.setTitle(titleText)
.setSubtitle(subtitleText)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
setDeviceCredentialAllowed(true)
} else {
setNegativeButton(negativeText, mainExecutor) { _, _ -> cancel() }
}
}
.build().authenticate(
@@ -39,7 +79,7 @@ class BiometricPromptActivity: ComponentActivity() {
cancel()
}
},
Executors.newSingleThreadExecutor(),
executor,
object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
cancel()
@@ -54,4 +94,17 @@ 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)
if (requestCode != deviceCredentialRequestCode) return
if (resultCode == RESULT_OK) {
setResult(RESULT_OK, Intent())
} else {
setResult(RESULT_CANCELED, Intent())
}
finish()
}
}

View File

@@ -4,9 +4,11 @@ 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
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
import me.eternal.purrfectsnap.bridge.snapclient.MessagingBridge
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
@@ -16,6 +18,7 @@ import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.common.util.toParcelable
import me.eternal.purrfectsnap.download.DownloadProcessor
import me.eternal.purrfectsnap.download.FFMpegProcessor
import me.eternal.purrfectsnap.download.call.CallDownloadSessionImpl
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.task.Task
import me.eternal.purrfectsnap.task.TaskType
@@ -25,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
}
@@ -45,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)
}
}
@@ -119,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
)
@@ -160,6 +200,7 @@ class BridgeService : Service() {
}
override fun sync(callback: SyncCallback) {
clearSyncCallback()
syncCallback = callback
measureTimeMillis {
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
@@ -183,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? {
@@ -248,5 +294,16 @@ class BridgeService : Service() {
override fun getDebugProp(key: String, defaultValue: String?): String? {
return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue
}
override fun startCallDownload(
startTimestamp: Long,
author: String
): CallDownloadSession {
return CallDownloadSessionImpl(
context = remoteSideContext,
callStartTimestamp = startTimestamp,
author = author
)
}
}
}

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,184 @@ class DownloadProcessor (
}
}
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
}
}
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
} else {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
?: throw Exception("Failed to open existing media for comparison")
existingInputStream.use { currentExistingInputStream ->
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 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
}
}
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
?: throw Exception("Failed to create output file $fileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $fileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
for (index in 0 until length) {
if (left[index] != right[index]) return false
}
return true
}
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.substringAfterLast("/").ifBlank { "media" }
val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName))
val currentExtension = rawName.substringAfterLast(".", "").lowercase()
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val extension = if (currentExtension == targetExtension) {
currentExtension
} else {
targetExtension
}
return "$sanitizedBase.$extension"
}
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.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 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 filesMatch(existingFile: File, inputFile: File): Boolean {
return streamsMatch(existingFile.inputStream(), inputFile.inputStream())
}
private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean {
existingInputStream.use { currentExistingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputInputStream.use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
return false
}
if (read1 == -1) break
}
}
}
return true
}
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("\\", "/")
.trim('/')
)
val baseRelative = when {
fileType.isImage -> Environment.DIRECTORY_PICTURES
fileType.isVideo -> Environment.DIRECTORY_MOVIES
@@ -228,23 +362,69 @@ class DownloadProcessor (
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
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName")
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
}
for (attempt in 0..100) {
val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt)
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)
}.onFailure {
remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}")
}.getOrNull() ?: continue
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream for $candidateName")
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) }
remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it)
}
}
throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath")
} 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)
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
var destFile = File(destDir, sanitizedFileName)
if (destFile.exists()) {
if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
var suffix = 1
while (destFile.exists()) {
destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++))
}
}
FileOutputStream(destFile).use { out ->
inputFile.inputStream().use { it.copyTo(out) }
}
@@ -255,7 +435,7 @@ class DownloadProcessor (
}
)
}
Uri.fromFile(destFile)
GallerySaveResult(Uri.fromFile(destFile))
}
}
@@ -263,8 +443,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 +456,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 +532,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 +559,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 +584,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -424,126 +604,130 @@ 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()) {
return@let
}
}
callbackOnFailure(translation["already_downloaded_toast"], null)
callbackOnFailure(translation["already_downloaded_toast"])
return@launch
} else {
callbackOnFailure(translation["already_queued_toast"], null)
}
return@launch
}
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

@@ -59,6 +59,12 @@ class FFMpegProcessor(
pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})")
}
)
fun newFFMpegProcessor(context: RemoteSideContext, onStatistics: (Statistics) -> Unit = {}) = FFMpegProcessor(
logManager = context.log,
ffmpegOptions = context.config.root.downloader.ffmpegOptions,
onStatistics = onStatistics
)
}
enum class Action {
DOWNLOAD_DASH,
@@ -66,6 +72,7 @@ class FFMpegProcessor(
CONVERSION,
MERGE_MEDIA,
DOWNLOAD_AUDIO_STREAM,
MERGE_AUDIO_STREAMS,
}
data class Request(
@@ -76,6 +83,7 @@ class FFMpegProcessor(
val startTime: Long? = null, //only for DOWNLOAD_DASH
val duration: Long? = null, //only for DOWNLOAD_DASH
val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM
val inputDelayOffsets: Map<String, Long>? = null, // only for MERGE_AUDIO_STREAMS
var videoCodec: String? = null,
var audioCodec: String? = null,
@@ -98,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 ->
@@ -127,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"
@@ -202,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]\""
@@ -220,8 +239,28 @@ class FFMpegProcessor(
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
}
Action.MERGE_AUDIO_STREAMS -> {
inputArguments.clear()
outputArguments.clear()
val filterParts = StringBuilder()
args.inputs.forEachIndexed { index, input ->
inputArguments += "-i" to input
val offset = args.inputDelayOffsets?.get(input) ?: 0L
if (offset > 0) {
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
} else {
filterParts.append("[$index:a]acopy[a$index];")
}
}
args.inputs.indices.forEach { index ->
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
}
}
outputArguments += args.output.absolutePath
newFFMpegTask(globalArguments, inputArguments, outputArguments)
}
}
}

View File

@@ -0,0 +1,191 @@
package me.eternal.purrfectsnap.download.call
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
import me.eternal.purrfectsnap.common.data.download.AudioStreamFormat
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
import me.eternal.purrfectsnap.download.DownloadProcessor
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
class CallDownloadSessionImpl(
private val context: RemoteSideContext,
private val callStartTimestamp: Long,
private val author: String,
): CallDownloadSession.Stub() {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private var callEnded: Boolean = false
private val streams = CopyOnWriteArrayList<CallStream>()
private var mergeJob: Job? = null
init {
context.log.verbose("Starting call callStartTimestamp=$callStartTimestamp")
}
inner class CallStream(
val startTimestampMillis: Long,
private val audioStreamFormat: AudioStreamFormat
) {
val job: Job
val writePfd: ParcelFileDescriptor
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
if (exists()) delete()
}
init {
val pipe = ParcelFileDescriptor.createPipe()
writePfd = pipe[1]
job = coroutineScope.launch {
runCatching {
FFMpegProcessor.newFFMpegProcessor(context).execute(
FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf("/proc/self/fd/${pipe[0].fd}"),
output = outputFile,
audioStreamFormat = audioStreamFormat
)
)
}.onFailure {
context.log.error("Error converting call audio stream", it)
runCatching {
outputFile.delete()
}
}
pipe.forEach { runCatching { it.close() } }
context.log.verbose("Call stream ended startTimestampMillis=$startTimestampMillis")
}
}
}
override fun createStream(
startTimestampMillis: Long,
channels: Int,
sampleRate: Int,
encoding: Int
): ParcelFileDescriptor? {
if (callEnded) return null
context.log.verbose("createFileDescriptor startTimestampMillis=$startTimestampMillis, channels=$channels, sampleRate=$sampleRate, encoding=$encoding")
val callStream = CallStream(
startTimestampMillis = startTimestampMillis,
audioStreamFormat = AudioStreamFormat(
channels = channels,
sampleRate = sampleRate,
encoding = encoding
)
)
synchronized(streams) {
streams.add(callStream)
}
return callStream.writePfd
}
override fun end() {
if (callEnded) return
callEnded = true
if (streams.isEmpty()) {
context.log.verbose("No call chunks to merge")
return
}
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 = context.translation.format("task_call_recording_title", "author" to author),
author = author,
hash = UUID.randomUUID().toString()
)
).apply {
addListener(PendingTaskListener(
onCancel = {
mergeJob?.cancel()
outputFile.delete()
streams.forEach { stream ->
stream.outputFile.delete()
}
}
))
}
mergeJob = coroutineScope.launch {
try {
streams.forEach { stream ->
stream.job.join()
}
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).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,
finalFileName,
downloadSource = MediaDownloadSource.VOICE_CALL,
mediaAuthor = author,
creationTimestamp = System.currentTimeMillis()
),
mediaAuthor = author,
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()
}
outputFile.delete()
}
context.log.verbose("ending call")
}
}
}

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.setup.patch
import com.google.gson.JsonParser
import java.util.concurrent.TimeUnit
import kotlin.random.Random
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -29,7 +30,7 @@ class AutoPatchServer(
fun fetchLatestSnapchatApk(): LatestApk? {
val request = Request.Builder()
.url("https://api.github.com/repos/particle-box/auto-patch-server/releases/latest")
.url("https://api.github.com/repos/particle-box/download-snap/releases/latest")
.build()
okHttpClient.newCall(request).execute().use { response ->
@@ -47,9 +48,9 @@ class AutoPatchServer(
name to downloadUrl
}
val selected = apkAssets.firstOrNull { it.first.contains("snapchat", ignoreCase = true) }
?: apkAssets.firstOrNull()
?: return null
val nonPrimary = apkAssets.filterNot { it.first.contains("snapchat", ignoreCase = true) }
val selectionPool = if (nonPrimary.isNotEmpty()) nonPrimary else apkAssets
val selected = selectionPool.random(Random.Default)
return LatestApk(
tagName = tagName,
@@ -59,4 +60,3 @@ class AutoPatchServer(
}
}
}

View File

@@ -11,9 +11,9 @@ data class PatchConfig(
) {
data class LSPConfig(
var API_CODE: Int = 93,
var VERSION_CODE: Int = 360,
var VERSION_NAME: String = "0.5.1",
var CORE_VERSION_CODE: Int = 6649,
var CORE_VERSION_NAME: String = "1.8.5",
var VERSION_CODE: Int = 430,
var VERSION_NAME: String = "0.7",
var CORE_VERSION_CODE: Int = 7137,
var CORE_VERSION_NAME: String = "1.10.1",
)
}

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

@@ -127,15 +127,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

@@ -16,6 +16,7 @@ import androidx.work.WorkerParameters
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.MainActivity
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
class UpdateCheckWorker(
private val appContext: Context,
@@ -24,7 +25,11 @@ class UpdateCheckWorker(
override suspend fun doWork(): Result {
return try {
val latestRelease = Updater.latestRelease
val channel = when (inputData.getString("update_channel")) {
"prerelease" -> Channel.PRERELEASE
else -> Channel.STABLE
}
val latestRelease = Updater.getLatestRelease(channel)
if (latestRelease != null) {
showUpdateNotification(latestRelease.versionName)
}
@@ -54,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,23 +24,29 @@ 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
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
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
@@ -61,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()
@@ -88,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() }
@@ -112,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
@@ -174,7 +211,32 @@ class MainActivity : ComponentActivity() {
} else {
PaddingValues(0.dp)
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(statusBarHeight + 24.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF241F52),
Color.Transparent
)
)
)
)
if (!isFullscreen) {
Box(
modifier = Modifier
@@ -209,4 +271,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) }
@@ -332,8 +378,7 @@ class Navigation(
}
val horizontalInset = 2.dp
val indicatorWidth = (with(density) { itemWidthPx.toDp() } - horizontalInset * 2)
.coerceAtLeast(70.dp)
.coerceAtMost(with(density) { itemWidthPx.toDp() })
.coerceAtLeast(0.dp)
Box(
modifier = Modifier
.fillMaxSize()
@@ -370,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(
@@ -431,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 = {
@@ -442,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,
@@ -457,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()
}
)
}
}
@@ -546,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) }
@@ -578,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
@@ -592,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)
}
}
},
@@ -664,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)
@@ -795,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

@@ -19,8 +19,10 @@ import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.ManageRuleFeature
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
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.RetroGameScreen
import me.eternal.purrfectsnap.ui.manager.pages.location.BetterLocationRoot
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.LoggedStories
@@ -52,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}"
@@ -67,8 +69,8 @@ class Routes(
var friendTrackerConfigJsonForImport: String? = null
var onRuleImported: (() -> Unit)? = null
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true, hasOwnTopBar = true), TasksRootSection())
@@ -76,6 +78,8 @@ class Routes(
val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}", hasOwnTopBar = true), ManageRuleFeature()).parent(features)
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true, hasOwnTopBar = true), HomeRootSection())
val about = route(RouteInfo("home_about", hasOwnTopBar = true), HomeAbout()).parent(home)
val retroGame = route(RouteInfo("retro_game", hasOwnTopBar = true), RetroGameScreen()).parent(home)
val settings = route(RouteInfo("home_settings", hasOwnTopBar = true), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs", hasOwnTopBar = true), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history", hasOwnTopBar = true), LoggerHistoryRoot()).parent(home)
@@ -87,7 +91,7 @@ class Routes(
val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog", hasOwnTopBar = true), FriendTrackerCatalog())
val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos", hasOwnTopBar = true), ManageFriendTrackerReposSection())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
val fileImports = route(RouteInfo("file_imports", hasOwnTopBar = true), FileImportsRoot()).parent(home)
val manageRepos = route(RouteInfo("manage_repos/?type={type}"), ManageReposSection())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true, hasOwnTopBar = true), SocialRootSection())

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

@@ -62,7 +62,9 @@ fun AestheticDialog(
loading: Boolean = false,
opaque: Boolean = false,
showCloseButton: Boolean = true,
confirmEnabled: Boolean = true
confirmEnabled: Boolean = true,
showIcon: Boolean = true,
showTitle: Boolean = true
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
@@ -94,28 +96,32 @@ fun AestheticDialog(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
if (showIcon) {
Box(
modifier = Modifier
.size(62.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
}
}
if (showTitle && title.isNotBlank()) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
if (text.isNotBlank()) {
Text(
text = text,
@@ -130,25 +136,27 @@ fun AestheticDialog(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
) {
if (dismissButtonText != null && onDismiss != null) {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) { Text(dismissButtonText) }
}
Button(
onClick = onConfirm,
enabled = confirmEnabled && !loading,
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
) { Text(dismissButtonText) }
}
Button(
onClick = onConfirm,
enabled = confirmEnabled && !loading,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White,
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
disabledContentColor = Color.White.copy(alpha = 0.75f)
)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = Color.White
)

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

@@ -1,11 +1,11 @@
package me.eternal.purrfectsnap.ui.manager.data
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import com.tonyodev.fetch2.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -13,15 +13,19 @@ 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: Context): Fetch {
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
fetch = run {
val fetchConfiguration = FetchConfiguration.Builder(context.applicationContext)
val fetchConfiguration = FetchConfiguration.Builder(context.androidContext)
.setDownloadConcurrentLimit(3)
.build()
Fetch.getInstance(fetchConfiguration)
@@ -56,27 +60,185 @@ object UpdateDownloader {
}
}
private fun resolveDownloadedApk(
remoteContext: RemoteSideContext,
downloadedFile: File
): File {
val context = remoteContext.androidContext
if (downloadedFile.extension.equals("zip", ignoreCase = true)) {
val unzipDir = File(context.externalCacheDir, "update")
if (unzipDir.exists()) unzipDir.deleteRecursively()
unzipDir.mkdirs()
remoteContext.log.info(
"Extracting update archive ${downloadedFile.absolutePath} -> ${unzipDir.absolutePath}",
TAG
)
unzip(downloadedFile, unzipDir)
return unzipDir.walk().firstOrNull { it.isFile && it.extension.equals("apk", true) }
?: throw IllegalStateException("No APK found in the downloaded archive")
}
if (!downloadedFile.extension.equals("apk", ignoreCase = true)) {
remoteContext.log.warn(
"Downloaded file is not an APK or ZIP (${downloadedFile.name}); attempting installation anyway.",
TAG
)
}
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(
context: Context,
remoteContext: RemoteSideContext,
downloadUrl: String,
fileName: String,
scope: CoroutineScope
) {
val fetch = getInstance(context)
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)
val request = Request(downloadUrl, filePath).apply {
priority = Priority.HIGH
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
var fallbackAttempted = false
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
remoteContext.log.info("Queued update download: ${download.file}", TAG)
}
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) {
@@ -84,44 +246,21 @@ object UpdateDownloader {
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
runCatching {
val downloadedFile = File(download.file)
val unzipDir = File(context.externalCacheDir, "update")
if (unzipDir.exists()) {
unzipDir.deleteRecursively()
}
unzipDir.mkdirs()
unzip(downloadedFile, unzipDir)
val apkFile = unzipDir.walk().find { it.isFile && it.extension == "apk" }
?: throw Exception("No APK found in the downloaded file")
val uri = FileProvider.getUriForFile(context, "me.eternal.purrfectsnap.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)
}
context.startActivity(installIntent)
}.onFailure {
it.printStackTrace()
Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
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()
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

@@ -8,13 +8,34 @@ import okhttp3.Request
object Updater {
enum class Channel { STABLE, PRERELEASE }
data class LatestRelease(
val versionName: String,
val releaseUrl: String,
val workflowId: Long?,
val assetDownloads: Map<String, String> = emptyMap(),
)
private fun fetchLatestRelease() = runCatching {
private fun isVersionGreater(version: String, current: String): Boolean {
fun segments(raw: String) = raw
.split(Regex("[^0-9]+"))
.filter { it.isNotBlank() }
.map { it.toIntOrNull() ?: 0 }
val v = segments(version)
val c = segments(current)
val size = maxOf(v.size, c.size)
for (i in 0 until size) {
val vi = v.getOrElse(i) { 0 }
val ci = c.getOrElse(i) { 0 }
if (vi > ci) return true
if (vi < ci) return false
}
return false
}
private fun fetchLatestRelease(channel: Channel) = runCatching {
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
@@ -24,17 +45,45 @@ object Updater {
if (it.size() == 0) throw Throwable("No releases found")
}
val latestRelease = releases.get(0).asJsonObject
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString
if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return@runCatching null
val currentVersion = BuildConfig.VERSION_NAME
val latestRelease = releases.mapNotNull { it.asJsonObject }.firstOrNull { release ->
if (release.get("draft")?.asBoolean != false) return@firstOrNull false
val matchesChannel = when (channel) {
Channel.STABLE -> release.get("prerelease")?.asBoolean == false
Channel.PRERELEASE -> release.get("prerelease")?.asBoolean == true
}
if (!matchesChannel) return@firstOrNull false
val latestVersion = release.getAsJsonPrimitive("tag_name")?.asString?.removePrefix("v") ?: return@firstOrNull false
isVersionGreater(latestVersion, currentVersion)
} ?: throw Throwable("No matching releases found for $channel channel")
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString.removePrefix("v")
if (latestVersion == BuildConfig.VERSION_NAME) return@runCatching null
val assets = latestRelease.getAsJsonArray("assets")?.mapNotNull { element ->
val obj = element.asJsonObject
val name = obj.getAsJsonPrimitive("name")?.asString?.lowercase() ?: return@mapNotNull null
val url = obj.getAsJsonPrimitive("browser_download_url")?.asString ?: return@mapNotNull null
name to url
} ?: emptyList()
val assetDownloads = buildMap<String, String> {
assets.forEach { (name, url) ->
when {
name.contains("arm64") || name.contains("armv8") -> put("arm64", url)
name.contains("armeabi") || name.contains("armv7") || name.contains("arm32") -> put("armv7", url)
}
}
}
LatestRelease(
versionName = latestVersion,
releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null
releaseUrl = latestRelease.getAsJsonPrimitive("html_url")?.asString
?: endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null,
assetDownloads = assetDownloads
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest release", it)
AbstractLogger.directError("Failed to fetch latest release ($channel)", it)
}.getOrNull()
private fun fetchLatestDebugCI() = runCatching {
@@ -60,7 +109,15 @@ object Updater {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull()
val latestRelease by lazy {
if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease()
private val cache = mutableMapOf<Channel, LatestRelease?>()
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
}
}
}

View File

@@ -4,6 +4,8 @@ import android.net.Uri
import android.text.format.Formatter
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,19 +22,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -45,6 +52,7 @@ import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.openFile
@@ -58,44 +66,75 @@ class FileImportsRoot: Routes.Route() {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@Composable
private fun ImportFab(onClick: () -> Unit) {
val shape = RoundedCornerShape(18.dp)
val border = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
PurrfectPalette.glowSecondary.copy(alpha = 0.6f)
)
)
val fill = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
)
Row(
modifier = Modifier
.shadow(
elevation = 12.dp,
shape = shape,
ambientColor = PurrfectPalette.glowSecondary.copy(alpha = 0.2f),
spotColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
)
.clip(shape)
.background(fill)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Default.Upload, contentDescription = null, tint = Color.White)
Text(
text = translation["import_file_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Row {
ExtendedFloatingActionButton(
icon = {
Icon(Icons.Default.Upload, contentDescription = null)
},
text = {
Text(translation["import_file_button"])
},
onClick = {
context.coroutineScope.launch {
activityLauncherHelper.openFile { filePath ->
val fileUri = Uri.parse(filePath)
runCatching {
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
if (!file.exists()) {
context.shortToast(translation["file_not_found"])
return@openFile
}
context.fileHandleManager.importFile(file.name!!) {
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
inputStream.copyTo(this)
}
ImportFab {
context.coroutineScope.launch {
activityLauncherHelper.openFile { filePath ->
val fileUri = Uri.parse(filePath)
runCatching {
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
if (!file.exists()) {
context.shortToast(translation["file_not_found"])
return@openFile
}
context.fileHandleManager.importFile(file.name!!) {
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
inputStream.copyTo(this)
}
}
}.onFailure {
context.log.error("Failed to import file", it)
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
}.onSuccess {
context.shortToast(translation["file_imported"])
coroutineScope.launch {
reloadDispatcher.dispatch()
}
}
}.onFailure {
context.log.error("Failed to import file", it)
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
}.onSuccess {
context.shortToast(translation["file_imported"])
coroutineScope.launch {
reloadDispatcher.dispatch()
}
}
}
})
}
}
}
@@ -103,78 +142,25 @@ class FileImportsRoot: Routes.Route() {
val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.fileHandleManager.getStoredFiles()
}
val density = LocalDensity.current
val titleText = context.translation["manager.routes.file_imports"]
var topBarHeight by remember { mutableStateOf(0.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier.fillMaxSize()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(
top = topBarHeight + 8.dp,
bottom = routes.bottomPadding + 16.dp
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = PurrfectPalette.cardOverlayColor
) {
Icon(
Icons.Default.AttachFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(10.dp)
)
}
Column {
Text(
text = translation["import_file_button"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = translation["manager.dialogs.file_imports.settings_select_file_hint"],
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 16.dp, top = 4.dp)
) {
item {
if (files.isEmpty()) {
Surface(
@@ -279,7 +265,15 @@ class FileImportsRoot: Routes.Route() {
}
}
}
}
FloatingTopBar(
title = titleText ?: translation["import_file_button"],
subtitle = null,
onBack = { routes.navController.popBackStack() },
modifier = Modifier.onGloballyPositioned {
topBarHeight = with(density) { it.size.height.toDp() }
}
)
}
}
}

View File

@@ -385,11 +385,12 @@ class LoggerHistoryRoot : Routes.Route() {
}
}
TextField(
OutlinedTextField(
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
placeholder = {
Text(
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",

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

@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -29,6 +34,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
@@ -45,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
@@ -128,10 +136,15 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val exportSensitiveData = it.arguments?.getBoolean("exportSensitiveData") ?: false
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
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>() }
@@ -140,11 +153,15 @@ class ConfigExportSummaryScreen : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
@@ -163,52 +180,42 @@ class ConfigExportSummaryScreen : Routes.Route() {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["back_button_description"],
tint = Color.White
)
}
Column {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
}
Surface(
shape = CircleShape,
color = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
IconButton(
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
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 {
@@ -220,14 +227,19 @@ class ConfigExportSummaryScreen : Routes.Route() {
)
}
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = translation["save_button"],
tint = Color.White
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
}
Text(exportLabel)
}
}
}

View File

@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -44,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") }
@@ -60,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>()
@@ -185,13 +232,18 @@ class ConfigImportConfirmationScreen : Routes.Route() {
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"]
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -213,40 +265,50 @@ class ConfigImportConfirmationScreen : Routes.Route() {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["back_button_description"],
tint = Color.White
)
}
Column {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
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"])
)
)
}
@@ -261,7 +323,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
contentColor = Color.White
)
) {
Text(translation["confirm_button"])
Text(importLabel)
}
}
}
@@ -276,118 +338,118 @@ class ConfigImportConfirmationScreen : Routes.Route() {
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
}
}
}
}
is String -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = parsedValue,
color = PurrfectPalette.glowSecondary,
textAlign = TextAlign.Start,
)
}
is String -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = parsedValue,
color = PurrfectPalette.glowSecondary,
textAlign = TextAlign.Start,
)
}
}
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun NumberBubble(number: Int) {

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

@@ -0,0 +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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
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.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.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
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()
}
}
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
}
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeAbout.AboutScreen(nav)
}
}
}
@Composable
internal fun DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Surface(
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.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
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().clip(CircleShape)
)
}
PurrfectMarqueeText(
text = name,
color = Color.White,
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(
@@ -226,6 +122,12 @@ class HomeLogs : Routes.Route() {
) {
val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } }
val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } }
val floatingButtonColors = IconButtonDefaults.filledIconButtonColors(
containerColor = PurrfectPalette.cardOverlayColor,
contentColor = Color.White,
disabledContainerColor = Color.White.copy(alpha = 0.08f),
disabledContentColor = Color.White.copy(alpha = 0.35f)
)
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
@@ -243,7 +145,8 @@ class HomeLogs : Routes.Route() {
logListState.scrollToItem(0)
}
},
enabled = firstVisibleItem != 0
enabled = firstVisibleItem != 0,
colors = floatingButtonColors
) {
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
}
@@ -253,7 +156,8 @@ class HomeLogs : Routes.Route() {
logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch)
}
},
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1,
colors = floatingButtonColors
) {
Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null)
}
@@ -263,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,
@@ -284,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
}
)
}
}
}
@@ -373,7 +282,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun EmptyLogsState() {
internal fun EmptyLogsState() {
Column(
modifier = Modifier
.fillMaxSize()
@@ -404,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,
@@ -414,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()
@@ -526,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"
@@ -542,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

@@ -4,6 +4,7 @@ import android.content.SharedPreferences
import android.net.Uri
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.*
@@ -15,11 +16,14 @@ 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
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
@@ -30,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
@@ -41,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
@@ -49,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
@@ -63,13 +69,33 @@ 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)
if (context.config.root.global.updateSettings.autoUpdateCheck.get()) {
val frequency = context.config.root.global.updateSettings.updateCheckFrequency.get()
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
@@ -86,6 +112,7 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
@@ -102,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),
@@ -115,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)) {
@@ -133,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()
@@ -141,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
@@ -161,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
@@ -193,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)
}
@@ -241,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,
@@ -255,501 +331,52 @@ class HomeSettings : Routes.Route() {
) { content(this) }
}
@OptIn(ExperimentalMaterial3Api::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()
}
}
}
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 {
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()
)
}
}
}
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 selectedFrequency by remember { mutableStateOf(context.config.root.global.updateSettings.updateCheckFrequency.getNullable() ?: "weekly") }
var frequencyMenuExpanded by remember { mutableStateOf(false) }
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
if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
selectedFrequency = "weekly"
context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
}
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 = frequencyMenuExpanded,
onExpandedChange = { frequencyMenuExpanded = it },
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 26.dp)
) {
TextField(
value = translation.getOrNull("update_check_frequency_${selectedFrequency}") ?: selectedFrequency,
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyMenuExpanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
ExposedDropdownMenu(
expanded = frequencyMenuExpanded,
onDismissRequest = { frequencyMenuExpanded = false }
) {
listOf("daily", "weekly", "monthly").forEach { frequency ->
DropdownMenuItem(
text = { Text(text = translation.getOrNull("update_check_frequency_${frequency}") ?: frequency) },
onClick = {
selectedFrequency = frequency
frequencyMenuExpanded = false
context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
context.config.writeConfig()
scheduleUpdateCheck()
}
)
}
}
}
}
}
}
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()
}
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()
)
Row(
modifier = Modifier
.wrapContentWidth()
.align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
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"])
}
}
}
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"])
}
}
}
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,
) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
Box(
modifier = Modifier
.weight(1f)
.padding(start = 26.dp)
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
TextField(
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.textFieldColors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
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

@@ -0,0 +1,378 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.view.MotionEvent
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.aspectRatio
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.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
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
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.geometry.Rect
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
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import kotlin.math.abs
import kotlin.math.min
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
val shipSpeed = 1.7f
val shipRadius = 4.5f
val maxSpeedMultiplier = 2.2f
val speedRampPerScore = 0.035f
val rng = remember { Random(System.currentTimeMillis()) }
val pixelFont = remember { FontFamily.Monospace }
data class Star(var x: Float, var y: Float, var speed: Float)
data class Planet(var x: Float, var y: Float, var radius: Float, var speed: Float)
var score by remember { mutableIntStateOf(0) }
var isGameOver by remember { mutableStateOf(false) }
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
var shipX by remember { mutableFloatStateOf(gridWidth / 2f) }
val shipY = gridHeight - 20f
val stars = remember { mutableListOf<Star>() }
val planets = remember { mutableListOf<Planet>() }
val frameBitmap = remember { ImageBitmap(gridWidth, gridHeight) }
val frameCanvas = remember { Canvas(frameBitmap) }
val paint = remember { Paint() }
var frameTick by remember { mutableIntStateOf(0) }
fun spawnPlanet(): Planet {
val radius = rng.nextInt(6, 14).toFloat()
val x = rng.nextInt(radius.roundToInt(), gridWidth - radius.roundToInt()).toFloat()
val y = rng.nextInt(-gridHeight, -20).toFloat()
val speed = rng.nextInt(10, 20) / 10f
return Planet(x = x, y = y, radius = radius, speed = speed)
}
fun resetGame() {
score = 0
isGameOver = false
shipX = gridWidth / 2f
stars.clear()
planets.clear()
repeat(28) {
stars.add(
Star(
x = rng.nextInt(0, gridWidth).toFloat(),
y = rng.nextInt(0, gridHeight).toFloat(),
speed = rng.nextInt(6, 12) / 10f
)
)
}
repeat(4) { planets.add(spawnPlanet()) }
}
fun drawRect(left: Float, top: Float, right: Float, bottom: Float, color: Color) {
paint.color = color
paint.alpha = 1f
frameCanvas.drawRect(Rect(left, top, right, bottom), paint)
}
fun drawCircle(centerX: Float, centerY: Float, radius: Float, color: Color) {
paint.color = color
frameCanvas.drawCircle(androidx.compose.ui.geometry.Offset(centerX, centerY), radius, paint)
}
fun drawShip() {
val sx = shipX.roundToInt().toFloat()
drawRect(sx - 2f, shipY, sx + 3f, shipY + 1f, Color(0xFFE7F6FF))
drawRect(sx - 3f, shipY + 1f, sx + 4f, shipY + 3f, Color(0xFFE7F6FF))
drawRect(sx - 4f, shipY + 3f, sx + 5f, shipY + 6f, Color(0xFFE7F6FF))
drawRect(sx - 2f, shipY + 6f, sx + 3f, shipY + 8f, Color(0xFFE7F6FF))
drawRect(sx - 1f, shipY + 2f, sx + 2f, shipY + 4f, Color(0xFF8AF4FF))
if (!isGameOver) {
drawRect(sx - 1f, shipY + 8f, sx + 2f, shipY + 10f, Color(0xFFFFB24D))
drawRect(sx, shipY + 10f, sx + 1f, shipY + 12f, Color(0xFFFF6B6B))
}
}
fun renderFrame() {
drawRect(0f, 0f, gridWidth.toFloat(), gridHeight.toFloat(), Color(0xFF1B1440))
drawRect(0f, 0f, gridWidth.toFloat(), 14f, Color(0xFF5236B8))
stars.forEach { star ->
drawRect(star.x, star.y, star.x + 1f, star.y + 1f, Color(0xFFEEE9FF))
}
planets.forEach { planet ->
drawCircle(planet.x, planet.y, planet.radius, Color(0xFF4FD4FF))
drawCircle(planet.x - planet.radius * 0.3f, planet.y - planet.radius * 0.2f, planet.radius * 0.35f, Color(0xFFB0F0FF))
}
drawShip()
if (isGameOver) {
drawRect(0f, gridHeight / 2f - 10f, gridWidth.toFloat(), gridHeight / 2f + 8f, Color(0xAA000000))
}
}
LaunchedEffect(Unit) {
resetGame()
while (true) {
delay(16)
if (!isGameOver) {
val speedMultiplier = min(
maxSpeedMultiplier,
1f + (score * speedRampPerScore)
)
val direction = (if (leftPressed) -1f else 0f) + (if (rightPressed) 1f else 0f)
shipX = (shipX + direction * shipSpeed).coerceIn(8f, gridWidth - 8f)
stars.forEach { star ->
star.y += star.speed * speedMultiplier
if (star.y > gridHeight) {
star.y = 0f
star.x = rng.nextInt(0, gridWidth).toFloat()
}
}
planets.forEachIndexed { index, planet ->
planet.y += planet.speed * speedMultiplier
if (planet.y - planet.radius > gridHeight) {
planets[index] = spawnPlanet()
score += 1
}
}
planets.forEach { planet ->
val dx = abs(planet.x - shipX)
val dy = abs(planet.y - shipY)
if (dx * dx + dy * dy < (planet.radius + shipRadius) * (planet.radius + shipRadius)) {
isGameOver = true
}
}
}
renderFrame()
frameTick++
}
}
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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
.padding(horizontal = 18.dp)
.fillMaxWidth()
.weight(1f, fill = true)
.aspectRatio(3f / 4f, matchHeightConstraintsFirst = true)
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(14.dp))
.background(Color(0xFF21195A), RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
key(frameTick) {
Image(
bitmap = frameBitmap,
contentDescription = null,
filterQuality = FilterQuality.None,
modifier = Modifier
.fillMaxSize()
.border(1.dp, Color.White.copy(alpha = 0.1f))
)
}
Text(
text = score.toString(),
color = Color.White,
fontSize = 16.sp,
fontFamily = pixelFont,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 6.dp)
)
if (isGameOver) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = translation["game_over_label"],
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = pixelFont
)
Spacer(modifier = Modifier.height(6.dp))
Button(
onClick = { resetGame() },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF5A43D6),
contentColor = Color.White
),
shape = RoundedCornerShape(6.dp)
) {
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
}
}
}
}
Row(
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier
.weight(1f)
.height(56.dp)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
leftPressed = true
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
leftPressed = false
true
}
else -> false
}
},
shape = RoundedCornerShape(10.dp),
color = Color(0xFF2E2568),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = translation["left_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
textAlign = TextAlign.Center
)
}
}
Surface(
modifier = Modifier
.weight(1f)
.height(56.dp)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
rightPressed = true
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
rightPressed = false
true
}
else -> false
}
},
shape = RoundedCornerShape(10.dp),
color = Color(0xFF2E2568),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = translation["right_button"],
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}

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 = context.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

@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -109,7 +110,14 @@ class ManageScriptReposSection : Routes.Route() {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
contentColor = Color.White,
shape = RoundedCornerShape(18.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
focusedElevation = 0.dp,
hoveredElevation = 0.dp
)
) {
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
@@ -247,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
}
@@ -284,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)
}
@@ -417,7 +419,7 @@ class ScriptingRootSection : Routes.Route() {
)
}
if (openSettings) {
Divider(color = Color.White.copy(alpha = 0.08f))
HorizontalDivider(color = Color.White.copy(alpha = 0.08f))
ScriptSettings(script)
}
}
@@ -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

@@ -27,7 +27,6 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -45,7 +44,7 @@ class AddFriendDialog(
val getGroupState: (group: MessagingGroupInfo) -> Boolean,
)
private val stateCache = mutableMapOf<String, Boolean>()
private val stateCache = mutableStateMapOf<String, Boolean>()
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
@Composable
@@ -57,9 +56,13 @@ class AddFriendDialog(
getCurrentState: () -> Boolean,
onState: (Boolean) -> Unit = {},
) {
var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) {
getCurrentState().also { stateCache[id] = it }
val cachedState = stateCache[id]
LaunchedEffect(id, cachedState) {
if (cachedState == null) {
stateCache[id] = getCurrentState()
}
}
val currentState = stateCache[id] ?: false
val coroutineScope = rememberCoroutineScope()
val cardShape = RoundedCornerShape(18.dp)
@@ -68,9 +71,9 @@ class AddFriendDialog(
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable {
currentState = !currentState
stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
val nextState = !currentState
stateCache[id] = nextState
coroutineScope.launch(Dispatchers.IO) { onState(nextState) }
},
shape = cardShape,
color = Color.Transparent,
@@ -125,9 +128,8 @@ class AddFriendDialog(
Switch(
checked = currentState,
onCheckedChange = {
currentState = it
stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
stateCache[id] = it
coroutineScope.launch(Dispatchers.IO) { onState(it) }
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
@@ -156,19 +158,12 @@ class AddFriendDialog(
)
.padding(horizontal = 16.dp, vertical = 18.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = translation["title"],
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Text(
text = translation["search_hint"],
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.85f)
)
}
Text(
text = translation["title"],
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
}
Surface(
@@ -185,10 +180,11 @@ class AddFriendDialog(
value = searchKeyword.value,
onValueChange = { searchKeyword.value = it },
placeholder = {
Text(text = translation["search_hint"])
Text(text = translation["search_hint"], color = PurrfectPalette.textSecondary)
},
modifier = Modifier
.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = translation["search_icon_description"])
@@ -198,8 +194,15 @@ class AddFriendDialog(
unfocusedContainerColor = PurrfectPalette.cardOverlayColor.copy(alpha = 0.8f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White
)
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.85f),
focusedPlaceholderColor = PurrfectPalette.textSecondary,
unfocusedPlaceholderColor = PurrfectPalette.textSecondary
),
textStyle = LocalTextStyle.current.copy(color = Color.White)
)
}
}
@@ -216,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 {
@@ -245,6 +255,13 @@ class AddFriendDialog(
}
}
}
DisposableEffect(Unit) {
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
me.eternal.purrfectsnap.ui.util.Dialog(
onDismissRequest = {
@@ -302,6 +319,15 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
stateCache[friend.userId] ?: actionHandler.getFriendState(friend)
}
}
}
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
DialogHeader(searchKeyword)
@@ -337,14 +363,62 @@ class AddFriendDialog(
item {
if (filteredFriends.isNotEmpty()) {
Text(
text = translation["category_friends"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 14.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_friends"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredFriends.forEach { friend ->
stateCache[friend.userId] = true
actionHandler.onFriendState(friend, true)
}
}
},
enabled = !allFriendsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredFriends.forEach { friend ->
stateCache[friend.userId] = false
actionHandler.onFriendState(friend, false)
}
}
},
enabled = hasFriendsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
)
}
}
}
}
}

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

@@ -4,18 +4,23 @@ import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.rounded.DeleteForever
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.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -46,6 +51,7 @@ import me.eternal.purrfectsnap.ui.util.AlertDialogs
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -72,7 +78,7 @@ class ManageScope: Routes.Route() {
var deleteConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf(id, scope)) {
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf<Any>(id, scope)) {
when (scope) {
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
@@ -85,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 = {
@@ -103,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 }) {
@@ -304,6 +310,61 @@ class ManageScope: Routes.Route() {
)
}
@Composable
private fun RowScope.E2eeActionButton(
label: String,
icon: ImageVector,
accent: Brush,
onClick: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.weight(1f)
.scaleOnPress(interactionSource)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = interactionSource,
indication = null
) { onClick() },
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(accent, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
private fun computeStreakETA(timestamp: Long): String? {
val now = System.currentTimeMillis()
val stringBuilder = StringBuilder()
@@ -344,7 +405,6 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.e2eEncryption.globalState == true) {
SectionTitle(translation["e2ee_title"])
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
context.e2eeImplementation.friendKeyExists(friend.userId)
}
@@ -378,40 +438,100 @@ class ManageScope: Routes.Route() {
}
ContentCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
),
RoundedCornerShape(14.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = Color.White
)
}
}) {
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
Text(
text = translation["export_base64_button"],
maxLines = 1
text = translation["e2ee_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 16.sp
)
Text(
text = translation["e2ee_subtitle"],
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (hasSecretKey) {
E2eeActionButton(
label = translation["export_base64_button"],
icon = Icons.Filled.Lock,
accent = Brush.horizontalGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.55f)
)
),
onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}
}
)
}
OutlinedButton(onClick = { importDialog = true }) {
Text(
text = translation["import_base64_button"],
maxLines = 1
E2eeActionButton(
label = translation["import_base64_button"],
icon = Icons.Filled.Lock,
accent = Brush.horizontalGradient(
listOf(
Color(0xFF7DD3FC),
Color(0xFF818CF8)
)
),
onClick = { importDialog = 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

@@ -10,18 +10,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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
@@ -30,32 +35,54 @@ 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(scope: SocialScope) {
internal fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
val list = when (scope) {
SocialScope.GROUP -> groups
SocialScope.FRIEND -> friends
}
LazyColumn(
modifier = Modifier
@@ -63,11 +90,7 @@ 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 = when (scope) {
SocialScope.GROUP -> groupList.size
SocialScope.FRIEND -> friendList.size
}
val listSize = list.size
if (listSize == 0) {
item {
@@ -76,14 +99,14 @@ class SocialRootSection : Routes.Route() {
}
items(listSize) { index ->
val id = when (scope) {
SocialScope.GROUP -> groupList[index].conversationId
SocialScope.FRIEND -> friendList[index].userId
}
val friend = if (scope == SocialScope.FRIEND) list[index] as MessagingFriendInfo else null
val group = if (scope == SocialScope.GROUP) list[index] as MessagingGroupInfo else null
val id = friend?.userId ?: group?.conversationId.orEmpty()
SocialCard(
scope = scope,
index = index,
friend = friend,
group = group,
onManage = {
routes.manageScope.navigate {
put("id", id)
@@ -175,50 +198,26 @@ 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 }
LaunchedEffect(Unit) {
updateScopeLists()
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
)
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND)
1 -> ScopeList(SocialScope.GROUP)
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@SocialRootSection.SocialScreen(nav)
}
}
}
@Composable
private fun SocialCard(
internal fun SocialCard(
scope: SocialScope,
index: Int,
friend: MessagingFriendInfo?,
group: MessagingGroupInfo?,
onManage: () -> Unit,
onPreview: () -> Unit,
remainingHours: Int
@@ -249,7 +248,7 @@ class SocialRootSection : Routes.Route() {
) {
when (scope) {
SocialScope.GROUP -> {
val group = groupList[index]
val groupInfo = group ?: return@Row
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.08f),
@@ -267,7 +266,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = group.name,
text = groupInfo.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
@@ -283,16 +282,16 @@ class SocialRootSection : Routes.Route() {
}
SocialScope.FRIEND -> {
val friend = friendList[index]
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
context.database.getFriendStreaks(friend.userId)
val friendInfo = friend ?: return@Row
val streaks by rememberAsyncMutableState(defaultValue = friendInfo.streaks) {
context.database.getFriendStreaks(friendInfo.userId)
}
BitmojiImage(
context = context,
url = BitmojiSelfie.getBitmojiSelfie(
friend.selfieId,
friend.bitmojiId,
friendInfo.selfieId,
friendInfo.bitmojiId,
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
)
@@ -302,7 +301,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = friend.displayName ?: friend.mutableUsername,
text = friendInfo.displayName ?: friendInfo.mutableUsername,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
@@ -310,7 +309,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = friend.mutableUsername,
text = friendInfo.mutableUsername,
maxLines = 1,
fontSize = 12.sp,
fontWeight = FontWeight.Light,
@@ -378,12 +377,14 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialHeader(
internal fun SocialHeader(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit,
friendCount: Int,
groupCount: Int
groupCount: Int,
searchActive: Boolean,
onSearchToggle: () -> Unit
) {
Surface(
modifier = Modifier
@@ -418,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
@@ -429,8 +430,15 @@ 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) translation["close_search_button_description"] else translation["search_button_description"],
tint = Color.White
)
}
}
}
SocialTabSwitcher(
@@ -443,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
@@ -489,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"]
@@ -516,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
)
@@ -531,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),
@@ -560,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,824 @@
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 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 } }
}
}
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
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
}
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 },
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 },
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 (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,537 @@
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.util.headerHeightTracker
@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 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()
)
}
// The "Structured Glass" Container (1:1 with build 33a7e8f)
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, 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))
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
}
}
items(activeTasks, key = { it.taskId }) { pendingTask ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(recentTasks, 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 = {
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
)
}
}
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
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["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
)
}
if (showConfirmDialog) {
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["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 { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
}
if (taskProgress != -1 && taskProgressLabel == null) {
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

@@ -54,6 +54,14 @@ import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
class EditRule : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
private data class RuleSnapshot(
val name: String,
val author: String,
val scopes: List<String>,
val events: List<TrackerRuleEvent>,
val scopeType: TrackerScopeType
)
@Composable
private fun RuleCard(
modifier: Modifier = Modifier,
@@ -174,11 +182,11 @@ class EditRule : Routes.Route() {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth()
) {
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth()
) {
val eventLabel = context.translation["tracker_events.${currentEventType.value}"]
Box(
modifier = Modifier.fillMaxWidth(),
@@ -186,8 +194,8 @@ class EditRule : Routes.Route() {
) {
OutlinedTextField(
modifier = Modifier
.menuAnchor()
.widthIn(min = 240.dp),
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.wrapContentWidth(),
value = eventLabel,
onValueChange = {},
readOnly = true,
@@ -207,7 +215,7 @@ class EditRule : Routes.Route() {
ExposedDropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false },
modifier = Modifier.widthIn(min = 240.dp),
modifier = Modifier.wrapContentWidth(),
containerColor = Color(0xFF121528),
shape = RoundedCornerShape(14.dp)
) {
@@ -295,24 +303,45 @@ class EditRule : Routes.Route() {
val authorName = rememberAsyncMutableState<String>(defaultValue = "", keys = arrayOf(currentRuleId)) {
currentRuleId?.let { ruleId -> context.database.getTrackerRule(ruleId)?.author ?: "" } ?: ""
}
val initialRuleState by remember(ruleName.value.isNotBlank() || events.isNotEmpty() || scopes.isNotEmpty()) {
mutableStateOf(
mapOf(
"name" to ruleName.value,
"author" to authorName.value,
"scopes" to scopes.toList(),
"events" to events.toList(),
"scopeType" to currentScopeType
)
)
fun snapshotEvents() = events.map { event ->
event.copy(params = event.params.copy(), actions = event.actions.toList())
}
fun buildSnapshot() = RuleSnapshot(
name = ruleName.value,
author = authorName.value,
scopes = scopes.toList(),
events = snapshotEvents(),
scopeType = currentScopeType
)
val initialSnapshot = remember(currentRuleId) {
mutableStateOf<RuleSnapshot?>(if (currentRuleId == null) buildSnapshot() else null)
}
LaunchedEffect(
currentRuleId,
ruleName.value,
authorName.value,
events.size,
scopes.size,
currentScopeType
) {
if (initialSnapshot.value == null) {
val hasLoaded = ruleName.value.isNotBlank() ||
authorName.value.isNotBlank() ||
events.isNotEmpty() ||
scopes.isNotEmpty()
if (hasLoaded) {
initialSnapshot.value = buildSnapshot()
}
}
}
val isDirty by remember {
derivedStateOf {
initialRuleState["name"] != ruleName.value ||
initialRuleState["author"] != authorName.value ||
initialRuleState["scopes"] != scopes.toList() ||
initialRuleState["events"] != events.toList() ||
initialRuleState["scopeType"] != currentScopeType
val snapshot = initialSnapshot.value ?: return@derivedStateOf false
snapshot.name != ruleName.value ||
snapshot.author != authorName.value ||
snapshot.scopes != scopes.toList() ||
snapshot.events != snapshotEvents() ||
snapshot.scopeType != currentScopeType
}
}
var deleteConfirmation by remember { mutableStateOf(false) }
@@ -426,6 +455,12 @@ class EditRule : Routes.Route() {
fontSize = 12.sp
)
}
if (currentRuleId != null) {
IconButton(onClick = { deleteConfirmation = true }) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
}
Spacer(modifier = Modifier.width(6.dp))
}
IconButton(onClick = {
if (events.isEmpty()) {
showEventsEmptyDialog = true
@@ -451,11 +486,6 @@ class EditRule : Routes.Route() {
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
routes.navController.popBackStack()
}) { Icon(Icons.Filled.Save, contentDescription = translation["save_button_description"], tint = Color.White) }
if (currentRuleId != null) {
IconButton(onClick = { deleteConfirmation = true }) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
}
}
}
}
}
@@ -466,9 +496,11 @@ class EditRule : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.padding(padding)
) {
val contentBottomPadding = routes.bottomPadding + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentBottomPadding)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -477,6 +509,7 @@ class EditRule : Routes.Route() {
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
val textFieldShape = RoundedCornerShape(16.dp)
Text(
translation["general_section_title"],
style = MaterialTheme.typography.titleMedium,
@@ -488,8 +521,11 @@ class EditRule : Routes.Route() {
value = ruleName.value,
onValueChange = { ruleName.value = it },
label = { Text(translation["rule_name_label"], color = PurrfectPalette.textSecondary) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp),
singleLine = true,
shape = textFieldShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.05f),
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
@@ -506,8 +542,11 @@ class EditRule : Routes.Route() {
value = authorName.value,
onValueChange = { authorName.value = it },
label = { Text(translation["author_name_label"], color = PurrfectPalette.textSecondary) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp),
singleLine = true,
shape = textFieldShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.05f),
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
@@ -640,12 +679,21 @@ class EditRule : Routes.Route() {
shape = selectorShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.38f)
)
)
)
) {
Row(
modifier = Modifier
.background(selectorBrush, selectorShape)
.clip(selectorShape)
.background(selectorBrush)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -773,7 +821,6 @@ class EditRule : Routes.Route() {
}
}
}
Spacer(Modifier.height(routes.bottomPadding))
}
}
}

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

@@ -18,20 +18,14 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoGraph
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Rule
import androidx.compose.material.icons.automirrored.filled.Rule
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.UploadFile
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()
@@ -466,7 +464,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Rule, contentDescription = null, tint = Color.White)
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
}
}
Column(
@@ -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

@@ -11,26 +11,24 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.ui.unit.DpOffset
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.text.TextStyle
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import java.util.Date
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.window.PopupProperties
@@ -328,36 +326,50 @@ fun LogsTab(
var dropDownExpanded by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
LaunchedEffect(selectionExpanded.value) {
if (!selectionExpanded.value) {
dropDownExpanded = false
}
}
LaunchedEffect(showDatePicker) {
if (showDatePicker) {
sinceDatePickerState.displayMode = DisplayMode.Picker
}
}
if (showDatePicker) {
Dialog(onDismissRequest = { showDatePicker = false }) {
val dialogShape = RoundedCornerShape(22.dp)
Surface(
shape = dialogShape,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
shadowElevation = 16.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, dialogShape)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
AestheticDialog(
onDismissRequest = { showDatePicker = false },
title = "",
text = "",
icon = Icons.Default.DateRange,
confirmButtonText = context.translation["button.ok"],
onConfirm = { showDatePicker = false },
dismissButtonText = context.translation["button.cancel"],
onDismiss = {
showDatePicker = false
sinceDatePickerState.selectedDateMillis = null
},
customContent = {
Surface(
modifier = Modifier.padding(horizontal = 6.dp),
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
translation["pick_a_date_button"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
DatePicker(
state = sinceDatePickerState,
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.02f), RoundedCornerShape(18.dp)),
.widthIn(max = 360.dp)
.padding(6.dp)
.background(PurrfectPalette.cardOverlayColor, RoundedCornerShape(14.dp)),
title = null,
headline = null,
showModeToggle = false,
colors = DatePickerDefaults.colors(
containerColor = Color.Transparent,
containerColor = PurrfectPalette.cardOverlayColor,
titleContentColor = Color.White,
headlineContentColor = Color.White,
weekdayContentColor = Color.White.copy(alpha = 0.9f),
@@ -368,45 +380,35 @@ fun LogsTab(
todayDateBorderColor = PurrfectPalette.glowSecondary,
dayContentColor = Color.White.copy(alpha = 0.85f),
disabledDayContentColor = Color.White.copy(alpha = 0.35f),
yearContentColor = Color.White,
currentYearContentColor = PurrfectPalette.glowSecondary,
selectedYearContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
selectedYearContentColor = Color.Black,
dividerColor = Color.White.copy(alpha = 0.14f),
navigationContentColor = Color.White
navigationContentColor = Color.White,
dateTextFieldColors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.6f),
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
disabledContainerColor = Color.White.copy(alpha = 0.04f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = PurrfectPalette.glowSecondary,
focusedLabelColor = PurrfectPalette.textSecondary,
unfocusedLabelColor = PurrfectPalette.textSecondary
)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Surface(
onClick = {
showDatePicker = false
sinceDatePickerState.selectedDateMillis = null
},
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
context.translation["button.cancel"],
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
Surface(
onClick = { showDatePicker = false },
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Text(
context.translation["button.ok"],
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
}
}
},
opaque = true,
showCloseButton = false,
showIcon = false,
showTitle = false
)
}
DropdownMenu(
@@ -443,27 +445,33 @@ fun LogsTab(
ExposedDropdownMenuBox(
expanded = dropDownExpanded,
onExpandedChange = { dropDownExpanded = it },
modifier = Modifier.weight(1f)
modifier = Modifier.wrapContentWidth()
) {
Surface(
onClick = { dropDownExpanded = true },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)),
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f)
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp
) {
Text(
filterType.name,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = Color.White
)
Row(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(filterType.name, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropDownExpanded)
}
}
ExposedDropdownMenu(
expanded = dropDownExpanded,
onDismissRequest = { dropDownExpanded = false },
containerColor = Color(0xFF101220),
shape = RoundedCornerShape(14.dp)
shape = RoundedCornerShape(14.dp),
modifier = Modifier.wrapContentWidth()
) {
FriendTrackerManagerRoot.FilterType.entries.forEach { type ->
DropdownMenuItem(
@@ -489,7 +497,6 @@ fun LogsTab(
checked = reverseSortOrder,
onCheckedChange = {
reverseSortOrder = it
selectionExpanded.value = false
},
colors = purrfectSwitchColors()
)

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -99,7 +100,14 @@ class ManageFriendTrackerReposSection: Routes.Route() {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
contentColor = Color.White,
shape = RoundedCornerShape(18.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
focusedElevation = 0.dp,
hoveredElevation = 0.dp
)
) {
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
@@ -237,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
}
@@ -274,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,223 @@
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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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
private const val BLUR_ZONE_PX = 120f
private const val BLUR_RADIUS = 30f
private const val FADE_ZONE_PX = 80f
// "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
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"
)
val runtimeShader = remember(bitmap) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
} else null
}
val shaderPaint = remember(runtimeShader, bitmap) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = animatedRadius.value
val center = request.originCenter
drawIntoCanvas { canvas ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
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)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
} else {
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun drawWithBlurReveal(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
}
val renderNode = android.graphics.RenderNode("blurRing").apply {
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
}
val nodeCanvas = renderNode.beginRecording()
nodeCanvas.save()
nodeCanvas.clipPath(ringPath)
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
nodeCanvas.restore()
renderNode.endRecording()
canvas.drawRenderNode(renderNode)
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
canvas.restore()
}
private fun drawWithClipFade(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
}
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
canvas.restore()
}

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
@@ -56,12 +57,14 @@ import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -76,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
@@ -89,6 +93,7 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.InstallModeScreen
@@ -97,8 +102,10 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.MappingsScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PermissionsScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
private data class SetupStepMeta(
val route: String,
@@ -125,14 +132,19 @@ class SetupActivity : ComponentActivity() {
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
val persistedRoute = setupPrefs.getString("setup_current_route", null)
val persistedSkipPatch = setupPrefs.getBoolean("setup_skip_patch", false)
val persistedInstallMode = setupPrefs.getString("setup_install_mode", null)
val skipPatchChoice = mutableStateOf(persistedSkipPatch)
val installModeChoice = mutableStateOf(
runCatching { persistedInstallMode?.let { InstallMode.valueOf(it) } }.getOrNull()
)
fun persistProgress(route: String, skipPatch: Boolean, inProgress: Boolean = true) {
fun persistProgress(route: String, skipPatch: Boolean, installMode: InstallMode?, inProgress: Boolean = true) {
if (!isFirstRunFlow) return
setupPrefs.edit()
.putBoolean("setup_in_progress", inProgress)
.putString("setup_current_route", route)
.putBoolean("setup_skip_patch", skipPatch)
.putString("setup_install_mode", installMode?.name)
.apply()
}
@@ -141,6 +153,7 @@ class SetupActivity : ComponentActivity() {
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
}
@@ -148,11 +161,19 @@ class SetupActivity : ComponentActivity() {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen { mode ->
skipPatchChoice.value = mode == InstallMode.ROOT
}.apply { route = "installMode" })
add(InstallModeScreen(
onModeChosen = { mode ->
installModeChoice.value = mode
skipPatchChoice.value = false
},
onSkipAutoSetup = {
skipPatchChoice.value = true
installModeChoice.value = null
}
).apply { route = "installMode" })
}
if (isFirstRunFlow) {
add(RootInstallSnapchatScreen().apply { route = "rootInstallSnapchat" })
add(PatchSnapchatScreen().apply { route = "patchSnapchat" })
}
}
@@ -173,31 +194,73 @@ class SetupActivity : ComponentActivity() {
}
requiredScreens.forEach { screen ->
screen.context = setupContext
screen.isFirstRunFlow = isFirstRunFlow
screen.init()
}
if (!isFirstRunFlow) {
clearProgress()
skipPatchChoice.value = false
}
if (!isFirstRunFlow) {
clearProgress()
skipPatchChoice.value = false
installModeChoice.value = null
}
setContent {
val context = LocalContext.current
val translation = setupContext.translation
val navController = rememberNavController()
var canGoNext by remember { mutableStateOf(false) }
var lastRoute by rememberSaveable { mutableStateOf("") }
var currentRoute by rememberSaveable {
mutableStateOf(
when {
requiredScreens.first().route == "language" -> requiredScreens.first().route
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } } != null -> persistedRoute
else -> requiredScreens.first().route
}
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } }
?: requiredScreens.first().route
)
}
val skipPatch by rememberSaveable { skipPatchChoice }
val visibleScreens = remember(skipPatch) {
requiredScreens.filterNot { skipPatch && it is PatchSnapchatScreen }
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
}
val stepMeta = remember(skipPatch) { visibleScreens.map { it.meta(setupContext) } }
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)) {
return@filterNot true
}
if (installMode == null && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
return@filterNot true
}
if (installMode == InstallMode.ROOT && screen is PatchSnapchatScreen) {
return@filterNot true
}
if (installMode == InstallMode.NON_ROOT && screen is RootInstallSnapchatScreen) {
return@filterNot true
}
false
}
}
val stepMeta = remember(skipPatch, installMode) { visibleScreens.map { it.meta(setupContext) } }
val currentStepIndex = visibleScreens.indexOfFirst { it.route == currentRoute }.let {
if (it == -1) 0 else it
}
@@ -218,15 +281,18 @@ class SetupActivity : ComponentActivity() {
}
}
LaunchedEffect(currentRoute, skipPatch) {
persistProgress(currentRoute, skipPatch, true)
LaunchedEffect(currentRoute, skipPatch, installMode) {
persistProgress(currentRoute, skipPatch, installMode, true)
if (navController.currentDestination?.route != currentRoute) {
navController.navigate(currentRoute) {
popUpTo(requiredScreens.first().route) { inclusive = false }
launchSingleTop = true
}
}
canGoNext = false
if (lastRoute != currentRoute) {
canGoNext = false
lastRoute = currentRoute
}
}
fun nextScreen() {
@@ -249,10 +315,25 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@Suppress("DEPRECATION")
window.statusBarColor = Color.Transparent.toArgb()
@Suppress("DEPRECATION")
window.navigationBarColor = Color.Transparent.toArgb()
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.isAppearanceLightStatusBars = false
@@ -263,6 +344,44 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
val bottomPadding = 118.dp + navBarPadding
@@ -360,43 +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 = 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
)
@@ -505,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(),
@@ -530,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
@@ -605,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
@@ -656,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,
@@ -750,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(
@@ -785,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

@@ -28,6 +28,7 @@ abstract class SetupScreen {
lateinit var allowNext: (canGoNext: Boolean) -> Unit
lateinit var goNext: () -> Unit
lateinit var route: String
var isFirstRunFlow: Boolean = false
@Composable
fun DialogText(text: String, modifier: Modifier = Modifier) {

View File

@@ -9,19 +9,26 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
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.setValue
@@ -32,9 +39,16 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.SpanStyle
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.foundation.interaction.MutableInteractionSource
import kotlinx.coroutines.delay
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.scaleOnPress
@@ -42,31 +56,150 @@ import me.eternal.purrfectsnap.ui.util.scaleOnPress
enum class InstallMode { ROOT, NON_ROOT }
class InstallModeScreen(
private val onModeChosen: (InstallMode) -> Unit
private val onModeChosen: (InstallMode) -> Unit,
private val onSkipAutoSetup: () -> Unit
) : SetupScreen() {
private var selectedMode: InstallMode? = null
private var skipAutoSetup = false
override fun init() {
selectedMode = null
skipAutoSetup = false
}
override fun onLeave() {
selectedMode?.let(onModeChosen)
if (skipAutoSetup) return
}
@Composable
override fun Content() {
var choice by remember { mutableStateOf(selectedMode) }
var skipSelected by remember { mutableStateOf(skipAutoSetup) }
var showGuides by remember { mutableStateOf(true) }
var timeout by remember { mutableIntStateOf(15) }
LaunchedEffect(choice) {
LaunchedEffect(choice, skipSelected) {
selectedMode = choice
allowNext(choice != null)
skipAutoSetup = skipSelected
allowNext(choice != null || skipSelected)
if (!skipSelected) {
choice?.let(onModeChosen)
}
}
LaunchedEffect(showGuides) {
if (showGuides) {
timeout = 15
while (timeout > 0) {
delay(1000)
timeout--
}
}
}
if (showGuides) {
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 = context.translation["setup.install_mode.notice_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (timeout == 0) showGuides = false },
confirmEnabled = timeout == 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 = context.translation["setup.install_mode.notice_intro"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = context.translation["setup.install_mode.notice_non_root_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.install_mode.notice_non_root_body"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = context.translation["setup.install_mode.notice_root_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.install_mode.notice_root_body"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = context.translation["setup.install_mode.notice_issues_hint"],
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.White)) {
append(context.translation["setup.install_mode.notice_note_prefix"])
}
append(context.translation["setup.install_mode.notice_note_body"])
},
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
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(
@@ -74,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(
@@ -84,11 +217,14 @@ class InstallModeScreen(
)
),
selected = choice == InstallMode.ROOT,
onClick = { choice = InstallMode.ROOT }
onClick = {
choice = InstallMode.ROOT
skipSelected = false
}
)
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(
@@ -97,9 +233,54 @@ class InstallModeScreen(
)
),
selected = choice == InstallMode.NON_ROOT,
onClick = { choice = InstallMode.NON_ROOT }
onClick = {
choice = InstallMode.NON_ROOT
skipSelected = false
}
)
}
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(interactionSource)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = interactionSource,
indication = null
) {
skipSelected = true
choice = null
onSkipAutoSetup()
goNext()
},
color = Color.White.copy(alpha = 0.04f),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = context.translation["setup.install_mode.skip_auto_setup"],
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(16.dp)
)
}
}
}
}

View File

@@ -11,13 +11,21 @@ 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
@@ -26,11 +34,15 @@ 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
@@ -40,13 +52,24 @@ 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()
}
}
if (infoText != null) {
fun dismiss() {
infoText = null
goNext()
finishMappings()
}
var visible by remember { mutableStateOf(false) }
@@ -64,26 +87,128 @@ class MappingsScreen : SetupScreen() {
}
}
LaunchedEffect(showCompletionNotice) {
if (showCompletionNotice) {
completionCountdown = 10
while (completionCountdown > 0) {
delay(1000)
completionCountdown--
}
}
}
if (showCompletionNotice) {
val confirmLabel = if (completionCountdown > 0) {
translation.format(
"setup.mappings.confirm_understand_timeout",
"seconds" to completionCountdown.toString()
)
} else {
translation["setup.mappings.confirm_understand"]
}
AestheticDialog(
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
title = translation["setup.mappings.notice_title"],
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 = translation["setup.mappings.notice_intro"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_1"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_2"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_3"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_title"],
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold,
lineHeight = 18.sp
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_body"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isGenerating) return@launch
isGenerating = true
runCatching {
if (context.installationSummary.snapchatInfo == null) {
context.mappings.init(context.androidContext)
if (context.mappings.getSnapchatPackageInfo() == null) {
throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"])
}
val warnings = context.mappings.refresh()
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
}
withContext(Dispatchers.Main) {
goNext()
finishMappings()
}
}.onFailure {
isGenerating = false
@@ -99,7 +224,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

@@ -12,6 +12,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,12 +20,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -52,8 +57,10 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
@@ -64,8 +71,10 @@ 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
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
@@ -85,7 +94,10 @@ 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) }
var patchedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
@@ -98,6 +110,7 @@ class PatchSnapchatScreen : SetupScreen() {
var installRequested by rememberSaveable { mutableStateOf(false) }
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -112,7 +125,7 @@ class PatchSnapchatScreen : SetupScreen() {
LaunchedEffect(installVerified) { allowNext(installVerified) }
fun pushLog(message: String) {
if (logs.size > 16) logs.removeFirst()
if (logs.size > 120) logs.removeAt(0)
logs.add(message)
}
@@ -135,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)
@@ -168,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)
@@ -206,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)),
@@ -230,12 +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()
pushStatus("Failed: ${it.message}")
it.stackTraceToString()
.lineSequence()
.filter { line -> line.isNotBlank() }
.forEach { line -> pushLog(line) }
pushStatus(
translation.format(
"setup.patch.failed_status",
"message" to message
)
)
}
isRunning = false
progress = -1f
@@ -261,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 {
@@ -273,14 +312,74 @@ class PatchSnapchatScreen : SetupScreen() {
)
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
)
}
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),
@@ -307,33 +406,52 @@ class PatchSnapchatScreen : SetupScreen() {
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val isDownloading = progress >= 0f
val isPatching = isRunning && downloadFinished && !isDownloading
Text(
text = if (progress >= 0f) {
"Downloading Snapchat ${(progress * 100).toInt()}%"
} else if (isRunning && downloadFinished) {
"Patching..."
} else {
"Initializing..."
text = when {
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
)
LinearProgressIndicator(
progress = if (progress >= 0f) progress.coerceIn(0f, 1f) else 0f,
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
if (isDownloading) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
} else {
LinearProgressIndicator(
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
}
}
}
LogsPanel(
logs = logs,
pulse = logPulse,
accent = accent
accent = accent,
translation = translation,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog(translation["setup.patch.logs_copied"])
}
)
error?.let {
@@ -367,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
)
@@ -376,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
@@ -384,11 +502,41 @@ 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
)
val issuesInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(issuesInteraction)
.clickable(
interactionSource = issuesInteraction,
indication = null
) { showIssuesDialog = true }
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = translation["setup.patch.issues_title"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
val manualInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
@@ -413,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
)
@@ -429,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),
@@ -455,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
)
@@ -520,7 +671,9 @@ private fun GradientActionButton(
private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush
accent: Brush,
translation: LocaleWrapper,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val animatedBrush = Brush.linearGradient(
@@ -547,27 +700,63 @@ private fun LogsPanel(
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Logs",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
Row(
modifier = Modifier
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = translation["setup.patch.logs_title"],
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
}
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { onCopy() }
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Text(
text = translation["setup.patch.copy_button"],
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
}
}
}
AnimatedVisibility(visible = expanded) {
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

@@ -0,0 +1,614 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Icon
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.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
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.core.content.FileProvider
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
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
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import okhttp3.OkHttpClient
import okhttp3.Request
class RootInstallSnapchatScreen : SetupScreen() {
private val autoPatchServer = AutoPatchServer()
private val okHttpClient = OkHttpClient.Builder()
.callTimeout(1, TimeUnit.HOURS)
.connectTimeout(1, TimeUnit.HOURS)
.readTimeout(1, TimeUnit.HOURS)
.writeTimeout(1, TimeUnit.HOURS)
.build()
private val targetPackage = "com.snapchat.android"
@Composable
override fun Content() {
val coroutineScope = rememberCoroutineScope()
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) }
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
val downloadedApk = remember(downloadedApkPath) { downloadedApkPath?.let(::File) }
var isRunning by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var downloadStartedAt by rememberSaveable { mutableStateOf(0L) }
var installVerified by rememberSaveable { mutableStateOf(false) }
var installRequested by rememberSaveable { mutableStateOf(false) }
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
val logPulse by rememberInfiniteTransition(label = "rootInstallLogPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(durationMillis = 2200, easing = FastOutSlowInEasing),
RepeatMode.Reverse
),
label = "rootInstallLogPulseValue"
)
LaunchedEffect(Unit) { allowNext(false) }
LaunchedEffect(installVerified) { allowNext(installVerified) }
fun pushLog(message: String) {
if (logs.size > 120) logs.removeAt(0)
logs.add(message)
}
fun isSnapchatInstalledAfter(timestamp: Long): Boolean {
if (timestamp == 0L) return false
val info = runCatching {
context.androidContext.packageManager.getPackageInfo(targetPackage, 0)
}.getOrNull() ?: return false
return info.lastUpdateTime >= timestamp
}
fun isSnapchatInstalled(): Boolean {
return runCatching { context.androidContext.packageManager.getPackageInfo(targetPackage, 0) }.isSuccess
}
fun startInstallWatcher() {
if (downloadStartedAt == 0L) return
installWatcher?.cancel()
installWatcher = coroutineScope.launch {
repeat(80) {
if (isSnapchatInstalledAfter(downloadStartedAt)) {
installVerified = true
pushLog(translation["setup.root_install.install_confirmed_log"])
return@launch
}
delay(1200)
}
}
}
LaunchedEffect(installRequested, downloadStartedAt) {
if (installRequested && downloadStartedAt > 0) {
startInstallWatcher()
}
}
LaunchedEffect(downloadStartedAt) {
if (downloadStartedAt > 0 && isSnapchatInstalledAfter(downloadStartedAt)) {
installVerified = true
}
}
LaunchedEffect(installVerified) {
if (installVerified) {
downloadedApk?.let { runCatching { it.delete() } }
downloadedApkPath = null
}
}
suspend fun pushStatus(message: String) = withContext(Dispatchers.Main) { pushLog(message) }
suspend fun setProgress(value: Float) = withContext(Dispatchers.Main) { progress = value }
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
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 = 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)
var read: Int
var totalRead = 0L
val totalSize = response.body?.contentLength() ?: -1L
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
totalRead += read
if (totalSize > 0) setProgress(totalRead.toFloat() / totalSize.toFloat())
}
} ?: return@withContext null
}
setProgress(-1f)
outputFile
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
installRequested = true
startInstallWatcher()
val uri = FileProvider.getUriForFile(
context.androidContext,
"${context.androidContext.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.androidContext.startActivity(intent)
}
fun markAlreadyInstalled() {
installRequested = false
installVerified = true
pushLog(translation["setup.root_install.mark_installed_log"])
}
fun startDownloadAndInstall() {
coroutineScope.launch(Dispatchers.Main) {
isRunning = true
allowNext(false)
error = null
progress = -1f
downloadedApkPath = null
installVerified = false
installRequested = false
downloadFinished = false
downloadStartedAt = System.currentTimeMillis()
logs.clear()
pushLog(translation["setup.root_install.start_download_log"])
runCatching {
if (isSnapchatInstalled()) {
pushStatus(translation["setup.root_install.uninstall_prompt_status"])
throw IllegalStateException(translation["setup.root_install.uninstall_prompt_error"])
}
pushStatus(translation["setup.root_install.fetching_apk_status"])
val downloaded = downloadSnapchatFromAutoPatchServer()
?: throw IllegalStateException(translation["setup.root_install.download_failed_error"])
downloadedApkPath = downloaded.absolutePath
pushStatus(
translation.format(
"setup.root_install.download_completed_status",
"fileName" to downloaded.name
)
)
downloadFinished = true
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(
translation.format(
"setup.root_install.failed_status",
"message" to message
)
)
}
isRunning = false
progress = -1f
}
}
val accent = remember {
Brush.linearGradient(
listOf(
PurrfectPalette.glowSecondary,
PurrfectPalette.glowPrimary
)
)
}
SetupCard {
StepTitle(
title = translation["setup.root_install.title"],
subtitle = null,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.03f),
tonalElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AnimatedVisibility(visible = isRunning || progress >= 0f) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val isDownloading = progress >= 0f
Text(
text = if (isDownloading) {
translation.format(
"setup.root_install.status_downloading",
"percent" to (progress * 100).toInt().toString()
)
} else {
translation["setup.root_install.status_preparing"]
},
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.Medium
)
if (isDownloading) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
} else {
LinearProgressIndicator(
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
}
}
}
LogsPanel(
logs = logs,
pulse = logPulse,
accent = accent,
translation = translation,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog(translation["setup.root_install.logs_copied"])
}
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (installVerified) {
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = Color.White
)
Text(
text = translation["setup.root_install.install_success"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
if (downloadedApk == null) {
GradientActionButton(
label = translation["setup.root_install.download_button"],
icon = Icons.Filled.Download,
onClick = { startDownloadAndInstall() },
enabled = !isRunning
)
}
if (downloadedApk != null) {
GradientActionButton(
label = translation["setup.root_install.install_button"],
icon = Icons.Filled.Verified,
onClick = { installDownloadedApk() },
enabled = true
)
val manualInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(manualInteraction)
.clickable(
interactionSource = manualInteraction,
indication = null
) { markAlreadyInstalled() }
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = translation["setup.root_install.already_installed_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun GradientActionButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
val interaction = remember { MutableInteractionSource() }
val gradient = Brush.horizontalGradient(listOf(PurrfectPalette.glowSecondary, PurrfectPalette.glowPrimary))
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Transparent)
.scaleOnPress(interaction)
.clickable(
enabled = enabled,
interactionSource = interaction,
indication = null,
onClick = onClick
),
tonalElevation = 0.dp,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Box(
modifier = Modifier
.background(if (enabled) gradient else Brush.horizontalGradient(listOf(Color.White.copy(alpha = 0.08f), Color.White.copy(alpha = 0.08f))))
.padding(vertical = 14.dp, horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
) {
Row(
modifier = Modifier.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White
)
Text(
text = label,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
}
}
}
}
@Composable
private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush,
translation: LocaleWrapper,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val animatedBrush = Brush.linearGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.18f + 0.1f * pulse),
Color.Transparent,
PurrfectPalette.glowSecondary.copy(alpha = 0.12f + 0.1f * (1 - pulse))
),
start = Offset.Zero,
end = Offset(400f * (0.6f + pulse), 260f * (0.4f + (1 - pulse)))
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.03f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
modifier = Modifier
.background(animatedBrush)
.background(Color.Black.copy(alpha = 0.25f))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = translation["setup.root_install.logs_title"],
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
}
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { onCopy() }
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Text(
text = translation["setup.root_install.copy_button"],
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
}
}
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
logs.forEach { line ->
Text(
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,
@@ -142,6 +152,28 @@ class SaveFolderScreen : SetupScreen() {
) {
Text(text = context.translation["setup.dialogs.select_save_folder_button"])
}
Spacer(modifier = Modifier.height(10.dp))
val defaultSrc = remember { MutableInteractionSource() }
OutlinedButton(
onClick = {
currentFolder = ""
context.config.root.downloader.saveFolder.set("")
context.sharedPreferences.edit().putBoolean("downloader_use_default_save_folder", true).apply()
context.config.writeConfig()
goNext()
},
interactionSource = defaultSrc,
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(defaultSrc),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Text(text = context.translation["setup.save_folder.use_default_location_button"])
}
if (showNoPickerDialog) {
Dialog(onDismissRequest = { showNoPickerDialog = false }) {
@@ -182,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,
@@ -208,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 = {
@@ -226,7 +258,7 @@ class SaveFolderScreen : SetupScreen() {
contentColor = Color.White
)
) {
Text("Use default")
Text(context.translation["setup.save_folder.use_default_button"])
}
}
}
@@ -234,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

@@ -40,7 +40,10 @@ class ActivityLauncherHelper(
fun launch(intent: Intent, callback: ActivityLauncherCallback, onFailure: ((Throwable) -> Unit)?) {
if (this.callback != null) {
throw IllegalStateException("Already launching an activity")
val error = IllegalStateException("Already launching an activity")
AbstractLogger.directError("Ignored concurrent activity launch", error)
onFailure?.invoke(error)
return
}
this.callback = callback
@@ -56,7 +59,11 @@ class ActivityLauncherHelper(
fun requestPermission(permission: String, callback: ActivityLauncherCallback) {
if (this.callback != null) {
throw IllegalStateException("Already launching an activity")
AbstractLogger.directError(
"Ignored concurrent permission request",
IllegalStateException("Already launching an activity")
)
return
}
this.callback = callback
permissionResultLauncher.launch(permission)

View File

@@ -38,6 +38,7 @@ 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
@@ -56,8 +57,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
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
@@ -207,23 +210,41 @@ class AlertDialogs(
@Composable
@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 {
add(0, "null")
if (noDisable) {
disabledKey?.let { remove(it) }
remove("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() ?: "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
property.value.setAny(if (index == 0) {
null
} else {
item
})
if (!noDisable && disabledKey != null && item == disabledKey) {
property.value.setAny(disabledKey)
return
}
if (!noDisable && disabledKey == null && index == 0) {
property.value.setAny(null)
return
}
property.value.setAny(item)
}
Row(
@@ -279,6 +300,7 @@ class AlertDialogs(
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
@@ -306,7 +328,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
}
@@ -362,6 +384,7 @@ class AlertDialogs(
fieldValue.value = it
},
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
@@ -476,7 +499,7 @@ class AlertDialogs(
currentColor = null
}
},
label = { Text(text = "Hex Color") },
label = { Text(text = translation["dialogs.hex_color_label"]) },
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
@@ -571,6 +594,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,7 +615,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)
@@ -665,28 +703,56 @@ class AlertDialogs(
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
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
)
}
}
}
}
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
)
} 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
}
@@ -750,7 +816,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
)
@@ -804,11 +870,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
)
},
@@ -928,7 +994,7 @@ class AlertDialogs(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Searching...",
text = betterLocationTranslation["searching_label"],
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
@@ -1076,7 +1142,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),
@@ -1096,7 +1162,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),

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.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

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