Compare commits

...

136 Commits

Author SHA1 Message Date
GitHub Actions
b6000c1db8 chore: bump version to 4.11.115 [skip ci] 2026-07-09 07:42:59 +00:00
83b4cbbd8e chore: update cinny submodule to latest commit and modify versioning for Android app
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-07-09 17:42:28 +10:00
GitHub Actions
8753ed2600 chore: bump version to 4.11.114 [skip ci] 2026-07-09 07:23:56 +00:00
cced6b8ab9 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m41s
Build / create-release (push) Successful in 15s
2026-07-09 17:23:47 +10:00
82effc9680 chore: update cinny submodule to latest commit for improved navigation and routing 2026-07-09 17:23:45 +10:00
GitHub Actions
deefb40277 chore: bump version to 4.11.113 [skip ci] 2026-07-09 06:56:00 +00:00
b496c70190 chore: bump cinny submodule to 9c7f7189 (compact nav and back routing)
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m33s
Build / create-release (push) Successful in 16s
2026-07-09 16:55:52 +10:00
GitHub Actions
5a4384ea1b chore: bump version to 4.11.112 [skip ci] 2026-07-08 17:41:01 +00:00
cc974414f8 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 03:40:53 +10:00
3245f41073 refactor: remove Android typography fix and associated styles from ClientNonUIFeatures 2026-07-09 03:40:51 +10:00
GitHub Actions
8b51d78db6 chore: bump version to 4.11.111 [skip ci] 2026-07-08 17:23:31 +00:00
e27ec9d7ab feat: add Android typography fix for Capacitor apps and integrate into ClientNonUIFeatures
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-07-09 03:23:22 +10:00
GitHub Actions
5c18e38314 chore: bump version to 4.11.110 [skip ci] 2026-07-08 16:43:01 +00:00
64e1de954a fix: update subproject commit reference in cinny to b425827
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 02:42:52 +10:00
GitHub Actions
0404cdc8ee chore: bump version to 4.11.109 [skip ci] 2026-07-08 16:07:17 +00:00
9dc177f527 fix: update version code and name in build.gradle; register PluginStoragePlugin in MainActivity; enhance text duplication handling in Editor component
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 15s
2026-07-09 02:07:03 +10:00
655603bb59 fix: update subproject commit reference in cinny to f67f08f1 2026-07-09 02:06:56 +10:00
GitHub Actions
fb9e8bc277 chore: bump version to 4.11.108 [skip ci] 2026-05-13 01:12:28 +00:00
803a076f98 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m34s
Build / create-release (push) Successful in 14s
2026-05-13 11:12:22 +10:00
86d284b353 fix: update subproject commit reference in cinny 2026-05-13 11:12:17 +10:00
6207d18b3c fix: update version code and name in build.gradle; adjust Modal component in ShareRoomPicker 2026-05-13 11:12:09 +10:00
GitHub Actions
8d2e35d1bc chore: bump version to 4.11.107 [skip ci] 2026-05-13 00:40:41 +00:00
6845398755 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m32s
Build / create-release (push) Successful in 14s
2026-05-13 10:40:34 +10:00
be52b0fa08 fix(mobile): update version code and name, adjust padding in ShareRoomPicker component 2026-05-13 10:40:31 +10:00
GitHub Actions
298bcfd1e3 chore: bump version to 4.11.106 [skip ci] 2026-05-13 00:24:11 +00:00
fce1736312 fix(mobile): increase top padding to 60px for status bar, reduce dialog height
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 10:24:02 +10:00
GitHub Actions
e11304d093 chore: bump version to 4.11.105 [skip ci] 2026-05-13 00:15:56 +00:00
60d76d5165 feat(mobile): add confirmation dialog back to share picker with backdrop
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m35s
Build / create-release (push) Successful in 14s
2026-05-13 10:15:47 +10:00
GitHub Actions
373e700a07 chore: bump version to 4.11.104 [skip ci] 2026-05-13 00:13:42 +00:00
f7b25261cc fix(mobile): make share picker fullscreen with full-width search, remove confirmation dialog
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Has started running
Build / create-release (push) Has been cancelled
2026-05-13 10:13:35 +10:00
GitHub Actions
41e1b0db9d chore: bump version to 4.11.103 [skip ci] 2026-05-13 00:08:24 +00:00
d1022635d2 feat(mobile): improve share dialog UI with padding, full-width search, and confirmation
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-05-13 10:08:16 +10:00
GitHub Actions
2e6be4b0b3 chore: bump version to 4.11.102 [skip ci] 2026-05-12 23:59:14 +00:00
0d97676edd fix(mobile): send shared content immediately instead of staging in draft
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-05-13 09:59:07 +10:00
GitHub Actions
f8e8058de3 chore: bump version to 4.11.101 [skip ci] 2026-05-12 23:54:07 +00:00
a1577a3b97 fix(mobile): navigate to room after share picker selection
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m28s
Build / create-release (push) Successful in 15s
2026-05-13 09:53:57 +10:00
GitHub Actions
80f1df6588 chore: bump version to 4.11.100 [skip ci] 2026-05-12 23:44:23 +00:00
375fa74309 fix: restore live server validation while keeping input stable during typing
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-05-13 09:44:14 +10:00
GitHub Actions
0862094e00 chore: bump version to 4.11.99 [skip ci] 2026-05-12 23:41:58 +00:00
45d66bbe28 fix(mobile): prevent server input focus loss and text modification while typing
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-05-13 09:41:49 +10:00
GitHub Actions
919bc310a7 chore: bump version to 4.11.98 [skip ci] 2026-05-12 23:32:30 +00:00
badcfb160c fix(mobile): handle Android keyboard autocorrect text duplication in overlay
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-05-13 09:32:22 +10:00
GitHub Actions
1b35f71bb8 chore: bump version to 4.11.97 [skip ci] 2026-05-12 22:59:45 +00:00
96349bf200 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m26s
Build / create-release (push) Successful in 14s
2026-05-13 08:59:38 +10:00
472d30940d chore: remove extra whitespace in README.md 2026-05-13 08:59:36 +10:00
GitHub Actions
ae9d2bfeb6 chore: bump version to 4.11.96 [skip ci] 2026-05-12 22:52:08 +00:00
61b82d1f40 fix: use consistent signing cert for all builds to prevent update conflicts
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m53s
Build / create-release (push) Successful in 14s
2026-05-13 08:51:57 +10:00
GitHub Actions
b008011d5f chore: bump version to 4.11.95 [skip ci] 2026-05-12 22:42:45 +00:00
938f000f65 chore: remove extra newline in README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 08:42:35 +10:00
GitHub Actions
7fdf369883 chore: bump version to 4.11.94 [skip ci] 2026-05-12 22:28:54 +00:00
c9883a1279 chore: remove extra newline in SIGNING_SETUP.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m49s
Build / create-release (push) Successful in 23s
2026-05-13 08:28:45 +10:00
GitHub Actions
5f09b0dd71 chore: bump version to 4.11.93 [skip ci] 2026-05-12 22:20:57 +00:00
0e436f076e Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m7s
Build / create-release (push) Successful in 14s
2026-05-13 08:20:49 +10:00
2281b31894 feat: add signing configuration for debug and release builds based on keystore properties 2026-05-13 08:20:47 +10:00
GitHub Actions
0eda439897 chore: bump version to 4.11.92 [skip ci] 2026-05-12 22:03:52 +00:00
a1bd03b277 chore: update subproject commit reference to 7ef9939
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
Build / create-release (push) Successful in 14s
2026-05-13 08:03:43 +10:00
GitHub Actions
1c2904898f chore: bump version to 4.11.91 [skip ci] 2026-04-28 05:46:30 +00:00
0fe41a55c1 feat: add custom notification sound for message alerts in MatrixSyncService
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m56s
Build / create-release (push) Successful in 14s
2026-04-28 15:46:20 +10:00
af2f7dbcbc feat: enhance MatrixSyncService with user profile handling and image notifications 2026-04-26 18:54:58 +10:00
GitHub Actions
0a4a9aa636 chore: bump version to 4.11.90 [skip ci] 2026-04-26 04:32:30 +00:00
746d0aa384 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m42s
Build / create-release (push) Successful in 14s
2026-04-26 14:32:22 +10:00
c263b681d4 feat: add ShareRoomPicker component for selecting rooms to share Android content
- Implemented ShareRoomPicker component to allow users to pick a room for sharing content received from Android intents.
- Integrated search functionality to filter rooms and directs.
- Added virtualized list for efficient rendering of room options.
- Created utility functions for handling Android share payloads, including fetching and clearing pending shares.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:32:19 +10:00
GitHub Actions
6f583d1227 chore: bump version to 4.11.89 [skip ci] 2026-04-26 00:56:21 +00:00
6df60c8348 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-04-26 10:56:12 +10:00
79d46857f9 feat: integrate UnifiedPush for background synchronization and event handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:56:10 +10:00
GitHub Actions
c9ac92a0c4 chore: bump version to 4.11.88 [skip ci] 2026-04-26 00:18:07 +00:00
cb44bc92e4 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m9s
Build / create-release (push) Successful in 13s
2026-04-26 10:17:58 +10:00
bc07974b4b feat: implement push ping receiver and one-shot sync fetch functionality
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:17:56 +10:00
GitHub Actions
abe44f6ad1 chore: bump version to 4.11.87 [skip ci] 2026-04-18 21:57:34 +00:00
c17e1c74ee chore: update subproject commit reference to a3dac17
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m4s
Build / create-release (push) Successful in 14s
2026-04-19 07:57:24 +10:00
GitHub Actions
255bfe2262 chore: bump version to 4.11.86 [skip ci] 2026-04-18 21:37:27 +00:00
8eea325e21 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m46s
Build / create-release (push) Successful in 14s
2026-04-19 07:37:19 +10:00
3afb2ee80d chore: update subproject commit reference 2026-04-19 07:37:17 +10:00
GitHub Actions
245188c1a1 chore: bump version to 4.11.85 [skip ci] 2026-04-18 21:24:49 +00:00
1d2057d75a Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m43s
Build / create-release (push) Successful in 13s
2026-04-19 07:24:41 +10:00
d8343d089b feat: implement Android app signing setup and update .gitignore for keystore files 2026-04-19 07:24:38 +10:00
GitHub Actions
5cc087330c chore: bump version to 4.11.84 [skip ci] 2026-04-18 21:09:32 +00:00
4163f22f71 Update README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m37s
Build / create-release (push) Successful in 14s
2026-04-19 07:09:24 +10:00
GitHub Actions
ed0761bd20 chore: bump version to 4.11.83 [skip ci] 2026-04-18 21:01:35 +00:00
6fdaa8dfd3 update base
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 5m10s
Build / create-release (push) Successful in 15s
2026-04-19 07:00:49 +10:00
GitHub Actions
88d0a3f887 chore: bump version to 4.11.82 [skip ci] 2026-04-18 20:58:45 +00:00
240d39593e Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m47s
Build / create-release (push) Has been skipped
2026-04-19 06:58:36 +10:00
f06d11711b fix: update subproject commit reference 2026-04-19 06:58:34 +10:00
GitHub Actions
75e1fe6812 chore: bump version to 4.11.81 [skip ci] 2026-04-18 20:54:19 +00:00
f8c1e0641d Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 1m9s
Build / create-release (push) Has been skipped
2026-04-19 06:54:12 +10:00
6795e692d6 fix: update subproject commit reference 2026-04-19 06:54:10 +10:00
GitHub Actions
1c42974fe8 chore: bump version to 4.11.80 [skip ci] 2026-04-18 10:32:07 +00:00
2cde79e87d Update README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m43s
Build / create-release (push) Successful in 13s
2026-04-18 20:32:00 +10:00
GitHub Actions
6ec54c6018 chore: bump version to 4.11.79 [skip ci] 2026-04-17 10:16:12 +00:00
e203a46b5a fix: move capacitor dependencies to root package.json
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m34s
Build / create-release (push) Successful in 14s
Capacitor CLI and Android libraries need to be in root node_modules since capacitor.config.json and android/ folder are at root. The overlay no longer needs to add these deps to cinny.
2026-04-17 20:16:04 +10:00
GitHub Actions
746b7e6166 chore: bump version to 4.11.78 [skip ci] 2026-04-17 10:10:03 +00:00
0d089d0244 fix: run cap sync from cinny directory where capacitor cli is installed
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 3m3s
Build / create-release (push) Has been skipped
2026-04-17 20:09:54 +10:00
GitHub Actions
1302bead0e chore: bump version to 4.11.77 [skip ci] 2026-04-17 10:06:13 +00:00
875c4d3b54 ci: use npm install for cinny deps after overlay modifies package.json
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m42s
Build / create-release (push) Has been skipped
2026-04-17 20:06:05 +10:00
GitHub Actions
bc3de084d2 chore: bump version to 4.11.76 [skip ci] 2026-04-17 10:03:23 +00:00
849f4b980d ci: apply overlay before installing dependencies, fix cap sync path
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 49s
Build / create-release (push) Has been skipped
2026-04-17 20:03:14 +10:00
GitHub Actions
f7b79b3e20 chore: bump version to 4.11.75 [skip ci] 2026-04-17 09:59:37 +00:00
149ebfbe1f ci: install cinny dependencies before build
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m38s
Build / create-release (push) Has been skipped
2026-04-17 19:59:27 +10:00
GitHub Actions
684b74a890 chore: bump version to 4.11.74 [skip ci] 2026-04-17 09:58:06 +00:00
b2538670cd feat: add android:apk:linux script for CI builds
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 41s
Build / create-release (push) Has been skipped
2026-04-17 19:57:58 +10:00
GitHub Actions
da9f19e331 chore: bump version to 4.11.73 [skip ci] 2026-04-17 09:55:59 +00:00
91bf4d6053 ci: trigger build with conflict fix
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 40s
Build / create-release (push) Has been skipped
2026-04-17 19:55:51 +10:00
6ef5fcc2ed fix: resolve package.json merge conflict [skip ci] 2026-04-17 19:51:22 +10:00
GitHub Actions
1f74a133a5 chore: bump version to 4.11.72 [skip ci] 2026-04-17 09:49:26 +00:00
61946d5842 ci: restructure workflow to match desktop pattern with separate build and release jobs
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 38s
Build / create-release (push) Has been skipped
2026-04-17 19:49:17 +10:00
GitHub Actions
e634217d81 chore: bump version to 4.11.71 [skip ci] 2026-04-17 09:45:29 +00:00
84ab4a76dd ci: trigger build to test workflow changes
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-and-release (push) Failing after 1m16s
2026-04-17 19:45:21 +10:00
dc5a416949 ci: use npm ci, use upload_url from github api for releases [skip ci] 2026-04-17 19:43:36 +10:00
1bfb18ba86 ci: retry push with rebase to prevent version bump race condition [skip ci] 2026-04-17 19:41:29 +10:00
GitHub Actions
49cc17d105 chore: bump version to 4.11.70 [skip ci] 2026-04-17 09:39:50 +00:00
174d62e68a chore: remove outdated GitHub templates and configuration files
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-and-release (push) Failing after 38s
2026-04-17 19:39:41 +10:00
GitHub Actions
0d61eed963 chore: bump version to 4.11.69 [skip ci] 2026-04-17 09:38:24 +00:00
4f58736f7f ci: add clean workflow
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-and-release (push) Has been cancelled
2026-04-17 19:38:17 +10:00
GitHub Actions
f6cf5696dd chore: bump version to 4.11.68 [skip ci] 2026-04-17 09:28:19 +00:00
2ca09bbdf0 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Failing after 6s
Build / build-and-release (push) Failing after 58s
2026-04-17 19:28:10 +10:00
c39a0ae248 feat: add GitHub release creation and asset upload to build process 2026-04-17 19:28:08 +10:00
GitHub Actions
1e848bc622 chore: bump version to 4.11.67 [skip ci] 2026-04-15 12:14:35 +00:00
3fe00eb3d7 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-and-release (push) Failing after 51s
2026-04-15 22:14:24 +10:00
85851d7288 fix: update version to 4.11.66 and streamline Android build scripts 2026-04-15 22:14:14 +10:00
GitHub Actions
47f1b2c40f chore: bump version to 4.11.83 [skip ci] 2026-04-07 12:48:26 +00:00
d04c1e8f77 fix: rename build job and streamline APK upload process
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-and-release (push) Successful in 4m45s
2026-04-07 22:48:17 +10:00
GitHub Actions
76a19f7e02 chore: bump version to 4.11.82 [skip ci] 2026-04-07 10:57:10 +00:00
3d35ea5930 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 4m37s
Build / create-release (push) Has been skipped
2026-04-07 20:57:03 +10:00
9fe79de3b5 fix: update upload artifact action to v4 and adjust APK naming convention 2026-04-07 20:57:02 +10:00
GitHub Actions
044631b05d chore: bump version to 4.11.81 [skip ci] 2026-04-07 10:45:54 +00:00
4bc5b4d790 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 5m11s
Build / create-release (push) Failing after 3m7s
2026-04-07 20:45:46 +10:00
da52ec84b7 fix: improve handling of joined rooms and event notifications in MatrixSyncService 2026-04-07 20:45:44 +10:00
GitHub Actions
f758796d83 chore: bump version to 4.11.80 [skip ci] 2026-04-07 10:42:53 +00:00
77d4071728 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-04-07 20:42:44 +10:00
ca855a6945 feat: sync version from package.json into build.gradle and set APK output name 2026-04-07 20:42:43 +10:00
GitHub Actions
a56662ab69 chore: bump version to 4.11.79 [skip ci] 2026-04-07 10:36:35 +00:00
f870fd1c2a Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 4m21s
Build / create-release (push) Has been skipped
2026-04-07 20:36:27 +10:00
70f7d41d9a feat: implement background sync service for Matrix integration 2026-04-07 20:36:25 +10:00
GitHub Actions
eba647c9c5 chore: bump version to 4.11.78 [skip ci] 2026-04-07 09:42:27 +00:00
08b6dce4ee Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m31s
Build / create-release (push) Successful in 8s
2026-04-07 19:42:19 +10:00
226f4863ff feat: add POST_NOTIFICATIONS permission to AndroidManifest.xml 2026-04-07 19:42:17 +10:00
61 changed files with 6201 additions and 386 deletions

View File

@@ -15,30 +15,34 @@ jobs:
outputs: outputs:
version: ${{ steps.bump.outputs.VERSION }} version: ${{ steps.bump.outputs.VERSION }}
should_build: ${{ steps.bump.outputs.SHOULD_BUILD }} should_build: ${{ steps.bump.outputs.SHOULD_BUILD }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Bump patch version - name: Bump patch version
id: bump id: bump
run: | run: |
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
echo "Current version: $CURRENT" echo "Current version: $CURRENT"
MAJOR=$(echo $CURRENT | cut -d. -f1) MAJOR=$(echo $CURRENT | cut -d. -f1)
MINOR=$(echo $CURRENT | cut -d. -f2) MINOR=$(echo $CURRENT | cut -d. -f2)
PATCH=$(echo $CURRENT | cut -d. -f3) PATCH=$(echo $CURRENT | cut -d. -f3)
NEW_PATCH=$((PATCH + 1)) NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New version: $NEW_VERSION" echo "New version: $NEW_VERSION"
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
grep '"version"' package.json | head -1 grep '"version"' package.json | head -1
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT
- name: Commit and push version bump - name: Commit and push version bump
run: | run: |
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
@@ -51,69 +55,76 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: increment-version needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: false submodules: false
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
- name: Checkout submodules manually - name: Checkout submodules manually
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Pull latest (after version bump) - name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
run: git pull origin master run: git pull origin master
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: '21' java-version: '21'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Install dependencies (root) - name: Install dependencies (root)
run: npm ci --prefer-offline
- name: Apply overlay
run: npm run apply-overlay
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm install --prefer-offline run: npm install --prefer-offline
- name: Clean previous cinny dist - name: Clean previous builds
run: rm -rf cinny/dist run: rm -rf cinny/dist
- name: Make gradlew executable - name: Make gradlew executable
run: chmod +x android/gradlew run: chmod +x android/gradlew
- name: Apply overlay and build Android APK - name: Build Android APK
run: npm run android:apk:linux run: npm run android:apk:linux
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: Paarrot-Android-debug.apk name: Paarrot-Android.apk
path: android/app/build/outputs/apk/debug/app-debug.apk path: android/app/build/outputs/apk/debug/Paarrot-*.apk
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [increment-version, build-android] needs: [increment-version, build-android]
if: always() && needs.build-android.result == 'success' if: always() && needs.build-android.result == 'success'
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
- name: Pull latest (after version bump) - name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
run: git pull origin master run: git pull origin master
- name: Get version - name: Get version
id: version id: version
run: | run: |
@@ -124,17 +135,19 @@ jobs:
fi fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Check if tag exists - name: Check if tag exists
id: tag_check id: tag_check
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ steps.version.outputs.VERSION }}"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists"
echo "exists=true" >> $GITHUB_OUTPUT echo "exists=true" >> $GITHUB_OUTPUT
else else
echo "Tag v$VERSION does not exist"
echo "exists=false" >> $GITHUB_OUTPUT echo "exists=false" >> $GITHUB_OUTPUT
fi fi
- name: Create and push tag - name: Create and push tag
if: steps.tag_check.outputs.exists == 'false' if: steps.tag_check.outputs.exists == 'false'
run: | run: |
@@ -142,23 +155,19 @@ jobs:
git config user.email "actions@github.com" git config user.email "actions@github.com"
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}" git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}" git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
path: artifacts path: artifacts
- name: Prepare release files - name: Prepare release files
run: | run: |
mkdir -p release-files mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "RELEASES*" \) -exec cp {} release-files/ \; find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
APK=$(find artifacts -type f -name "*.apk" | head -1)
if [ -n "$APK" ]; then
cp "$APK" "release-files/Paarrot.apk"
fi
ls -la release-files/ ls -la release-files/
- name: Create or Update Release - name: Create or Update Gitea Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -166,10 +175,10 @@ jobs:
TAG_NAME="v${VERSION}" TAG_NAME="v${VERSION}"
API_BASE="http://synbox.ruv.wtf:8418/api/v1" API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="litruv/cinny-mobile" REPO="litruv/cinny-mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty') "${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Creating new release for ${TAG_NAME}..." echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \ RELEASE_ID=$(curl -s -X POST \
@@ -179,18 +188,20 @@ jobs:
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id') "${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}" echo "Created release with ID: ${RELEASE_ID}"
else else
echo "Release exists with ID: ${RELEASE_ID}, deleting existing assets..." echo "Release exists with ID: ${RELEASE_ID}"
echo "Deleting existing assets..."
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id') "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
for ASSET_ID in $ASSETS; do for ASSET_ID in $ASSETS; do
echo "Deleting asset ${ASSET_ID}..."
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
done done
fi fi
echo "Uploading assets..." echo "Uploading assets..."
for FILE in release-files/*; do for FILE in release-files/*; do
FILENAME=$(basename "$FILE") FILENAME="Paarrot-${VERSION}.apk"
echo "Uploading ${FILENAME}..." echo "Uploading ${FILENAME}..."
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
@@ -199,5 +210,45 @@ jobs:
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo "" echo ""
done done
echo "Release complete!" echo "Gitea release complete!"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new GitHub release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id')
echo "Created GitHub release with ID: ${RELEASE_ID}"
else
echo "GitHub release exists with ID: ${RELEASE_ID}"
fi
echo "Uploading assets to GitHub..."
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
for FILE in release-files/*; do
FILENAME="Paarrot-${VERSION}.apk"
echo "Uploading ${FILENAME} to GitHub..."
curl -s -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${UPLOAD_URL}?name=${FILENAME}"
echo ""
done
echo "GitHub release complete!"

View File

@@ -0,0 +1,30 @@
name: Clean
on:
workflow_dispatch:
jobs:
clean:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: false
- name: Clean build artifacts
run: |
echo "Cleaning cinny dist..."
rm -rf cinny/dist
echo "Cleaning Android build outputs..."
rm -rf android/app/build
echo "Cleaning root node_modules..."
rm -rf node_modules
echo "Cleaning cinny node_modules..."
rm -rf cinny/node_modules
echo "Done."

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
github: ajbura
liberapay: ajbura
open_collective: cinny

View File

@@ -1,58 +0,0 @@
name: 🐞 Bug Report
description: Report a bug
labels: 'type: bug'
body:
- type: markdown
attributes:
value: |
## First of all
1. Please search for [existing issues](https://github.com/cinnyapp/cinny-desktop/issues?q=is%3Aissue) about this problem first.
2. Make sure Cinny is up to date.
3. Make sure it's an issue with Cinny and not something else you are using.
4. Remember to be friendly.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear description of what the bug is. Include screenshots if applicable.
placeholder: Bug description
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Steps to reproduce the behavior.
placeholder: |
1. Go to ...
2. Click on ...
3. See error
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear description of what you expected to happen.
- type: textarea
id: info
attributes:
label: Platform and versions
description: "Provide OS, browser and Cinny version with your Homeserver."
placeholder: |
1. OS: [e.g. Windows 10, MacOS]
2. Cinny version: [e.g. 1.8.1]
3. Matrix homeserver: [e.g. matrix.org]
4. Downloaded from: [e.g. GitHub, Flatpak]
render: shell
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

View File

@@ -1,4 +0,0 @@
contact_links:
- name: 💬 Matrix Chat
url: https://matrix.to/#/#cinny:matrix.org
about: Ask questions and talk to other Cinny users and the maintainers

View File

@@ -1,34 +0,0 @@
name: 💡 Feature Request
description: Suggest an idea
labels: 'type: feature'
body:
- type: textarea
id: problem
attributes:
label: Describe the problem
description: A clear description of the problem this feature would solve
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: A clear description of what change you would like
placeholder: "I would like to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: "Any alternative solutions you've considered"
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

View File

@@ -1,22 +0,0 @@
<!-- Please read https://github.com/cinnyapp/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes #
#### Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
### Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

3
.github/SECURITY.md vendored
View File

@@ -1,3 +0,0 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**

View File

@@ -1,30 +0,0 @@
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
version: 2
updates:
# - package-ecosystem: npm
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
# - package-ecosystem: cargo
# directory: /src-tauri/
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 5

21
.github/renovate.json vendored
View File

@@ -1,21 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboardApproval"
],
"labels": [
"Dependencies"
],
"packageRules": [
{
"matchUpdateTypes": [
"lockFileMaintenance"
]
}
],
"lockFileMaintenance": {
"enabled": true
},
"dependencyDashboard": true
}

View File

@@ -1,22 +0,0 @@
name: "Upload zip-archive"
on:
release:
types: [published]
jobs:
zip-archive:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.0
with:
submodules: true
- name: Create zip including submodules
run: |
cd ..
zip ${{ github.event.repository.name }}/${{ github.event.repository.name }}-${{ github.ref_name }}.zip ${{ github.event.repository.name }} -r
- name: Upload zip to release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
with:
files: |
${{ github.event.repository.name }}-${{ github.ref_name }}.zip

View File

@@ -1,36 +0,0 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: 'signatures.json'
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'main'
allowlist: ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: cinnyapp
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

View File

@@ -1,26 +0,0 @@
name: NPM Lockfile Changes
on:
pull_request:
paths:
- 'package-lock.json'
jobs:
lockfile_changes:
runs-on: ubuntu-latest
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.0
- name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values.
collapsibleThreshold: 25
failOnDowngrade: false
path: package-lock.json
updateComment: true

View File

@@ -1,40 +0,0 @@
name: "Build pull request"
on:
#pull_request:
#types: ['opened', 'synchronize']
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.0
with:
submodules: true
- name: Setup node
uses: actions/setup-node@v4.4.0
with:
node-version: 20.12.2
cache: 'npm'
- name: Install Rust stable
uses: actions-rs/toolchain@v1.0.7
with:
toolchain: stable
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Install cinny dependencies
run: cd cinny && npm ci
- name: Install tauri dependencies
run: npm ci
- name: Build desktop app with Tauri
uses: tauri-apps/tauri-action@v0.5.14
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,7 +1,7 @@
# Paarrot # Paarrot
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny. Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
## Download ## Download
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases). Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).

4
android/.gitignore vendored
View File

@@ -53,9 +53,7 @@ captures/
.idea/navEditor.xml .idea/navEditor.xml
# Keystore files # Keystore files
# Uncomment the following lines if you do not want to check your keystore files in. *.jks
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later # External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild .externalNativeBuild

61
android/SIGNING_SETUP.md Normal file
View File

@@ -0,0 +1,61 @@
# Android App Signing Setup
## Why This Matters
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.
## Setup Instructions
### 1. Create a Keystore (if you don't have one)
Run this command in the `android/` directory:
```bash
keytool -genkey -v -keystore paarrot-release.keystore -alias paarrot -keyalg RSA -keysize 2048 -validity 10000
```
You'll be asked for:
- Keystore password (remember this!)
- Key password (remember this!)
- Your name, organization, etc.
**IMPORTANT**: Back up this keystore file and remember the passwords! If you lose either, you cannot update your app.
### 2. Create keystore.properties
Copy the example file:
```bash
cp keystore.properties.example keystore.properties
```
Then edit `keystore.properties` and fill in:
- `storeFile`: path to your keystore (e.g., `paarrot-release.keystore`)
- `storePassword`: the keystore password you set
- `keyAlias`: the alias (e.g., `paarrot`)
- `keyPassword`: the key password you set
### 3. Verify
The file `keystore.properties` should be gitignored (check `.gitignore`).
### 4. Build
Now your release builds will be properly signed:
```bash
cd android
./gradlew assembleRelease
```
The signed APK will be in: `app/build/outputs/apk/release/`
## Troubleshooting
### "Updates not installing"
- You're using a different keystore than the original APK
- Solution: Use the same keystore that was used for the first release
### "keystore.properties not found"
- Normal for debug builds
- Only needed for release builds
- Create the file following step 2 above

View File

@@ -1,25 +1,61 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
// Load keystore properties
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.paarrot.app" namespace = "com.paarrot.app"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
defaultConfig { defaultConfig {
applicationId "com.paarrot.app" applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1783582912
versionName "1.0" versionName "2026-07-09.074152.772"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
} }
buildTypes { buildTypes {
debug {
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
outputFileName = "Paarrot-${variant.versionName}.apk"
} }
} }
} }
@@ -36,6 +72,8 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.unifiedpush.android:connector:3.3.2'
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -22,6 +22,24 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity> </activity>
<provider <provider
@@ -33,11 +51,37 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data> android:resource="@xml/file_paths"></meta-data>
</provider> </provider>
<service
android:name=".MatrixSyncService"
android:foregroundServiceType="dataSync"
android:exported="false"
android:stopWithTask="false" />
<service
android:name=".UnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

View File

@@ -0,0 +1,21 @@
package com.paarrot.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Re-registers UnifiedPush after boot when credentials still exist.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
UnifiedPushManager.register(context, activity = null)
}
}

View File

@@ -1,5 +1,14 @@
package com.paarrot.app; package com.paarrot.app;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {} public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -0,0 +1,423 @@
package com.paarrot.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.media.AudioAttributes
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
* a wake ping from UnifiedPush or a manual diagnostic trigger.
*/
class MatrixSyncService : Service() {
private val job = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
private val shownEventIds = HashSet<String>(64)
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
private val displayNameCache = HashMap<String, String>(16)
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
private val avatarCache = HashMap<String, Bitmap?>(16)
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = intent?.getStringExtra(EXTRA_HOMESERVER)
?: prefs.getString(EXTRA_HOMESERVER, null)
val token = intent?.getStringExtra(EXTRA_TOKEN)
?: prefs.getString(EXTRA_TOKEN, null)
val userId = intent?.getStringExtra(EXTRA_USER_ID)
?: prefs.getString(EXTRA_USER_ID, null) ?: ""
if (homeserver == null || token == null) {
stopSelf()
return START_NOT_STICKY
}
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
serviceScope.launch {
try {
runSingleSyncFetch(homeserver, token, userId)
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
} finally {
stopForegroundCompat()
stopSelf(startId)
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) {
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val since = prefs.getString(KEY_SINCE, null)
val isFirstSync = since == null
try {
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
val (responseCode, body) = doHttpGet(url, token)
when (responseCode) {
200 -> {
val json = JSONObject(body ?: "{}")
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
if (nextBatch != null) {
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
if (!isFirstSync && !appInForeground) {
processRoomEvents(json, userId)
}
}
}
401, 403 -> {
Log.w(TAG, "Auth error $responseCode — clearing credentials")
applicationContext
.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
.edit()
.clear()
.apply()
}
else -> {
Log.w(TAG, "Sync fetch failed with HTTP $responseCode")
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Sync error: ${e.message}")
}
}
private fun buildSyncUrl(base: String, since: String?): String {
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
val encodedFilter = URLEncoder.encode(filter, "UTF-8")
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
}
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
withContext(Dispatchers.IO) {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 5_000
conn.readTimeout = 35_000
val code = conn.responseCode
val body = if (code == 200) conn.inputStream.bufferedReader().readText() else null
Pair(code, body)
} catch (e: Exception) {
Pair(-1, null)
} finally {
conn.disconnect()
}
}
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm)
val roomIds = joinedRooms.keys().asSequence().toList()
for (roomId in roomIds) {
val timeline = joinedRooms.optJSONObject(roomId)
?.optJSONObject("timeline") ?: continue
val events = timeline.optJSONArray("events") ?: continue
for (i in 0 until events.length()) {
val event = events.optJSONObject(i) ?: continue
val eventId = event.optString("event_id")
if (event.optString("type") != "m.room.message") continue
if (event.optString("sender") == myUserId) continue
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
}
val sender = event.optString("sender")
val profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) {
content.optString("url").takeIf { it.startsWith("mxc://") }
?.let { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
} else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
}
}
}
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
val fallback = mxid.substringAfter("@").substringBefore(":")
return try {
val encodedId = URLEncoder.encode(mxid, "UTF-8")
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
val conn = URL(url).openConnection() as HttpURLConnection
val (displayName, avatarMxc) = try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 4_000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
val obj = JSONObject(body)
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
Pair(name, mxc)
} else {
Pair(fallback, null)
}
} finally {
conn.disconnect()
}
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
displayNameCache[mxid] = fallback
avatarCache[mxid] = null
UserProfile(fallback, null)
}
}
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
}
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
null
}
}
private fun showMessageNotification(
nm: NotificationManager,
sender: String,
body: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
val pi = PendingIntent.getActivity(
this, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(sender)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
}
private fun buildStatusNotification(): Notification {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_STATUS, "Sync Status",
NotificationManager.IMPORTANCE_MIN,
).apply {
description = "Paarrot background sync status"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
return NotificationCompat.Builder(this, CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle("Paarrot")
.setContentText("Checking for new messages")
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.build()
}
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
}
companion object {
private const val TAG = "MatrixSyncService"
const val EXTRA_HOMESERVER = "homeserver_url"
const val EXTRA_TOKEN = "access_token"
const val EXTRA_USER_ID = "user_id"
const val EXTRA_DEVICE_ID = "device_id"
const val EXTRA_TRIGGER_REASON = "trigger_reason"
const val PREFS = "matrix_sync_prefs"
const val KEY_SINCE = "since_token"
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot"
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
@Volatile
var appInForeground = false
/**
* Starts a one-shot sync fetch if credentials are available and the call is not rate-limited.
*/
fun requestSyncFetch(context: Context, reason: String) {
val credsPrefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = credsPrefs.getString(EXTRA_HOMESERVER, null) ?: return
val token = credsPrefs.getString(EXTRA_TOKEN, null) ?: return
val userId = credsPrefs.getString(EXTRA_USER_ID, null) ?: ""
val deviceId = credsPrefs.getString(EXTRA_DEVICE_ID, null) ?: ""
val syncPrefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastWake = syncPrefs.getLong(KEY_LAST_WAKE_MS, 0L)
if (now - lastWake < MIN_WAKE_INTERVAL_MS) {
Log.d(TAG, "Skipping wake: rate-limited ($reason)")
return
}
syncPrefs.edit().putLong(KEY_LAST_WAKE_MS, now).apply()
val intent = Intent(context, MatrixSyncService::class.java).apply {
putExtra(EXTRA_HOMESERVER, homeserver)
putExtra(EXTRA_TOKEN, token)
putExtra(EXTRA_USER_ID, userId)
putExtra(EXTRA_DEVICE_ID, deviceId)
putExtra(EXTRA_TRIGGER_REASON, reason)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
}

View File

@@ -0,0 +1,250 @@
package com.paarrot.app
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipInputStream
/** Capacitor plugin that stores and manages user extension plugins on Android. */
@CapacitorPlugin(name = "PluginStorage")
class PluginStoragePlugin : Plugin() {
private fun pluginsDir(): File {
val dir = File(context.filesDir, "plugins")
dir.mkdirs()
return dir
}
@PluginMethod
fun getPath(call: PluginCall) {
val result = JSObject()
result.put("success", true)
result.put("data", pluginsDir().absolutePath)
call.resolve(result)
}
@PluginMethod
fun list(call: PluginCall) {
try {
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
val array = JSArray()
for (dir in plugins) {
val metadata = readMetadata(dir)
val obj = JSObject()
obj.put("id", dir.name)
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
obj.put("version", metadata?.optString("version", "") ?: "")
obj.put("description", metadata?.optString("description", "") ?: "")
obj.put("author", metadata?.optString("author", "") ?: "")
obj.put("repository", metadata?.optString("repository", "") ?: "")
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
obj.put("tags", metadataTagsToJsArray(metadata))
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
obj.put("path", dir.absolutePath)
array.put(obj)
}
val result = JSObject()
result.put("success", true)
result.put("data", array)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to list plugins")
call.resolve(result)
}
}
@PluginMethod
fun download(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val downloadUrl = call.getString("downloadUrl")
?: return call.reject("downloadUrl required")
val name = call.getString("name") ?: pluginId
Thread {
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
destDir.mkdirs()
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
downloadFile(downloadUrl, tempZip)
extractZip(tempZip, destDir)
tempZip.delete()
normalisePluginLayout(destDir)
if (!File(destDir, "index.js").exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Downloaded plugin is missing index.js")
call.resolve(result)
return@Thread
}
val result = JSObject()
result.put("success", true)
val data = JSObject()
data.put("path", destDir.absolutePath)
result.put("data", data)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to download plugin: $name")
call.resolve(result)
}
}.start()
}
@PluginMethod
fun uninstall(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
val result = JSObject()
result.put("success", true)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to uninstall plugin")
call.resolve(result)
}
}
@PluginMethod
fun readPluginCode(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val indexFile = File(pluginsDir(), "$pluginId/index.js")
if (!indexFile.exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Plugin not found: $pluginId")
call.resolve(result)
return
}
val result = JSObject()
result.put("success", true)
result.put("data", indexFile.readText())
call.resolve(result)
}
private fun readMetadata(pluginDir: File): JSONObject? {
val metadataFile = File(pluginDir, "plugin-metadata.json")
if (!metadataFile.exists()) return null
return try {
JSONObject(metadataFile.readText())
} catch (_: Exception) {
null
}
}
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
val tags = JSArray()
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
for (i in 0 until jsonTags.length()) {
tags.put(jsonTags.optString(i))
}
return tags
}
private fun formatInstalledDate(timestamp: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
return formatter.format(Date(timestamp))
}
private fun downloadFile(downloadUrl: String, destFile: File) {
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 60_000
instanceFollowRedirects = true
requestMethod = "GET"
}
try {
connection.connect()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
}
private fun extractZip(zipFile: File, destDir: File) {
destDir.mkdirs()
val destPath = destDir.canonicalPath + File.separator
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val outFile = File(destDir, entry.name)
if (!outFile.canonicalPath.startsWith(destPath)) {
throw SecurityException("Zip entry outside target directory")
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zipInput.copyTo(output)
}
}
zipInput.closeEntry()
entry = zipInput.nextEntry
}
}
}
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
private fun normalisePluginLayout(destDir: File) {
if (File(destDir, "index.js").exists()) return
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
if (children.size != 1) return
val nested = children[0]
nested.listFiles()?.forEach { child ->
child.renameTo(File(destDir, child.name))
}
nested.deleteRecursively()
}
}

View File

@@ -0,0 +1,196 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileOutputStream
/** Payload for a single shared file. */
data class SharedFilePayload(
val path: String,
val name: String,
val mimeType: String,
val size: Long,
)
/** Full payload delivered to JS when a share intent is received. */
data class SharePayload(
val text: String?,
val subject: String?,
val files: List<SharedFilePayload>,
val receivedAt: Long,
) {
/** Serialize to a [JSObject] for the Capacitor bridge. */
fun toJsObject(): JSObject {
val obj = JSObject()
obj.put("text", text)
obj.put("subject", subject)
val filesArray = JSArray()
for (f in files) {
val fileObj = JSObject()
fileObj.put("path", f.path)
fileObj.put("name", f.name)
fileObj.put("mimeType", f.mimeType)
fileObj.put("size", f.size)
filesArray.put(fileObj)
}
obj.put("files", filesArray)
obj.put("receivedAt", receivedAt)
return obj
}
}
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
object ShareIntentStore {
var plugin: ShareHandlerPlugin? = null
var pendingShare: SharePayload? = null
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
fun handleIntent(context: Context, intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND_MULTIPLE) {
@Suppress("UNCHECKED_CAST")
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
list?.let { uris.addAll(it) }
} else {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { uris.add(it) }
}
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
val payload = SharePayload(
text = text,
subject = subject,
files = copiedFiles,
receivedAt = receivedAt,
)
pendingShare = payload
plugin?.emitShare(payload)
}
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
return try {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: "shared_file"
val safeName = sanitizeFileName(originalName)
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
destDir.mkdirs()
var destFile = File(destDir, safeName)
var counter = 1
while (destFile.exists()) {
val dot = safeName.lastIndexOf('.')
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
val ext = if (dot >= 0) safeName.substring(dot) else ""
destFile = File(destDir, "${base}_$counter$ext")
counter++
}
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output -> input.copyTo(output) }
}
SharedFilePayload(
path = destFile.absolutePath,
name = destFile.name,
mimeType = mimeType,
size = destFile.length(),
)
} catch (e: Exception) {
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
}
/** Remove cached files from a previous share and clear the stored payload. */
fun clearIntent(receivedAt: Long?) {
if (receivedAt != null) {
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
dir?.deleteRecursively()
}
pendingShare = null
}
}
/** Capacitor plugin that exposes Android share intents to JavaScript. */
@CapacitorPlugin(name = "AndroidShareHandler")
class ShareHandlerPlugin : Plugin() {
override fun load() {
ShareIntentStore.plugin = this
ShareIntentStore.handleIntent(context, activity.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
ShareIntentStore.handleIntent(context, intent)
}
/** Fire the `shareReceived` event toward the JS layer. */
fun emitShare(payload: SharePayload) {
notifyListeners("shareReceived", payload.toJsObject())
}
/** Returns the pending share payload (if any) as a JS object. */
@PluginMethod
fun getPendingShare(call: PluginCall) {
val pending = ShareIntentStore.pendingShare
if (pending == null) {
val result = JSObject()
result.put("share", JSObject.NULL)
call.resolve(result)
} else {
val result = JSObject()
result.put("share", pending.toJsObject())
call.resolve(result)
}
}
/** Clears the pending share and removes any copied temp files. */
@PluginMethod
fun clearPendingShare(call: PluginCall) {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve()
}
}

View File

@@ -0,0 +1,103 @@
package com.paarrot.app
import android.content.Context
import android.content.Intent
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
/**
* Capacitor plugin that controls [MatrixSyncService] from JS.
*
* JS API:
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
* - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns current fetch and UnifiedPush state
*/
@CapacitorPlugin(name = "MatrixBackgroundSync")
class SyncServicePlugin : Plugin() {
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
notifyListeners(eventName, payload, true)
}
override fun load() {
super.load()
UnifiedPushManager.setPlugin(this)
}
override fun handleOnDestroy() {
UnifiedPushManager.clearPlugin(this)
super.handleOnDestroy()
}
/** Persists Matrix credentials and starts UnifiedPush registration. */
@PluginMethod
fun start(call: PluginCall) {
val homeserver = call.getString("homeserverUrl")
?: return call.reject("homeserverUrl required")
val token = call.getString("accessToken")
?: return call.reject("accessToken required")
val userId = call.getString("userId") ?: ""
val deviceId = call.getString("deviceId") ?: ""
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
.putString(MatrixSyncService.EXTRA_TOKEN, token)
.putString(MatrixSyncService.EXTRA_USER_ID, userId)
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
.apply()
UnifiedPushManager.register(context, activity)
call.resolve()
}
/** Manually triggers a one-shot sync fetch (mainly for diagnostics/testing). */
@PluginMethod
fun triggerPing(call: PluginCall) {
val reason = call.getString("reason") ?: "manual_plugin_ping"
MatrixSyncService.requestSyncFetch(context, reason)
call.resolve()
}
/** Stops the sync service and erases persisted credentials. */
@PluginMethod
fun stop(call: PluginCall) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
context.stopService(Intent(context, MatrixSyncService::class.java))
UnifiedPushManager.unregister(context)
call.resolve()
}
/**
* Notifies the service whether the Capacitor WebView UI is currently visible.
* When `foreground == true` the service skips firing notifications because
* the JS layer handles them directly via LocalNotifications.
*/
@PluginMethod
fun setAppForeground(call: PluginCall) {
MatrixSyncService.appInForeground = call.getBoolean("foreground", false) ?: false
call.resolve()
}
/** Returns whether the sync service is currently running. */
@PluginMethod
fun getStatus(call: PluginCall) {
val am = context.getSystemService(Context.ACTIVITY_SERVICE)
as android.app.ActivityManager
@Suppress("DEPRECATION")
val running = am.getRunningServices(Int.MAX_VALUE)
.any { it.service.className == MatrixSyncService::class.java.name }
val result = UnifiedPushManager.getStatus(context).apply { put("running", running) }
call.resolve(result)
}
companion object {
const val PREFS = "sync_service_prefs"
}
}

View File

@@ -0,0 +1,149 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.util.Log
import com.getcapacitor.JSObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
import org.unifiedpush.android.connector.UnifiedPush
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
object UnifiedPushManager {
private const val TAG = "UnifiedPushManager"
private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance"
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications"
@Volatile
private var plugin: SyncServicePlugin? = null
fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin
}
fun clearPlugin(plugin: SyncServicePlugin) {
if (this.plugin === plugin) {
this.plugin = null
}
}
fun register(context: Context, activity: Activity?) {
val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
if (savedDistributor.isNotBlank()) {
requestRegistration(context)
return
}
if (activity == null) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
return
}
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
if (success) {
requestRegistration(context)
} else {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
}
}
}
fun unregister(context: Context) {
runCatching {
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
}.onFailure {
Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}")
}
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT)
}
fun getStatus(context: Context): JSObject {
val status = JSObject()
status.put("endpoint", getEndpoint(context) ?: "")
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put(
"distributors",
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
)
return status
}
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply {
put("endpoint", endpoint)
put("instance", instance)
put("previousEndpoint", previousEndpoint ?: "")
}
dispatch(EVENT_NEW_ENDPOINT, payload)
}
fun onRegistrationFailed(reason: String, instance: String) {
dispatchRegistrationFailed(reason, instance)
}
fun onUnregistered(context: Context, instance: String) {
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance)
}
private fun requestRegistration(context: Context) {
runCatching {
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
}
}
private fun dispatchUnregistered(previousEndpoint: String?, instance: String) {
val payload = JSObject().apply {
put("previousEndpoint", previousEndpoint ?: "")
put("instance", instance)
}
dispatch(EVENT_UNREGISTERED, payload)
}
private fun dispatchRegistrationFailed(reason: String, instance: String) {
val payload = JSObject().apply {
put("reason", reason)
put("instance", instance)
}
dispatch(EVENT_REGISTRATION_FAILED, payload)
}
private fun dispatch(eventName: String, payload: JSObject) {
plugin?.emitUnifiedPushEvent(eventName, payload)
}
private fun persistEndpoint(context: Context, endpoint: String, instance: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_ENDPOINT, endpoint)
.putString(KEY_INSTANCE, instance)
.apply()
}
private fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_ENDPOINT)
.remove(KEY_INSTANCE)
.apply()
}
private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
private fun getInstance(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null)
}

View File

@@ -0,0 +1,35 @@
package com.paarrot.app
import android.util.Log
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */
class UnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance")
UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance)
}
override fun onMessage(message: PushMessage, instance: String) {
Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}")
MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message")
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
}
override fun onUnregistered(instance: String) {
Log.i(TAG, "UnifiedPush unregistered for instance=$instance")
UnifiedPushManager.onUnregistered(applicationContext, instance)
}
private companion object {
const val TAG = "UnifiedPushService"
}
}

View File

@@ -2,15 +2,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="18"
android:viewportHeight="24"> android:viewportHeight="18">
<path <!--
android:fillColor="#FFFFFFFF" Converted from public/res/svg/notification.svg.
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" /> Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
<path x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
android:fillColor="#FFFFFFFF" = scale 1.7754, translate(-5.337,-11.908)
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" /> -->
<path <group
android:fillColor="#FFFFFFFF" android:scaleX="1.7754"
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" /> android:scaleY="1.7754"
android:translateX="-5.337"
android:translateY="-11.908">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
</group>
</vector> </vector>

Binary file not shown.

View File

@@ -1,7 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext {
kotlin_version = '2.3.0'
}
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@@ -9,6 +12,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4' classpath 'com.google.gms:google-services:4.4.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

View File

@@ -0,0 +1,4 @@
storeFile=../paarrot-release.keystore
storePassword=paarrot2026
keyAlias=paarrot
keyPassword=paarrot2026

View File

@@ -0,0 +1,14 @@
# Copy this file to keystore.properties and fill in your actual values
# NEVER commit keystore.properties to git!
# Path to your keystore file (relative to android/ directory)
storeFile=paarrot-release.keystore
# Keystore password
storePassword=your_keystore_password_here
# Key alias
keyAlias=paarrot
# Key password
keyPassword=your_key_password_here

Binary file not shown.

2
cinny

Submodule cinny updated: 0659f3c1b0...9c7f71896c

BIN
full_logcat.txt Normal file

Binary file not shown.

View File

@@ -1,8 +1,3 @@
{ {
"dependencies": { "dependencies": {}
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
}
} }

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<MobileSwipeBackPanel>
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
</MobileSwipeBackPanel>
);
}

View File

@@ -0,0 +1,287 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
ReactEditor,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle mobile autocorrect replacement that causes text duplication in Slate
if (inputEvent.inputType === 'insertReplacementText') {
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (!data) return;
event.preventDefault();
const domRanges = inputEvent.getTargetRanges?.() ?? [];
if (domRanges.length > 0) {
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
exactMatch: false,
suppressThrow: true,
});
if (slateRange) {
Transforms.select(editor, slateRange);
Transforms.delete(editor);
editor.insertText(data);
return;
}
}
const { selection } = editor;
if (!selection) return;
if (!Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
} else {
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
if (wordBefore) {
const wordRange = { anchor: wordBefore, focus: selection.anchor };
Transforms.select(editor, wordRange);
Transforms.delete(editor);
}
}
editor.insertText(data);
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View File

@@ -0,0 +1,222 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import * as css from './mobile-gestures.css';
const COMMIT_RATIO = 0.28;
const MIN_COMMIT_PX = 72;
const MAX_START_Y_RATIO = 0.88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
moved: boolean;
offset: number;
};
type MobileSwipeBackPanelProps = {
children: ReactNode;
};
const readTransformOffset = (el: HTMLElement | null): number => {
if (!el) return 0;
const transform = window.getComputedStyle(el).transform;
if (!transform || transform === 'none') return 0;
return new DOMMatrix(transform).m41;
};
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
const compact = useCompactNav();
const { canGoBack, goBack } = useBackRoute();
const enabled = compact && canGoBack;
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const [animating, setAnimating] = useState(false);
const resetTransform = useCallback((animate = true) => {
const content = contentRef.current;
if (!content) return;
setAnimating(animate);
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
content.style.transform = 'translateX(0px)';
if (dragRef.current) {
dragRef.current.offset = 0;
}
}, []);
const setTransform = useCallback((px: number, animate = false) => {
const content = contentRef.current;
if (!content) return;
if (dragRef.current) {
dragRef.current.offset = px;
}
content.style.transition = animate
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
: 'none';
content.style.transform = `translateX(${px}px)`;
}, []);
const commitBack = useCallback(() => {
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setAnimating(true);
setTransform(width, true);
window.setTimeout(() => {
goBack();
resetTransform(false);
setAnimating(false);
}, 180);
}, [goBack, resetTransform, setTransform]);
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
};
const releaseCapture = useCallback((pointerId: number) => {
const root = rootRef.current;
if (root?.hasPointerCapture(pointerId)) {
root.releasePointerCapture(pointerId);
}
}, []);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
dragRef.current = null;
clearMobileGesture(pointerId);
if (!drag.moved) {
resetTransform(false);
return;
}
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const currentOffset = Math.max(
drag.offset,
readTransformOffset(contentRef.current)
);
const shouldCommit = currentOffset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
if (shouldCommit) {
commitBack();
return;
}
resetTransform(true);
},
[commitBack, releaseCapture, resetTransform]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'back') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (deltaX <= 0) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('back', evt.pointerId)) return;
drag.moved = true;
try {
rootRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setTransform(Math.min(Math.max(deltaX, 0), width), false);
},
[setTransform]
);
const handlePointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
if (shouldIgnoreTarget(evt.target)) return;
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
dragRef.current = {
pointerId: evt.pointerId,
startX: evt.clientX,
startY: evt.clientY,
moved: false,
offset: 0,
};
},
[animating, enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetTransform(false);
}, [enabled, resetTransform]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={rootRef}
className={css.SwipeBackRoot}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../icons';
import { useCompactNav } from '../../hooks/useCompactNav';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { startReplyToEvent } from '../../features/room/replyToMessage';
import {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import * as css from './mobile-gestures.css';
const SWIPE_THRESHOLD = 56;
const MAX_SWIPE = 88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
messageEl: HTMLElement;
messageId: string;
moved: boolean;
offset: number;
};
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
children: ReactNode;
};
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
const compact = useCompactNav();
const enabled = compact;
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const layerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
const [indicatorActive, setIndicatorActive] = useState(false);
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
const target = el ?? dragRef.current?.messageEl;
if (!target) return;
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
target.style.transform = 'translateX(0px)';
}, []);
const releaseCapture = useCallback((pointerId: number) => {
const layer = layerRef.current;
if (layer?.hasPointerCapture(pointerId)) {
layer.releasePointerCapture(pointerId);
}
}, []);
const resetGesture = useCallback(
(pointerId?: number) => {
if (pointerId !== undefined) {
releaseCapture(pointerId);
clearMobileGesture(pointerId);
}
clearMessageTransform();
dragRef.current = null;
setIndicatorTop(null);
setIndicatorActive(false);
},
[clearMessageTransform, releaseCapture]
);
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
return target.closest('[data-message-id]') as HTMLElement | null;
};
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
const layer = layerRef.current;
if (!layer) return;
const messageRect = messageEl.getBoundingClientRect();
const layerRect = layer.getBoundingClientRect();
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
}, []);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
const { messageEl, messageId, moved, offset } = drag;
dragRef.current = null;
clearMobileGesture(pointerId);
if (!moved) {
resetGesture();
return;
}
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
clearMessageTransform(messageEl, true);
setIndicatorTop(null);
setIndicatorActive(false);
if (shouldReply) {
startReplyToEvent(room, messageId, setReplyDraft, editor);
}
},
[clearMessageTransform, editor, releaseCapture, resetGesture, room, setReplyDraft]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'reply') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
resetGesture(evt.pointerId);
return;
}
if (deltaX >= 0) {
resetGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('reply', evt.pointerId)) return;
drag.moved = true;
try {
layerRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const offset = Math.max(deltaX, -MAX_SWIPE);
drag.offset = offset;
drag.messageEl.style.transition = 'none';
drag.messageEl.style.transform = `translateX(${offset}px)`;
updateIndicator(drag.messageEl, offset);
},
[resetGesture, updateIndicator]
);
const handlePointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
if (shouldIgnoreTarget(evt.target)) return;
const messageEl = findMessageElement(evt.target);
const messageId = messageEl?.getAttribute('data-message-id');
if (!messageEl || !messageId) return;
dragRef.current = {
pointerId: evt.pointerId,
startX: evt.clientX,
startY: evt.clientY,
messageEl,
messageId,
moved: false,
offset: 0,
};
},
[enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetGesture();
}, [room.roomId, resetGesture]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={layerRef}
className={css.SwipeToReplyLayer}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
export const SwipeBackUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
pointerEvents: 'none',
zIndex: 0,
});
export const SwipeBackSidebarPeek = style({
width: toRem(66),
flexShrink: 0,
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
});
export const SwipeBackChannelPeek = style({
flex: 1,
minWidth: 0,
backgroundColor: color.Background.Container,
});
export const SwipeBackContent = style({
position: 'relative',
zIndex: 1,
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
willChange: 'transform',
});
export const SwipeToReplyLayer = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
export const SwipeToReplyIndicator = style({
position: 'absolute',
right: config.space.S300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
pointerEvents: 'none',
zIndex: 2,
opacity: 0,
transform: 'scale(0.85)',
transition: 'opacity 0.12s ease, transform 0.12s ease',
});
export const SwipeToReplyIndicatorActive = style({
opacity: 1,
transform: 'scale(1)',
});

View File

@@ -0,0 +1,27 @@
export type MobileGestureKind = 'back' | 'reply';
type ActiveGesture = {
kind: MobileGestureKind;
pointerId: number;
};
let activeGesture: ActiveGesture | null = null;
export const claimMobileGesture = (kind: MobileGestureKind, pointerId: number): boolean => {
if (!activeGesture || activeGesture.pointerId === pointerId) {
activeGesture = { kind, pointerId };
return true;
}
return activeGesture.kind === kind && activeGesture.pointerId === pointerId;
};
export const getActiveMobileGesture = (pointerId: number): MobileGestureKind | null => {
if (!activeGesture || activeGesture.pointerId !== pointerId) return null;
return activeGesture.kind;
};
export const clearMobileGesture = (pointerId: number) => {
if (activeGesture?.pointerId === pointerId) {
activeGesture = null;
}
};

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react';
type PointerLikeEvent = {
pointerId: number;
clientX: number;
clientY: number;
preventDefault?: () => void;
};
type UseWindowPointerDragOptions = {
enabled: boolean;
isActivePointer: (pointerId: number) => boolean;
onMove: (evt: PointerLikeEvent) => void;
onEnd: (pointerId: number) => void;
};
export const useWindowPointerDrag = ({
enabled,
isActivePointer,
onMove,
onEnd,
}: UseWindowPointerDragOptions) => {
useEffect(() => {
if (!enabled) return;
const handlePointerMove = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onMove(evt);
};
const handlePointerEnd = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onEnd(evt.pointerId);
};
window.addEventListener('pointermove', handlePointerMove, true);
window.addEventListener('pointerup', handlePointerEnd, true);
window.addEventListener('pointercancel', handlePointerEnd, true);
return () => {
window.removeEventListener('pointermove', handlePointerMove, true);
window.removeEventListener('pointerup', handlePointerEnd, true);
window.removeEventListener('pointercancel', handlePointerEnd, true);
};
}, [enabled, isActivePointer, onEnd, onMove]);
};

View File

@@ -0,0 +1,316 @@
import { useAtomValue } from 'jotai';
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { allRoomsAtom } from '../state/room-list/roomList';
import { mDirectAtom } from '../state/mDirectList';
import { useDirects, useRooms } from '../state/hooks/roomList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
import { nameInitials } from '../utils/common';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { factoryRoomIdByActivity } from '../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../hooks/useAsyncSearch';
import { VirtualTile } from '../components/virtualizer';
import { AndroidSharePayload } from '../utils/androidShare';
import { stopPropagation } from '../utils/keyboard';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 200,
matchOptions: {
contain: true,
},
};
type ShareRoomPickerProps = {
/** The pending share payload to display a preview of. */
share: AndroidSharePayload;
/** Called when the user picks a room. */
onPick: (roomId: string) => void;
/** Called when the user dismisses without picking. */
onDismiss: () => void;
};
/**
* Full-screen modal that lets the user pick a room to send
* an incoming Android share intent into.
*/
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const allItems = useMemo(
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
[mx, rooms, directs]
);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Header
size="500"
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
style={{ width: '100%' }}
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
})()}
</>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false;
}
if (FN_KEYS_REGEX.test(code)) return false;
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
code.startsWith('Shift') ||
code.startsWith('Alt') ||
code.startsWith('Control') ||
code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' ||
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
) {
return false;
}
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
}
},
[editor]
)
);
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<MobileSwipeToReplyLayer room={room} editor={editor}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
</MobileSwipeToReplyLayer>
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</>
)}
</Page>
);
}

View File

@@ -0,0 +1,41 @@
import { RelationType, Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { getEditedEvent } from '../../utils/room';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export const startReplyToEvent = (
room: Room,
eventId: string,
setReplyDraft: (draft: IReplyDraft | undefined) => void,
editor?: Editor,
startThread = false
): boolean => {
const replyEvt = room.findEventById(eventId);
if (!replyEvt || replyEvt.isRedacted()) return false;
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (!senderId || typeof body !== 'string') return false;
const relation = startThread
? { rel_type: RelationType.Thread, event_id: eventId }
: replyEvt.getWireContent()['m.relates_to'];
setReplyDraft({
userId: senderId,
eventId,
body,
formattedBody,
relation,
});
if (editor) {
setTimeout(() => ReactEditor.focus(editor), 100);
}
return true;
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk'; import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri'; import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
import {
isBackgroundSyncSupported,
getBackgroundSyncStatus,
requestResetPushRegistration,
triggerBackgroundSyncPing,
} from '../../../utils/backgroundSync';
function EmailNotification() { function EmailNotification() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -147,6 +153,7 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />} after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/> />
</SequenceCard> </SequenceCard>
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"
@@ -158,3 +165,126 @@ export function SystemNotification() {
</Box> </Box>
); );
} }
type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
}
: undefined
);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
await refresh();
}, [refresh])
);
const [pingState, ping] = useAsyncCallback(
useCallback(async () => {
await triggerBackgroundSyncPing('manual-test');
}, [])
);
const isBusy =
loading ||
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void reset()}
>
{resetState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Reset</Text>
)}
</Button>
}
/>
<SettingTile
title="Test Notification"
description="Trigger a test push ping to verify the pipeline works."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void ping()}
>
{pingState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Send Test</Text>
)}
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
import { getPluginHost } from '../../../utils/pluginHost';
const PLUGIN_INDEX_URL =
import.meta.env.VITE_PLUGIN_INDEX_URL ||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
import.meta.env.VITE_PLUGIN_BASE_URL ||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
const host = getPluginHost();
if (!host) {
return [];
}
const result = await host.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},
installPlugin: async (plugin) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
},
uninstallPlugin: async (pluginId) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
},
},
});

View File

@@ -0,0 +1,190 @@
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { getPluginHost } from '../../../utils/pluginHost';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
children: React.ReactNode;
}
/**
* Component that loads and initializes enabled plugins.
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
const pluginHost = getPluginHost();
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] plugin host available:', !!pluginHost);
if (!pluginHost) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
return;
}
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
const pluginModule = await loadPluginModule(installedPlugin.id);
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
);
continue;
}
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
);
continue;
}
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
}
};
loadPlugins();
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);
return <>{children}</>;
}
/**
* Reads and evaluates a plugin's JS module from the native filesystem host.
* Supports both CommonJS and ESM-style exports.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
const pluginHost = getPluginHost();
if (!pluginHost) {
throw new Error('Plugins are only supported in the desktop or Android app');
}
const response = await pluginHost.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
const pluginExports: any = {};
const mod = { exports: pluginExports };
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
return mod.exports;
}
/**
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -0,0 +1,159 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenuAnchor(undefined);
};
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
setServerMenuAnchor(target.getBoundingClientRect());
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={!!serverMenuAnchor}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
</PopOut>
)
}
/>
);
}

View File

@@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useAtom, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -28,6 +29,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import { import {
isTauri, isTauri,
isElectron, isElectron,
@@ -36,6 +39,25 @@ import {
setupNotificationTapListener, setupNotificationTapListener,
} from '../../utils/tauri'; } from '../../utils/tauri';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
import {
startBackgroundSync,
stopBackgroundSync,
setAppForegroundState,
} from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix';
import {
AndroidSharePayload,
clearPendingAndroidShare,
getPendingAndroidShare,
isAndroidShareSupported,
listenForAndroidShares,
materializeSharedFile,
} from '../../utils/androidShare';
/** /**
* Applies the selected emoji style font to the document. * Applies the selected emoji style font to the document.
@@ -413,6 +435,36 @@ function MessageNotifications() {
return null; return null;
} }
/**
* Configures native Android push-ping background sync on login, keeps it
* informed of foreground state so it doesn't double-fire notifications,
* and clears it cleanly on unmount (logout).
* Only active on Android Capacitor builds.
*/
function BackgroundSyncSetup() {
const mx = useMatrixClient();
// Try to make a simple fetch to verify component is rendering
try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
} catch {}
useEffect(() => {
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
startBackgroundSync(mx);
const onVisibility = () => setAppForegroundState(!document.hidden);
document.addEventListener('visibilitychange', onVisibility);
setAppForegroundState(!document.hidden);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
stopBackgroundSync();
};
}, [mx]);
return null;
}
/** /**
* Initializes the Paarrot API for Electron integration * Initializes the Paarrot API for Electron integration
* Registers the navigate function and sets up IPC handlers * Registers the navigate function and sets up IPC handlers
@@ -463,6 +515,167 @@ function TaskbarFlashStopper() {
return null; return null;
} }
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const textContent = {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
}
// Upload and send files if present
if (share.files.length > 0) {
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
fileToUpload = encrypted.file;
encInfo = encrypted.encInfo;
}
// Upload file
const uploadResult = await mx.uploadContent(fileToUpload);
const mxc = uploadResult?.content_uri;
if (!mxc) continue;
// Determine message type and send
const fileType = originalFile.type;
let msgtype = 'm.file' as const;
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
}
await clearPendingAndroidShare();
return true;
},
[mx]
);
const handlePickRoom = useCallback(
(roomId: string) => {
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
});
},
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
}, []);
useEffect(() => {
if (!isAndroidShareSupported()) return;
let mounted = true;
let listenerHandle: { remove: () => Promise<void> } | undefined;
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
setPendingShare(share);
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to get pending share:', err);
});
listenForAndroidShares((share) => {
if (mounted) {
setPendingShare(share);
}
})
.then((handle) => {
listenerHandle = handle;
})
.catch((err) => {
console.error('[AndroidShare] Failed to listen for shares:', err);
});
return () => {
mounted = false;
void listenerHandle?.remove();
};
}, []);
if (!pendingShare) return null;
return (
<ShareRoomPicker
share={pendingShare}
onPick={handlePickRoom}
onDismiss={handleDismiss}
/>
);
}
type ClientNonUIFeaturesProps = { type ClientNonUIFeaturesProps = {
children: ReactNode; children: ReactNode;
}; };
@@ -475,8 +688,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<FaviconUpdater /> <FaviconUpdater />
<InviteNotifications /> <InviteNotifications />
<MessageNotifications /> <MessageNotifications />
<BackgroundSyncSetup />
<PaarrotAPIInitializer /> <PaarrotAPIInitializer />
<TaskbarFlashStopper /> <TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children} {children}
</> </>
); );

View File

@@ -0,0 +1,73 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
/** Metadata for a file received from an Android share intent. */
export type AndroidSharedFile = {
path: string;
name: string;
mimeType: string;
size: number;
};
/** Payload persisted by the native Android share handler. */
export type AndroidSharePayload = {
text?: string;
subject?: string;
files: AndroidSharedFile[];
receivedAt: number;
};
interface AndroidShareHandlerPlugin {
/** Returns the last pending share payload, if one exists. */
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
/** Returns true when the Android share bridge is available. */
export const isAndroidShareSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Fetches the current pending native share payload. */
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
if (!isAndroidShareSupported()) return null;
const result = await AndroidShareHandler.getPendingShare();
return result.share;
};
/** Clears the native pending share payload. */
export const clearPendingAndroidShare = async (): Promise<void> => {
if (!isAndroidShareSupported()) return;
await AndroidShareHandler.clearPendingShare();
};
/** Subscribes to new incoming native share payloads. */
export const listenForAndroidShares = async (
listener: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle | undefined> => {
if (!isAndroidShareSupported()) return undefined;
return AndroidShareHandler.addListener('shareReceived', listener);
};
/** Converts a cached native shared file into a browser File for upload. */
export const materializeSharedFile = async (
sharedFile: AndroidSharedFile,
receivedAt: number
): Promise<File> => {
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
}
const blob = await response.blob();
return new File([blob], sharedFile.name, {
type: sharedFile.mimeType || blob.type,
lastModified: receivedAt,
});
};

View File

@@ -0,0 +1,518 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
type UnifiedPushStatus = {
running: boolean;
endpoint: string;
instance: string;
registered: boolean;
distributor: string;
distributors: string[] | string;
};
type UnifiedPushEndpointEvent = {
endpoint: string;
previousEndpoint: string;
instance: string;
};
type UnifiedPushUnregisteredEvent = {
previousEndpoint: string;
instance: string;
};
type UnifiedPushRegistrationFailedEvent = {
reason: string;
instance: string;
};
interface MatrixBackgroundSyncPlugin {
/** Persist credentials and request UnifiedPush registration. */
start(options: {
homeserverUrl: string;
accessToken: string;
userId: string;
deviceId: string;
}): Promise<void>;
/** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise<void>;
/** Re-open distributor setup flow and retry registration. */
requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise<void>;
/**
* Notify the service whether the app UI is visible.
* When foreground is true, the service suppresses native notifications
* because the JS layer handles them via LocalNotifications.
*/
setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
addListener(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushUnregistered',
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushRegistrationFailed',
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
): Promise<PluginListenerHandle>;
}
type StoredPusherState = {
endpoint: string;
appId: string;
};
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
/** Returns true when the current platform is Android Capacitor. */
export const isBackgroundSyncSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
const buildPusherAppId = (deviceId: string | null): string => {
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
return raw.length > 64 ? raw.slice(0, 64) : raw;
};
const loadStoredPusherState = (
userId: string | null,
deviceId: string | null
): StoredPusherState | undefined => {
if (typeof window === 'undefined' || !window.localStorage) return undefined;
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
if (!raw) return undefined;
try {
return JSON.parse(raw) as StoredPusherState;
} catch {
return undefined;
}
};
const saveStoredPusherState = (
userId: string | null,
deviceId: string | null,
state: StoredPusherState
): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
};
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
};
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
const trimmed = raw.trim();
if (!trimmed || trimmed === '[]') return [];
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed
.slice(1, -1)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [trimmed];
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
discoveryUrl.search = '';
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
const body = (await response.json()) as {
gateway?: string;
unifiedpush?: { gateway?: string };
};
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
return discoveryUrl.toString();
}
} catch (err) {
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
}
return DEFAULT_UNIFIED_PUSH_GATEWAY;
};
class AndroidUnifiedPushManager {
private client: MatrixClient | undefined;
private listenerHandles: PluginListenerHandle[] = [];
private listenersReady = false;
private distributorSetupAttempted = false;
private distributorPromptShown = false;
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise<void> {
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
if (!isBackgroundSyncSupported()) {
console.log('[BackgroundSync] Background sync not supported on this platform');
return;
}
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
this.client = mx;
this.distributorSetupAttempted = false;
this.distributorPromptShown = false;
await this.ensureListeners();
try {
console.log('[BackgroundSync] Calling plugin.start()...');
await MatrixBackgroundSync.start({
homeserverUrl,
accessToken,
userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] plugin.start() completed');
} catch (err) {
console.error('[BackgroundSync] plugin.start() failed:', err);
throw err;
}
await this.syncExistingEndpoint();
await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested');
}
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
async stop(): Promise<void> {
if (!isBackgroundSyncSupported()) return;
const client = this.client;
if (client) {
const deviceId = client.getDeviceId();
const userId = client.getUserId();
const status = await this.safeGetStatus();
const stored = loadStoredPusherState(userId, deviceId);
const endpoint = status?.endpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(deviceId);
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(userId, deviceId);
}
await MatrixBackgroundSync.stop();
await this.disposeListeners();
this.client = undefined;
console.log('[BackgroundSync] UnifiedPush stopped');
}
/** Update the Matrix pusher when a new native endpoint is published. */
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
const client = this.client;
if (!client) return;
if (event.previousEndpoint) {
await this.removePusher(client, event.previousEndpoint);
}
await this.upsertPusher(client, event.endpoint);
}
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
const client = this.client;
if (!client) return;
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
const endpoint = event.previousEndpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(client.getUserId(), client.getDeviceId());
}
/** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
return;
}
this.distributorSetupAttempted = true;
void this.tryRequestDistributorSetup();
}
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
private async tryRequestDistributorSetup(): Promise<void> {
try {
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
const status = await this.safeGetStatus();
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
const distributors = normalizeDistributors(status?.distributors ?? []);
if (distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
} catch (err) {
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
}
}
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
private async ensureDistributorPromptFromStatus(): Promise<void> {
const status = await this.safeGetStatus();
if (!status) return;
const distributors = normalizeDistributors(status.distributors);
if (!status.registered && distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
}
/** Show a one-time actionable prompt when no distributor app is installed. */
private showNoDistributorPrompt(): void {
if (this.distributorPromptShown) return;
this.distributorPromptShown = true;
const message =
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
const docsUrl = 'https://unifiedpush.org/users/distributors/';
if (typeof window === 'undefined') return;
try {
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
if (openDocs) {
window.open(docsUrl, '_blank', 'noopener,noreferrer');
}
} catch {
window.alert(message);
}
}
/** Install plugin listeners once for the active Matrix client. */
private async ensureListeners(): Promise<void> {
if (this.listenersReady) return;
this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
void this.handleNewEndpoint(event).catch((err) => {
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
});
}),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event);
}),
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
this.handleRegistrationFailed(event);
}),
];
this.listenersReady = true;
}
/** Remove all plugin listeners when the manager stops. */
private async disposeListeners(): Promise<void> {
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
this.listenerHandles = [];
this.listenersReady = false;
}
/** Reconcile an already-persisted native endpoint after app startup. */
private async syncExistingEndpoint(): Promise<void> {
const client = this.client;
if (!client) return;
const status = await this.safeGetStatus();
console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint);
}
}
/** Create or refresh the Matrix HTTP pusher for the current device. */
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
const deviceId = mx.getDeviceId();
const userId = mx.getUserId();
const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
console.log('[BackgroundSync] Upserting Matrix pusher', {
appId,
endpoint,
gatewayUrl,
deviceId,
userId,
});
try {
await mx.setPusher({
kind: 'http',
app_id: appId,
pushkey: endpoint,
app_display_name: 'Paarrot',
device_display_name: deviceId ?? 'Android',
lang: 'en',
data: {
url: gatewayUrl,
format: 'event_id_only',
},
append: false,
device_id: deviceId ?? undefined,
} as unknown as IPusherRequest);
const pushers = (await mx.getPushers())?.pushers ?? [];
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
totalPushers: pushers.length,
foundThisPusher: Boolean(thisPusher),
});
} catch (err) {
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
throw err;
}
saveStoredPusherState(userId, deviceId, { endpoint, appId });
}
/** Remove a previously-registered Matrix HTTP pusher for this device. */
private async removePusher(
mx: MatrixClient,
endpoint: string,
appId = buildPusherAppId(mx.getDeviceId())
): Promise<void> {
try {
await mx.setPusher({
pushkey: endpoint,
app_id: appId,
kind: null,
} as unknown as IPusherRequest);
} catch (err) {
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
}
}
/** Read native registration state without failing the caller. */
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
return undefined;
}
}
}
const unifiedPushManager = new AndroidUnifiedPushManager();
/** Start native UnifiedPush registration and sync the Matrix pusher. */
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
await unifiedPushManager.start(mx);
};
/**
* Manually triggers a one-shot native fetch.
* Useful for diagnostics and bridge testing.
*/
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.triggerPing({ reason });
} catch (err) {
console.warn('[BackgroundSync] triggerPing failed:', err);
}
};
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
export const stopBackgroundSync = async (): Promise<void> => {
await unifiedPushManager.stop();
};
/**
* Tells the native service whether the app UI is in the foreground.
* Call when document visibility changes so the service can suppress
* duplicate notifications while the JS layer is active.
* @param foreground true if the WebView UI is currently visible
*/
export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.setAppForeground({ foreground });
} catch (err) {
console.warn('[BackgroundSync] setAppForeground failed:', err);
}
};
/**
* Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable.
*/
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) {
console.warn('[BackgroundSync] Background sync not supported on this platform');
return { success: false };
}
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };
}
};
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] getStatus failed:', err);
return undefined;
}
};

View File

@@ -0,0 +1,72 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
export type PluginHostListItem = {
id: string;
name: string;
version?: string;
description?: string;
author?: string;
repository?: string;
thumbnail?: string;
homepage?: string;
tags?: string[];
installedDate: string;
path: string;
};
export type PluginHostResult<T> = {
success: boolean;
data?: T;
error?: string;
};
export interface PluginHostAPI {
getPath: () => Promise<PluginHostResult<string>>;
download: (
pluginId: string,
downloadUrl: string,
name: string
) => Promise<PluginHostResult<{ path: string }>>;
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
}
interface PluginStoragePlugin {
getPath(): Promise<PluginHostResult<string>>;
download(options: {
pluginId: string;
downloadUrl: string;
name: string;
}): Promise<PluginHostResult<{ path: string }>>;
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
}
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
const androidPluginHost: PluginHostAPI = {
getPath: () => PluginStorage.getPath(),
download: (pluginId, downloadUrl, name) =>
PluginStorage.download({ pluginId, downloadUrl, name }),
list: () => PluginStorage.list(),
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
};
/** Returns true when a native plugin filesystem host is available. */
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
/** Returns the active plugin host for Electron or Android. */
export const getPluginHost = (): PluginHostAPI | null => {
if (window.electron?.plugins) {
return window.electron.plugins;
}
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
return androidPluginHost;
}
return null;
};

View File

@@ -248,6 +248,24 @@ export const isAndroid = (): boolean => {
return ua.includes('android'); return ua.includes('android');
}; };
/** Returns true when running as a Capacitor app on Android. */
export const isCapacitorAndroid = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
};
/**
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/** /**
* Apply safe area insets for mobile devices * Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding * On Android, CSS env() may not work, so we apply fallback padding

18
overlay/src/font-setup.ts Normal file
View File

@@ -0,0 +1,18 @@
const cap =
typeof window !== 'undefined'
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
: undefined;
export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
);
if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty(
'--font-secondary',
'system-ui, Roboto, "Noto Sans", sans-serif'
);
} else {
await import('@fontsource-variable/inter');
}

506
overlay/src/index.css Normal file
View File

@@ -0,0 +1,506 @@
/* Twemoji font (Twitter emoji) */
@font-face {
font-family: Twemoji;
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
@font-face {
font-family: AppleColorEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Noto Color Emoji');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* System emoji - uses native OS emoji */
@font-face {
font-family: SystemEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Android Emoji'),
local('EmojiOne Color');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
:root {
--tc-link: hsl(213deg 100% 45%);
/* user mxid colors */
--mx-uc-1: hsl(208, 100%, 45%);
--mx-uc-2: hsl(302, 100%, 30%);
--mx-uc-3: hsl(163, 100%, 30%);
--mx-uc-4: hsl(343, 100%, 45%);
--mx-uc-5: hsl(24, 100%, 45%);
--mx-uc-6: hsl(181, 100%, 30%);
--mx-uc-7: hsl(242, 100%, 45%);
--mx-uc-8: hsl(94, 100%, 35%);
--font-emoji: 'AppleColorEmoji';
--font-secondary: 'InterVariable', sans-serif;
}
.dark-theme,
.butter-theme,
.discord-theme,
.discord-darker-theme,
.twilight-theme,
.mocha-theme {
--tc-link: hsl(213deg 100% 80%);
--mx-uc-1: hsl(208, 100%, 75%);
--mx-uc-2: hsl(301, 100%, 80%);
--mx-uc-3: hsl(163, 100%, 70%);
--mx-uc-4: hsl(343, 100%, 75%);
--mx-uc-5: hsl(24, 100%, 70%);
--mx-uc-6: hsl(181, 100%, 60%);
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 100%, 80%);
--font-secondary: 'InterVariable', sans-serif;
}
html {
height: 100%;
overflow: hidden;
/* Enable safe area variables */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
padding: 0;
/* Safe area insets for mobile devices (notch, navigation bar) */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #262626;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
}
/* Theme-specific body backgrounds for safe area padding */
body.light-theme {
background-color: #F0F0F0;
}
body.silver-theme {
background-color: #DEDEDE;
}
body.dark-theme {
background-color: #262626;
}
body.butter-theme {
background-color: #1A1916;
}
body.discord-theme {
background-color: #2B2D31;
}
body.discord-darker-theme {
background-color: #1E1F22;
}
body.twilight-theme {
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
}
body.mocha-theme {
background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%);
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #262626;
}
.twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.mocha-theme #root {
background: linear-gradient(180deg, rgba(36, 32, 25, 0.5) 0%, rgba(45, 39, 33, 0.3) 100%);
}
/* Twilight theme gradient overlays */
.twilight-theme [role="navigation"],
.twilight-theme aside {
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
backdrop-filter: blur(8px);
}
.twilight-theme main {
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.twilight-theme header {
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
}
/* CSS animations for route transitions */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Apply transition to route changes - works on ALL themes */
[data-route-transition="true"] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Twilight theme enhanced transitions */
.twilight-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Also apply to main content areas for double coverage */
.twilight-theme [data-room-view],
.twilight-theme [data-space-view],
.twilight-theme [data-inbox-view],
.twilight-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.twilight-theme button,
.twilight-theme [role="button"],
.twilight-theme a,
.twilight-theme input,
.twilight-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.twilight-theme button:hover,
.twilight-theme [role="button"]:hover {
transform: translateY(-1px);
}
.twilight-theme button:active,
.twilight-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.twilight-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.twilight-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.twilight-theme [data-selected="true"],
.twilight-theme [aria-selected="true"],
.twilight-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.twilight-theme [data-selected="true"]::before,
.twilight-theme [aria-selected="true"]::before,
.twilight-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
/* Mocha theme gradient overlays */
.mocha-theme [role="navigation"],
.mocha-theme aside {
background: linear-gradient(180deg, rgba(26, 22, 20, 0.8) 0%, rgba(36, 32, 25, 0.9) 100%);
backdrop-filter: blur(8px);
}
.mocha-theme main {
background: linear-gradient(135deg, rgba(36, 32, 25, 0.4) 0%, rgba(45, 39, 33, 0.3) 100%);
}
.mocha-theme header {
background: linear-gradient(90deg, rgba(26, 22, 20, 0.9) 0%, rgba(36, 32, 25, 0.8) 100%);
}
/* Mocha theme enhanced transitions */
.mocha-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mocha-theme [data-room-view],
.mocha-theme [data-space-view],
.mocha-theme [data-inbox-view],
.mocha-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.mocha-theme button,
.mocha-theme [role="button"],
.mocha-theme a,
.mocha-theme input,
.mocha-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.mocha-theme button:hover,
.mocha-theme [role="button"]:hover {
transform: translateY(-1px);
}
.mocha-theme button:active,
.mocha-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.mocha-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.mocha-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(84, 73, 63, 0.15) 0%, rgba(112, 97, 81, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.mocha-theme [data-selected="true"],
.mocha-theme [aria-selected="true"],
.mocha-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.mocha-theme [data-selected="true"]::before,
.mocha-theme [aria-selected="true"]::before,
.mocha-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #D4A574 0%, #B48660 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
/* Subtle dot pattern overlay */
.mocha-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.mocha-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.mocha-theme input:focus,
.mocha-theme textarea:focus,
.mocha-theme button:focus-visible {
outline: 2px solid rgba(212, 165, 116, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
/* Subtle dot pattern overlay */
.twilight-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.twilight-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.twilight-theme input:focus,
.twilight-theme textarea:focus,
.twilight-theme button:focus-visible {
outline: 2px solid rgba(139, 127, 255, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--tc-link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
[data-mx-spoiler][aria-pressed='true'] a {
color: transparent;
pointer-events: none;
}
b {
font-weight: 500;
}
label {
margin: 0;
padding: 0;
}
button,
textarea {
margin: 0;
padding: 0;
background-color: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
border: none;
}
button {
max-width: 100%;
text-transform: none;
text-align: inherit;
overflow: visible;
-webkit-appearance: button;
}
textarea,
input,
input[type],
input[type='text'],
input[type='username'],
input[type='password'],
input[type='email'],
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
color: inherit;
word-spacing: inherit;
}
audio:not([controls]) {
display: none !important;
}
/* Android WebView: Inter Variable + emoji fonts in the text stack break space metrics */
html.android-capacitor,
html.android-capacitor.dark-theme,
html.android-capacitor.butter-theme,
html.android-capacitor.discord-theme,
html.android-capacitor.discord-darker-theme,
html.android-capacitor.twilight-theme,
html.android-capacitor.mocha-theme,
html.android-capacitor.light-theme,
html.android-capacitor.silver-theme {
--font-secondary: system-ui, Roboto, 'Noto Sans', sans-serif;
}
html.android-capacitor body {
font-family: system-ui, Roboto, 'Noto Sans', sans-serif;
letter-spacing: normal;
word-spacing: normal;
font-variant-ligatures: normal;
}

112
overlay/src/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
/* eslint-disable import/first */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
await import('./font-setup');
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
enableMapSet();
import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
// Apply safe area insets for mobile devices
applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'token' && event.data?.responseKey) {
const getCurrentAccessToken = async () => {
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
return getToken();
};
getCurrentAccessToken().then((token) => {
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
});
}
});
}
const isElectron = (): boolean => 'electron' in window;
async function checkForUpdates() {
if (isElectron()) {
console.log('Update check skipped - Electron handles updates natively');
return;
}
if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri');
return;
}
console.log('Checking for updates...');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { ask } = await import('@tauri-apps/plugin-dialog');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
console.log('Update check result:', update);
if (update) {
console.log(`Update available: ${update.version}`);
const shouldUpdate = await ask(
`A new version (${update.version}) is available. Would you like to update now?`,
{ title: 'Update Available', kind: 'info' }
);
if (shouldUpdate) {
console.log('User chose to update, downloading...');
await update.downloadAndInstall();
await relaunch();
}
} else {
console.log('App is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();
setTimeout(() => {
checkForUpdates();
}, 3000);

1063
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,19 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.77", "version": "4.11.115",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"scripts": { "scripts": {
"android:prepare": "node scripts/apply-overlay.mjs && cd cinny && npm install && npm run build && npx cap sync android", "apply-overlay": "node scripts/apply-overlay.mjs",
"android:patch-settings:linux": "sed -i 's|../node_modules/|../cinny/node_modules/|g' android/capacitor.settings.gradle", "android:prepare": "cd cinny && npm run build && cd .. && npx cap sync android",
"android:restore": "node scripts/restore-cinny.mjs", "android:open": "npx cap open android",
"android:open": "cd cinny && npx cap open android", "android:run": "npx cap run android",
"android:run": "cd cinny && npx cap run android", "android:apk": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat assembleDebug",
"android:apk": "npm run android:prepare && cd cinny/android && gradlew.bat assembleDebug && cd ../.. && node scripts/restore-cinny.mjs", "android:apk:release": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat assembleRelease",
"android:apk:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew assembleDebug && cd ../.. && node scripts/restore-cinny.mjs", "android:apk:linux": "cd cinny && npm run build && cd .. && npx cap sync android && cd android && ./gradlew assembleDebug",
"android:apk:release": "npm run android:prepare && cd cinny/android && gradlew.bat assembleRelease && cd ../.. && node scripts/restore-cinny.mjs", "android:aab": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat bundleRelease"
"android:apk:release:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew assembleRelease && cd ../.. && node scripts/restore-cinny.mjs",
"android:aab": "npm run android:prepare && cd cinny/android && gradlew.bat bundleRelease && cd ../.. && node scripts/restore-cinny.mjs",
"android:aab:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew bundleRelease && cd ../.. && node scripts/restore-cinny.mjs"
}, },
"keywords": [], "keywords": [],
"author": { "author": {
@@ -24,6 +21,12 @@
"email": "mates.media@users.noreply.github.com" "email": "mates.media@users.noreply.github.com"
}, },
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
},
"devDependencies": { "devDependencies": {
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"png2icons": "2.0.1", "png2icons": "2.0.1",

View File

@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' }); const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
if (cinnyStatus.trim()) { if (cinnyStatus.trim()) {
console.log('[apply-overlay] Stashing pre-existing cinny changes...'); console.log('[apply-overlay] Stashing pre-existing cinny changes...');
// Ensure android/ junction is excluded from the cinny git view — otherwise
// `git stash -u` would traverse the junction into the root android/ directory
// and delete files there when the stash is created.
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
if (!excludeContent.includes('android/')) {
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
}
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' }); execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
writeFileSync(STASH_FLAG, '1', 'utf8'); writeFileSync(STASH_FLAG, '1', 'utf8');
console.log('[apply-overlay] Stash saved.'); console.log('[apply-overlay] Stash saved.');
@@ -120,7 +128,21 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`); console.log(` android/ link already exists, skipping`);
} }
// 7. Write android/local.properties with the current machine's Android SDK path // 8. Sync time-based version into android/app/build.gradle
console.log('[apply-overlay] Syncing time-based version into build.gradle...');
const buildTimestampMs = Date.now();
const versionCode = Math.floor(buildTimestampMs / 1000);
const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
let buildGradle = readFileSync(buildGradlePath, 'utf8');
buildGradle = buildGradle
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
.replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
writeFileSync(buildGradlePath, buildGradle, 'utf8');
console.log(` versionName "${versionName}", versionCode ${versionCode}`);
// 9. Write android/local.properties with the current machine's Android SDK path
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
if (androidHome) { if (androidHome) {
console.log('[apply-overlay] Writing android/local.properties...'); console.log('[apply-overlay] Writing android/local.properties...');