303 Commits

Author SHA1 Message Date
45a460a11f chore(all): Use moxxy_native from hosted Pub repo 2023-09-08 15:43:34 +02:00
7655befcc3 chore(docs): Update build instructions 2023-09-08 15:38:03 +02:00
c96880d437 fix(android): (Hopefully) fix build issues
Okay, so it turns out we cannot just have a plugin right
next to the actual app because then Flutter won't generate
both the GeneratedPluginRegistrant and the correct links to
the plugins. As such, we have to move the plugin code out of
Moxxy into the new moxxy_native library.

However, we need access to the activity to receive the notification
tap events (at least on Android). To fix this, we have a quirk that
checks if we have a notification event before finishing the handling
of the PreStartDoneEvent.
2023-09-08 15:33:39 +02:00
21c8079f49 release: Release v0.5.0 2023-09-07 17:05:16 +02:00
175b2d5f2a Merge pull request 'Translations update from Weblate' (#333) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/333
2023-09-07 12:21:17 +00:00
Codeberg Translate
38ef7efac9 Translated using Weblate (Dutch)
Currently translated at 100.0% (309 of 309 strings)

Update translation files

Updated by "Squash Git commits" hook in Weblate.

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
Translation: Moxxy/Fastlane Metadata
Translation: Moxxy/Moxxy
2023-09-07 12:19:17 +00:00
Codeberg Translate
aca226b764 Translated using Weblate (Galician)
Currently translated at 100.0% (309 of 309 strings)

Co-authored-by: ghose <correo@xmgz.eu>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/gl/
Translation: Moxxy/Moxxy
2023-09-07 12:19:17 +00:00
2c5fbd8d5d Merge pull request 'Use Android 13's photo picker' (#331) from feat/photo-picker into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/331
2023-09-07 12:19:13 +00:00
17295320c3 fix(android): Make notification tapping work again 2023-09-06 21:17:18 +02:00
37390eea97 chore(all): Remove file_picker 2023-09-06 18:11:45 +02:00
ab71754faf chore(docs): Mention the new photo picker 2023-09-06 18:10:51 +02:00
17396c3db2 chore(android): Lint using ktlint 2023-09-06 18:01:28 +02:00
742f8b6d8d chore(ui): Fix linter issues 2023-09-06 18:00:34 +02:00
161dd5cca8 fix(ui,service): Fix exception when exiting conversation 2023-09-06 17:58:57 +02:00
4dfacc759b chore(android): Refactor the plugin code a bit 2023-09-06 17:55:57 +02:00
da57b113f8 feat(android,ui): Use Android 13's new photo picker 2023-09-06 17:51:53 +02:00
4a1d30e8c4 fix(android): Add missing drawables 2023-09-06 13:14:57 +02:00
4e4a5c1b52 Merge pull request 'Translations update from Weblate' (#327) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/327
2023-09-05 14:07:06 +00:00
4e097d8286 chore(docs): Mention pigeon 2023-09-05 11:58:09 +02:00
bfe2a0f58a feat(service): Notify on blocking failure
Fixes #189.
2023-09-04 15:41:25 +02:00
Codeberg Translate
13ece41a8f Translated using Weblate (Russian)
Currently translated at 90.8% (277 of 305 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: evmexa-prog <vladvle@yandex.ru>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ru/
Translation: Moxxy/Moxxy
2023-09-04 12:59:16 +00:00
Codeberg Translate
f35cb04359 Translated using Weblate (Galician)
Currently translated at 100.0% (305 of 305 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: ghose <correo@xmgz.eu>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/gl/
Translation: Moxxy/Moxxy
2023-09-04 12:59:16 +00:00
Codeberg Translate
08d11f3e9f Translated using Weblate (Polish)
Currently translated at 6.8% (21 of 305 strings)

Added translation using Weblate (Polish)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/pl/
Translation: Moxxy/Moxxy
2023-09-04 12:59:16 +00:00
63253e9cae Merge pull request 'Thumbnail generation on download and Android 13 fixes' (#329) from feat/thumbnails into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/329
2023-09-04 12:59:11 +00:00
26dafb4e9e chore(docs): Update the changelogs more 2023-09-03 22:12:17 +02:00
111c66aa6a chore(docs): Update changelog 2023-09-03 22:07:54 +02:00
5b03fc9b47 fix(service): Various fixes
- Fix a fourth notificatio channel appearing
- Pass a locale to the service on startup to prevent misnamed
  notification channels
2023-09-03 22:03:13 +02:00
672ae736d3 chore(docs): Mention ktlint for Android code 2023-09-03 16:40:03 +02:00
e37db3d00c fix(service): Fix typo in JID migration 2023-09-03 16:34:35 +02:00
919ed6f0a1 chore(android): Format with ktlint 2023-09-03 14:52:21 +02:00
79867e4eaa chore(all): Update moxplatform 2023-09-03 13:51:59 +02:00
99600bafb0 fix(ui,service): Merge ConversationExited with sending a gone 2023-09-03 00:03:23 +02:00
59aad79aa0 feat(service): Show video thumbnails in the notification 2023-09-02 20:53:49 +02:00
6fc4672a6e fix(service,ui): Fix notifications not updating 2023-09-02 20:17:04 +02:00
478c639ae7 feat(android): Refactor a little bit
Also, add VIBRATE permission.
2023-09-02 13:06:46 +02:00
7f2c978736 fix(service): Bring back notification tap events 2023-09-02 13:03:24 +02:00
f472239102 fix(ui): Replace context.read with GetIt 2023-08-30 21:00:27 +02:00
f2844122c0 chore(ui,service): Rename getVideoThumbnailPath 2023-08-30 20:59:14 +02:00
7781b12dac fix(service): Ensure that the correct locale is used 2023-08-30 20:21:16 +02:00
1e795b8b10 fix(service): Translate the foreground service info 2023-08-30 20:19:32 +02:00
7d0896d84f fix(service): Fix message channel name 2023-08-30 16:05:40 +02:00
fc0aade0ae fix(all): Remove Awesome Notifications 2023-08-30 16:04:39 +02:00
d969622675 feat(service): Replace the notification channels to apply new options 2023-08-30 15:51:51 +02:00
23839b6ec6 fix(service,ui): Fix notification issues 2023-08-26 23:28:49 +02:00
0b120c1e9c fix(service): Fix a fresh start on Android 13 2023-08-26 22:57:21 +02:00
e84d8f9455 feat(service): Store the occupant id with the messages
Linked issue: #315
2023-08-22 21:52:38 +02:00
c4973910f4 feat(all): Remove omemo_dart override 2023-08-22 20:25:30 +02:00
4ed57bc1d4 chore(ui): Move sendfiles.dart 2023-08-17 18:07:06 +02:00
e8125b661b Merge pull request 'Display the avatar and title when sharing files' (#325) from feat/sendfiles-avatar-title into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/325
2023-08-17 16:00:33 +00:00
c6b7bba5ee Merge branch 'master' into feat/sendfiles-avatar-title 2023-08-17 18:00:16 +02:00
cf5ee6c703 Merge pull request 'Translations update from Weblate' (#326) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/326
2023-08-17 15:59:59 +00:00
63038b77bc fix(ui): Correctly handle the self-chat's "avatar" 2023-08-17 17:58:53 +02:00
7be67a9551 fix(ui): Also use the contact title in the share selection 2023-08-17 17:47:14 +02:00
0dc44572b1 fix(service): Optionally send the roster item's contact title 2023-08-17 17:42:51 +02:00
1de6bab86e feat(ui): Improve contrast with conversation indicator 2023-08-17 16:32:22 +02:00
0852e0efbf feat(ui,service): Allow usage of the conversation indicator in all cases 2023-08-17 15:05:55 +02:00
Codeberg Translate
8fe11f30f2 Translated using Weblate (French)
Currently translated at 100.0% (305 of 305 strings)

Co-authored-by: lejun <lejun@gmx.fr>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/fr/
Translation: Moxxy/Moxxy
2023-08-16 10:53:05 +00:00
8b0a863949 feat(ui): Display recipient(s) and avatar 2023-08-15 21:49:43 +02:00
bde689b1b3 fix(ui): Fix overflows of images in the sendfiles page
Fixes #210.
2023-08-15 12:57:01 +02:00
2ff2402078 feat(ui): Make the server info page prettier 2023-08-14 21:27:38 +02:00
cb47fa210a Merge pull request 'Translations update from Weblate' (#320) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/320
2023-08-14 17:08:25 +00:00
Codeberg Translate
599e56875d Update translation files
Updated by "Squash Git commits" hook in Weblate.

Translation: Moxxy/Fastlane Metadata
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/
2023-08-14 17:07:56 +00:00
Codeberg Translate
74569ccfd6 Translated using Weblate (French)
Currently translated at 28.5% (2 of 7 strings)

Translated using Weblate (French)

Currently translated at 24.2% (74 of 305 strings)

Added translation using Weblate (French)

Translated using Weblate (Dutch)

Currently translated at 100.0% (305 of 305 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Co-authored-by: doverdufflebag <alunyamoder@proton.me>
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/fr/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/fr/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
Translation: Moxxy/Fastlane Metadata
Translation: Moxxy/Moxxy
2023-08-14 17:07:56 +00:00
a009a68941 fix(ui): Fix issue with the self-avatar in the reaction list
Fixes #319.
2023-08-14 19:07:33 +02:00
37455c54e6 chore(service): Fix formatting 2023-08-14 18:54:50 +02:00
16dded8dee Merge pull request 'Make everything account JID dependent' (#318) from fix/jid-attribute into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/318
2023-08-14 16:51:53 +00:00
fa92ba1974 fix(service): Fix id of reaction messages 2023-08-14 18:51:36 +02:00
3011af4f0e fix(service): Fix linter issue 2023-08-14 14:53:29 +02:00
8a98815fd7 feat(service): Adjust the database creation 2023-08-14 14:48:06 +02:00
018f40d6db fix(service): Fix SQL syntax error 2023-08-13 12:07:27 +02:00
1d7e55c086 fix(service): Match by sid instead of origin id 2023-08-12 22:45:25 +02:00
d57e133d0d feat(service): Migrate GroupchatDetails 2023-08-12 21:00:01 +02:00
191996c5d4 fix(service): Fix minor bugs 2023-08-12 20:42:06 +02:00
bcef2dd818 fix(service): Fix foreign key issues 2023-08-12 19:08:38 +02:00
0358bf15a0 chore(service): Fix formatting 2023-08-12 18:59:00 +02:00
301ff664a8 fix(ui,shared,service): Use a UUID as a unique message key 2023-08-12 18:58:24 +02:00
865846af9a fix(service): Fix reactions 2023-08-12 16:48:34 +02:00
96b2c5f3c4 fix(service,shared): Fix more migration issues 2023-08-12 16:12:16 +02:00
db5e4e3c1f feat(service): Do not migrate data if we have no active JID 2023-08-12 15:56:55 +02:00
e3cd4aa3dd fix(service): Fix database migration 2023-08-12 15:51:45 +02:00
fbca84ae2f feat(service): Dismiss notifications on logout 2023-08-12 15:30:40 +02:00
6034ce5ba4 feat(ui,shared,service): Fix formatting and linter issues 2023-08-12 15:25:33 +02:00
2e71ffebd4 feat(service): Migrate the new notifications system 2023-08-12 13:43:38 +02:00
3ca7d63e8c fix(service): Fix some merge issues 2023-08-12 13:34:50 +02:00
ff2ca7397f Merge branch 'master' into fix/jid-attribute 2023-08-12 13:27:55 +02:00
843a1171b7 Merge pull request 'Translations update from Weblate' (#316) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/316
2023-08-12 11:05:50 +00:00
Codeberg Translate
f0057e5487 Translated using Weblate (Galician)
Currently translated at 100.0% (301 of 301 strings)

Translated using Weblate (Japanese)

Currently translated at 32.5% (98 of 301 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (301 of 301 strings)

Translated using Weblate (Japanese)

Currently translated at 28.3% (84 of 296 strings)

Translated using Weblate (Japanese)

Currently translated at 18.5% (55 of 296 strings)

Translated using Weblate (Russian)

Currently translated at 94.5% (280 of 296 strings)

Translated using Weblate (Galician)

Currently translated at 100.0% (296 of 296 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (296 of 296 strings)

Added translation using Weblate (Galician)

Co-authored-by: 0eoc <0eoc@users.noreply.translate.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Co-authored-by: ghose <correo@xmgz.eu>
Co-authored-by: psw1747 <informatiekantoor@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/gl/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ja/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ru/
Translation: Moxxy/Moxxy
2023-08-12 11:05:15 +00:00
d3914e3269 Merge pull request 'Deprecate Extensible File Thumbnails' (#317) from feat/remove-eft into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/317
2023-08-12 11:05:12 +00:00
431641e248 Merge branch 'master' into feat/remove-eft 2023-08-12 13:01:57 +02:00
e259b3ee63 Merge pull request 'Unencrypted files are now a warning, not an error' (#314) from fix/unencrypted-error into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/314
2023-08-12 11:00:06 +00:00
e34bbde27e fix(shared,service): Fix linter issues 2023-08-12 12:55:36 +02:00
f84593ec19 Merge branch 'master' into fix/unencrypted-error 2023-08-12 12:51:35 +02:00
49425440a5 Merge pull request 'Rework notifications' (#312) from rework/notifications into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/312
2023-08-12 10:49:37 +00:00
ee9332f6f2 fix(service): Adjust to groupchat changes 2023-08-11 23:21:44 +02:00
d06de37924 Merge branch 'master' into rework/notifications 2023-08-11 22:54:01 +02:00
0872b2a134 chore(service): Fix service port 2023-08-11 20:55:23 +02:00
95a6a458db chore(ui): Port the UI 2023-08-11 20:47:20 +02:00
112048df36 chore(service): Port everything (besides notifications) in the service 2023-08-11 18:15:18 +02:00
24a26fe454 chore(docs): Update and fix DOAP 2023-08-10 14:34:14 +02:00
fe846468ee Merge pull request 'Add groupchat support for Moxxy' (#300) from ikjot-2605/moxxy:feat/groupchat into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/300
2023-08-09 23:30:39 +00:00
be299ac90f feat(service): More refactoring 2023-08-10 01:28:15 +02:00
Ikjot Singh Dhody
549e61a168 feat(all): Fix linter issues.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-09 22:34:00 +05:30
f7e7c17598 chore(shared): Clean-up the message model 2023-08-09 14:19:03 +02:00
ikjot-2605
26bcaccd81 Merge branch 'master' into feat/groupchat 2023-08-08 15:39:43 +00:00
Ikjot Singh Dhody
df5810a347 feat(all): Formatting change, lock file update
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-08 21:09:00 +05:30
071ea6db5d feat(service): Write (untested) migration to the new format 2023-08-08 15:43:51 +02:00
01f46d4cdc feat(service): Replace EFT with JingleContentThumbnail
Just how Cheogram used to make them :)

Fixes #243.
2023-08-06 13:28:01 +02:00
2baf852a9a fix(service): Request batter-saving excemption only when optimised 2023-08-05 15:25:28 +02:00
7f5d8c353a feat(ui,service): Plumb the service into the requests dialog 2023-08-05 01:17:23 +02:00
f3a5683e13 feat(ui): Allow excluding Moxxy from battery optimisation 2023-08-05 00:14:12 +02:00
4323561774 chore(ui): Split the request BLoC into three files 2023-08-04 23:32:38 +02:00
6225ac65d9 feat(ui): Implement a better solution to handling permission requests 2023-08-04 23:26:29 +02:00
178648a5ee chore(all): Bump moxplatform 2023-08-04 16:38:03 +02:00
Ikjot Singh Dhody
ef931c566f feat(all): Send messages/chat state from Moxxy to MUC.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-03 23:04:13 +05:30
78dbf5473e feat(service): Migrate messages with the incorrect error 2023-08-03 16:18:58 +02:00
edcb95ac6f fix(service): Unencrypted files are now a warning, not an error
Fixes #214.
2023-08-03 16:11:08 +02:00
2fc23876e6 Merge branch 'master' into rework/notifications 2023-08-02 19:34:16 +00:00
293908c40c fix(service): Fix crash when the message contains no file
Fix extracted from #312.
2023-08-02 21:28:15 +02:00
Ikjot Singh Dhody
a3d4883406 feat(all): Remove unnecessary buttons/options for MUC.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-03 00:22:59 +05:30
Ikjot Singh Dhody
532f0b1bb2 feat(all): Formatting fix, and navigation fix.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-03 00:04:33 +05:30
Ikjot Singh Dhody
a98fe0d9f3 feat(all): Minor fixes - strings, formatting.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-02 23:42:36 +05:30
7919d11b00 feat(service): Implement a service for permission handling 2023-08-01 23:39:41 +02:00
ac5a2c9dd2 feat(service,ui): Request notification permission
This is a preparation for changes in Android 13.
2023-08-01 19:11:58 +02:00
Ikjot Singh Dhody
a7c3bd507f Merge branch 'master' of https://codeberg.org/moxxy/moxxy into feat/groupchat
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-01 22:30:01 +05:30
06d132b0b0 chore(service): Minor clean-up 2023-08-01 14:28:18 +02:00
837787fdaf fix(service): Reuse logic from the conversation model 2023-08-01 12:56:46 +02:00
9545b3d9cc chore(service,shared): Inline getStickerPackPath 2023-07-31 22:43:58 +02:00
Ikjot Singh Dhody
fba2cf86ae feat(all): Minor formatting changes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-08-01 00:16:38 +05:30
d17a656d64 fix(service): Use the actual data directory instead of path_providers
This fixes an issue with creating a notifiction because we cannot expose
path_provider's data directory from the native FileProvider.
2023-07-31 20:46:26 +02:00
a5082762b5 fix(service): Fix crash when updating a notification 2023-07-31 18:17:30 +02:00
4160143f6e fix(ui): Fix mark as read not dismissing overlay
Fixes #313.
2023-07-31 17:50:02 +02:00
9fd6f847fe feat(service): React to locale changes 2023-07-31 17:26:08 +02:00
f65f8ec541 feat(service): Tell the notifications plugin about our own avatar 2023-07-30 22:33:37 +02:00
Ikjot Singh Dhody
af481bf465 feat(all): remove unnecessary comparator override.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-31 01:48:48 +05:30
Ikjot Singh Dhody
e6ae8182c2 feat(all): Organize import in main.dart.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-31 01:47:16 +05:30
31d0c9c9ec feat(service): Implement the reply button 2023-07-30 22:05:16 +02:00
de3bf12c18 feat(service): Re-implement (and fix) mark as read 2023-07-30 20:40:40 +02:00
9d2f6b29f7 fix(service): Null-pointer dereference 2023-07-29 22:34:37 +02:00
ee764d2213 feat(service): Migrate to Moxplatform's notifications 2023-07-29 22:23:31 +02:00
Ikjot Singh Dhody
de7b9adfa6 feat(all): Fix user flow for joining MUC.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-27 01:07:13 +05:30
8c53055341 fix(service): Remove database caching as it's probably broken
Fixes #299.
2023-07-26 12:33:46 +02:00
f0225eed4c feat(service): Show a notification when a message is bounced
Fixes #212. At the moment, this only happens with

- remote-server-not-found
- remote-server-timeout
- service-unavailable
2023-07-26 12:26:48 +02:00
b57bae878f fix(ui): Pop the system stack if we cannot pop the Flutter navigator
Fixes #305.
2023-07-25 22:48:42 +02:00
1a83ff37ff feat(all): Prepare for a correctly signed APK
Fixes #304.
2023-07-25 22:09:13 +02:00
e205785246 Merge pull request 'Translations update from Weblate' (#306) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/306
2023-07-25 12:36:18 +00:00
3edda978fb Merge pull request 'Implement share shortcuts' (#302) from feat/direct-share into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/302
2023-07-25 12:35:00 +00:00
Codeberg Translate
ebabb0e445 Translated using Weblate (Russian)
Currently translated at 86.7% (255 of 294 strings)

Co-authored-by: 0eoc <0que@proton.me>
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ru/
Translation: Moxxy/Moxxy
2023-07-24 18:38:06 +00:00
Ikjot Singh Dhody
e4f98bb82f feat(all): Add label to nick text field.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-24 23:46:11 +05:30
Ikjot Singh Dhody
56d6f97168 feat(all): Minor changes, fixes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-24 23:37:45 +05:30
Ikjot Singh Dhody
b0067f055f feat(all): Move away from exception type design.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-22 14:26:26 +05:30
117d263e25 fix(service): Fix shared-to JIDs being the conversation title 2023-07-21 22:26:36 +02:00
63e66e5dce fix(service): Use a fallback icon when no avatar is available
This commit also fixes the Android SDK situation via the flake.
2023-07-21 19:24:10 +02:00
140a16ec0c fix(ui,service): Allow sharing to the self-chat
This also fixes some issues with chat that don't
have a profile picture.
2023-07-18 13:32:35 +02:00
44b95bbb5b feat(ui): Open the chat when sharing text via a direct share 2023-07-18 13:04:21 +02:00
4eeaa8c37b feat(ui): Handle direct shares 2023-07-17 22:27:11 +02:00
c95f2efd65 Merge branch 'master' into feat/direct-share 2023-07-17 17:17:58 +00:00
9dbf4b5467 feat(service): Implement share shortcuts 2023-07-17 19:16:12 +02:00
Ikjot Singh Dhody
bd094dfc9a feat(all): Remove unnecessary function.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 21:42:50 +05:30
Ikjot Singh Dhody
7e9d7d6281 feat(all): Check if Groupchat exists before method.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 21:41:29 +05:30
Ikjot Singh Dhody
2cf781459d feat(all): Debug mode and translatable string
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 20:28:05 +05:30
Ikjot Singh Dhody
4ff9e3c81e feat(all): Remove title from GroupchatDetails.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 20:15:03 +05:30
Ikjot Singh Dhody
e337f1c579 feat(all): Minor refactors - naming.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 20:04:36 +05:30
Ikjot Singh Dhody
7c840334e1 feat(all): Add docs to groupchat service methods.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-17 19:47:28 +05:30
0a120f1073 Merge pull request 'Translations update from Weblate' (#301) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/301
2023-07-16 16:11:02 +00:00
Codeberg Translate
269738e618 Translated using Weblate (Dutch)
Currently translated at 100.0% (294 of 294 strings)

Translated using Weblate (German)

Currently translated at 28.5% (2 of 7 strings)

Translated using Weblate (German)

Currently translated at 100.0% (293 of 293 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Co-authored-by: nautilusx <translate@disroot.org>
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/de/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/de/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
Translation: Moxxy/Fastlane Metadata
Translation: Moxxy/Moxxy
2023-07-15 20:38:05 +00:00
Ikjot Singh Dhody
06eab1d6f5 feat(all): Make ConversationType an enhanced enum.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-14 14:55:55 +05:30
Ikjot Singh Dhody
008e816d70 feat(all): Rename JoinGroupchatResultEvent.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-14 14:30:01 +05:30
Ikjot Singh Dhody
2bbbc164b5 feat(all): Remove incorrect translations.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-14 14:26:51 +05:30
Ikjot Singh Dhody
11f4fd9932 feat(all): Add title to GroupchatDetails.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-12 12:09:12 +05:30
Ikjot Singh Dhody
a1451c6fbf feat(all): Refactor groupchat to new object in db.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-12 11:48:16 +05:30
1e94910ebd Merge pull request 'Paginate sticker pack access' (#298) from feat/sticker-pagination into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/298
2023-07-11 11:20:21 +00:00
Ikjot Singh Dhody
993da40297 feat(all): Complete join groupchat flow.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-11 02:15:31 +05:30
Ikjot Singh Dhody
09684b1268 feat(all): Fix fromDatabaseJson for MUC details.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-11 02:00:12 +05:30
Ikjot Singh Dhody
0abb89cf38 feat(all): Fix database issues with nick.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-11 01:48:35 +05:30
63d251a7f1 feat(service): Remove page caches for sticker packs 2023-07-10 20:20:30 +02:00
799af75bcc feat(ui,service): Only request stickers when required 2023-07-10 18:47:17 +02:00
8966c490fe fix(ui): Fix TODOs 2023-07-10 18:35:59 +02:00
4c09dbab60 fix(ui): Fix overflow in the sticker pack view 2023-07-10 16:37:31 +02:00
6fd5c33b0a fix(ui,service): Fix various issues 2023-07-10 16:35:13 +02:00
Ikjot Singh Dhody
7880f51b76 feat(all): Add db support for GroupchatDetails.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-10 01:32:17 +05:30
30e8a885bb fix(ui): Fix not disposing the sticker pack controller 2023-07-09 21:27:39 +02:00
42c695a2a1 fix(ui,service): Re-implement fetching local/remote sticker packs 2023-07-09 21:12:58 +02:00
Ikjot Singh Dhody
f0a79ca0e0 feat(all): Add GroupchatDetails model.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-09 21:51:36 +05:30
3ef2f3b8d6 feat(ui,service): Remove sticker packs list from StickersBloc 2023-07-09 17:15:38 +02:00
ae995b8670 feat(ui,service): Paginate the sticker picker 2023-07-09 16:49:30 +02:00
75c2f103bd feat(service): Try to not JUST ignore duplicate hash pointers 2023-07-09 14:22:41 +02:00
bc7958559a Merge pull request 'Translations update from Weblate' (#296) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/296
2023-07-09 10:40:59 +00:00
Codeberg Translate
11228a0de0 Translated using Weblate (German)
Currently translated at 100.0% (293 of 293 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: PapaTutuWawa <papatutuwawa@polynom.me>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/nl/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/de/
Translation: Moxxy/Fastlane Metadata
Translation: Moxxy/Moxxy
2023-07-09 10:40:24 +00:00
b6fe5cc29b fix(ui): Fix altIcon scaling 2023-07-09 12:32:55 +02:00
cb34b51149 fix(ui): Fix self-chat's avatar causing issues
Fixes #297.
2023-07-09 12:26:33 +02:00
6c9421a21a fix(docs): Fix typo 2023-07-09 12:15:28 +02:00
684a3a658d chore(docs): Add a note on translations 2023-07-09 12:14:08 +02:00
0ab7cccef6 Merge pull request 'Implement storage management' (#295) from feat/storage-management into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/295
2023-07-09 10:07:01 +00:00
c405717bcc feat(ui): Handle removing a sticker pack 2023-07-08 23:19:21 +02:00
7dcd14ef3a fix(service): Download a file if the file metadata has no path 2023-07-08 21:20:21 +02:00
4f033438ed feat(ui): Make the bar chart look prettier 2023-07-08 21:12:07 +02:00
21e40c38ca fix(ui): Only show the image if we're done loading 2023-07-08 21:05:08 +02:00
53e3b3c561 feat(ui): Prepare for selectively removing media files 2023-07-08 20:57:02 +02:00
d6416c77b8 fix(service): Fix crash when re-downloading a file 2023-07-08 20:36:30 +02:00
9ade143352 fix(ui): Make the "media bar" not red 2023-07-08 19:24:44 +02:00
dc05876ac7 feat(ui): Show a placeholder, when no shared media are available 2023-07-08 19:22:23 +02:00
d691d63353 fix(i18n): Make the sticker pack size string translatable 2023-07-08 19:09:26 +02:00
b666a06252 feat(ui): Reduce rounding of shared media items 2023-07-08 19:07:16 +02:00
27b209b4d8 fix(ui): Make storage strings translatable 2023-07-08 19:01:14 +02:00
d0cdb2cebe fix(service): Ensure that stickers always have a size != null 2023-07-08 17:28:46 +02:00
2123f5b51f feat(ui): Add a link to the stickers menu 2023-07-08 17:18:41 +02:00
93511da3f1 feat(ui): Refactor the bar chart into its own widget 2023-07-08 17:12:08 +02:00
267eef2a55 feat(ui,service): Show sticker packs' storage overhead 2023-07-08 15:01:45 +02:00
8f69b9ff3d fix(service): Exclude stickers from shared media 2023-07-08 14:35:15 +02:00
0d182cc4e5 feat(ui,service): Implement a bar chart showing storage usage 2023-07-08 14:31:26 +02:00
Ikjot Singh Dhody
06dcd5491b feat(all): Basic groupchat error handling set up.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-08 14:33:20 +05:30
Ikjot Singh Dhody
3641be4f56 feat(all): Join room functionality complete.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-08 00:43:01 +05:30
Ikjot Singh Dhody
18e28c3bbf feat(all): Groupchat service/bloc updated.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-08 00:11:21 +05:30
Ikjot Singh Dhody
62e39bf066 feat(all): Set base for groupchat implementation.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-07-07 12:52:48 +05:30
f6bfbff62c feat(ui): Add a parameter to showConfirmationDialog 2023-07-06 22:08:19 +02:00
dd0cb88841 feat(ui): Add a confirmation dialog 2023-07-06 22:04:27 +02:00
23ed1f6b1a feat(ui): Apply i18n to the deletion dialog 2023-07-06 21:58:59 +02:00
d12154b4ba fix(service): Fix a potential SQL injection 2023-07-06 21:52:22 +02:00
b625ee5c4e feat(service): Actually remove the files 2023-07-06 20:57:45 +02:00
4e48962fae feat(service): Write a query to find media files to delete 2023-07-06 12:48:31 +02:00
21832042df feat(ui): Make the storage page a bit prettier 2023-07-05 17:53:26 +02:00
d48c8371e4 feat(ui): Make the shared media list a bit prettier 2023-07-05 17:35:32 +02:00
67f6fb8236 feat(ui,service): Implement viewing all shared media files 2023-07-05 16:54:34 +02:00
369cc3e823 feat(ui,service): Implement showing how much storage we use 2023-07-05 14:19:56 +02:00
4857245a96 Merge remote-tracking branch 'weblate/master' 2023-07-04 18:03:30 +02:00
Vistaus
2f39d4b60b Translated using Weblate (Dutch)
Currently translated at 100.0% (7 of 7 strings)

Translation: Moxxy/Fastlane Metadata
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/nl/
2023-07-04 16:01:55 +00:00
Vistaus
0528aca3ad Translated using Weblate (Dutch)
Currently translated at 100.0% (273 of 273 strings)

Translation: Moxxy/Moxxy
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
2023-07-04 16:01:54 +00:00
d99e5d8801 Translated using Weblate (Dutch)
Currently translated at 100.0% (273 of 273 strings)

Translation: Moxxy/Moxxy
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
2023-07-04 16:01:54 +00:00
5cda06db24 fix(ui): Add missing languages to the locale menu 2023-07-04 18:01:51 +02:00
c93c3140cf chore(docs): Update CHANGELOG 2023-07-04 17:41:22 +02:00
f17bba7282 feat(ui): Parse legacy quotes
Fixes #226.
2023-07-04 17:32:03 +02:00
fe3dbd265e Merge pull request 'Implement sending of read receipts' (#293) from feat/read-markers into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/293
2023-07-04 12:53:07 +00:00
e6eee134bd fix(ui): Fix avatar for self-chat 2023-07-04 14:51:53 +02:00
1f5c75a647 chore(docs): Update CHANGELOG 2023-07-04 14:09:38 +02:00
d70527d65f feat(service): Move marking as read into MessageService 2023-07-04 14:06:55 +02:00
efb3f4b371 fix(ui): The message should be *just* visible 2023-07-04 13:47:41 +02:00
71f5e8f0b4 feat(ui,service): Implement dynamically sending read markers 2023-07-04 13:44:50 +02:00
a13bdd2e2b fix(i18n): Fix build issues with incomplete translations
Fallback to en as a locale, if a key does not exist. Also
fixes some whackiness from doing changes and not reflecting them back
to Weblate.
2023-07-03 22:34:54 +02:00
7fbc1ba812 fix(ui): Fix long-press requiring precise pointer placement 2023-07-03 16:59:08 +02:00
7f864f1d25 chore(docs): Mention new translations 2023-07-03 15:50:12 +02:00
ejix
ef3c11e870 Translated using Weblate (Russian)
Currently translated at 83.7% (227 of 271 strings)

Translation: Moxxy/Moxxy
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ru/
2023-07-03 14:51:12 +02:00
Vistaus
c20bc964c3 Translated using Weblate (Dutch)
Currently translated at 100.0% (271 of 271 strings)

Translation: Moxxy/Moxxy
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
2023-07-03 14:51:12 +02:00
dd2629d073 Merge pull request 'Translations update from Weblate' (#290) from translate/moxxy:weblate-moxxy-moxxy into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/290
2023-07-03 12:45:35 +00:00
e5fa199925 fix(i18n): Make hard-coded string translatable 2023-07-03 14:43:15 +02:00
675a647a8d feat(ui): Show a toast when copying a message 2023-07-03 14:40:57 +02:00
f845c4134c fix(ui): Focus the textfield when editing a message 2023-07-03 14:36:18 +02:00
6442e9cab5 fix(ui): Make message corrections work again
Fixes #291.
2023-07-02 12:43:18 +02:00
0629a3d5bd fix(ui): Dismiss the keyboard on message selection 2023-07-01 22:03:25 +02:00
62d18588d7 chore(docs): Add the OMEMO fixes in the changelog 2023-07-01 22:00:28 +02:00
Codeberg Translate
4ee191e238 Translated using Weblate (Russian)
Currently translated at 57.3% (153 of 267 strings)

Added translation using Weblate (Russian)

Translated using Weblate (Dutch)

Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Dutch)

Currently translated at 50.0% (3 of 6 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (267 of 267 strings)

Added translation using Weblate (Dutch)

Translated using Weblate (Japanese)

Currently translated at 19.8% (53 of 267 strings)

Added translation using Weblate (Japanese)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Vistaus <vistausss@fastmail.com>
Co-authored-by: ejix <ejix@zp1.net>
Co-authored-by: mmbd <stefan@kikeriki.at>
Translate-URL: https://translate.codeberg.org/projects/moxxy/fastlane-metadata/nl/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ja/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/nl/
Translate-URL: https://translate.codeberg.org/projects/moxxy/moxxy/ru/
Translation: Moxxy/Fastlane Metadata
Translation: Moxxy/Moxxy
2023-07-01 19:51:23 +00:00
351de5ee93 feat(ui): Allow sending corrections for older messages 2023-07-01 21:47:36 +02:00
dae8ddb3b5 feat(shared): Migrate ConversationType to a JsonConverter 2023-07-01 21:38:19 +02:00
631a62e8ce feat(shared): Make more use of JsonConverters 2023-07-01 21:22:39 +02:00
59877a3e60 chore(all): Bump moxxmpp
Should fix errors when "fetching" avatars for JIDs where the
DiscoManager throws a more general StanzaError instead of a
DiscoError.
2023-07-01 13:21:15 +02:00
08da843d50 feat(ui,service): Merge adding a contact and "joining" groupchats
Even though groupchats are not implemented yet. This also handles
starting chats with JIDs, where the server cannot be reached/times out.
2023-07-01 13:11:56 +02:00
949781003a feat(docs): Add a link to the Weblate instance 2023-06-25 23:43:58 +02:00
4338c7a777 feat(ui,service): Refresh avatars using events 2023-06-24 21:09:48 +02:00
86de5cd22d feat(i18n): Rename English translation to support Weblate 2023-06-21 22:41:50 +02:00
bf754a4e51 feat(docs): Update DOAP 2023-06-21 20:37:44 +02:00
8913977c0a Merge pull request 'OMEMO Rework' (#286) from omemo-rework into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/286
2023-06-21 17:48:01 +00:00
f4be336e39 fix(service): Actual messages should come after the pseudo messages 2023-06-20 23:08:46 +02:00
836fe1bf87 fix(service): Fix message loading and receiving 2023-06-20 22:52:28 +02:00
7bca95203e feat(service,ui): Create pseudo messages for new/replaced ratchets 2023-06-20 17:19:46 +02:00
059a22cbe8 feat(service): Be smarter about creating pseudo messages 2023-06-20 16:42:15 +02:00
2740692772 feat(service): "Create" pseudo messages 2023-06-20 00:00:45 +02:00
c0008fece9 feat(ui,service): Minor changes
- Adjust UI functions to reflect that the ratchets are only with
  hasSessioWith = true
- Update moxxmpp to fix stream management reconnection loops
2023-06-19 23:21:21 +02:00
d7bb54a088 fix(service): Fix loading IK public keys as the wrong type 2023-06-19 14:35:42 +02:00
eb41b3f0f7 chore(all): Update moxxmpp and omemo_dart 2023-06-18 22:00:17 +02:00
e3cbc47cc8 feat(service): Create the new OMEMO tables at creation time 2023-06-18 20:19:17 +02:00
75767d26b4 chore(service,ui): Format 2023-06-18 20:14:03 +02:00
a01667a8f7 feat(service): Cleanup 2023-06-18 20:13:02 +02:00
e4dec4168c feat(service): Implement all old functionality 2023-06-18 20:07:50 +02:00
59f1a3a289 feat(service): Reimplement more old functionality 2023-06-18 19:56:19 +02:00
9c8aec6543 feat(service): MAKE OMEMO WORK FIRST TRY 2023-06-18 15:58:10 +02:00
7c8a368d73 chore(ui,service): Migrate to moxlib 0.2.0 2023-06-18 12:28:22 +02:00
0bda382e40 feat(service): Stub out the persistence layer 2023-06-18 00:22:52 +02:00
330b4dd69f feat(service): Adjust to (some) moxxmpp changes
This commit is broken.
2023-06-17 23:59:50 +02:00
7a7e43eb3c fix(service): Fix database creation 2023-06-13 17:40:53 +02:00
5e797d6b54 fix(service): Do not use contact avatar path when contact has no avatar 2023-06-11 22:58:58 +02:00
1b3dd0634b fix(service): Disabling contact integration removes pseudo roster items 2023-06-11 22:53:32 +02:00
b1bdacb834 feat(service): Implement FAST
Fixes #175 (with the previous commit).
2023-06-11 22:26:17 +02:00
a4b9485019 feat(service): Implement SASL2 with Bind2 2023-06-11 22:09:30 +02:00
20489fbb25 Merge pull request 'Improve avatar and presence handling' (#285) from feat/better-avatar-handling into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/285
2023-06-10 22:20:23 +00:00
a2fa000a31 feat(all): Improve presence subscription handling 2023-06-10 21:10:21 +02:00
343f0e7dae fix(service): Add missing OOB fallback 2023-06-09 00:11:27 +02:00
f0f13097c0 feat(all): Use CachedXMPPAvatar also for our own avatar 2023-06-08 23:53:37 +02:00
0025e83be5 fix(docs): Remove XEP-0054 from DOAP 2023-06-08 21:26:57 +02:00
ffb8e9f3fc feat(service): Hopefully improve roster handling 2023-06-08 21:21:24 +02:00
8081931c55 feat(service): Remove VCardManager 2023-06-08 18:06:06 +02:00
792276d06a fix(service): Get missing metadata 2023-06-07 23:47:21 +02:00
58edc256fd chore(all): Update moxxmpp to 0.4.0 2023-06-07 20:59:25 +02:00
f30d04a593 feat(service): Current state 2023-06-07 19:02:13 +02:00
cc42f32b21 feat(all): Move avatars to CachingXMPPAvatar 2023-05-29 18:49:15 +02:00
353623c5ae chore(all): Add a script to check formatting 2023-05-29 15:48:53 +02:00
a09c30a076 release: Hotfix 0.4.3 2023-05-29 15:41:59 +02:00
3bfd72fde1 fix(ui): Fix crash with plaintext messages
Fixes #284.
2023-05-29 15:23:27 +02:00
39e6b4a48b feat(all): Remove Flutter fork 2023-05-29 15:21:13 +02:00
32b2e35d42 feat(docs): Add fastlane changelog for 0.4.2 2023-05-26 21:34:48 +02:00
8e1bcbfd1d fix(all): Actually, build with debug info 2023-05-26 21:31:09 +02:00
336a6d56cd release: Release 0.4.2 2023-05-26 21:29:05 +02:00
a283454cae feat(ui): Move interactions with the FilePicker to a safe wrapper
Handle getting the storage permission ourselves. That way we can show
a toast, if we did not get the permission. Also fixes an observed
situation where FilePicker would crash in its native code due to not
having the storage permission.

Fixes #283.
2023-05-26 21:15:35 +02:00
8b16a8a37b chore(ui): Fix formatting error 2023-05-26 20:47:22 +02:00
727a1a3423 fix(ui,service): Fix self-chat files showing a spinner
Fixes #282.
Fixes #281.
2023-05-26 20:47:11 +02:00
207 changed files with 13333 additions and 5122 deletions

View File

@@ -7,7 +7,7 @@ line-length=72
[title-trailing-punctuation]
[title-hard-tab]
[title-match-regex]
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake)+(,(service|ui|shared|all|tests|i18n|docs|flake))*\)|release): [A-Z0-9].*$
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos)+(,(service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos))*\)|release): [A-Z0-9].*$
[body-trailing-whitespace]

View File

@@ -3,6 +3,11 @@
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
on the Moxxy codebase.
## Non-Code Contributions
### Translations
You can contribute to Moxxy by translating parts of Moxxy into a language you can speak. To do that, head over to [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/), where you can start translating.
## Prerequisites
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
@@ -30,7 +35,8 @@ repository, use `git clone --recursive https://codeberg.org/moxxy/moxxy.git`
In order to build Moxxy, you first have to run the code generator. To do that, first install all dependencies with
`flutter pub get`. Next, run the code generator using `flutter pub run build_runner build`. This builds required
data classes and the i18n support.
data classes and the i18n support. Next, run `dart run pigeon --input pigeon/quirks.dart` to generate the communication
channels with the native code.
Finally, you can build Moxxy using `flutter run`, if you want to test a change, or `flutter build apk --release` to build
an optimized release build. The release builds found in the repository's releases are build using `flutter build apk --release --split-per-abi`.
@@ -51,7 +57,28 @@ Before creating a pull request, please make sure you checked every item on the f
If you think that your code is ready for a pull request, but you are not sure if it is ready, prefix the PR's title with "WIP: ", so that discussion
can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
### Android
In case you modified the Android-native code, please also make sure that you checked every item on the following checklist:
- [ ] I checked that [ktlint](https://github.com/pinterest/ktlint) is not showing any linting issues (`ktlint android/app/src/main/kotlin/org/moxxy/moxxyv2/ '!android/app/src/main/kotlin/org/moxxy/moxxyv2/quirks'`)
### Tips
#### `data_classes.yaml`
When you add, remove, or modify data classes in `data_classes.yaml`, you need to rebuild the classes using `flutter pub run build_runner build`. However, there appears
to be a bug in my own build runner script, which prevents the data classes from being
rebuilt if they are changed. To fix this, remove the generated data classes by running
`rm lib/shared/*.moxxy.dart`, after which build_runner will rebuild the data classes.
### Code Guidelines
#### Translations
If your code adds new strings that should be translated, only add them to the base
language, which is English. Even if you know more than English, do not add the keys
to other language files. To prevent merge conflicts between Weblate and the repository,
all other languages are managed via [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
#### Commit messages
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file

View File

@@ -2,7 +2,7 @@
An experimental XMPP client that tries to be as easy, modern and beautiful as possible.
The code is also available on [codeberg](https://codeberg.org/moxxy/moxxy).
The code is also available on [Codeberg](https://codeberg.org/moxxy/moxxy).
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/org.moxxy.moxxyv2)
@@ -19,6 +19,12 @@ For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CON
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
### Translating
If you want to contribute by translating Moxxy, you can do that on [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
[![Translation status](https://translate.codeberg.org/widgets/moxxy/-/moxxy/multi-auto.svg)](https://translate.codeberg.org/engage/moxxy/)
## A Bit of History
This project is the successor of moxxyv1, which was written in *React Native* and abandoned

View File

@@ -14,3 +14,4 @@ analyzer:
- "**/*.freezed.dart"
- "**/*.moxxy.dart"
- "lib/i18n/*.dart"
- "pigeon/quirks.dart"

View File

@@ -26,12 +26,11 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
//compileSdkVersion flutter.compileSdkVersion
compileSdkVersion 33
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
@@ -45,22 +44,22 @@ android {
defaultConfig {
applicationId "org.moxxy.moxxyv2"
// TODO: Remove once https://github.com/fluttercommunity/flutter_launcher_icons/pull/313 is merged
minSdkVersion 23
targetSdkVersion 31
//minSdkVersion flutter.minSdkVersion
//targetSdkVersion flutter.targetSdkVersion
minSdkVersion 26
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
// Externally signed using a security key
signingConfig null
}
}
dependencies {
compileOnly rootProject.findProject(":moxxy_native")
}
}
flutter {
@@ -69,4 +68,5 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.activity:activity-ktx:1.7.2"
}

View File

@@ -1,33 +1,35 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moxxy.moxxyv2">
<application
android:label="Moxxy"
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="Moxxy">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Allow receiving share intents for all kinds of things -->
<!-- Allow receiving share intents for all kinds of things -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -38,21 +40,34 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Enable usage of direct share -->
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_targets" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</manifest>

View File

@@ -1,6 +1,57 @@
package org.moxxy.moxxyv2
import android.content.Intent
import android.os.Bundle
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import org.moxxy.moxxyv2.quirks.MoxxyQuirkApi
import org.moxxy.moxxyv2.quirks.QuirkNotificationEvent
import org.moxxy.moxxyv2.quirks.QuirkNotificationEventType
class MainActivity: FlutterActivity() {
class MainActivity : FlutterActivity(), MoxxyQuirkApi {
private var lastEvent: QuirkNotificationEvent? = null
private fun handleIntent(intent: Intent?): Boolean {
if (intent == null) return false
when (intent.action) {
org.moxxy.moxxy_native.TAP_ACTION -> {
Log.d("Moxxy", "Handling tap data")
lastEvent = QuirkNotificationEvent(
intent.getLongExtra(org.moxxy.moxxy_native.NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_KEY)!!,
QuirkNotificationEventType.OPEN,
null,
org.moxxy.moxxy_native.notifications.extractPayloadMapFromIntent(intent),
)
return true
}
else -> {
Log.d("Moxxy", "Unknown intent action: ${intent.action}")
return false
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
MoxxyQuirkApi.setUp(flutterEngine.dartExecutor.binaryMessenger, this)
super.configureFlutterEngine(flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun earlyNotificationEventQuirk(): QuirkNotificationEvent? {
val event = lastEvent
lastEvent = null
return event
}
}

View File

@@ -0,0 +1,150 @@
// Autogenerated from Pigeon (v11.0.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
package org.moxxy.moxxyv2.quirks
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
private fun wrapError(exception: Throwable): List<Any?> {
if (exception is FlutterError) {
return listOf(
exception.code,
exception.message,
exception.details
)
} else {
return listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class QuirkNotificationEventType(val raw: Int) {
MARKASREAD(0),
REPLY(1),
OPEN(2);
companion object {
fun ofRaw(raw: Int): QuirkNotificationEventType? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class QuirkNotificationEvent (
/** The notification id. */
val id: Long,
/** The JID the notification was for. */
val jid: String,
/** The type of event. */
val type: QuirkNotificationEventType,
/**
* An optional payload.
* - type == NotificationType.reply: The reply message text.
* Otherwise: undefined.
*/
val payload: String? = null,
/** Extra data. Only set when type == NotificationType.reply. */
val extra: Map<String?, String?>? = null
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): QuirkNotificationEvent {
val id = list[0].let { if (it is Int) it.toLong() else it as Long }
val jid = list[1] as String
val type = QuirkNotificationEventType.ofRaw(list[2] as Int)!!
val payload = list[3] as String?
val extra = list[4] as Map<String?, String?>?
return QuirkNotificationEvent(id, jid, type, payload, extra)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
id,
jid,
type.raw,
payload,
extra,
)
}
}
@Suppress("UNCHECKED_CAST")
private object MoxxyQuirkApiCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
128.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
QuirkNotificationEvent.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is QuirkNotificationEvent -> {
stream.write(128)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface MoxxyQuirkApi {
fun earlyNotificationEventQuirk(): QuirkNotificationEvent?
companion object {
/** The codec used by MoxxyQuirkApi. */
val codec: MessageCodec<Any?> by lazy {
MoxxyQuirkApiCodec
}
/** Sets up an instance of `MoxxyQuirkApi` to handle messages through the `binaryMessenger`. */
@Suppress("UNCHECKED_CAST")
fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyQuirkApi?) {
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyQuirkApi.earlyNotificationEventQuirk", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
var wrapped: List<Any?>
try {
wrapped = listOf<Any?>(api.earlyNotificationEventQuirk())
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="org.moxxy.moxxyv2.MainActivity">
<data android:mimeType="*/*" />
<category android:name="org.moxxy.moxxyv2.dynamic_share_target" />
</share-target>
</shortcuts>

View File

@@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -1,354 +0,0 @@
{
"@@name": "English",
"global": {
"title": "Moxxy",
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
"dialogAccept": "Okay",
"dialogCancel": "Cancel",
"yes": "Yes",
"no": "No"
},
"notifications": {
"permanent": {
"idle": "Idle",
"ready": "Ready to receive messages",
"connecting": "Connecting...",
"disconnect": "Disconnected",
"error": "Error"
},
"message": {
"reply": "Reply",
"markAsRead": "Mark as read"
},
"channels": {
"messagesChannelName": "Messages",
"messagesChannelDescription": "The notification channel for received messages",
"warningChannelName": "Warnings",
"warningChannelDescription": "Warnings related to Moxxy"
},
"titles": {
"error": "Error"
}
},
"dateTime": {
"justNow": "Just now",
"nMinutesAgo": "${min}min ago",
"mondayAbbrev": "Mon",
"tuesdayAbbrev": "Tue",
"wednessdayAbbrev": "Wed",
"thursdayAbbrev": "Thu",
"fridayAbbrev": "Fri",
"saturdayAbbrev": "Sat",
"sundayAbbrev": "Sun",
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December",
"today": "Today",
"yesterday": "Yesterday"
},
"messages": {
"image": "Image",
"video": "Video",
"audio": "Audio",
"file": "File",
"sticker": "Sticker",
"retracted": "The message has been retracted",
"retractedFallback": "A previous message has been retracted but your client does not support it",
"you": "You"
},
"errors": {
"omemo": {
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
"notEncryptedForDevice": "This message was not encrypted for this device",
"invalidHmac": "Could not decrypt message",
"noDecryptionKey": "No decryption key available",
"messageInvalidAfixElement": "Invalid encrypted message",
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
"verificationWrongJid": "Wrong XMPP-address",
"verificationWrongDevice": "Wrong OMEMO:2 device",
"verificationNotInList": "Wrong OMEMO:2 device",
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
},
"connection": {
"connectionTimeout": "Could not connect to server",
"saslAccountDisabled": "Your account is disabled",
"saslInvalidCredentials": "Your account credentials are invalid",
"unrecoverable": "Connection lost due to unrecoverable error"
},
"login": {
"saslFailed": "Invalid login credentials",
"startTlsFailed": "Failed to establish a secure connection",
"noConnection": "Failed to establish a connection",
"unspecified": "Unspecified error"
},
"message": {
"unspecified": "Unknown error",
"fileUploadFailed": "The file upload failed",
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
"fileDownloadFailed": "The file download failed",
"serviceUnavailable": "The message could not be delivered to the contact",
"remoteServerTimeout": "The message could not be delivered to the contact's server",
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
"failedToEncrypt": "The message could not be encrypted",
"failedToEncryptFile": "The file could not be encrypted",
"failedToDecryptFile": "The file could not be decrypted",
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
},
"conversation": {
"audioRecordingError": "Failed to finalize audio recording",
"openFileNoAppError": "No app found to open this file",
"openFileGenericError": "Failed to open file",
"messageErrorDialogTitle": "Error"
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Could not verify file integrity"
},
"conversation": {
"holdForLonger": "Hold button longer to record a voice message"
}
},
"pages": {
"intro": {
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
"loginButton": "Login",
"registerButton": "Register"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Address",
"password": "Password",
"advancedOptions": "Advanced options",
"createAccount": "Create account on server"
},
"conversations": {
"speeddialNewChat": "New chat",
"speeddialJoinGroupchat": "Join groupchat",
"speeddialAddNoteToSelf": "Note to self",
"overlaySettings": "Settings",
"noOpenChats": "You have no open chats",
"startChat": "Start a chat",
"closeChat": "Close chat",
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
"markAsRead": "Mark as read"
},
"conversation": {
"unencrypted": "Unencrypted",
"encrypted": "Encrypted",
"closeChat": "Close chat",
"closeChatConfirmTitle": "Close chat",
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
"blockShort": "Block",
"blockUser": "Block user",
"online": "Online",
"retract": "Retract message",
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
"forward": "Forward",
"edit": "Edit",
"quote": "Quote",
"copy": "Copy content",
"addReaction": "Add reaction",
"showError": "Show error",
"showWarning": "Show warning",
"addToContacts": "Add to contacts",
"addToContactsTitle": "Add ${jid} to contacts",
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
"stickerSettings": "Sticker settings",
"newDeviceMessage": "${title} added a new encryption device",
"messageHint": "Send a message...",
"sendImages": "Send images",
"sendFiles": "Send files",
"takePhotos": "Take photos"
},
"addcontact": {
"title": "Add new contact",
"xmppAddress": "XMPP-Address",
"subtitle": "You can add a contact either by typing in their XMPP address or by scanning their QR code",
"buttonAddToContact": "Add to contacts"
},
"newconversation": {
"title": "Start new chat",
"addContact": "Add contact",
"createGroupchat": "Create groupchat"
},
"crop": {
"setProfilePicture": "Set as profile picture"
},
"shareselection": {
"shareWith": "Share with...",
"confirmTitle": "Send file",
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
},
"profile": {
"general": {
"omemo": "Security",
"profile": "Profile",
"media": "Media"
},
"conversation": {
"notifications": "Notifications",
"notificationsMuted": "Muted",
"notificationsEnabled": "Enabled",
"sharedMedia": "Media"
},
"owndevices": {
"title": "Own Devices",
"thisDevice": "This device",
"otherDevices": "Other devices",
"deleteDeviceConfirmTitle": "Delete device",
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
"recreateOwnSessions": "Rebuild sessions",
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
"recreateOwnDevice": "Recreate device",
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
},
"devices": {
"title": "Security",
"recreateSessions": "Rebuild sessions",
"recreateSessionsConfirmTitle": "Rebuild sessions?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
}
},
"blocklist": {
"title": "Blocklist",
"noUsersBlocked": "You have no users blocked",
"unblockAll": "Unblock all",
"unblockAllConfirmTitle": "Are you sure?",
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
"unblockJidConfirmTitle": "Unblock ${jid}?",
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
},
"cropbackground": {
"blur": "Blur background",
"setAsBackground": "Set as background image"
},
"stickerPack": {
"removeConfirmTitle": "Remove sticker pack",
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
"installConfirmTitle": "Install sticker pack",
"installConfirmBody": "Are you sure you want to install this sticker pack?",
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
"fetchingFailure": "Could not find the sticker pack"
},
"settings": {
"settings": {
"title": "Settings",
"conversationsSection": "Conversations",
"accountSection": "Account",
"signOut": "Sign out",
"signOutConfirmTitle": "Sign Out",
"signOutConfirmBody": "You are about to sign out. Proceed?",
"miscellaneousSection": "Miscellaneous",
"debuggingSection": "Debugging",
"general": "General"
},
"about": {
"title": "About",
"licensed": "Licensed under GPL3",
"version": "Version ${version}",
"viewSourceCode": "View source code",
"nMoreToGo": "${n} more to go...",
"debugMenuShown": "You are now a developer!",
"debugMenuAlreadyShown": "You are already a developer!"
},
"appearance": {
"title": "Appearance",
"languageSection": "Language",
"language": "App language",
"languageSubtext": "Currently selected: $selectedLanguage",
"systemLanguage": "Default language"
},
"licenses": {
"title": "Open-Source Licenses",
"licensedUnder": "Licensed under $license"
},
"conversation": {
"title": "Chat",
"appearance": "Appearance",
"selectBackgroundImage": "Select background image",
"selectBackgroundImageDescription": "This image will be the background of all your chats",
"removeBackgroundImage": "Remove background image",
"removeBackgroundImageConfirmTitle": "Remove background image",
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
"newChatsSection": "New Conversations",
"newChatsMuteByDefault": "Mute new chats by default",
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
"behaviourSection": "Behaviour",
"contactsIntegration": "Contacts integration",
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
},
"debugging": {
"title": "Debugging options",
"generalSection": "General",
"generalEnableDebugging": "Enable debugging",
"generalEncryptionPassword": "Encryption password",
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
"generalLoggingIp": "Logging IP",
"generalLoggingIpSubtext": "The IP the logs should be sent to",
"generalLoggingPort": "Logging Port",
"generalLoggingPortSubtext": "The IP the logs should be sent to"
},
"network": {
"title": "Network",
"automaticDownloadsSection": "Automatic Downloads",
"automaticDownloadsText": "Moxxy will automatically download files on...",
"automaticDownloadsMaximumSize": "Maximum Download Size",
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
"automaticDownloadAlways": "Always",
"wifi": "Wifi",
"mobileData": "Mobile data"
},
"privacy": {
"title": "Privacy",
"generalSection": "General",
"showContactRequests": "Show contact requests",
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
"profilePictureVisibility": "Make profile picture public",
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
"conversationsSection": "Conversation",
"sendChatMarkers": "Send chat markers",
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
"sendChatStates": "Send chat states",
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
"redirectsSection": "Redirects",
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
"currentlySelected": "Currently selected: $proxy",
"redirectsTitle": "$serviceName Redirect",
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
"urlEmpty": "URL cannot be empty",
"urlInvalid": "Invalid URL",
"redirectDialogTitle": "$serviceName Redirect",
"stickersPrivacy": "Keep sticker list public",
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Display stickers in chat",
"autoDownload": "Automatically download stickers",
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
"stickerPacksSection": "Sticker packs",
"importStickerPack": "Import sticker pack",
"importSuccess": "Sticker pack successfully imported",
"importFailure": "Failed to import sticker pack"
}
}
}
}

View File

@@ -1,354 +1,409 @@
{
"@@name": "Deutsch",
"language": "Deutsch",
"global": {
"title": "Moxxy",
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
"dialogAccept": "Okay",
"dialogCancel": "Abbrechen",
"yes": "Ja",
"no": "Nein"
"title": "Moxxy",
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
"dialogAccept": "Okay",
"dialogCancel": "Abbrechen",
"yes": "Ja",
"no": "Nein"
},
"notifications": {
"permanent": {
"idle": "Bereit",
"ready": "Bereit zum Nachrichtenempfang",
"connecting": "Verbinde...",
"disconnect": "Keine Verbindung",
"error": "Fehler"
},
"message": {
"reply": "Antworten",
"markAsRead": "Als gelesen markieren"
},
"channels": {
"messagesChannelName": "Nachrichten",
"messagesChannelDescription": "Empfangene Nachrichten",
"warningChannelName": "Warnungen",
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
},
"titles": {
"error": "Fehler"
}
"permanent": {
"idle": "Bereit",
"ready": "Bereit zum Nachrichtenempfang",
"connecting": "Verbinde...",
"disconnect": "Keine Verbindung",
"error": "Fehler"
},
"message": {
"reply": "Antworten",
"markAsRead": "Als gelesen markieren"
},
"channels": {
"messagesChannelName": "Nachrichten",
"messagesChannelDescription": "Empfangene Nachrichten",
"warningChannelName": "Warnungen",
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
},
"titles": {
"error": "Fehler"
}
},
"dateTime": {
"justNow": "Gerade",
"nMinutesAgo": "vor ${min}min",
"mondayAbbrev": "Mon",
"tuesdayAbbrev": "Die",
"wednessdayAbbrev": "Mit",
"thursdayAbbrev": "Don",
"fridayAbbrev": "Fre",
"saturdayAbbrev": "Sam",
"sundayAbbrev": "Son",
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember",
"today": "Heute",
"yesterday": "Gestern"
"justNow": "Gerade",
"nMinutesAgo": "vor ${min}min",
"mondayAbbrev": "Mon",
"tuesdayAbbrev": "Die",
"wednessdayAbbrev": "Mit",
"thursdayAbbrev": "Don",
"fridayAbbrev": "Fre",
"saturdayAbbrev": "Sam",
"sundayAbbrev": "Son",
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember",
"today": "Heute",
"yesterday": "Gestern"
},
"messages": {
"image": "Bild",
"video": "Video",
"audio": "Audio",
"file": "Datei",
"sticker": "Sticker",
"retracted": "Die Nachricht wurde zurückgezogen",
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
"you": "Du"
"image": "Bild",
"video": "Video",
"audio": "Audio",
"file": "Datei",
"sticker": "Sticker",
"retracted": "Die Nachricht wurde zurückgezogen",
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
"you": "Du"
},
"errors": {
"omemo": {
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
"verificationWrongJid": "Falsche XMPP-Addresse",
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
},
"connection": {
"connectionTimeout": "Verbindung zum Server nicht möglich",
"saslAccountDisabled": "Dein Account ist deaktiviert",
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
},
"login": {
"saslFailed": "Ungültige Logindaten",
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
"noConnection": "Konnte keine Verbindung zum Server aufbauen",
"unspecified": "Unbestimmter Fehler"
},
"message": {
"unspecified": "Unbekannter Fehler",
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen",
"contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht",
"fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen",
"serviceUnavailable": "Die Nachricht konnte nicht gesendet werden",
"remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden",
"remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist",
"failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden",
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
},
"conversation": {
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
"openFileGenericError": "Fehler beim Öffnen der Datei",
"messageErrorDialogTitle": "Fehler"
}
"general": {
"noInternet": "Keine Internetverbindung."
},
"filePicker": {
"permissionDenied": "Die Speicherberechtigung wurde nicht erteilt."
},
"omemo": {
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
"verificationWrongJid": "Falsche XMPP-Addresse",
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
},
"connection": {
"connectionTimeout": "Verbindung zum Server nicht möglich",
"saslAccountDisabled": "Dein Konto ist deaktiviert",
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
},
"login": {
"saslFailed": "Ungültige Logindaten",
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
"noConnection": "Konnte keine Verbindung zum Server aufbauen",
"unspecified": "Unbestimmter Fehler"
},
"message": {
"unspecified": "Unbekannter Fehler",
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen",
"contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht",
"fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen",
"serviceUnavailable": "Die Nachricht konnte nicht gesendet werden",
"remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden",
"remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist",
"failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden",
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
},
"conversation": {
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
"openFileGenericError": "Fehler beim Öffnen der Datei",
"messageErrorDialogTitle": "Fehler"
},
"newChat": {
"remoteServerError": "Konnte den Server nicht erreichen.",
"groupchatUnsupported": "Das Beitreten eines Gruppenchats ist aktuell nicht unterstützt.",
"unknown": "Unbekannter Fehler."
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
},
"conversation": {
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
}
"message": {
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
},
"conversation": {
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
}
},
"pages": {
"intro": {
"noAccount": "Kein XMPP-Account vorhanden? Einen zu erstellen ist sehr einfach.",
"loginButton": "Einloggen",
"registerButton": "Registrieren"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Adresse",
"password": "Passwort",
"advancedOptions": "Fortgeschrittene Optionen",
"createAccount": "Account auf dem Server erstellen"
},
"conversations": {
"speeddialNewChat": "Neuer chat",
"speeddialJoinGroupchat": "Gruppenchat beitreten",
"speeddialAddNoteToSelf": "Notiz an mich",
"overlaySettings": "Einstellungen",
"noOpenChats": "Du hast keine offenen chats",
"startChat": "Einen chat anfangen",
"closeChat": "Chat schließen",
"closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?",
"markAsRead": "Als gelesen markieren"
},
"conversation": {
"unencrypted": "Unverschlüsselt",
"encrypted": "Verschlüsselt",
"closeChat": "Chat schließen",
"closeChatConfirmTitle": "Chat schließen",
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
"blockShort": "Blockieren",
"blockUser": "Nutzer blockieren",
"online": "Online",
"retract": "Nachricht löschen",
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
"forward": "Weiterleiten",
"edit": "Bearbeiten",
"quote": "Zitieren",
"copy": "Inhalt kopieren",
"addReaction": "Reaktion hinzufügen",
"showError": "Fehler anzeigen",
"showWarning": "Warnung anzeigen",
"addToContacts": "Zu Kontaken hinzufügen",
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"stickerSettings": "Stickereinstellungen",
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
"messageHint": "Nachricht senden...",
"sendImages": "Bilder senden",
"sendFiles": "Dateien senden",
"takePhotos": "Bilder aufnehmen"
},
"addcontact": {
"title": "Neuen Kontakt hinzufügen",
"xmppAddress": "XMPP-Adresse",
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
"buttonAddToContact": "Kontakt hinzufügen"
},
"newconversation": {
"title": "Neuer chat",
"addContact": "Kontakt hinzufügen",
"createGroupchat": "Gruppenchat erstellen"
},
"crop": {
"setProfilePicture": "Als Profilbild festlegen"
},
"shareselection": {
"shareWith": "Teilen mit...",
"confirmTitle": "Dateien senden?",
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
},
"profile": {
"general": {
"omemo": "Sicherheit",
"profile": "Profil",
"media": "Medien"
},
"conversation": {
"notifications": "Benachrichtigungen",
"notificationsMuted": "Stumm",
"notificationsEnabled": "Eingeschaltet",
"sharedMedia": "Medien"
},
"owndevices": {
"title": "Eigene Geräte",
"thisDevice": "Dieses Gerät",
"otherDevices": "Andere Geräte",
"deleteDeviceConfirmTitle": "Gerät löschen",
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
"recreateOwnSessions": "Sessions neuerstellen",
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
"recreateOwnDevice": "Gerät neuerstellen",
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
},
"devices": {
"title": "Sicherheit",
"recreateSessions": "Sessions zurücksetzen",
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
"noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
}
},
"blocklist": {
"title": "Blockliste",
"noUsersBlocked": "Du hast niemanden blockiert",
"unblockAll": "Alle entblocken",
"unblockAllConfirmTitle": "Alle entblocken",
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
"unblockJidConfirmTitle": "${jid} entblocken?",
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
},
"cropbackground": {
"blur": "Hintergrund weichzeichnen",
"setAsBackground": "Als Hintergrundbild festlegen"
},
"stickerPack": {
"removeConfirmTitle": "Stickerpack entfernen",
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
"installConfirmTitle": "Stickerpack installieren",
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
"fetchingFailure": "Konnte das Stickerpack nicht finden"
},
"settings": {
"settings": {
"title": "Einstellungen",
"conversationsSection": "Unterhaltungen",
"accountSection": "Account",
"signOut": "Abmelden",
"signOutConfirmTitle": "Abmelden",
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
"miscellaneousSection": "Unterschiedlich",
"debuggingSection": "Debugging",
"general": "Generell"
},
"about": {
"title": "Über",
"licensed": "Lizensiert unter GPL3",
"version": "Version ${version}",
"viewSourceCode": "Quellcode anschauen",
"nMoreToGo": "Noch ${n}...",
"debugMenuShown": "Du bist jetzt ein Entwickler!",
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
},
"appearance": {
"title": "Aussehen",
"languageSection": "Sprache",
"language": "Appsprache",
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
"systemLanguage": "Systemsprache"
},
"licenses": {
"title": "Open-Source Lizenzen",
"licensedUnder": "Lizensiert unter $license"
},
"conversation": {
"title": "Chat",
"appearance": "Aussehen",
"selectBackgroundImage": "Hintergrundbild auswählen",
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
"removeBackgroundImage": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
"newChatsSection": "Neue Chats",
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
"behaviourSection": "Verhalten",
"contactsIntegration": "Kontaktintegration",
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
},
"debugging": {
"title": "Debuggingoptionen",
"generalSection": "Generell",
"generalEnableDebugging": "Debugging einschalten",
"generalEncryptionPassword": "Verschlüsselungspasswort",
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
"generalLoggingIp": "Logging-IP",
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
"generalLoggingPort": "Logging-Port",
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
},
"network": {
"title": "Netzwerk",
"automaticDownloadsSection": "Automatische Downloads",
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"automaticDownloadAlways": "Immer",
"wifi": "Wifi",
"mobileData": "Mobile Daten"
},
"privacy": {
"title": "Privatsphäre",
"generalSection": "Generell",
"showContactRequests": "Kontaktanfragen zeigen",
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
"profilePictureVisibility": "Öffentliches Profilbild",
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
"conversationsSection": "Unterhaltungen",
"sendChatMarkers": "Chatmarker senden",
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
"sendChatStates": "Chatstates senden",
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
"redirectsSection": "Weiterleitungen",
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
"currentlySelected": "Aktuell ausgewählt: $proxy",
"redirectsTitle": "${serviceName}weiterleitung",
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
"urlEmpty": "URL kann nicht leer sein",
"urlInvalid": "Ungültige URL",
"redirectDialogTitle": "${serviceName}weiterleitung",
"stickersPrivacy": "Stickerliste öffentlich halten",
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Sticker im Chat anzeigen",
"autoDownload": "Sticker automatisch herunterladen",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
"stickerPacksSection": "Stickerpacks",
"importStickerPack": "Stickerpack importieren",
"importSuccess": "Stickerpack erfolgreich importiert",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
}
}
"intro": {
"noAccount": "Kein XMPP-Konto vorhanden? Keine Sorge, es ist ganz einfach, eines zu erstellen.",
"loginButton": "Einloggen",
"registerButton": "Registrieren"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Adresse",
"password": "Passwort",
"advancedOptions": "Fortgeschrittene Optionen",
"createAccount": "Konto auf dem Server erstellen"
},
"conversations": {
"speeddialNewChat": "Neuer chat",
"speeddialJoinGroupchat": "Gruppenchat beitreten",
"speeddialAddNoteToSelf": "Notiz an mich",
"overlaySettings": "Einstellungen",
"noOpenChats": "Du hast keine offenen chats",
"startChat": "Einen chat anfangen",
"closeChat": "Chat schließen",
"closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?",
"markAsRead": "Als gelesen markieren"
},
"conversation": {
"unencrypted": "Unverschlüsselt",
"encrypted": "Verschlüsselt",
"closeChat": "Chat schließen",
"closeChatConfirmTitle": "Chat schließen",
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
"blockShort": "Blockieren",
"blockUser": "Nutzer blockieren",
"online": "Online",
"retract": "Nachricht löschen",
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
"forward": "Weiterleiten",
"edit": "Bearbeiten",
"quote": "Zitieren",
"copy": "Inhalt kopieren",
"messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
"addReaction": "Reaktion hinzufügen",
"showError": "Fehler anzeigen",
"showWarning": "Warnung anzeigen",
"warning": "Warnung",
"addToContacts": "Zu Kontaken hinzufügen",
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"stickerSettings": "Stickereinstellungen",
"newDeviceMessage": {
"one": "Ein neues Gerät wurde hinzugefügt",
"other": "Mehrere neue Geräte wurden hinzugefügt"
},
"replacedDeviceMessage": {
"one": "Ein Gerät hat sich verändert",
"other": "Mehrere Geräte haben sich verändert"
},
"messageHint": "Nachricht senden...",
"sendImages": "Bilder senden",
"sendFiles": "Dateien senden",
"takePhotos": "Bilder aufnehmen"
},
"startchat": {
"title": "Neuer Chat",
"xmppAddress": "XMPP-Adresse",
"subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
"buttonAddToContact": "Neuen Chat beginnen"
},
"newconversation": {
"title": "Neuer Chat",
"startChat": "Neuen Chat beginnen",
"createGroupchat": "Gruppenchat erstellen"
},
"crop": {
"setProfilePicture": "Als Profilbild festlegen"
},
"shareselection": {
"shareWith": "Teilen mit...",
"confirmTitle": "Dateien senden?",
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
},
"profile": {
"general": {
"omemo": "Sicherheit",
"profile": "Profil",
"media": "Medien"
},
"conversation": {
"notifications": "Benachrichtigungen",
"notificationsMuted": "Stumm",
"notificationsEnabled": "Eingeschaltet",
"sharedMedia": "Medien"
},
"owndevices": {
"title": "Eigene Geräte",
"thisDevice": "Dieses Gerät",
"otherDevices": "Andere Geräte",
"deleteDeviceConfirmTitle": "Gerät löschen",
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
"recreateOwnSessions": "Sessions neuerstellen",
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
"recreateOwnDevice": "Gerät neuerstellen",
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
},
"devices": {
"title": "Sicherheit",
"recreateSessions": "Sessions zurücksetzen",
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
"noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
}
},
"blocklist": {
"title": "Blockliste",
"noUsersBlocked": "Du hast niemanden blockiert",
"unblockAll": "Alle entblocken",
"unblockAllConfirmTitle": "Alle entblocken",
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
"unblockJidConfirmTitle": "${jid} entblocken?",
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
},
"cropbackground": {
"blur": "Hintergrund weichzeichnen",
"setAsBackground": "Als Hintergrundbild festlegen"
},
"stickerPack": {
"removeConfirmTitle": "Stickerpack entfernen",
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
"installConfirmTitle": "Stickerpack installieren",
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
"fetchingFailure": "Konnte das Stickerpack nicht finden"
},
"settings": {
"settings": {
"title": "Einstellungen",
"conversationsSection": "Unterhaltungen",
"accountSection": "Konto",
"signOut": "Abmelden",
"signOutConfirmTitle": "Abmelden",
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
"miscellaneousSection": "Unterschiedlich",
"debuggingSection": "Debugging",
"general": "Generell"
},
"about": {
"title": "Über",
"licensed": "Lizensiert unter GPL3",
"version": "Version ${version}",
"viewSourceCode": "Quellcode anschauen",
"nMoreToGo": "Noch ${n}...",
"debugMenuShown": "Du bist jetzt ein Entwickler!",
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
},
"appearance": {
"title": "Aussehen",
"languageSection": "Sprache",
"language": "Appsprache",
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
"systemLanguage": "Systemsprache"
},
"licenses": {
"title": "Open-Source Lizenzen",
"licensedUnder": "Lizensiert unter $license"
},
"conversation": {
"title": "Chat",
"appearance": "Aussehen",
"selectBackgroundImage": "Hintergrundbild auswählen",
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
"removeBackgroundImage": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
"newChatsSection": "Neue Chats",
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
"behaviourSection": "Verhalten",
"contactsIntegration": "Kontaktintegration",
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
},
"debugging": {
"title": "Debuggingoptionen",
"generalSection": "Generell",
"generalEnableDebugging": "Debugging einschalten",
"generalEncryptionPassword": "Verschlüsselungspasswort",
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
"generalLoggingIp": "Logging-IP",
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
"generalLoggingPort": "Logging-Port",
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
},
"network": {
"title": "Netzwerk",
"automaticDownloadsSection": "Automatische Downloads",
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"automaticDownloadAlways": "Immer",
"wifi": "WLAN",
"mobileData": "Mobile Daten"
},
"privacy": {
"title": "Privatsphäre",
"generalSection": "Generell",
"showContactRequests": "Kontaktanfragen zeigen",
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
"profilePictureVisibility": "Öffentliches Profilbild",
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
"conversationsSection": "Unterhaltungen",
"sendChatMarkers": "Chatmarker senden",
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
"sendChatStates": "Chatstates senden",
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
"redirectsSection": "Weiterleitungen",
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
"currentlySelected": "Aktuell ausgewählt: $proxy",
"redirectsTitle": "${serviceName}weiterleitung",
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
"urlEmpty": "URL kann nicht leer sein",
"urlInvalid": "Ungültige URL",
"redirectDialogTitle": "${serviceName}weiterleitung",
"stickersPrivacy": "Stickerliste öffentlich halten",
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Sticker im Chat anzeigen",
"autoDownload": "Sticker automatisch herunterladen",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
"stickerPacksSection": "Stickerpacks",
"importStickerPack": "Stickerpack importieren",
"importSuccess": "Stickerpack erfolgreich importiert",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten",
"stickerPackSize": "(${size})"
},
"storage": {
"title": "Speicher",
"sizePlaceholder": "Berechne...",
"storageManagement": "Speicherverwaltung",
"removeOldMedia": {
"title": "Alte Medien entfernen",
"description": "Löscht alte Medien vom Gerät"
},
"removeOldMediaDialog": {
"title": "Medien löschen",
"options": {
"all": "Alle Medien",
"oneMonth": "Älter als 1 Monat",
"oneWeek": "Älter als 1 Woche"
},
"delete": "Löschen",
"confirmation": {
"body": "Bist Du dir sicher, dass du alte Medien löschen möchtest?"
}
},
"viewMediaFiles": "Medien anzeigen",
"mediaFiles": "Medien",
"types": {
"media": "Medien",
"stickers": "Sticker"
},
"manageStickers": "Stickerpacks verwalten",
"storageUsed": "Speicherplatz verbraucht: ${size}"
}
},
"sharedMedia": {
"empty": {
"chat": "Keine Medien für diesen Chat vorhanden",
"general": "Keine Medien vorhanden"
}
}
}
}

View File

@@ -0,0 +1,443 @@
{
"language": "English",
"global": {
"title": "Moxxy",
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
"dialogAccept": "Okay",
"dialogCancel": "Cancel",
"yes": "Yes",
"no": "No"
},
"notifications": {
"permanent": {
"idle": "Idle",
"ready": "Ready to receive messages",
"connecting": "Connecting…",
"disconnect": "Disconnected",
"error": "Error"
},
"message": {
"reply": "Reply",
"markAsRead": "Mark as read"
},
"channels": {
"messagesChannelName": "Messages",
"messagesChannelDescription": "The notification channel for received messages",
"warningChannelName": "Warnings",
"warningChannelDescription": "Warnings related to Moxxy",
"serviceChannelName": "Foreground Service",
"serviceChannelDescription": "Holds the persistent foreground service notification"
},
"titles": {
"error": "Error"
},
"warnings": {
"blockingError": {
"title": "Error while blocking user",
"body": "Could not block user ${jid} because your server does not support blocking."
}
},
"errors": {
"messageError": {
"title": "Message delivery failure",
"body": "Failed to deliver message to ${conversationTitle}"
}
}
},
"permissions": {
"requests": {
"notification": {
"reason": "In order to notify of incoming messages, Moxxy needs permission to show notifications."
},
"batterySaving": {
"reason": "In order to receive messages in the background, Moxxy should be excempt from Android's battery saving."
}
},
"allow": "Allow",
"skip": "Skip"
},
"dateTime": {
"justNow": "Just now",
"nMinutesAgo": "${min}min ago",
"mondayAbbrev": "Mon",
"tuesdayAbbrev": "Tue",
"wednessdayAbbrev": "Wed",
"thursdayAbbrev": "Thu",
"fridayAbbrev": "Fri",
"saturdayAbbrev": "Sat",
"sundayAbbrev": "Sun",
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December",
"today": "Today",
"yesterday": "Yesterday"
},
"messages": {
"image": "Image",
"video": "Video",
"audio": "Audio",
"file": "File",
"sticker": "Sticker",
"retracted": "The message has been retracted",
"retractedFallback": "A previous message has been retracted but your client does not support it",
"you": "You"
},
"errors": {
"general": {
"noInternet": "Not connected to the Internet."
},
"filePicker": {
"permissionDenied": "The storage permission has been denied."
},
"omemo": {
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
"notEncryptedForDevice": "This message was not encrypted for this device",
"invalidHmac": "Could not decrypt message",
"noDecryptionKey": "No decryption key available",
"messageInvalidAfixElement": "Invalid encrypted message",
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
"verificationWrongJid": "Wrong XMPP-address",
"verificationWrongDevice": "Wrong OMEMO:2 device",
"verificationNotInList": "Wrong OMEMO:2 device",
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
},
"connection": {
"connectionTimeout": "Could not connect to server",
"saslAccountDisabled": "Your account is disabled",
"saslInvalidCredentials": "Your account credentials are invalid",
"unrecoverable": "Connection lost due to unrecoverable error"
},
"login": {
"saslFailed": "Invalid login credentials",
"startTlsFailed": "Failed to establish a secure connection",
"noConnection": "Failed to establish a connection",
"unspecified": "Unspecified error"
},
"message": {
"unspecified": "Unknown error",
"fileUploadFailed": "The file upload failed",
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
"fileDownloadFailed": "The file download failed",
"serviceUnavailable": "The message could not be delivered to the contact",
"remoteServerTimeout": "The message could not be delivered to the contact's server",
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
"failedToEncrypt": "The message could not be encrypted",
"failedToEncryptFile": "The file could not be encrypted",
"failedToDecryptFile": "The file could not be decrypted",
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
},
"conversation": {
"audioRecordingError": "Failed to finalize audio recording",
"openFileNoAppError": "No app found to open this file",
"openFileGenericError": "Failed to open file",
"messageErrorDialogTitle": "Error"
},
"newChat": {
"remoteServerError": "Failed to contact the remote server.",
"groupchatUnsupported": "Joining a groupchat is currently not supported.",
"unknown": "Unknown error."
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Could not verify file integrity"
},
"conversation": {
"holdForLonger": "Hold button longer to record a voice message"
}
},
"pages": {
"intro": {
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
"loginButton": "Login",
"registerButton": "Register"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Address",
"password": "Password",
"advancedOptions": "Advanced options",
"createAccount": "Create account on server"
},
"conversations": {
"speeddialNewChat": "New chat",
"speeddialJoinGroupchat": "Join groupchat",
"speeddialAddNoteToSelf": "Note to self",
"overlaySettings": "Settings",
"noOpenChats": "You have no open chats",
"startChat": "Start a chat",
"closeChat": "Close chat",
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
"markAsRead": "Mark as read"
},
"conversation": {
"unencrypted": "Unencrypted",
"encrypted": "Encrypted",
"closeChat": "Close chat",
"closeChatConfirmTitle": "Close chat",
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
"blockShort": "Block",
"blockUser": "Block user",
"online": "Online",
"retract": "Retract message",
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
"forward": "Forward",
"edit": "Edit",
"quote": "Quote",
"copy": "Copy content",
"messageCopied": "Message content copied to clipboard",
"addReaction": "Add reaction",
"showError": "Show error",
"showWarning": "Show warning",
"warning": "Warning",
"addToContacts": "Add to contacts",
"addToContactsTitle": "Add ${jid} to contacts",
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
"stickerSettings": "Sticker settings",
"newDeviceMessage": {
"one": "A new device has been added",
"other": "Multiple new devices have been added"
},
"replacedDeviceMessage": {
"one": "A device has been changed",
"other": "Multiple devices have been added"
},
"messageHint": "Send a message…",
"sendImages": "Send images",
"sendFiles": "Send files",
"takePhotos": "Take photos"
},
"startchat": {
"title": "New Chat",
"xmppAddress": "XMPP address",
"subtitle": "You can start a new chat by either entering a XMPP address or by scanning their QR code.",
"buttonAddToContact": "Start new chat"
},
"newconversation": {
"title": "New chat",
"startChat": "Start new chat",
"createGroupchat": "New groupchat",
"joinGroupChat": "Join groupchat",
"nullNickname": "Nickname cannot be null!",
"nick": "Nickname",
"enterNickname": "Enter Nickname",
"nicknameSubtitle": "You need to enter a unique nickname in order to join a MUC."
},
"crop": {
"setProfilePicture": "Set as profile picture"
},
"shareselection": {
"shareWith": "Share with…",
"confirmTitle": "Send file",
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
},
"profile": {
"general": {
"omemo": "Security",
"profile": "Profile",
"media": "Media"
},
"conversation": {
"notifications": "Notifications",
"notificationsMuted": "Muted",
"notificationsEnabled": "Enabled",
"sharedMedia": "Media"
},
"owndevices": {
"title": "Own Devices",
"thisDevice": "This device",
"otherDevices": "Other devices",
"deleteDeviceConfirmTitle": "Delete device",
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
"recreateOwnSessions": "Rebuild sessions",
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
"recreateOwnDevice": "Recreate device",
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
},
"devices": {
"title": "Security",
"recreateSessions": "Rebuild sessions",
"recreateSessionsConfirmTitle": "Rebuild sessions?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
}
},
"blocklist": {
"title": "Blocklist",
"noUsersBlocked": "You have no users blocked",
"unblockAll": "Unblock all",
"unblockAllConfirmTitle": "Are you sure?",
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
"unblockJidConfirmTitle": "Unblock ${jid}?",
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
},
"cropbackground": {
"blur": "Blur background",
"setAsBackground": "Set as background image"
},
"stickerPack": {
"removeConfirmTitle": "Remove sticker pack",
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
"installConfirmTitle": "Install sticker pack",
"installConfirmBody": "Are you sure you want to install this sticker pack?",
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
"fetchingFailure": "Could not find the sticker pack"
},
"sharedMedia": {
"empty": {
"chat": "No shared media for this chat",
"general": "No media files available"
}
},
"settings": {
"settings": {
"title": "Settings",
"conversationsSection": "Conversations",
"accountSection": "Account",
"signOut": "Sign out",
"signOutConfirmTitle": "Sign Out",
"signOutConfirmBody": "You are about to sign out. Proceed?",
"miscellaneousSection": "Miscellaneous",
"debuggingSection": "Debugging",
"general": "General"
},
"about": {
"title": "About",
"licensed": "Licensed under GPL3",
"version": "Version ${version}",
"viewSourceCode": "View source code",
"nMoreToGo": "${n} more to go…",
"debugMenuShown": "You are now a developer!",
"debugMenuAlreadyShown": "You are already a developer!"
},
"appearance": {
"title": "Appearance",
"languageSection": "Language",
"language": "App language",
"languageSubtext": "Currently selected: $selectedLanguage",
"systemLanguage": "Default language"
},
"licenses": {
"title": "Open-Source Licenses",
"licensedUnder": "Licensed under $license"
},
"conversation": {
"title": "Chat",
"appearance": "Appearance",
"selectBackgroundImage": "Select background image",
"selectBackgroundImageDescription": "This image will be the background of all your chats",
"removeBackgroundImage": "Remove background image",
"removeBackgroundImageConfirmTitle": "Remove background image",
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
"newChatsSection": "New Conversations",
"newChatsMuteByDefault": "Mute new chats by default",
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
"behaviourSection": "Behaviour",
"contactsIntegration": "Contacts integration",
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
},
"debugging": {
"title": "Debugging options",
"generalSection": "General",
"generalEnableDebugging": "Enable debugging",
"generalEncryptionPassword": "Encryption password",
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
"generalLoggingIp": "Logging IP",
"generalLoggingIpSubtext": "The IP the logs should be sent to",
"generalLoggingPort": "Logging Port",
"generalLoggingPortSubtext": "The IP the logs should be sent to"
},
"network": {
"title": "Network",
"automaticDownloadsSection": "Automatic Downloads",
"automaticDownloadsText": "Moxxy will automatically download files on…",
"automaticDownloadsMaximumSize": "Maximum Download Size",
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
"automaticDownloadAlways": "Always",
"wifi": "Wifi",
"mobileData": "Mobile data"
},
"privacy": {
"title": "Privacy",
"generalSection": "General",
"showContactRequests": "Show contact requests",
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
"profilePictureVisibility": "Make profile picture public",
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
"conversationsSection": "Conversation",
"sendChatMarkers": "Send chat markers",
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
"sendChatStates": "Send chat states",
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
"redirectsSection": "Redirects",
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
"currentlySelected": "Currently selected: $proxy",
"redirectsTitle": "$serviceName Redirect",
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
"urlEmpty": "URL cannot be empty",
"urlInvalid": "Invalid URL",
"redirectDialogTitle": "$serviceName Redirect",
"stickersPrivacy": "Keep sticker list public",
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Display stickers in chat",
"autoDownload": "Automatically download stickers",
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
"stickerPacksSection": "Sticker packs",
"importStickerPack": "Import sticker pack",
"importSuccess": "Sticker pack successfully imported",
"importFailure": "Failed to import sticker pack",
"stickerPackSize": "(${size})"
},
"stickerPacks": {
"title": "Sticker Packs"
},
"storage": {
"title": "Storage",
"storageUsed": "Storage used: ${size}",
"sizePlaceholder": "Computing…",
"storageManagement": "Storage Management",
"removeOldMedia": {
"title": "Remove old media",
"description": "Removes old media files from the device"
},
"removeOldMediaDialog": {
"title": "Delete media files",
"options": {
"all": "All media files",
"oneWeek": "Older than 1 week",
"oneMonth": "Older than 1 month"
},
"delete": "Delete",
"confirmation": {
"body": "Are you sure you want to delete old media files?"
}
},
"viewMediaFiles": "View media files",
"mediaFiles": "Media Files",
"types": {
"media": "Media",
"stickers": "Stickers"
},
"manageStickers": "Manage sticker packs"
}
}
}
}

View File

@@ -0,0 +1,435 @@
{
"language": "Français",
"global": {
"yes": "Oui",
"no": "Non",
"title": "Moxxy",
"dialogAccept": "OK",
"dialogCancel": "Annuler",
"moxxySubtitle": "Une expérience de construire un facile, moderne et belle client XMPP."
},
"notifications": {
"permanent": {
"ready": "Prêt pour recevoir des messages",
"disconnect": "Déconnecté",
"error": "Erreur",
"connecting": "Connexion…",
"idle": "Inactif"
},
"message": {
"markAsRead": "Marquer comme lu",
"reply": "Répondre"
},
"channels": {
"messagesChannelName": "Messages",
"warningChannelName": "Alertes",
"warningChannelDescription": "Alertes liées à Moxxy",
"messagesChannelDescription": "La voie des notifications pour des messages reçu"
},
"errors": {
"messageError": {
"body": "N'est pas parvenu à livrée la message au ${conversationTitle}",
"title": "Échec au livraison du message"
}
},
"titles": {
"error": "Erreur"
}
},
"messages": {
"file": "Fichier",
"image": "Image",
"video": "Vidéo",
"audio": "Audio",
"sticker": "Autocollant",
"retracted": "La message a été retirée",
"you": "Toi",
"retractedFallback": "Un précédent message a été retiré, mais votre client ne le prend pas en charge"
},
"permissions": {
"allow": "Autoriser",
"skip": "Sauter",
"requests": {
"notification": {
"reason": "Pour notifier des nouvelles messages, Moxxy à besoin de permission pour afficher les notifications."
},
"batterySaving": {
"reason": "Pour recevoir des messages dans l'arrière-plan, Moxxy devrait être exempté de la fonction d'économie de la batterie d'Android."
}
}
},
"dateTime": {
"justNow": "A l'instant",
"nMinutesAgo": "Il y a ${min}min",
"mondayAbbrev": "Lun",
"tuesdayAbbrev": "Mar",
"wednessdayAbbrev": "Mer",
"thursdayAbbrev": "Jeu",
"fridayAbbrev": "Ven",
"saturdayAbbrev": "Sam",
"sundayAbbrev": "Dim",
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre",
"today": "Aujourdhui",
"yesterday": "Hier"
},
"errors": {
"general": {
"noInternet": "N'est pas connecté au l'Internet."
},
"omemo": {
"notEncryptedForDevice": "Cet message n'était pas chiffré pour cette appareil",
"invalidHmac": "Ne pouvait pas déchiffrer la message",
"noDecryptionKey": "Aucune clé de déchiffrer disponible",
"messageInvalidAfixElement": "Message chiffré invalide",
"verificationWrongJid": "Mauvais adresse XMPP",
"verificationWrongDevice": "Mauvais appareil OMEMO:2",
"verificationNotInList": "Mauvais appareil OMEMO:2",
"verificationWrongFingerprint": "Empreinte OMEMO:2 invalide",
"verificationInvalidOmemoUrl": "Empreinte OMEMO:2 invalide",
"couldNotPublish": "Ne pouvait pas publier l'identité cryptographique au la serveur. Cela signifie que chiffrement de bout en bout risque de ne pas fonctionner."
},
"filePicker": {
"permissionDenied": "L'autorisation de permission de storage a été déclinée."
},
"login": {
"saslFailed": "Identifiants non-valides",
"startTlsFailed": "Impossible d'établir une connexion sécurisée",
"unspecified": "Erreur non-connue",
"noConnection": "Impossible d'établir une connexion"
},
"message": {
"unspecified": "Erreur inconnue",
"fileUploadFailed": "Le téléversement du fichier a échoué",
"contactDoesntSupportOmemo": "Le chiffrement OMEMO:2 n'est pas pris en charge par l'autre personne",
"serviceUnavailable": "Le message n'a pas pu être délivré",
"failedToEncrypt": "Le message n'a pas pu être chiffré",
"failedToEncryptFile": "Le fichier n'a pas pu être chiffré",
"failedToDecryptFile": "Le fichier n'a pas pu être déchiffré",
"fileNotEncrypted": "La conversation est chiffrée, mais pas le fichier",
"fileDownloadFailed": "Le téléchargement du fichier a échoué",
"remoteServerTimeout": "Le message n'a pas pu être délivré au serveur de destination",
"remoteServerNotFound": "Serveur introuvable; Le message n'a pas pu être délivré"
},
"connection": {
"connectionTimeout": "Impossible de joindre le serveur",
"saslAccountDisabled": "Votre compte est désactivé",
"saslInvalidCredentials": "Vos identifiants ne sont pas valides",
"unrecoverable": "Connexion perdue due à une erreur"
},
"conversation": {
"audioRecordingError": "Erreur d'enregistrement audio",
"openFileNoAppError": "Pas d'application pour ce type de fichier",
"messageErrorDialogTitle": "Erreur",
"openFileGenericError": "Impossible d'ouvrir le fichier"
},
"newChat": {
"remoteServerError": "Impossible de contacter le serveur distant.",
"groupchatUnsupported": "Les conversations de groupes ne sont pas encore prises en charge.",
"unknown": "Erreur inconnue."
}
},
"pages": {
"settings": {
"stickers": {
"title": "Autocollants",
"stickerSection": "Autocollant",
"stickerPacksSection": "Paquets d'autocollants",
"stickerPackSize": "(${size})",
"displayStickers": "Afficher les autocollants dans les conversations",
"autoDownload": "Téléchargement automatique des autocollants",
"importStickerPack": "Importer série d'autocollants",
"importSuccess": "Série d'autocollants importée avec succès",
"importFailure": "Erreur lors de l'import de la série d'autocollants",
"autoDownloadBody": "Si activée, les autocollants seront téléchargés automatiquement avec les personnes de votre liste de contact."
},
"stickerPacks": {
"title": "Paquets d'autocollants"
},
"storage": {
"types": {
"stickers": "Autocollants",
"media": "Médias"
},
"title": "Stockage",
"storageUsed": "Espace utilisé: ${size}",
"storageManagement": "Gestion de stockage",
"removeOldMedia": {
"title": "Supprimer les anciens médias",
"description": "Supprimer les anciens fichiers médias de votre appareil"
},
"removeOldMediaDialog": {
"title": "Supprimer les fichiers médias",
"options": {
"oneWeek": "Plus anciens qu'une semaine",
"oneMonth": "Plus anciens qu'un mois",
"all": "Tous les fichiers médias"
},
"delete": "Supprimer",
"confirmation": {
"body": "Confirmer la suppresion des anciens fichiers médias ?"
}
},
"viewMediaFiles": "Voir les fichiers médias",
"mediaFiles": "Fichiers médias",
"manageStickers": "Gestion des autocollants",
"sizePlaceholder": "Calcul…"
},
"settings": {
"title": "Paramètres",
"conversationsSection": "Conversations",
"accountSection": "Compte",
"signOut": "Déconnexion",
"signOutConfirmBody": "Confirmer la déconnexion ?",
"miscellaneousSection": "Autres",
"debuggingSection": "Développement",
"general": "Général",
"signOutConfirmTitle": "Déconnexion"
},
"about": {
"title": "À propos",
"licensed": "Sous licence GPLv3",
"version": "Version ${version}",
"viewSourceCode": "Voir le code source",
"debugMenuShown": "Mode développement disponible !",
"debugMenuAlreadyShown": "Le mode développement est déjà disponible !",
"nMoreToGo": "Plus que ${n}…"
},
"appearance": {
"title": "Apparence",
"languageSection": "Langue",
"language": "Langue de l'application",
"systemLanguage": "Langue par défaut",
"languageSubtext": "Langue actuelle: $selectedLanguage"
},
"licenses": {
"title": "Licences Open-Source",
"licensedUnder": "Sous licence $license"
},
"conversation": {
"title": "Conversation",
"appearance": "Apparence",
"selectBackgroundImage": "Sélectionner l'image d'arrière-plan",
"removeBackgroundImage": "Supprimer l'image d'arrière-plan",
"removeBackgroundImageConfirmTitle": "Suppression de l'image d'arrière-plan",
"removeBackgroundImageConfirmBody": "Confirmer la suppression de l'image d'arrière-plan de conversation ?",
"newChatsSection": "Nouvelles conversations",
"newChatsMuteByDefault": "Mettre les nouvelles conversation en sourdine par défaut",
"behaviourSection": "Comportement",
"contactsIntegration": "Intégration de contacts",
"selectBackgroundImageDescription": "Cette image sera en arrière-plan de toutes vos conversations",
"newChatsE2EE": "Activer le chiffrement de bout en bout par défaut. ATTENTION: Expérimental",
"contactsIntegrationBody": "Lorsque activé, les données de votre appareil fourniront les titres de conversation, et images de profils. Aucune donnée n'est envoyée au serveur."
},
"debugging": {
"title": "Option de développement",
"generalSection": "Général",
"generalEnableDebugging": "Activer le mode développement",
"generalEncryptionPassword": "Mot de passe de chiffrement",
"generalEncryptionPasswordSubtext": "Les journaux contiennent des informations sensibles, choisissez un mot de passe fort",
"generalLoggingIp": "IP de journaux",
"generalLoggingIpSubtext": "L'IP à laquelle envoyer les journaux",
"generalLoggingPort": "Port de journaux",
"generalLoggingPortSubtext": "L'IP où envoyer les journaux"
},
"network": {
"title": "Réseau",
"automaticDownloadsSection": "Téléchargements automatiques",
"automaticDownloadsText": "Moxxy téléchargera automatiquement les fichiers sur…",
"automaticDownloadsMaximumSize": "Taille maximale de téléchargement",
"automaticDownloadAlways": "Toujours",
"wifi": "WiFi",
"mobileData": "Données mobiles",
"automaticDownloadsMaximumSizeSubtext": "La taille maximale des fichiers à télécharger automatiquement"
},
"privacy": {
"title": "Vie privée",
"generalSection": "Général",
"showContactRequests": "Afficher les requêtes de contact",
"showContactRequestsSubtext": "Cela affiche les personnes qui vous ont ajouté à leurs listes de contacts mais ne vous ont pas encore envoyé de message",
"profilePictureVisibility": "Rendre publique l'image de profil",
"profilePictureVisibilitSubtext": "Lorsque activée, tout le monde peut voir votre image de profil. Désactivée, seulement vos contacts peuvent la voir.",
"conversationsSection": "Conversation",
"sendChatMarkers": "Envoyer des marqueurs de conversation",
"sendChatStates": "Envoyer l'état de conversation",
"sendChatStatesSubtext": "Informe votre destinataire si vous être en train d'écrire, ou lire la conversation",
"sendChatMarkersSubtext": "Informe votre destinataire lorsqu'un message est reçu, ou lu",
"redirectsSection": "Redirections",
"redirectText": "Redirige les liens $serviceName vers un service de proxy, comme $exampleProxy",
"currentlySelected": "Service sélectionné: $proxy",
"redirectsTitle": "$serviceName Redirection",
"cannotEnableRedirect": "Impossible d'activer les redirections $serviceName",
"urlEmpty": "URL vide",
"urlInvalid": "URL non-valide",
"redirectDialogTitle": "$serviceName Redirect",
"stickersPrivacy": "Rendre publique la liste d'autoocollants",
"stickersPrivacySubtext": "Si activée, tout le monde pourra voir vos autocollants installés.",
"cannotEnableRedirectSubtext": "Service de redirection manquant, appuyer sur le champ à côté de l'interrupteur"
}
},
"conversation": {
"stickerSettings": "Paramètres des autocollants",
"unencrypted": "Non-chiffré",
"encrypted": "Chiffré",
"closeChat": "Fermer la conversation",
"closeChatConfirmTitle": "Fermeture de conversation",
"blockShort": "Bloquer",
"blockUser": "Bloquer cette personne",
"online": "En ligne",
"retract": "Supprimer le message",
"forward": "Transférer",
"edit": "Modifier",
"quote": "Citer",
"copy": "Copier le contenu",
"messageCopied": "Message copié vers le presse-papier",
"addReaction": "Ajouter une réaction",
"showError": "Afficher l'erreur",
"showWarning": "Afficher l'avertissement",
"warning": "Avertissement",
"addToContacts": "Ajouter aux contacts",
"addToContactsBody": "Confirmer l'ajout de ${jid} aux contacts ?",
"stickerPickerNoStickersLine1": "Pas d'autocollants installés.",
"newDeviceMessage": {
"one": "Un nouvel appareil a été ajouté",
"other": "De nouveaux appareils ont été ajoutés"
},
"replacedDeviceMessage": {
"one": "Un appareil a été modifié",
"other": "Plusieurs appareils ont été ajoutés"
},
"sendImages": "Envoyer des images",
"sendFiles": "Envoyer des fichiers",
"takePhotos": "Prendre des photos",
"messageHint": "Envoyer un message…",
"closeChatConfirmSubtext": "Confirmer la fermeture de cette conversation ?",
"retractBody": "Confirmer la demande de suppression du message ? Cela n'est qu'une demande que le client n'a pas a honorer.",
"addToContactsTitle": "Ajouter ${jid} aux contacts",
"stickerPickerNoStickersLine2": "Des autocollants sont disponibles à l'installation dans les paramètres."
},
"intro": {
"noAccount": "Pas de compte XMPP ? Aucun soucis, en créer un est très simple.",
"loginButton": "Connexion",
"registerButton": "Inscription"
},
"login": {
"title": "Connexion",
"xmppAddress": "Adresse XMPP",
"password": "Mot de passe",
"advancedOptions": "Paramètres avancés",
"createAccount": "Créer un compte sur le serveur"
},
"conversations": {
"speeddialNewChat": "Nouvelle conversation",
"speeddialJoinGroupchat": "Rejoindre une conversation de groupe",
"speeddialAddNoteToSelf": "Note personnelle",
"overlaySettings": "Paramètres",
"noOpenChats": "Aucune conversation d'ouverte",
"startChat": "Démarrer une conversation",
"closeChatBody": "Souhaitez-vous fermer la conversation avec ${conversationTitle} ?",
"markAsRead": "Marquer comme lue",
"closeChat": "Fermer une conversation"
},
"startchat": {
"title": "Nouvelle conversation",
"xmppAddress": "Adresse XMPP",
"buttonAddToContact": "Démarrer une nouvelle conversation",
"subtitle": "Vous pouvez démarrer une conversation à l'aide d'une adresse XMPP ou en scannant un code QR."
},
"newconversation": {
"title": "Nouvelle conversation",
"startChat": "Démarrer une nouvelle conversation",
"createGroupchat": "Nouvelle conversation de groupe",
"joinGroupChat": "Rejoindre une conversation de groupe",
"nullNickname": "Le pseudonyme ne peut pas être vide !",
"nick": "Pseudonyme",
"enterNickname": "Entrer un pseudonyme",
"nicknameSubtitle": "Un pseudonyme unique est requis pour rejoindre une CUM."
},
"crop": {
"setProfilePicture": "Utiliser comme image de profil"
},
"shareselection": {
"shareWith": "Partager avec…",
"confirmTitle": "Envoyer le fichier",
"confirmBody": "Une ou plusieurs conversations ne sont pas chiffrées. Le fichier sera accessible au serveur. Confirmer ?"
},
"profile": {
"general": {
"omemo": "Sécurité",
"profile": "Profil",
"media": "Média"
},
"conversation": {
"notifications": "Notifications",
"notificationsMuted": "En sourdine",
"notificationsEnabled": "Activées",
"sharedMedia": "Média"
},
"owndevices": {
"title": "Appareils disponibles",
"otherDevices": "Autres appareils",
"thisDevice": "Cet appareil",
"deleteDeviceConfirmTitle": "Supprimer appareil",
"recreateOwnSessions": "Recréer sessions",
"recreateOwnSessionsConfirmTitle": "Recréer ses propres sessions ?",
"recreateOwnDevice": "Recréer appareil",
"recreateOwnDeviceConfirmTitle": "Récréer cet appareil ?",
"deleteDeviceConfirmBody": "Vos contact ne pourront pas chiffrer pour cet appareil, continuer ?",
"recreateOwnSessionsConfirmBody": "Cela recréera les sessions cryptographiques de vos appareils. À n'utiliser qu'en cas d'erreur de déchiffrement.",
"recreateOwnDeviceConfirmBody": "Cela récréera l'identité cryptographique de cet appareil. Cette opération peut prendre du temps. Les contact ayant vérifié cet appareils auront à le faire de nouveau, continuer ?"
},
"devices": {
"title": "Sécurité",
"recreateSessions": "Recréer sessions",
"recreateSessionsConfirmTitle": "Recréer les sessions ?",
"noSessions": "Il n'y a pas de sessions cryptographiques utilisées pour du chiffrement de bout en bout.",
"recreateSessionsConfirmBody": "Cela recréera les sessions cryptographiques de vos appareils. À n'utiliser qu'en cas d'erreur de déchiffrement sur ceux-ci."
}
},
"blocklist": {
"title": "Liste de blocage",
"noUsersBlocked": "Aucune personne bloquée",
"unblockAll": "Tout débloquer",
"unblockAllConfirmTitle": "Confirmer ?",
"unblockAllConfirmBody": "Confirmer le débloquage de toutes les personnes ?",
"unblockJidConfirmTitle": "Débloquer ${jid} ?",
"unblockJidConfirmBody": "Confirmer le débloquage de ${jid} ? Vous recevrez de nouveau les messages de sa part."
},
"cropbackground": {
"blur": "Flouter l'arrière-plan",
"setAsBackground": "Utiliser comme arrière-plan"
},
"stickerPack": {
"removeConfirmTitle": "Supprimer autocollant",
"removeConfirmBody": "Confirmer la suppression de cette série d'autocollants ?",
"installConfirmTitle": "Installer autocollant",
"restricted": "Cette série est limitée. Les autocollants s'afficheront mais ne pourront être envoyés.",
"fetchingFailure": "Autocollants introuvables",
"installConfirmBody": "Confirmer l'installation de cette série d'autocollants ?"
},
"sharedMedia": {
"empty": {
"chat": "Pas de média partagé dans cette conversation",
"general": "Pas de fichier média disponible"
}
}
},
"warnings": {
"conversation": {
"holdForLonger": "Maintenez le bouton pressé pour enregistrer un message vocal"
},
"message": {
"integrityCheckFailed": "Impossible de vérifier l'intégrité du fichier"
}
}
}

View File

@@ -0,0 +1,443 @@
{
"dateTime": {
"justNow": "Neste momento",
"nMinutesAgo": "fai ${min}min",
"mondayAbbrev": "Lun",
"tuesdayAbbrev": "Mar",
"wednessdayAbbrev": "Mér",
"thursdayAbbrev": "Xov",
"fridayAbbrev": "Ven",
"saturdayAbbrev": "Sáb",
"sundayAbbrev": "Dom",
"january": "Xaneiro",
"february": "Febreiro",
"march": "Marzo",
"april": "Abril",
"may": "Maio",
"june": "Xuño",
"july": "Xullo",
"august": "Agosto",
"september": "Setembro",
"october": "Outubro",
"november": "Novembro",
"december": "Decembro",
"today": "Hoxe",
"yesterday": "Onte"
},
"messages": {
"image": "Imaxe",
"video": "Vídeo",
"audio": "Audio",
"file": "Ficheiro",
"sticker": "Adhesivo",
"retracted": "A mensaxe foi editada",
"you": "Ti",
"retractedFallback": "Unha mensaxe anterior foi editada pero o teu cliente non soporta esa función"
},
"errors": {
"general": {
"noInternet": "Sen conexión a Internet."
},
"filePicker": {
"permissionDenied": "Non se concedeu permiso de acceso á almacenaxe."
},
"omemo": {
"couldNotPublish": "Non se puido publicar a identidade criptográfica no servidor. Debido a isto a cifraxe de extremo-a-extremo podería non funcionar.",
"invalidHmac": "Non se descifrou a mensaxe",
"noDecryptionKey": "Non se dispón de chave de descifrado",
"messageInvalidAfixElement": "Mensaxe cifrada non válida",
"verificationInvalidOmemoUrl": "Impresión dixital OMEMO:2 non válida",
"verificationWrongJid": "Enderezo XMPP incorrecto",
"verificationWrongDevice": "Dispositivo OMEMO:2 incorrecto",
"verificationNotInList": "Dispositivo OMEMO:2 incorrecto",
"verificationWrongFingerprint": "Impresión dixital OMEMO:2 incorrecta",
"notEncryptedForDevice": "Non se cifrou a mensaxe para este dispositivo"
},
"connection": {
"connectionTimeout": "Non hai conexión co servidor",
"saslAccountDisabled": "A conta está desactivada",
"saslInvalidCredentials": "As credenciais da conta non son válidas",
"unrecoverable": "Perdeuse a conexión debido a un erro irremediable"
},
"login": {
"saslFailed": "Credenciais de acceso incorrectas",
"startTlsFailed": "Non se puido establecer unha conexión segura",
"noConnection": "Fallou o establecemento da conexión",
"unspecified": "Erro inconcreto"
},
"message": {
"unspecified": "Erro descoñecido",
"fileUploadFailed": "Fallou a subida do ficheiro",
"contactDoesntSupportOmemo": "O contacto non ten soporte para a cifraxe usando OMEMO:2",
"fileDownloadFailed": "Fallou a descarga do ficheiro",
"serviceUnavailable": "Non se puido entregar a mensaxe ao contacto",
"remoteServerTimeout": "A mensaxe non se puido entregar ao servidor do contacto",
"remoteServerNotFound": "A mensaxe non se puido entregar ao servidor do contacto e non sabemos onde está",
"failedToEncrypt": "Non se puido cifrar a mensaxe",
"failedToEncryptFile": "Non se puido cifrar o ficheiro",
"failedToDecryptFile": "Non se puido descifrar o ficheiro",
"fileNotEncrypted": "A conversa está cifrada pero o ficheiro non está cifrado"
},
"conversation": {
"audioRecordingError": "Fallou a finalización da gravación de audio",
"openFileNoAppError": "Non se atopa unha app para abrir o ficheiro",
"openFileGenericError": "Non se puido abrir o ficheiro",
"messageErrorDialogTitle": "Erro"
},
"newChat": {
"remoteServerError": "Non se puido contactar co servidor remoto.",
"groupchatUnsupported": "Por agora non hai soporte para unirse a conversas en grupo.",
"unknown": "Erro descoñecido."
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Non se puido verificar a integridade do ficheiro"
},
"conversation": {
"holdForLonger": "Mantén premido o botón para gravar a mensaxe de voz"
}
},
"pages": {
"intro": {
"noAccount": "Non tes unha conta XMPP? Sen problema, crear unha é doado.",
"loginButton": "Acceder",
"registerButton": "Crear conta"
},
"login": {
"title": "Acceder",
"xmppAddress": "Enderezo-XMPP",
"password": "Contrasinal",
"advancedOptions": "Opcións avanzadas",
"createAccount": "Crear conta no servidor"
},
"conversations": {
"speeddialNewChat": "Nova conversa",
"speeddialJoinGroupchat": "Entrar na conversa en grupo",
"speeddialAddNoteToSelf": "Notas propias",
"overlaySettings": "Axustes",
"noOpenChats": "Non tes conversas abertas",
"startChat": "Inicia unha conversa",
"closeChat": "Pecha a conversa",
"markAsRead": "Marcar como lido",
"closeChatBody": "Tes a certeza de querer pechar a conversa con ${conversationTitle}?"
},
"conversation": {
"unencrypted": "Sen cifrar",
"encrypted": "Cifrada",
"closeChat": "Pechar conversa",
"closeChatConfirmTitle": "Pechar conversa",
"closeChatConfirmSubtext": "Tes a certeza de querer pechar esta conversa?",
"blockShort": "Bloquear",
"blockUser": "Bloquear conta",
"online": "En liña",
"retract": "Editar mensaxe",
"retractBody": "Tes a certeza de querer editar a mensaxe? Ten en conta que esta só é unha solicitude e que o cliente non ten porque facerlle caso.",
"forward": "Reenviar",
"edit": "Editar",
"quote": "Citar",
"showWarning": "Mostrar aviso",
"warning": "Aviso",
"addToContacts": "Engadir aos contactos",
"addToContactsTitle": "Engadir a ${jid} aos contactos",
"addToContactsBody": "Tes a certeza de querer engadir a ${jid} aos teus contactos?",
"stickerPickerNoStickersLine1": "Non tes paquetes de adhesivos instalados.",
"stickerPickerNoStickersLine2": "Pódelos instalar a través dos axustes dos adhesivos.",
"stickerSettings": "Axustes dos adhesivos",
"newDeviceMessage": {
"one": "Engadiuse un novo dispositivo",
"other": "Engadíronse varios dispositivos novos"
},
"replacedDeviceMessage": {
"one": "Un dos dispositivos cambiou",
"other": "Engadíronse varios dispositivos"
},
"messageHint": "Enviar unha mensaxe…",
"sendImages": "Enviar imaxes",
"sendFiles": "Enviar ficheiros",
"takePhotos": "Facer fotos",
"copy": "Copiar contido",
"messageCopied": "Copiouse o contido da mensaxe no portapapeis",
"addReaction": "Engadir reacción",
"showError": "Mostar erro"
},
"startchat": {
"title": "Nova Conversa",
"xmppAddress": "Enderezo XMPP",
"subtitle": "Podes iniciar unha nova conversa escribindo un enderezo XMPP ou escaneando o seu código QR.",
"buttonAddToContact": "Iniciar nova conversa"
},
"newconversation": {
"title": "Nova conversa",
"startChat": "Iniciar nova conversa",
"createGroupchat": "Nova conversa en grupo",
"nullNickname": "O alcume non pode estar baleiro!",
"nick": "Alcume",
"nicknameSubtitle": "Tes que escribir un alcume único para poder unirte á MUC.",
"joinGroupChat": "Unirse a conversa en grupo",
"enterNickname": "Escribe un alcume"
},
"crop": {
"setProfilePicture": "Establecer imaxe de perfil"
},
"shareselection": {
"shareWith": "Compartir con…",
"confirmTitle": "Enviar ficheiro",
"confirmBody": "Unha ou varias conversas non están cifradas. Isto significa que o ficheiro podería ser visto no servidor. Desexas continuar?"
},
"profile": {
"general": {
"omemo": "Seguridade",
"profile": "Perfil",
"media": "Multimedia"
},
"conversation": {
"notifications": "Notificacións",
"notificationsMuted": "Acalada",
"notificationsEnabled": "Activada",
"sharedMedia": "Multimedia"
},
"owndevices": {
"title": "Dispositivos propios",
"thisDevice": "Este dispositivo",
"otherDevices": "Outros dispositivos",
"deleteDeviceConfirmTitle": "Eliminar dispositivo",
"deleteDeviceConfirmBody": "Isto significa que os contactos non poderán cifrar as mensaxes para ese dispositivo. Continuar?",
"recreateOwnSessions": "Reconstruír sesións",
"recreateOwnSessionsConfirmTitle": "Recrear as sesións propias?",
"recreateOwnSessionsConfirmBody": "Isto volverá a crear as sesións criptográficas dos teus dispositivos. Fai isto só se os teus dispositivos mostran erros ao descrifrar.",
"recreateOwnDevice": "Recrear dispositivo",
"recreateOwnDeviceConfirmTitle": "Recrear o dispositivo propio?",
"recreateOwnDeviceConfirmBody": "Isto recreará a identidade criptográfica deste dispositivo? Podería levarlle un anaco. Se os contactos verificaron este dispositivo, terán que facelo outra vez. Continuar?"
},
"devices": {
"title": "Seguridade",
"recreateSessions": "Reconstruír sesións",
"recreateSessionsConfirmTitle": "Reconstruír sesións?",
"recreateSessionsConfirmBody": "Isto volverá a crear as sesións criptográficas dos teus dispositivos. Usa isto só se os teus dispositivos mostran erros ao descifrar.",
"noSessions": "Non hai sesións criptográficas en uso para a cifraxe de extremo-a-extremo."
}
},
"sharedMedia": {
"empty": {
"general": "Non hai dispoñibles ficheiros multimedia",
"chat": "Non hai multimedia compartido nesta conversa"
}
},
"settings": {
"about": {
"debugMenuAlreadyShown": "Xa es desenvolvedora!",
"title": "Acerca de",
"licensed": "Baixo licenza GPL3",
"version": "Versión ${version}",
"viewSourceCode": "Ver código fonte",
"nMoreToGo": "${n} máis para rematar…",
"debugMenuShown": "Agora xa es desenvolvedora!"
},
"settings": {
"title": "Axustes",
"conversationsSection": "Conversas",
"accountSection": "Conta",
"signOut": "Pechar sesión",
"signOutConfirmTitle": "Pechar Sesión",
"signOutConfirmBody": "Vas pechar a sesión. Continuar?",
"miscellaneousSection": "Varios",
"debuggingSection": "Depuración",
"general": "Xeral"
},
"appearance": {
"title": "Aparencia",
"languageSection": "Idioma",
"language": "Idioma da app",
"languageSubtext": "Idioma actual: $selectedLanguage",
"systemLanguage": "Idioma por defecto"
},
"licenses": {
"title": "Licenzas Open-Source",
"licensedUnder": "Baixo licenza $license"
},
"conversation": {
"title": "Conversa",
"appearance": "Aparencia",
"selectBackgroundImage": "Elexir imaxe de fondo",
"selectBackgroundImageDescription": "Esta será a imaxe de fondo en todas as túas conversas",
"removeBackgroundImage": "Retirar imaxe de fondo",
"removeBackgroundImageConfirmTitle": "Retirar imaxe de fondo",
"removeBackgroundImageConfirmBody": "Tes a certeza de querer eliminar a imaxe de fondo da conversa?",
"newChatsSection": "Novas Conversas",
"newChatsMuteByDefault": "Por defecto acalar as novas conversas",
"newChatsE2EE": "Por defecto activar a cifraxe de extremo-a-extremo. AVISO: Experimental",
"behaviourSection": "Comportamento",
"contactsIntegration": "Integración dos contactos",
"contactsIntegrationBody": "Ao activala, os datos da libreta de enderezos usaranse para proporcionar título ás conversas e imaxes de perfil. Non se transmite información ao servidor."
},
"debugging": {
"title": "Opción de depuración",
"generalSection": "Xeral",
"generalEnableDebugging": "Activar depuración",
"generalEncryptionPassword": "Contrasinal de cifraxe",
"generalLoggingIp": "IP de rexistro",
"generalLoggingIpSubtext": "O IP ao que se deben enviar os rexistros",
"generalLoggingPort": "Porto de rexistro",
"generalLoggingPortSubtext": "O IP ao que se deben enviar os rexistros",
"generalEncryptionPasswordSubtext": "Os rexistros poderían conter información sensible así que mellor elixe un contrasinal forte"
},
"network": {
"title": "Rede",
"automaticDownloadsSection": "Descargas automáticas",
"automaticDownloadsText": "Moxxy descargará automáticamente os ficheiros en…",
"automaticDownloadsMaximumSize": "Tamaño máximo de descarga",
"automaticDownloadsMaximumSizeSubtext": "O tamaño máximo dos ficheiros a descargar de xeito automático",
"automaticDownloadAlways": "Sempre",
"wifi": "Wifi",
"mobileData": "Datos móbiles"
},
"privacy": {
"profilePictureVisibility": "Facer pública a foto de perfil",
"profilePictureVisibilitSubtext": "Se o activas, calquera poderá ver a túa foto de perfil. Se o desactivas, só as persoas da túa lista de contactos poderán ver a foto de perfil.",
"conversationsSection": "Conversa",
"sendChatMarkers": "Enviar marcadores de conversa",
"title": "Privacidade",
"generalSection": "Xeral",
"sendChatMarkersSubtext": "Vai indicar aos teus correspondentes se recibiches ou liches a mensaxe",
"showContactRequests": "Mostrar solicitudes de contacto",
"showContactRequestsSubtext": "Mostrarache as persoas que te engadiron á súa lista de contactos pero que aínda non che enviaron mensaxes",
"sendChatStates": "Enviar estados da conversa",
"sendChatStatesSubtext": "Vai indicar aos teus correspondentes se estás escribindo ou lendo na conversa",
"redirectsSection": "Reenvíos",
"redirectsTitle": "Redirixir $serviceName",
"cannotEnableRedirect": "Non se pode activar a redirección $serviceName",
"cannotEnableRedirectSubtext": "Primeiro tes que establecer o servizo proxy ao que queres redirixir. Para facelo toca no campo a carón do botón.",
"urlEmpty": "O URL non pode estar baleiro",
"urlInvalid": "URL non válido",
"redirectDialogTitle": "Redirixir $serviceName",
"stickersPrivacy": "Manter como pública a lista de adhesivos",
"stickersPrivacySubtext": "Se o activas, calquera poderá ver a túa lista de paquetes de adhesivos instalados.",
"redirectText": "Redireccionará as ligazóns a $serviceName nas que premas cara o servizo proxy, ex. $exampleProxy",
"currentlySelected": "Seleccionado actualmente: $proxy"
},
"stickers": {
"title": "Adhesivos",
"stickerSection": "Adhesivo",
"displayStickers": "Mostar adhesivos na conversa",
"autoDownload": "Descargar automaticamente adhesivos",
"autoDownloadBody": "Se o activas, descargarás automaticamente os adhesivos cando o remitente estea na túa lista de contactos.",
"stickerPacksSection": "Paquetes de adhesivos",
"importStickerPack": "Importar paquete de adhesivos",
"importSuccess": "Paquete de adhesivos importado correctamente",
"importFailure": "Fallou a importación do paquete de adhesivos",
"stickerPackSize": "(${size})"
},
"stickerPacks": {
"title": "Paquetes de Adhesivos"
},
"storage": {
"title": "Almacenaxe",
"storageUsed": "Almacenaxe utilizada: ${size}",
"sizePlaceholder": "Calculando…",
"storageManagement": "Xestión da almacenaxe",
"removeOldMedia": {
"title": "Eliminar multimedia antigo",
"description": "Elimina ficheiros multimedia antigos do dispositivo"
},
"removeOldMediaDialog": {
"title": "Eliminar ficheiros multimedia",
"options": {
"all": "Todos os ficheiros multimedia",
"oneWeek": "Anteriores a 1 semana",
"oneMonth": "Anteriores a 1 mes"
},
"delete": "Eliminar",
"confirmation": {
"body": "Tes a certeza de querer eliminar os ficheiros multimedia antigos?"
}
},
"viewMediaFiles": "Ver ficheiros multimedia",
"mediaFiles": "Ficheiros multimedia",
"types": {
"media": "Multimedia",
"stickers": "Adhesivos"
},
"manageStickers": "Xestión dos paquetes de adhesivos"
}
},
"blocklist": {
"title": "Lista de bloqueo",
"noUsersBlocked": "Non tes contas bloqueadas",
"unblockAll": "Desbloquear todo",
"unblockAllConfirmTitle": "Tes certeza?",
"unblockAllConfirmBody": "Tes a certeza de querer desbloquear todas as contas?",
"unblockJidConfirmTitle": "Desbloquear a ${jid}?",
"unblockJidConfirmBody": "Tes a certeza de querer desbloquear a ${jid}? Volverás a recibir mensaxes desde esta conta."
},
"cropbackground": {
"blur": "Desenfocar o fondo",
"setAsBackground": "Establecer como imaxe de fondo"
},
"stickerPack": {
"removeConfirmTitle": "Eliminar paquete de adhesivos",
"removeConfirmBody": "Tes a certeza de querer eliminar este paquete de adhesivos?",
"installConfirmTitle": "Instalar paquete de adhesivos",
"installConfirmBody": "Tes a certeza de querer instalar este paquete de adhesivos?",
"restricted": "Este paquete de adhesivos ten restricións. Significa que poden ser vistos pero non enviados.",
"fetchingFailure": "Non se atopa o paquete de adhesivos"
}
},
"global": {
"title": "Moxxy",
"dialogAccept": "Oká",
"dialogCancel": "Cancelar",
"yes": "Si",
"no": "Non",
"moxxySubtitle": "Un experimento para crear un cliente XMPP moderno, bonito e doado de usar."
},
"notifications": {
"permanent": {
"idle": "Detido",
"ready": "Preparado para recibir mensaxes",
"connecting": "Conectando…",
"disconnect": "Desconectado",
"error": "Erro"
},
"message": {
"reply": "Responder",
"markAsRead": "Marcar como lido"
},
"channels": {
"messagesChannelName": "Mensaxes",
"warningChannelName": "Avisos",
"warningChannelDescription": "Avisos en relación a Moxxy",
"messagesChannelDescription": "A canle de notificación para as mensaxes recibidas",
"serviceChannelName": "Servizo en primeiro plano",
"serviceChannelDescription": "Mantén activa a notificación do servizo en primeiro plano"
},
"titles": {
"error": "Erro"
},
"errors": {
"messageError": {
"title": "Fallou a entrega da mensaxe",
"body": "Fallou a entrega da mensaxe a ${conversationTitle}"
}
},
"warnings": {
"blockingError": {
"title": "Erro ao bloquear a usuaria",
"body": "Non se puido bloquear a ${jid} porque o teu servidor non ten soporte para bloqueos."
}
}
},
"language": "Galego",
"permissions": {
"requests": {
"batterySaving": {
"reason": "Para poder recibir mensaxes cando está en segundo plano, Moxxy ten que evitar que Android force o aforro de batería."
},
"notification": {
"reason": "Para poder informar das mensaxes que chegan, Moxxy precisa permiso para mostrar notificacións."
}
},
"allow": "Permitir",
"skip": "Evitar"
}
}

View File

@@ -0,0 +1,172 @@
{
"language": "日本語",
"global": {
"yes": "はい",
"no": "いいえ",
"dialogCancel": "キャンセル",
"title": "Moxxy",
"dialogAccept": "OK"
},
"dateTime": {
"thursdayAbbrev": "木",
"fridayAbbrev": "金",
"saturdayAbbrev": "土",
"january": "1月",
"february": "2月",
"march": "3月",
"may": "5月",
"june": "6月",
"july": "7月",
"september": "9月",
"october": "10月",
"justNow": "ちょうど今",
"nMinutesAgo": "${min}分前",
"mondayAbbrev": "月",
"tuesdayAbbrev": "火",
"wednessdayAbbrev": "水",
"sundayAbbrev": "日",
"april": "4月",
"august": "8月",
"november": "11月",
"december": "12月",
"today": "今日",
"yesterday": "昨日"
},
"messages": {
"audio": "音声",
"you": "自分",
"image": "画像",
"video": "ビデオ",
"file": "ファイル",
"sticker": "スタンプ",
"retracted": "メッセージ取り消された",
"retractedFallback": "前のメッセージを取り消しましたが、このクライエントはサポートしていません"
},
"errors": {
"connection": {
"connectionTimeout": "サーバー接続中にタイムアウトが発生しました",
"saslInvalidCredentials": "ユーザー名またはパスワードが無効",
"saslAccountDisabled": "あなたのアカウントは停止されています",
"unrecoverable": "回復不能のエラーで接続が途絶えました"
},
"login": {
"noConnection": "接続できませんでした",
"startTlsFailed": "接続できませんでした",
"unspecified": "特定できないエラー",
"saslFailed": "無効なログイン証明書です"
},
"message": {
"fileUploadFailed": "アップロードに失敗しました",
"fileDownloadFailed": "ダウンロードに失敗しました",
"serviceUnavailable": "配信に失敗しました",
"unspecified": "不明なエラー",
"failedToDecryptFile": "そのファイルは復号化できません",
"fileNotEncrypted": "チャットは暗号化されますが、ファイルは暗号化されません"
},
"omemo": {
"notEncryptedForDevice": "このデバイス向けにメッセージは暗号化されませんでした",
"couldNotPublish": "サーバに対して暗号化IDを発行できませんでした、端末間暗号通信はできません",
"invalidHmac": "メッセージを復号できませんでした",
"noDecryptionKey": "復号キーがありません",
"messageInvalidAfixElement": "無効な暗号化メッセージです",
"verificationInvalidOmemoUrl": "無効な OMEMO:2 フィンガープリントです",
"verificationWrongJid": "正しくない XMPP アドレスです",
"verificationWrongDevice": "正しくない OMEMO:2 機器です",
"verificationNotInList": "正しくない OMEMO:2 機器です",
"verificationWrongFingerprint": "正しくない OMEMO:2 フィンガープリントです"
},
"conversation": {
"messageErrorDialogTitle": "エラー",
"audioRecordingError": "音声録音の処理に失敗しました",
"openFileNoAppError": "このファイルを開くアプリケーションを発見できませんでした",
"openFileGenericError": "ファイルを開くのに失敗しました"
},
"filePicker": {
"permissionDenied": "書き込み用メディアへの権限がありません"
},
"general": {
"noInternet": "インターネットに接続されていません"
},
"newChat": {
"remoteServerError": "リモートサーバーへの接続に失敗しました",
"groupchatUnsupported": "グループチャットへの参加は現在サポートされていません",
"unknown": "不明なエラー"
}
},
"warnings": {
"conversation": {
"holdForLonger": "長押しすると音声記録できます"
},
"message": {
"integrityCheckFailed": "ファイルの整合性を確認できませんでした"
}
},
"pages": {
"intro": {
"loginButton": "ログイン",
"registerButton": "新規登録",
"noAccount": "XMPPアドレスをお持ちですかXMPPアドレスの作成は簡単です。"
},
"login": {
"title": "ログイン",
"xmppAddress": "XMPPアドレス",
"password": "パスワード",
"advancedOptions": "詳細設定",
"createAccount": "サーバーにアカウントを作成する"
},
"conversations": {
"speeddialJoinGroupchat": "グループチャットに参加",
"speeddialAddNoteToSelf": "自分用メモ",
"speeddialNewChat": "新しいチャット",
"overlaySettings": "設定"
},
"conversation": {
"blockShort": "ブロック",
"blockUser": "ユーザをブロック",
"retract": "メッセージを取り消す",
"retractBody": "本当にメッセージを取り消しますか? これはただのリクエストであり面目や体裁に関わるものではありません",
"forward": "送る",
"edit": "編集する",
"online": "オンライン",
"quote": "引用する",
"copy": "内容をコピー",
"addReaction": "リアクションを追加する",
"showError": "エラーを表示",
"showWarning": "警告を表示",
"warning": "警告",
"messageCopied": "メッセージはクリップボードにコピーされました"
}
},
"notifications": {
"permanent": {
"connecting": "接続中…",
"error": "エラー",
"idle": "待機中",
"ready": "メッセージを受信する準備ができました",
"disconnect": "切断済み"
},
"message": {
"reply": "返信",
"markAsRead": "既読"
},
"channels": {
"warningChannelName": "警告",
"messagesChannelName": "メッセージ",
"messagesChannelDescription": "受信メッセージのためのお知らせチャンネル",
"warningChannelDescription": "Moxxy に関する警告"
},
"titles": {
"error": "エラー"
},
"errors": {
"messageError": {
"title": "メッセージの送信に失敗しました",
"body": "${conversationTitle} に送信したメッセージは届きませんでした"
}
}
},
"permissions": {
"skip": "スキップ",
"allow": "許可する"
}
}

View File

@@ -0,0 +1,443 @@
{
"language": "Nederlands",
"global": {
"title": "Moxxy",
"dialogAccept": "Oké",
"dialogCancel": "Annuleren",
"yes": "Ja",
"no": "Nee",
"moxxySubtitle": "Een xmpp-experiment: het bouwen van een moderne, eenvoudige en mooie client."
},
"notifications": {
"permanent": {
"idle": "Inactief",
"ready": "Klaar om berichten te ontvangen",
"connecting": "Bezig met verbinden…",
"disconnect": "Verbinding verbroken",
"error": "Foutmelding"
},
"message": {
"reply": "Beantwoorden",
"markAsRead": "Markeren als gelezen"
},
"channels": {
"messagesChannelName": "Berichten",
"warningChannelName": "Waarschuwingen",
"warningChannelDescription": "Aan Moxxy gerelateerde waarschuwingen",
"messagesChannelDescription": "Het meldingskanaal voor het ontvangen van berichten",
"serviceChannelName": "Voorgronddienst",
"serviceChannelDescription": "Toont de vaste voorgronddienstmelding"
},
"titles": {
"error": "Foutmelding"
},
"errors": {
"messageError": {
"title": "Bericht afleveren mislukt",
"body": "Her bericht aan ${conversationTitle} kan niet worden afgeleverd"
}
},
"warnings": {
"blockingError": {
"title": "Blokkeren mislukt",
"body": "De gebruiker ${jid} kan niet worden geblokkeerd omdat je server dit niet ondersteunt."
}
}
},
"dateTime": {
"justNow": "Zojuist",
"nMinutesAgo": "${min} min. geleden",
"mondayAbbrev": "ma",
"tuesdayAbbrev": "di",
"wednessdayAbbrev": "woe",
"thursdayAbbrev": "do",
"fridayAbbrev": "vrij",
"saturdayAbbrev": "za",
"sundayAbbrev": "zo",
"january": "januari",
"february": "februari",
"march": "maart",
"april": "april",
"may": "mei",
"june": "juni",
"july": "juli",
"august": "augustus",
"september": "september",
"october": "oktober",
"november": "november",
"december": "december",
"today": "Vandaag",
"yesterday": "Gisteren"
},
"messages": {
"image": "Afbeelding",
"video": "Video",
"audio": "Audio",
"file": "Bestand",
"sticker": "Sticker",
"retracted": "Dit bericht is herroepen",
"retractedFallback": "Er is een eerder bericht herroepen, maar je client heeft hier geen ondersteuning voor",
"you": "Ik"
},
"errors": {
"filePicker": {
"permissionDenied": "Het opslagrecht is geweigerd."
},
"omemo": {
"notEncryptedForDevice": "Dit bericht is niet versleuteld op dit apparaat",
"invalidHmac": "Het bericht kan niet worden ontsleuteld",
"noDecryptionKey": "Er is geen sleutel beschikbaar",
"messageInvalidAfixElement": "Het bericht is ongeldig versleuteld",
"verificationInvalidOmemoUrl": "Ongeldige OMEMO:2-vingerafdruk",
"verificationWrongJid": "Ongeldig xmpp-adres",
"verificationWrongDevice": "Ongeldig OMEMO:2-apparaat",
"verificationNotInList": "Ongeldig OMEMO:2-apparaat",
"verificationWrongFingerprint": "Ongeldige OMEMO:2-vingerafdruk",
"couldNotPublish": "De versleutelde identiteit kan niet worden gepubliceerd op de server. Hierdoor werkt eind-tot-eindversleuteling mogelijk niet."
},
"connection": {
"saslAccountDisabled": "Je account is uitgeschakeld",
"saslInvalidCredentials": "Je inloggegevens zijn ongeldig",
"unrecoverable": "De verbinding is verbroken wegens een onoplosbare fout",
"connectionTimeout": "Er kan geen verbinding worden gemaakt met de server"
},
"login": {
"saslFailed": "De inloggegevens zijn ongeldig",
"noConnection": "Er kan geen verbinding worden opgezet",
"unspecified": "Onbekende foutmelding",
"startTlsFailed": "Er kan geen beveiligde verbinding worden opgezet"
},
"message": {
"unspecified": "Onbekende foutmelding",
"fileUploadFailed": "Het bestand kan niet worden geüpload",
"contactDoesntSupportOmemo": "Deze contactpersoon heeft geen ondersteuning voon OMEMO:2-versleuteling",
"fileDownloadFailed": "Het bestand kan niet worden opgehaald",
"serviceUnavailable": "Het bericht kan niet worden bezorgd",
"remoteServerTimeout": "Het bericht kan niet worden verstuurd naar de server",
"failedToEncrypt": "Het bericht kan niet worden versleuteld",
"failedToEncryptFile": "Het bestand kan niet worden versleuteld",
"failedToDecryptFile": "Het bestand kan niet worden ontsleuteld",
"fileNotEncrypted": "Het gesprek is versleuteld, maar het bestand niet",
"remoteServerNotFound": "Het bericht kan niet worden verstuurd naar de server omdat het niet bestaat"
},
"conversation": {
"audioRecordingError": "De audio-opname kan niet worden afgerond",
"openFileGenericError": "Het bestand kan niet worden geopend",
"messageErrorDialogTitle": "Foutmelding",
"openFileNoAppError": "Er is geen app die dit bestand kan openen"
},
"general": {
"noInternet": "Er is geen internetverbinding."
},
"newChat": {
"groupchatUnsupported": "Deelnemen aan groepsgesprekken wordt momenteel niet ondersteund.",
"unknown": "Onbekende foutmelding.",
"remoteServerError": "Er kan geen verbinding worden gemaakt met de externe server."
}
},
"warnings": {
"message": {
"integrityCheckFailed": "De bestandsintegriteit kan niet worden vastgesteld"
},
"conversation": {
"holdForLonger": "Houd langer ingedrukt om een spraakbericht op te nemen"
}
},
"pages": {
"intro": {
"loginButton": "Inloggen",
"registerButton": "Registreren",
"noAccount": "Geen xmpp-account? Geen zorgen: je maakt er in een handomdraai een aan."
},
"login": {
"title": "Inloggen",
"xmppAddress": "Xmpp-adres",
"password": "Wachtwoord",
"advancedOptions": "Geavanceerde opties",
"createAccount": "Account aanmaken op server"
},
"conversations": {
"speeddialNewChat": "Nieuw gesprek",
"speeddialJoinGroupchat": "Deelnemen aan groepsgesprek",
"speeddialAddNoteToSelf": "Zelfmemo",
"overlaySettings": "Instellingen",
"noOpenChats": "Er zijn geen openstaande gesprekken",
"startChat": "Gesprek starten",
"closeChat": "Gesprek sluiten",
"closeChatBody": "Weet je zeker dat je het gesprek “${conversationTitle}” wilt sluiten?",
"markAsRead": "Markeren als gelezen"
},
"conversation": {
"unencrypted": "Onversleuteld",
"encrypted": "Versleuteld",
"closeChat": "Gesprek sluiten",
"closeChatConfirmSubtext": "Weet je zeker dat je dit gesprek wilt sluiten?",
"blockShort": "Blokkeren",
"blockUser": "Gebruiker blokkeren",
"online": "Online",
"retract": "Bericht herroepen",
"forward": "Doorsturen",
"edit": "Bewerken",
"quote": "Citeren",
"copy": "Inhoud kopiëren",
"addReaction": "Reageren",
"showError": "Foutmelding tonen",
"showWarning": "Waarschuwing tonen",
"addToContacts": "Toevoegen aan contactpersonen",
"addToContactsTitle": "${jid} toevoegen aan contactpersonen",
"addToContactsBody": "Weet je zeker dat je ${jid} wilt toevoegen aan je contactpersonen?",
"stickerPickerNoStickersLine1": "Er zijn geen stickerpakketten beschikbaar.",
"stickerSettings": "Stickerinstellingen",
"newDeviceMessage": {
"one": "Er is een nieuw apparaat toegevoegd",
"other": "Er zijn meerdere nieuwe apparaten toegevoegd"
},
"replacedDeviceMessage": {
"one": "Er is een apparaat gewijzigd",
"other": "Er zijn meerdere apparaten toegevoegd"
},
"messageHint": "Verstuur een bericht…",
"sendImages": "Afbeeldingen versturen",
"sendFiles": "Bestanden versturen",
"closeChatConfirmTitle": "Gesprek sluiten",
"retractBody": "Weet je zeker dat je dit bericht wilt herroepen? Dit is slechts een verzoek aan de client dat niet in acht hoeft te worden genomen.",
"stickerPickerNoStickersLine2": "Installeer pakketten via de stickerinstellingen.",
"takePhotos": "Foto's maken",
"warning": "Waarschuwing",
"messageCopied": "De berichtinhoud is gekopieerd naar het klembord"
},
"newconversation": {
"title": "Nieuw gesprek",
"startChat": "Gesprek starten",
"createGroupchat": "Nieuw groepsgesprek",
"nick": "Bijnaam",
"enterNickname": "Voer een bijnaam in",
"nicknameSubtitle": "Voer een unieke bijnaam in om deel te kunnen nemen aan een MUC.",
"joinGroupChat": "Deelnemen aan groepsgesprek",
"nullNickname": "Voer een bijnaam in!"
},
"crop": {
"setProfilePicture": "Instellen als profielfoto"
},
"shareselection": {
"shareWith": "Delen met…",
"confirmTitle": "Bestand versturen",
"confirmBody": "Een of meerdere gesprekken zijn onversleuteld. Dit houdt in dat het bestand kan worden uitgelezen door de server. Weet je zeker dat je wilt doorgaan?"
},
"profile": {
"general": {
"omemo": "Beveiliging",
"profile": "Profiel",
"media": "Media"
},
"conversation": {
"notifications": "Meldingen",
"notificationsMuted": "Gedempt",
"notificationsEnabled": "Ingeschakeld",
"sharedMedia": "Media"
},
"owndevices": {
"title": "Mijn apparaten",
"thisDevice": "Dit apparaat",
"otherDevices": "Overige apparaten",
"deleteDeviceConfirmTitle": "Apparaat verwijderen",
"deleteDeviceConfirmBody": "Let op: hierdoor kunnen contactpersonen het apparaat niet meer versleutelen. Wil je doorgaan?",
"recreateOwnSessions": "Sessies heraanmaken",
"recreateOwnSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
"recreateOwnDevice": "Apparaat heraanmaken",
"recreateOwnDeviceConfirmTitle": "Wil je je apparaat heraanmaken?",
"recreateOwnSessionsConfirmBody": "Hierdoor worden de versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als apparaten ontsleutelfoutmeldingen tonen.",
"recreateOwnDeviceConfirmBody": "Hierdoor wordt de versleutelde identiteit van dit apparaat opnieuw aangemaakt. Dit kan even duren. Als contactpersonen je apparaat hebben goedgekeurd, dan dienen ze dit opnieuw te doen. Wil je doorgaan?"
},
"devices": {
"title": "Beveiliging",
"recreateSessions": "Sessies heraanmaken",
"recreateSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
"recreateSessionsConfirmBody": "Hierdoor worden alle versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als je apparaten ontsleutelfoutmeldingen tonen.",
"noSessions": "Er zijn geen versleutelde sessies die worden gebruikt voor eind-tot-eindversleuteling."
}
},
"blocklist": {
"title": "Blokkadelijst",
"noUsersBlocked": "Er zijn geen geblokkeerde gebruikers",
"unblockAll": "Iedereen deblokkeren",
"unblockAllConfirmTitle": "Weet je het zeker?",
"unblockAllConfirmBody": "Weet je zeker dat je alle gebruikers wilt deblokkeren?",
"unblockJidConfirmTitle": "Wil je ${jid} deblokkeren?",
"unblockJidConfirmBody": "Weet je zeker dat je ${jid} wilt deblokkeren? Hierdoor ontvang je weer berichten van deze gebruiker."
},
"cropbackground": {
"blur": "Achtergrond vervagen",
"setAsBackground": "Instellen als achtergrond"
},
"stickerPack": {
"removeConfirmTitle": "Stickerpakket verwijderen",
"installConfirmTitle": "Stickerpakket installeren",
"installConfirmBody": "Weet je zeker dat je dit stickerpakket wilt installeren?",
"fetchingFailure": "Het stickerpakket is niet gevonden",
"removeConfirmBody": "Weet je zeker dat je dit stickerpakket wilt verwijderen?",
"restricted": "Dit stickerpakket is beperkt toegankelijk. Dit houdt in dat de stickers kunnen worden getoond, maar niet worden verstuurd."
},
"settings": {
"settings": {
"title": "Instellingen",
"conversationsSection": "Gesprekken",
"accountSection": "Account",
"signOut": "Uitloggen",
"signOutConfirmTitle": "Uitloggen",
"signOutConfirmBody": "Je staat op het punt om uit te loggen. Wil je doorgaan?",
"miscellaneousSection": "Overig",
"debuggingSection": "Foutopsporing",
"general": "Algemeen"
},
"about": {
"title": "Over",
"licensed": "Uitgebracht onder de GPL3-licentie",
"version": "Versie ${version}",
"viewSourceCode": "Broncode bekijken",
"nMoreToGo": "Nog ${n} te gaan…",
"debugMenuShown": "Je bent nu een ontwikkelaar!",
"debugMenuAlreadyShown": "Je bent al een ontwikkelaar!"
},
"appearance": {
"title": "Vormgeving",
"languageSection": "Taal",
"language": "Apptaal",
"languageSubtext": "Huidige taal: $selectedLanguage",
"systemLanguage": "Standaardtaal"
},
"licenses": {
"title": "Opensourcelicenties",
"licensedUnder": "Uitgebracht onder de $license-licentie"
},
"conversation": {
"title": "Gesprek",
"appearance": "Vormgeving",
"selectBackgroundImage": "Kies een achtergrond",
"removeBackgroundImage": "Afbeelding verwijderen",
"removeBackgroundImageConfirmTitle": "Afbeelding verwijderen",
"removeBackgroundImageConfirmBody": "Weet je zeker dat je de huidige gespreksachtergrond wilt verwijderen?",
"newChatsSection": "Nieuwe gesprekken",
"newChatsMuteByDefault": "Nieuwe gesprekken dempen",
"newChatsE2EE": "Eind-tot-eindversleuteling standaard inschakelen (WAARSCHUWING: experimenteel)",
"behaviourSection": "Gedrag",
"contactsIntegration": "Contactpersoonintegratie",
"contactsIntegrationBody": "Schakel in om het adresboek te gebruiken om gesprekstitels en profielfoto's in te stellen. Er worden geen gegevens verstuurd naar de server.",
"selectBackgroundImageDescription": "Deze afbeelding wordt gebruikt als achtergrond in al je gesprekken"
},
"debugging": {
"title": "Foutopsporingsopties",
"generalSection": "Algemeen",
"generalEnableDebugging": "Foutopsporing inschakelen",
"generalEncryptionPassword": "Versleutelwachtwoord",
"generalLoggingIp": "Ip-log",
"generalLoggingIpSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
"generalLoggingPort": "Logpoort",
"generalLoggingPortSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
"generalEncryptionPasswordSubtext": "Let op: de logboeken kunnen privéinformatie bevatten, dus stel een sterk wachtwoord in"
},
"network": {
"title": "Netwerk",
"automaticDownloadsSection": "Automatisch ophalen",
"automaticDownloadsMaximumSize": "Maximale downloadomvang",
"automaticDownloadsMaximumSizeSubtext": "De maximale bestandsgrootte van automatisch op te halen bestanden",
"automaticDownloadAlways": "Ieder netwerk",
"wifi": "Wifi",
"mobileData": "Mobiel internet",
"automaticDownloadsText": "Moxxy zal bestanden automatisch ophalen bij gebruik van…"
},
"privacy": {
"title": "Privacy",
"generalSection": "Algemeen",
"showContactRequests": "Contactpersoonverzoeken tonen",
"profilePictureVisibility": "Profielfoto aan iedereen tonen",
"conversationsSection": "Gesprek",
"sendChatMarkersSubtext": "Hiermee kan je gesprekspartner zien of je een bericht gelezen of ontvangen hebt",
"sendChatMarkers": "Gespreksacties versturen",
"sendChatStates": "Gespreksstatussen versturen",
"sendChatStatesSubtext": "Hiermee kan je gesprekspartner zien of je aan het typen of het gesprek aan het bekijken bent",
"redirectsSection": "Doorverwijzingen",
"redirectText": "Hiermee worden $serviceName-links doorverwezen naar een proxy, bijvoorbeeld $exampleProxy",
"currentlySelected": "Huidige proxy: $proxy",
"redirectsTitle": "$serviceName-doorverwijzing",
"cannotEnableRedirect": "$serviceName-doorverwijzingen mislukt",
"cannotEnableRedirectSubtext": "Stel eerst een proxy als doorverwijzing in. Druk hiervoor op het veld naast de schakelaar.",
"urlEmpty": "Voer een url in",
"urlInvalid": "De url is ongeldig",
"redirectDialogTitle": "$serviceName-doorverwijzing",
"stickersPrivacy": "Stickerlijst aan iedereen tonen",
"stickersPrivacySubtext": "Schakel in om je lijst met stickerpakketten aan iedereen te tonen",
"showContactRequestsSubtext": "Hiermee worden verzoeken getoond van personen die je hebben toegevoegd, maar nog geen bericht hebben gestuurd",
"profilePictureVisibilitSubtext": "Schakel in om iedereen je profielfoto te tonen; schakel uit om alleen gebruikers op je lijst je profielfoto te tonen"
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Stickers in gesprekken tonen",
"autoDownload": "Stickers automatisch ophalen",
"autoDownloadBody": "Schakel in om stickers automatisch op te halen na het toevoegen van de afzender",
"stickerPacksSection": "Stickerpakketten",
"importStickerPack": "Stickerpakket importeren",
"importSuccess": "Het stickerpakket is geïmporteerd",
"importFailure": "Het stickerpakket kan niet worden geïmporteerd",
"stickerPackSize": "(${size})"
},
"storage": {
"title": "Opslag",
"storageUsed": "In gebruik: ${size}",
"sizePlaceholder": "Bezig met berekenen…",
"storageManagement": "Opslagbeheer",
"removeOldMedia": {
"title": "Oude media verwijderen",
"description": "Verwijdert oude mediabestanden van het apparaat"
},
"removeOldMediaDialog": {
"title": "Mediabestanden verwijderen",
"options": {
"all": "Alle mediabestanden",
"oneWeek": "Ouder dan 1 week",
"oneMonth": "Ouder dan 1 maand"
},
"delete": "Verwijderen",
"confirmation": {
"body": "Weet je zeker dat je oude mediabestanden wilt verwijderen?"
}
},
"viewMediaFiles": "Mediabestanden bekijken",
"mediaFiles": "Mediabestanden",
"types": {
"media": "Media",
"stickers": "Stickers"
},
"manageStickers": "Stickerpakketten beheren"
},
"stickerPacks": {
"title": "Stickerpakketten"
}
},
"startchat": {
"title": "Nieuw gesprek",
"xmppAddress": "Xmpp-adres",
"subtitle": "Je kunt een nieuw gesprek starten door een xmpp-adres in te voeren of een QR-code te scannen.",
"buttonAddToContact": "Gesprek starten"
},
"sharedMedia": {
"empty": {
"chat": "Er is geen gedeelde media in dit gesprek",
"general": "Er zijn geen mediabestanden beschikbaar"
}
}
},
"permissions": {
"allow": "Toestaan",
"skip": "Overslaan",
"requests": {
"notification": {
"reason": "Moxxy heeft het recht om meldingen te tonen nodig om meldingen van inkomende berichten te kunnen tonen."
},
"batterySaving": {
"reason": "Sluit Moxxy uit van Androids accubesparing om berichten op de achtergrond te ontvangen."
}
}
}
}

View File

@@ -0,0 +1,37 @@
{
"language": "Polski",
"global": {
"title": "Moxxy",
"dialogAccept": "OK",
"yes": "Tak",
"no": "Nie",
"dialogCancel": "Anuluj"
},
"notifications": {
"permanent": {
"connecting": "Łączenie…",
"disconnect": "Rozłączono",
"error": "Błąd"
},
"channels": {
"messagesChannelName": "Wiadomości",
"warningChannelName": "Ostrzeżenia"
},
"titles": {
"error": "Błąd"
}
},
"permissions": {
"allow": "Zezwól",
"skip": "Pomiń"
},
"dateTime": {
"mondayAbbrev": "Pon",
"tuesdayAbbrev": "Wt",
"wednessdayAbbrev": "Śr",
"thursdayAbbrev": "Czw",
"fridayAbbrev": "Pt",
"saturdayAbbrev": "Sob",
"sundayAbbrev": "Nd"
}
}

View File

@@ -0,0 +1,422 @@
{
"global": {
"title": "Moxxy",
"dialogAccept": "Принять",
"dialogCancel": "Отмена",
"yes": "Да",
"no": "Нет",
"moxxySubtitle": "Эксперементальный XMPP-клиент, простой, современный и красивый."
},
"notifications": {
"permanent": {
"idle": "Idle",
"ready": "Готов к приему сообщений",
"connecting": "Подключение…",
"disconnect": "Отключен",
"error": "Ошибка"
},
"message": {
"reply": "Ответ",
"markAsRead": "Прочитано"
},
"channels": {
"messagesChannelName": "Сообщения",
"warningChannelName": "Предупреждения",
"warningChannelDescription": "Предупреждения, связанные с Moxxy",
"messagesChannelDescription": "Канал уведомлений о полученных сообщениях"
},
"titles": {
"error": "Ошибка"
},
"errors": {
"messageError": {
"body": "Не удалось доставить сообщение для ${conversationTitle}",
"title": "Сообщение не доставлено"
}
}
},
"dateTime": {
"justNow": "Сейчас",
"nMinutesAgo": "${min}минут назад",
"mondayAbbrev": "Пн",
"tuesdayAbbrev": "Вт",
"wednessdayAbbrev": "Ср",
"thursdayAbbrev": "Чт",
"fridayAbbrev": "Пт",
"sundayAbbrev": "Вс",
"january": "Январь",
"february": "Февраль",
"may": "Май",
"june": "Июнь",
"october": "Октябрь",
"november": "Ноябрь",
"december": "Декабрь",
"today": "Сегодня",
"saturdayAbbrev": "Сб",
"march": "Март",
"april": "Апрель",
"july": "Июль",
"august": "Август",
"september": "Сентябрь",
"yesterday": "Вчера"
},
"messages": {
"image": "Изображение",
"video": "Видео",
"file": "Файл",
"sticker": "Стикер",
"you": "Ты",
"audio": "Аудио",
"retracted": "Сообщение удалено",
"retractedFallback": "Предыдущее сообщение было удалено, но это не поддерживается Вашим клиентом"
},
"errors": {
"filePicker": {
"permissionDenied": "Доступ к хранилищу не был выдан"
},
"omemo": {
"notEncryptedForDevice": "Сообщение зашифровано, но не для этого устройства",
"invalidHmac": "Не удалось расшифровать сообщение",
"noDecryptionKey": "Нет ключа для расшифровки",
"verificationWrongFingerprint": "Неправильный отпечаток OMEMO:2",
"couldNotPublish": "Не удалось опубликовать ключи шифрования на сервере. Это означает, что сквозное шифрование может не работать.",
"messageInvalidAfixElement": "Ошибка в зашифрованном сообщении",
"verificationInvalidOmemoUrl": "неверный отпечаток OMEMO:2",
"verificationWrongJid": "Неправильный XMPP-адрес",
"verificationWrongDevice": "Неправильное OMEMO:2 устройство",
"verificationNotInList": "Неправильное устройство OMEMO:2"
},
"connection": {
"connectionTimeout": "Нет соединения с сервером",
"saslInvalidCredentials": "Данные учетной записи недействительны",
"unrecoverable": "Соединение прервано из-за ошибки",
"saslAccountDisabled": "Аккаунт отключен"
},
"login": {
"saslFailed": "Неверный логин",
"startTlsFailed": "Не удалось установить безопасное соединение",
"noConnection": "Не удалось установить соединение",
"unspecified": "Неопределенная ошибка"
},
"message": {
"fileDownloadFailed": "Не удалось загрузить файл",
"remoteServerTimeout": "Сообщение не доставлено на сервер получателя",
"unspecified": "Неизвесная ошибка",
"fileUploadFailed": "Не удалось отправить файл",
"contactDoesntSupportOmemo": "Получатель не поддерживает OMEMO:2 шифрование",
"serviceUnavailable": "Сообщение не доставлено получателю",
"remoteServerNotFound": "Cообщение не доставлено, не найден сервер получателя",
"failedToEncrypt": "Сообщение не может быть зашифровано",
"failedToEncryptFile": "Файл не может быть зашифрован",
"failedToDecryptFile": "Файл не может быть расшифрован",
"fileNotEncrypted": "Этот чат зашифрован, но файл нет"
},
"conversation": {
"audioRecordingError": "Не удалось завершить аудиозапись",
"openFileNoAppError": "Приложения для открытия этого файла не найдены",
"openFileGenericError": "Не удалось открыть файл",
"messageErrorDialogTitle": "Ошибка"
},
"newChat": {
"groupchatUnsupported": "Вступление в групповой чат пока не поддерживается.",
"remoteServerError": "Не удалось связаться с удалённым сервером.",
"unknown": "Неизвестная ошибка."
},
"general": {
"noInternet": "Нет подключения к интернету."
}
},
"pages": {
"intro": {
"registerButton": "Регистрация",
"loginButton": "Login",
"noAccount": "Нет XMPP аккаунта? Зарегистрируйтесь на одном из серверов, это не сложно."
},
"login": {
"title": "Login",
"password": "Пароль",
"xmppAddress": "XMPP-адрес",
"advancedOptions": "Расширенные опции",
"createAccount": "Зарегистрироваться на сервере"
},
"conversations": {
"speeddialNewChat": "Написать",
"speeddialJoinGroupchat": "Групповой чат",
"speeddialAddNoteToSelf": "Заметка себе",
"overlaySettings": "Настройки",
"startChat": "Начать диалог",
"markAsRead": "Пометить как прочитанное",
"noOpenChats": "У вас пока нет диалогов",
"closeChat": "Завершить диалог",
"closeChatBody": "Вы уверены, что хотите завершить диалог с ${conversationTitle}?"
},
"conversation": {
"unencrypted": "Незашифрованно",
"encrypted": "Зашифрованно",
"closeChat": "Закрыть чат",
"closeChatConfirmTitle": "Закрыть чат",
"closeChatConfirmSubtext": "Вы уверены что хотите закрыть этот чат?",
"blockShort": "Заблокировать",
"blockUser": "Заблокировать пользователя",
"online": "В сети",
"retract": "Отозвать сообщение",
"copy": "Копировать содержимое",
"addReaction": "Реакция",
"showError": "Показать ошибки",
"showWarning": "Показать предупреждения",
"sendFiles": "Отправить файлы",
"takePhotos": "Сделать фотографии",
"retractBody": "Вы уверены, что хотите отозвать сообщение? Помните, что это всего лишь просьба, которую клиент не обязан выполнять.",
"forward": "Переслать",
"edit": "Изменить",
"quote": "Цитировать",
"addToContacts": "Добавить в контакты",
"addToContactsTitle": "Добавить ${jid} в контакты",
"addToContactsBody": "Вы уверены, что хотите добавить ${jid} в контакты?",
"stickerPickerNoStickersLine1": "Нет установленных стикерпаков.",
"stickerPickerNoStickersLine2": "Их можно установить в настройках стикеров.",
"stickerSettings": "Настройки стикеров",
"newDeviceMessage": {
"one": "Добавлено новое устройство",
"other": "Добавлено несколько новых устройств"
},
"replacedDeviceMessage": {
"one": "Устройство было изменено",
"other": "Добавлено несколько устройств"
},
"messageHint": "Сообщение...",
"sendImages": "Отправить изображение",
"messageCopied": "Сообщение скопировано в буфер",
"warning": "Предупреждение"
},
"startchat": {
"xmppAddress": "XMPP-адрес",
"buttonAddToContact": "Добавить в контакты",
"title": "Добавить контакт",
"subtitle": "Вы можете добавить контакт введя его XMPP адрес или отсканировав QR код"
},
"newconversation": {
"title": "Новый чат",
"startChat": "Добавить контакт",
"createGroupchat": "Создать новый групповой чат"
},
"shareselection": {
"shareWith": "Поделиться с...",
"confirmTitle": "Отправить файл",
"confirmBody": "Один или несколько чатов не зашифрованы, из-за чего файл будет доступен администрации сервера. Вы уверены, что хотите продолжить?"
},
"profile": {
"general": {
"omemo": "Безопасность",
"profile": "Профиль",
"media": "Медиа"
},
"conversation": {
"sharedMedia": "Медиа",
"notifications": "Уведомления",
"notificationsMuted": "Без звука",
"notificationsEnabled": "Включено"
},
"owndevices": {
"thisDevice": "Это устройство",
"recreateOwnDevice": "Восстановить устройство",
"title": "Мои устройства",
"otherDevices": "Другие устройства",
"deleteDeviceConfirmTitle": "Удалить устройство",
"deleteDeviceConfirmBody": "Контакты не смогут быть зашифрованы для этого устройства. Продолжить?",
"recreateOwnSessions": "Пересоздать сеанс",
"recreateOwnSessionsConfirmTitle": "Пересоздать свои сеансы?",
"recreateOwnSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае.",
"recreateOwnDeviceConfirmTitle": "Восстановить это устройство?",
"recreateOwnDeviceConfirmBody": "Это создаст новый криптографический отпечаток устройства, что займёт некоторое время. Если ваше устройство было подтверждено контактами, им придётся сделать это снова. Продолжить?"
},
"devices": {
"title": "Безопасность",
"recreateSessionsConfirmTitle": "Пересоздать сеанс?",
"noSessions": "Нет криптографических сессий, используемых для сквозного шифрования.",
"recreateSessions": "Пересоздать сеанс",
"recreateSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае."
}
},
"blocklist": {
"unblockAll": "Разблокировать всех",
"unblockJidConfirmTitle": "Разблокировать ${jid}?",
"title": "Блоклист",
"noUsersBlocked": "Нет заблокированных пользователей",
"unblockAllConfirmTitle": "Вы уверены?",
"unblockAllConfirmBody": "Вы действительно хотите разблокировать всех пользователей?",
"unblockJidConfirmBody": "Вы уверены, что хотите разблокировать ${jid}? Вы снова будете получать сообщения от этого пользователя."
},
"cropbackground": {
"blur": "Размыть фон",
"setAsBackground": "Установить фоновое изображение"
},
"crop": {
"setProfilePicture": "Загрузить аватар"
},
"stickerPack": {
"removeConfirmTitle": "Удалить стикерпак",
"removeConfirmBody": "Вы действительно хотите удалить этот стикерпак?",
"installConfirmTitle": "добавить стикерпак",
"installConfirmBody": "Вы действительно хотите установить этот стикерпак?",
"restricted": "Этот стикерпак ограничен, стикеры будут отображаться, но отправить их нельзя.",
"fetchingFailure": "Стикерпак не найден"
},
"settings": {
"about": {
"version": "Версия ${version}",
"debugMenuShown": "Теперь ты разработчик ^_^",
"debugMenuAlreadyShown": "Ты уже разработчик :^",
"title": "О нас",
"viewSourceCode": "Исходный код",
"nMoreToGo": "Осталось еще ${n}...",
"licensed": "Лицензировано под GPL3"
},
"conversation": {
"removeBackgroundImageConfirmBody": "Вы действительно хотите удалить фон?",
"title": "Чат",
"appearance": "Внешний вид",
"selectBackgroundImage": "Выбрать фон",
"removeBackgroundImage": "Удалить фон",
"removeBackgroundImageConfirmTitle": "Удалить фон",
"newChatsSection": "Новые чаты",
"newChatsMuteByDefault": "Отключать звук в новых чатах по умолчанию",
"newChatsE2EE": "Включить оконечное шифрование по умолчанию",
"behaviourSection": "Поведение",
"contactsIntegration": "Синхронизация контактов",
"selectBackgroundImageDescription": "Это изображение будет фоном для ваших чатов",
"contactsIntegrationBody": "При включении данные из Контактов будут использованы для названий чатов и фото профилей. На сервер ничего отправлено не будет."
},
"debugging": {
"title": "Опции отладки",
"generalSection": "Основные",
"generalEnableDebugging": "Включить отладку",
"generalEncryptionPassword": "Пароль шифрования",
"generalEncryptionPasswordSubtext": "Журналы могут содержать конфиденциальную информацию, поэтому поставте надежный пароль",
"generalLoggingIpSubtext": "IP, на который должны отправляться журналы",
"generalLoggingIp": "IP для логов",
"generalLoggingPort": "порт для логов",
"generalLoggingPortSubtext": "IP, на который должны отправляться журналы"
},
"network": {
"automaticDownloadsSection": "Автоматическая загрузка",
"title": "Сеть",
"automaticDownloadsMaximumSizeSubtext": "Максимальный размер, при котором файлы будут автоматически загружаться",
"automaticDownloadsMaximumSize": "Максимальный размер для загрузки",
"automaticDownloadAlways": "Всегда",
"wifi": "Wifi",
"mobileData": "Мобильный интернет",
"automaticDownloadsText": "Moxxy будет автоматически загружать файлы до..."
},
"privacy": {
"showContactRequests": "Показывать запрос в контакты",
"showContactRequestsSubtext": "Это покажет людей, добавивших вас в свой список контактов",
"generalSection": "Основные",
"profilePictureVisibility": "Сделать фото профиля публичным",
"sendChatMarkers": "Отправлять маркеры",
"redirectText": "Это позволит перенаправлять ссылки с ${serviceName} на прокси, такие как ${exampleProxy}",
"redirectsSection": "Перенаправление",
"currentlySelected": "Выбрано сейчас: $proxy",
"redirectsTitle": "$serviceName Перенаправление",
"urlEmpty": "URL не может быть пустым",
"title": "Приватность",
"conversationsSection": "Диалог",
"sendChatMarkersSubtext": "Это сообщит вашему собеседнику о получении или прочтении сообщения",
"sendChatStates": "Отправлять состояние чата",
"sendChatStatesSubtext": "Собеседник будет видеть, когда вы набираете сообщение или просматриваете чат",
"cannotEnableRedirect": "Невозможно включить перенаправления $serviceName",
"cannotEnableRedirectSubtext": "Сначала нужно добавить прокси сервер. Для этого нажмите слева от переключателя",
"urlInvalid": "Недопустимый URL",
"redirectDialogTitle": "$serviceName Перенаправление",
"stickersPrivacy": "Публиковать список стикеров",
"stickersPrivacySubtext": "Если включено, ваши установленные наборы стикеров будут видны всем.",
"profilePictureVisibilitSubtext": "Когда включено, все видят Ваш аватар; когда выключено - только контакты"
},
"stickers": {
"importSuccess": "Стикерпаки успешно импортированы",
"title": "Стикеры",
"stickerSection": "Стикеры",
"displayStickers": "Показывать стикеры",
"autoDownload": "Загружать стикеры автоматически",
"autoDownloadBody": "Стикеры будут загружаться автоматически, если их отправитель у вас в контактах.",
"stickerPacksSection": "Стикерпаки",
"importStickerPack": "Импортировать стикерпаки",
"importFailure": "Ошибка при импорте стикерпаков",
"stickerPackSize": "(${size})"
},
"settings": {
"title": "Настройки",
"conversationsSection": "Чаты",
"accountSection": "Учётная запись",
"signOut": "Выйти",
"signOutConfirmTitle": "Выйти",
"signOutConfirmBody": "Вы хотите выйти, продолжить?",
"miscellaneousSection": "Другое",
"debuggingSection": "Отладка",
"general": "Основные"
},
"appearance": {
"title": "Внешний вид",
"languageSection": "Язык",
"language": "Язык в приложении",
"systemLanguage": "Как в системе",
"languageSubtext": "Выбранный язык: ${selectedLanguage}"
},
"licenses": {
"title": "Открытые лицензии",
"licensedUnder": "Лицензировано под ${license}"
},
"storage": {
"removeOldMedia": {
"description": "Удалит старые медиафайлы с устройства",
"title": "Удалить старые медиафайлы"
},
"title": "Хранилище",
"sizePlaceholder": "Вычисление...",
"storageManagement": "Управление хранилищем",
"removeOldMediaDialog": {
"options": {
"all": "Все медиафайлы",
"oneWeek": "Старее 1 недели",
"oneMonth": "Старее 1 месяца"
},
"delete": "Удалить",
"title": "Удалить медиафайлы",
"confirmation": {
"body": "Вы точно хотите удалить старые медиафайлы?"
}
},
"viewMediaFiles": "Просмотреть медиафайлы",
"mediaFiles": "Медиафайлы",
"types": {
"media": "Медиа",
"stickers": "Стикеры"
},
"storageUsed": "Использование хранилища: ${size}",
"manageStickers": "Управлять наборами стикеров"
},
"stickerPacks": {
"title": "Наборы стикеров"
}
},
"sharedMedia": {
"empty": {
"chat": "Нет общих медиафайлов для этого чата",
"general": "Нет доступных медиаустройств"
}
}
},
"language": "Русский",
"warnings": {
"message": {
"integrityCheckFailed": "Не удалось проверить целостность файла"
},
"conversation": {
"holdForLonger": "Удерживайте для записи"
}
},
"permissions": {
"allow": "Разрешить",
"skip": "Пропустить"
}
}

View File

@@ -5,3 +5,5 @@ targets:
options:
input_directory: assets/i18n
output_directory: lib/i18n
fallback_strategy: base_locale
base_locale: en

View File

@@ -0,0 +1 @@
Moxxy ist ein experimenteller XMPP-Client, der modern und einfach sein soll.

View File

@@ -0,0 +1 @@
Moxxy

View File

@@ -0,0 +1,12 @@
Many changes in this release are under the hood, but there are many changes nonetheless:
- Messages that are sent while offline are now queued up until we're online again
- Moxxy now makes use of SFS's caching possibilities. Receiving files sent via SFS are thus only downloaded if the file is not already locally available
- Messages and shared media files are now shown in paged lists
- Reworked various pages, like the Conversation page and the profile page
- Rework the reactions UI
- Add a "note to self" feature. This was a teaser task in the context of this year's GSoC
- Chat states are no longer sent if a chat is no longer focused
- Sending a sticker when a message is selected for quoting, the sticker is sent as a reply to that message
- The database design was massively overhauled
- The emoji/sticker picker should no longer jump around when switching from the keyboard

View File

@@ -0,0 +1,7 @@
This is a hotfix release.
Sending a message with no attached file results in a gray
box being displayed over the entire message list. This release
contains a fix for that.
(I also dropped my fork of the Flutter SDK)

View File

@@ -0,0 +1,13 @@
- (Hopefully) fix OMEMO between two Moxxy clients.
- Allow correcting messages older than the last one. Whether all clients will accept such a correction is unclear.
- Add (incomplete) translations for Dutch, French, Galician, Japanese, Polish, and Russian.
- Fix having to long-press a message bubble on its corner to active the selection menu.
- If enabled, read markers are automatically sent.
- Highlight legacy quotes in text messages.
- Fix Moxxy's app icon having a badge because of the foreground service.
- Make the notifications much prettier and compliant with Android 13.
- Prevent Moxxy from crashing on startup on a fresh device.
- Video thumbnails are now generated, if possible, after a video has been downloaded.
- The Moxxy APKs will now be signed by a different key stored on my YubiKey. You will have to uninstall and reinstall Moxxy. This will remove all your data.
- Moxxy now uses Android's direct share shortcuts.
- Moxxy now uses Android 13's new photo picker, whenever possible. This should allow Moxxy to require fewer permissions to work.

View File

@@ -0,0 +1 @@
Moxxy est un client XMPP expérimental qui vise dêtre moderne et facile.

View File

@@ -0,0 +1 @@
Moxxy

View File

@@ -0,0 +1,10 @@
- Offline-berichten worden verstuurd als de verbinding hersteld is;
- SFS-cache, waardoor downloaden alleen plaatsvindt indien niet lokaal beschikbaar;
- Berichten en mediabestanden worden op pagina's getoond;
- Diverse pagina's bijgewerkt;
- Reacties herontworpen;
- Zelfmemofunctie;
- Gespreksstatussen worden niet meer verstuurd indien ongefocust;
- Stickers als antwoord op citaten;
- Nieuw databankontwerp;
- Verbeterde emoji-/stickerkeuze i.c.m. toetsenbord.

View File

@@ -0,0 +1,5 @@
Dit is een oplossingsversie:
Het versturen van een bericht zonder bijlage zorgde voor een grijs vlak op de berichtenlijst. Dat is nu opgelost.
(Ook ben ik gestopt met de ontwikkeling van mijn afsplitsing van de Flutter-sdk.)

View File

@@ -0,0 +1,5 @@
-(Hopelijk) Oplossing voor OMEMO tussen twee Moxxy-clients;
-Oplossing voor het lang ingedrukt houden van een bericht om het keuzemenu te openen;
-Leesmarkeringen worden voortaan automatisch verzonden (indien ingeschakeld);
-Nieuw: (onvolledige) Nederlandse, Japanse en Russische vertalingen;
-Nieuw: bewerken van berichten ouder dan het recentste bericht. Onduidelijk of alle clients dit op de juiste manier tonen.

View File

@@ -0,0 +1,7 @@
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
* Maybe fix a connection race condition
* Allow sharing media with the app when it was closed
* Make quotes prettier
* Make the bottom part of the conversation page prettier
* Fix roster fetching
* Fix OMEMO key generation

View File

@@ -0,0 +1,24 @@
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.
Let op: Moxxy is momenteel in de alfafase. Dit houdt in dat er gegarandeerd bugs en
problemen zullen zijn. Gebruik Moxxy dus niet voor belangrijke zaken.
Huidige functies:
<ul>
<li>Verstuur bestanden en afbeeldingen;</li>
<li>Stel je profielfoto in;</li>
<li>Typmeldingen en berichtstatussen;</li>
<li>Gespreksachtergronden;</li>
<li>Draait op de achtergrond zónder pushmeldingen;</li>
<li>OMEMO (momenteel niet compatibel met de meeste apps);</li>
<li>Stickers.</li>
</ul>
Voor de beste gebruikservaring is het belangrijk om een server te gebruiken met:
<ul>
<li>Ondersteuning voor TLS/StartTLS op dezelfde domeinnaam als in de Jid;</li>
<li>Ondersteuning voor SCRAM-SHA-1, SCRAM-SHA-256 of SCRAM-SHA-512;</li>
<li>Ondersteuning voor HTTP-bestandsupload;</li>
<li>Ondersteuning voor streambeheer;</li>
<li>Ondersteuning voor Client State Indication.</li>
</ul>

View File

@@ -0,0 +1 @@
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.

View File

@@ -0,0 +1 @@
Moxxy

192
flake.lock generated
View File

@@ -1,6 +1,103 @@
{
"nodes": {
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1689798050,
"narHash": "sha256-ZyFPra7N0MF803o55dYQQyX9b/BmXr6QTCyN7slRThY=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "9aa0e2990da86de8ca203af313668851dcb9ea6e",
"type": "github"
},
"original": {
"owner": "tadfisher",
"repo": "android-nixpkgs",
"type": "github"
}
},
"bab": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1694100130,
"narHash": "sha256-3xQgPgFNVtuftoYsUxtUTFu/P5ZzcIaRIrLwNs4xrBg=",
"ref": "refs/heads/master",
"rev": "8e98e366f7de0d8636a387ee857ead7cc8c1b646",
"revCount": 6,
"type": "git",
"url": "https://codeberg.org/PapaTutuWawa/bits-and-bytes.git"
},
"original": {
"type": "git",
"url": "https://codeberg.org/PapaTutuWawa/bits-and-bytes.git"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1688380630,
"narHash": "sha256-8ilApWVb1mAi4439zS3iFeIT0ODlbrifm/fegWwgHjA=",
"owner": "numtide",
"repo": "devshell",
"rev": "f9238ec3d75cefbb2b42a44948c4e8fb1ae9a205",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_3": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
@@ -17,24 +114,103 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1676076353,
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=",
"owner": "AtaraxiaSjel",
"lastModified": 1689679375,
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6",
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
"type": "github"
},
"original": {
"owner": "AtaraxiaSjel",
"ref": "update/flutter",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1689935543,
"narHash": "sha256-6GQ9ib4dA/r1leC5VUpsBo0BmDvNxLjKrX1iyL+h8mc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e43e2448161c0a2c4928abec4e16eae1516571bc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
"android-nixpkgs": "android-nixpkgs",
"bab": "bab",
"flake-utils": "flake-utils_3",
"nixpkgs": "nixpkgs_3"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},

107
flake.nix
View File

@@ -1,35 +1,52 @@
{
description = "Moxxy v2";
inputs = {
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
android-nixpkgs.url = "github:tadfisher/android-nixpkgs";
bab.url = "git+https://codeberg.org/PapaTutuWawa/bits-and-bytes.git";
};
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let
outputs = { self, nixpkgs, flake-utils, android-nixpkgs, bab }: flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config = {
android_sdk.accept_license = true;
allowUnfree = true;
# Fix to allow building the NDK package
# TODO: Remove once https://github.com/tadfisher/android-nixpkgs/issues/62 is resolved
permittedInsecurePackages = [
"python-2.7.18.6"
];
};
};
android = pkgs.androidenv.composeAndroidPackages {
# TODO: Find a way to pin these
#toolsVersion = "26.1.1";
#platformToolsVersion = "31.0.3";
#buildToolsVersions = [ "31.0.0" ];
#includeEmulator = true;
#emulatorVersion = "30.6.3";
platformVersions = [ "28" ];
includeSources = false;
includeSystemImages = true;
systemImageTypes = [ "default" ];
abiVersions = [ "x86_64" ];
includeNDK = false;
useGoogleAPIs = false;
useGoogleTVAddOns = false;
};
# Everything to make Flutter happy
sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest
build-tools-30-0-3
build-tools-33-0-2
build-tools-34-0-0
platform-tools
emulator
patcher-v4
platforms-android-28
platforms-android-29
platforms-android-30
platforms-android-31
platforms-android-33
# For flutter_zxing
cmake-3-18-1
#ndk-21-4-7075529
(ndk-21-4-7075529.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.python27 ];
}))
]);
lib = pkgs.lib;
babPkgs = bab.packages."${system}";
pinnedJDK = pkgs.jdk17;
flutterVersion = pkgs.flutter37;
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
requests pyyaml # For the build scripts
@@ -38,13 +55,59 @@
in {
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android
pythonEnv gnumake # Build scripts
gitlint jq # Code hygiene
ripgrep # General utilities
# Android
pinnedJDK sdk ktlint
scrcpy
# Flutter
flutterVersion
# Build scripts
pythonEnv gnumake
# Code hygiene
gitlint jq
];
ANDROID_SDK_ROOT = "${sdk}/share/android-sdk";
ANDROID_HOME = "${sdk}/share/android-sdk";
JAVA_HOME = pinnedJDK;
# Fix an issue with Flutter using an older version of aapt2, which does not know
# an used parameter.
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/share/android-sdk/build-tools/34.0.0/aapt2";
};
apps = let
providerArg = pkgs.writeText "provider-arg.cfg" ''
name = OpenSC-PKCS11
description = SunPKCS11 via OpenSC
library = ${pkgs.opensc}/lib/opensc-pkcs11.so
slotListIndex = 0
'';
mkBuildScript = skipBuild: pkgs.writeShellScript "build-moxxy.sh" ''
${babPkgs.flutter-build}/bin/flutter-build \
--name Moxxy \
--not-signed \
--zipalign ${sdk}/share/android-sdk/build-tools/34.0.0/zipalign \
--apksigner ${sdk}/share/android-sdk/build-tools/34.0.0/apksigner \
--pigeon ./pigeon/quirks.dart \
--flutter ${flutterVersion}/bin/flutter \
--dart ${flutterVersion}/bin/dart \
--provider-config ${providerArg} ${lib.optionalString skipBuild "--skip-build"}
'';
in {
# Skip the build and just sign
onlySign = {
type = "app";
program = "${mkBuildScript true}";
};
# Build everything and sign
build = {
type = "app";
program = "${mkBuildScript false}";
};
};
});
}

View File

@@ -22,7 +22,8 @@ files:
- JsonImplementation
attributes:
state: String
permissionsToRequest: List<int>
requestNotificationPermission: bool
excludeFromBatteryOptimisation: bool
preferences:
type: PreferencesState
deserialise: true
@@ -36,9 +37,6 @@ files:
roster:
type: List<RosterItem>?
deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Triggered if a conversation has been added.
# Also returned by [AddConversationCommand]
- name: ConversationAddedEvent
@@ -110,7 +108,7 @@ files:
implements:
- JsonImplementation
attributes:
id: int
id: String
progress: double?
# Triggered by [RosterService] if we receive a roster push.
- name: RosterDiffEvent
@@ -208,7 +206,7 @@ files:
attributes:
conversationJid: String
title: String
avatarUrl: String
avatarPath: String
- name: StickerPackImportSuccessEvent
extends: BackgroundEvent
implements:
@@ -274,6 +272,89 @@ files:
reactions:
type: List<ReactionGroup>
deserialise: true
# Triggered when the stream negotiations have been completed
- name: StreamNegotiationsCompletedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
resumed: bool
- name: AvatarUpdatedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
jid: String
path: String
# Returned when attempting to start a chat with a groupchat
- name: JidIsGroupchatEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
jid: String
# Returned when an error occured
- name: ErrorEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
errorId: int
# Triggered by the service in response to an [JoinGroupchatCommand].
- name: JoinGroupchatResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
conversation:
type: Conversation
deserialise: true
# Returned after a [GetStorageUsageCommand]
- name: GetStorageUsageEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
# The used storage in bytes for media files
mediaUsage: int
# The used storage in bytes for stickers
stickerUsage: int
# Returned after [DeleteOldMediaFilesCommand]
- name: DeleteOldMediaFilesDoneEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
# The used storage in bytes after the deletion operation is done
newUsage: int
# The new list of Conversations
conversations:
type: List<Conversation>
deserialize: true
- name: PagedStickerPackResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPacks:
type: List<StickerPack>
deserialise: true
- name: GetStickerPackByIdResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack?
deserialise: true
- name: FetchRecipientInformationResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
items:
type: List<SendFilesRecipient>
deserialise: true
generate_builder: true
builder_name: "Event"
builder_baseclass: "BackgroundEvent"
@@ -322,7 +403,6 @@ files:
type: Message?
deserialise: true
editSid: String?
editId: int?
currentConversationJid: String?
- name: SendFilesCommand
extends: BackgroundCommand
@@ -401,6 +481,12 @@ files:
- JsonImplementation
attributes:
jid: String
- name: ExitConversationCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
conversationType: String
- name: SendChatStateCommand
extends: BackgroundCommand
implements:
@@ -408,6 +494,7 @@ files:
attributes:
state: String
jid: String
conversationType: String
- name: GetFeaturesCommand
extends: BackgroundCommand
implements:
@@ -484,24 +571,21 @@ files:
implements:
- JsonImplementation
attributes:
conversationJid: String
sid: String
newUnreadCounter: int
id: String
sendMarker: bool
- name: AddReactionToMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
conversationJid: String
id: String
emoji: String
- name: RemoveReactionFromMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
conversationJid: String
id: String
emoji: String
- name: MarkOmemoDeviceAsVerifiedCommand
extends: BackgroundCommand
@@ -567,7 +651,7 @@ files:
implements:
- JsonImplementation
attributes:
conversationJid: String
conversationJid: String?
olderThan: bool
timestamp: int?
- name: GetReactionsForMessageCommand
@@ -575,7 +659,60 @@ files:
implements:
- JsonImplementation
attributes:
messageId: int
id: String
- name: RequestAvatarForJidCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
hash: String?
ownAvatar: bool
- name: GetStorageUsageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
- name: DeleteOldMediaFilesCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
# Milliseconds from now in the past; The maximum age of a file to not
# get deleted.
timeOffset: int
- name: GetPagedStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
olderThan: bool
timestamp: int?
includeStickers: bool
- name: GetStickerPackByIdCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
id: String
- name: DebugCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
id: int
- name: JoinGroupchatCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
nick: String
- name: FetchRecipientInformationCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jids: List<String>
generate_builder: true
# get${builder_Name}FromJson
builder_name: "Command"

View File

@@ -10,22 +10,24 @@ import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/groupchat/joingroupchat_bloc.dart';
import 'package:moxxyv2/ui/bloc/login_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/request_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
import 'package:moxxyv2/ui/bloc/startchat_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@@ -35,7 +37,6 @@ import 'package:moxxyv2/ui/events.dart';
import "package:moxxyv2/ui/pages/register/register.dart";
import "package:moxxyv2/ui/pages/postregister/postregister.dart";
*/
import 'package:moxxyv2/ui/pages/addcontact.dart';
import 'package:moxxyv2/ui/pages/blocklist.dart';
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
import 'package:moxxyv2/ui/pages/conversations.dart';
@@ -46,7 +47,7 @@ import 'package:moxxyv2/ui/pages/newconversation.dart';
import 'package:moxxyv2/ui/pages/profile/devices.dart';
import 'package:moxxyv2/ui/pages/profile/own_devices.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
import 'package:moxxyv2/ui/pages/sendfiles.dart';
import 'package:moxxyv2/ui/pages/sendfiles/sendfiles.dart';
import 'package:moxxyv2/ui/pages/server_info.dart';
import 'package:moxxyv2/ui/pages/settings/about.dart';
import 'package:moxxyv2/ui/pages/settings/appearance/appearance.dart';
@@ -57,14 +58,22 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
import 'package:moxxyv2/ui/pages/settings/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.dart';
import 'package:moxxyv2/ui/pages/settings/sticker_packs.dart';
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart';
import 'package:moxxyv2/ui/pages/settings/storage/storage.dart';
import 'package:moxxyv2/ui/pages/share_selection.dart';
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/pages/startchat.dart';
import 'package:moxxyv2/ui/pages/startgroupchat.dart';
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
import 'package:moxxyv2/ui/service/connectivity.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/progress.dart';
import 'package:moxxyv2/ui/service/read.dart';
import 'package:moxxyv2/ui/service/sharing.dart';
import 'package:moxxyv2/ui/theme.dart';
import 'package:page_transition/page_transition.dart';
@@ -83,7 +92,13 @@ void setupLogging() {
Future<void> setupUIServices() async {
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
GetIt.I.registerSingleton<UIDataService>(UIDataService());
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
GetIt.I.registerSingleton<UISharingService>(UISharingService());
GetIt.I.registerSingleton<UIConnectivityService>(UIConnectivityService());
GetIt.I.registerSingleton<UIReadMarkerService>(UIReadMarkerService());
/// Initialize services
await GetIt.I.get<UIConnectivityService>().initialize();
}
void setupBlocs(GlobalKey<NavigatorState> navKey) {
@@ -95,7 +110,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
GetIt.I.registerSingleton<CropBloc>(CropBloc());
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
@@ -105,6 +120,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
GetIt.I.registerSingleton<RequestBloc>(RequestBloc());
GetIt.I.registerSingleton<JoinGroupchatBloc>(JoinGroupchatBloc());
}
void main() async {
@@ -147,8 +164,8 @@ void main() async {
BlocProvider<PreferencesBloc>(
create: (_) => GetIt.I.get<PreferencesBloc>(),
),
BlocProvider<AddContactBloc>(
create: (_) => GetIt.I.get<AddContactBloc>(),
BlocProvider<StartChatBloc>(
create: (_) => GetIt.I.get<StartChatBloc>(),
),
BlocProvider<CropBloc>(
create: (_) => GetIt.I.get<CropBloc>(),
@@ -177,6 +194,12 @@ void main() async {
BlocProvider<StickerPackBloc>(
create: (_) => GetIt.I.get<StickerPackBloc>(),
),
BlocProvider<RequestBloc>(
create: (_) => GetIt.I.get<RequestBloc>(),
),
BlocProvider<JoinGroupchatBloc>(
create: (_) => GetIt.I.get<JoinGroupchatBloc>(),
),
],
child: TranslationProvider(
child: MyApp(navKey),
@@ -268,11 +291,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case newConversationRoute:
return NewConversationPage.route;
case conversationRoute:
final args = settings.arguments! as ConversationPageArguments;
return PageTransition<dynamic>(
type: PageTransitionType.rightToLeft,
settings: settings,
child: ConversationPage(
conversationJid: settings.arguments! as String,
conversationJid: args.conversationJid,
initialText: args.initialText,
conversationType: args.type,
),
);
// case sharedMediaRoute:
@@ -298,7 +324,11 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case debuggingRoute:
return DebuggingPage.route;
case addContactRoute:
return AddContactPage.route;
return StartChatPage.route;
case joinGroupchatRoute:
return JoinGroupchatPage.getRoute(
settings.arguments! as JoinGroupchatArguments,
);
case cropRoute:
return CropPage.route;
case sendFilesRoute:
@@ -323,8 +353,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
case stickersRoute:
return StickersSettingsPage.route;
case stickerPacksRoute:
return StickerPacksSettingsPage.route;
case stickerPackRoute:
return StickerPackPage.route;
case storageSettingsRoute:
return StorageSettingsPage.route;
case storageSharedMediaSettingsRoute:
return StorageSharedMediaPage.route;
}
return null;

View File

@@ -1,11 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
@@ -14,60 +13,107 @@ import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
/// Removes line breaks and spaces from [original]. This might happen when we request the
/// avatar data. Returns the cleaned version.
String _cleanBase64String(String original) {
var ret = original;
for (final char in ['\n', ' ']) {
ret = ret.replaceAll(char, '');
}
return ret;
}
class _AvatarData {
const _AvatarData(this.data, this.id);
final List<int> data;
final String id;
}
class AvatarService {
final Logger _log = Logger('AvatarService');
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
await updateAvatarForJid(
event.jid,
event.hash,
base64Decode(_cleanBase64String(event.base64)),
);
/// List of JIDs for which we have already requested the avatar in the current stream.
final List<JID> _requestedInStream = [];
void resetCache() {
_requestedInStream.clear();
}
Future<void> updateAvatarForJid(
String jid,
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawAvatar = await am.getUserAvatar(jid);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar for $jid');
return false;
}
final avatar = rawAvatar.get<UserAvatarData>();
await _updateAvatarForJid(
jid,
avatar.hash,
avatar.data,
);
return true;
}
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
///
/// Returns true, if everything went okay. Returns false if an error occurred.
Future<bool> requestAvatar(JID jid, String? oldHash) async {
if (_requestedInStream.contains(jid)) {
return true;
}
_requestedInStream.add(jid);
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawId = await am.getAvatarId(jid);
if (rawId.isType<AvatarError>()) {
_log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
return false;
}
final id = rawId.get<String>();
if (id == oldHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return true;
}
return _fetchAvatarForJid(jid, id);
}
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
if (event.metadata.isEmpty) return;
// TODO(Unknown): Maybe make a better decision?
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
}
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
Future<void> _updateAvatarForJid(
JID jid,
String hash,
List<int> data,
) async {
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final originalConversation = await cs.getConversationByJid(jid);
final originalRoster = await rs.getRosterItemByJid(jid);
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final originalConversation =
await cs.getConversationByJid(jid.toString(), accountJid!);
final originalRoster = await rs.getRosterItemByJid(
jid.toString(),
accountJid,
);
if (originalConversation == null && originalRoster == null) return;
final avatarPath = await saveAvatarInCache(
data,
hash,
jid,
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
jid.toString(),
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
);
if (originalConversation != null) {
final conversation = await cs.createOrUpdateConversation(
jid,
jid.toString(),
accountJid,
update: (c) async {
return cs.updateConversation(
jid,
avatarUrl: avatarPath,
jid.toString(),
accountJid,
avatarPath: avatarPath,
avatarHash: hash,
);
},
);
@@ -80,89 +126,23 @@ class AvatarService {
if (originalRoster != null) {
final roster = await rs.updateRosterItem(
originalRoster.id,
avatarUrl: avatarPath,
originalRoster.jid,
accountJid,
avatarPath: avatarPath,
avatarHash: hash,
);
sendEvent(RosterDiffEvent(modified: [roster]));
}
}
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
final am = GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) {
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
return null;
}
final id = idResult.get<String>();
if (id == oldHash) return null;
final avatarResult = await am.getUserAvatar(jid);
if (avatarResult.isType<AvatarError>()) {
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
return null;
}
final avatar = avatarResult.get<UserAvatar>();
return _AvatarData(
base64Decode(_cleanBase64String(avatar.base64)),
avatar.hash,
sendEvent(
AvatarUpdatedEvent(
jid: jid.toString(),
path: avatarPath,
),
);
}
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
// Query the vCard
final vm = GetIt.I
.get<XmppConnection>()
.getManagerById<VCardManager>(vcardManager)!;
final vcardResult = await vm.requestVCard(jid);
if (vcardResult.isType<VCardError>()) return null;
final binval = vcardResult.get<VCard>().photo?.binval;
if (binval == null) return null;
final data = base64Decode(_cleanBase64String(binval));
final rawHash = await Sha1().hash(data);
final hash = HEX.encode(rawHash.bytes);
vm.setLastHash(jid, hash);
return _AvatarData(
data,
hash,
);
}
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
_AvatarData? data;
data ??= await _handleUserAvatar(jid, oldHash);
data ??= await _handleVcardAvatar(jid, oldHash);
if (data != null) {
await updateAvatarForJid(jid, data.id, data.data);
}
}
Future<bool> subscribeJid(String jid) async {
return (await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.subscribe(jid))
.isType<bool>();
}
Future<bool> unsubscribeJid(String jid) async {
return (await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.unsubscribe(jid))
.isType<bool>();
}
/// Publishes the data at [path] as an avatar with PubSub ID
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
/// of the avatar data.
@@ -201,6 +181,7 @@ class AvatarService {
imageSize.height.toInt(),
// TODO(PapaTutuWawa): Maybe do a check here
'image/png',
null,
),
public,
);
@@ -213,38 +194,45 @@ class AvatarService {
return true;
}
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
Future<void> requestOwnAvatar() async {
final xss = GetIt.I.get<XmppStateService>();
final accountJid = await xss.getAccountJid();
final state = await xss.state;
final jid = JID.fromString(accountJid!);
if (_requestedInStream.contains(jid)) {
return;
}
_requestedInStream.add(jid);
final am = GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final jid = state.jid!;
final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) {
_log.info('Error while getting latest avatar id for own avatar');
final rawId = await am.getAvatarId(jid);
if (rawId.isType<AvatarError>()) {
_log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
return;
}
final id = idResult.get<String>();
final id = rawId.get<String>();
if (id == state.avatarHash) return;
_log.info(
'Mismatch between saved avatar data and server-side avatar data about ourself',
);
final avatarDataResult = await am.getUserAvatar(jid);
if (avatarDataResult.isType<AvatarError>()) {
_log.severe('Failed to fetch our avatar');
if (id == state.avatarHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return;
}
final avatarData = avatarDataResult.get<UserAvatar>();
_log.info('Received data for our own avatar');
final rawAvatar = await am.getUserAvatar(jid);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar for $jid');
return;
}
final avatarData = rawAvatar.get<UserAvatarData>();
final avatarPath = await saveAvatarInCache(
base64Decode(_cleanBase64String(avatarData.base64)),
avatarData.data,
avatarData.hash,
jid,
jid.toString(),
state.avatarUrl,
);
await xss.modifyXmppState(
@@ -254,6 +242,9 @@ class AvatarService {
),
);
// Update our notification avatar
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
}
}

View File

@@ -5,6 +5,7 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
enum BlockPushType { block, unblock }
@@ -16,19 +17,20 @@ class BlocklistService {
bool? _supported;
final Logger _log = Logger('BlocklistService');
Future<void> _removeBlocklistEntry(String jid) async {
Future<void> _removeBlocklistEntry(String jid, String accountJid) async {
await GetIt.I.get<DatabaseService>().database.delete(
blocklistTable,
where: 'jid = ?',
whereArgs: [jid],
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
}
Future<void> _addBlocklistEntry(String jid) async {
Future<void> _addBlocklistEntry(String jid, String accountJid) async {
await GetIt.I.get<DatabaseService>().database.insert(
blocklistTable,
{
'jid': jid,
'accountJid': accountJid,
},
);
}
@@ -59,6 +61,7 @@ class BlocklistService {
return;
}
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final blocklist = await GetIt.I
.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
@@ -69,7 +72,7 @@ class BlocklistService {
final removedItems = List<String>.empty(growable: true);
for (final item in blocklist) {
if (!_blocklist!.contains(item)) {
await _addBlocklistEntry(item);
await _addBlocklistEntry(item, accountJid!);
_blocklist!.add(item);
newItems.add(item);
}
@@ -78,7 +81,7 @@ class BlocklistService {
// Diff the cache with the received blocklist
for (final item in _blocklist!) {
if (!blocklist.contains(item)) {
await _removeBlocklistEntry(item);
await _removeBlocklistEntry(item, accountJid!);
_blocklist!.remove(item);
removedItems.add(item);
}
@@ -98,10 +101,13 @@ class BlocklistService {
}
/// Returns the blocklist from the database
Future<List<String>> getBlocklist() async {
Future<List<String>> getBlocklist(String accountJid) async {
if (_blocklist == null) {
final blocklistRaw =
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
final blocklistRaw = await GetIt.I.get<DatabaseService>().database.query(
blocklistTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
);
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
if (!_requested) {
@@ -129,6 +135,7 @@ class BlocklistService {
// We will fetch it later when getBlocklist is called
if (!_requested) return;
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final newBlocks = List<String>.empty(growable: true);
final removedBlocks = List<String>.empty(growable: true);
for (final item in items) {
@@ -139,7 +146,7 @@ class BlocklistService {
_blocklist!.add(item);
newBlocks.add(item);
await _addBlocklistEntry(item);
await _addBlocklistEntry(item, accountJid!);
}
break;
case BlockPushType.unblock:
@@ -147,7 +154,7 @@ class BlocklistService {
_blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item);
await _removeBlocklistEntry(item);
await _removeBlocklistEntry(item, accountJid!);
}
break;
}
@@ -169,7 +176,10 @@ class BlocklistService {
}
_blocklist!.add(jid);
await _addBlocklistEntry(jid);
await _addBlocklistEntry(
jid,
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
return GetIt.I
.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
@@ -184,7 +194,10 @@ class BlocklistService {
}
_blocklist!.remove(jid);
await _removeBlocklistEntry(jid);
await _removeBlocklistEntry(
jid,
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
return GetIt.I
.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
@@ -201,7 +214,11 @@ class BlocklistService {
}
_blocklist!.clear();
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
await GetIt.I.get<DatabaseService>().database.delete(
blocklistTable,
where: 'accountJid = ?',
whereArgs: [await GetIt.I.get<XmppStateService>().getAccountJid()],
);
return GetIt.I
.get<XmppConnection>()

View File

@@ -10,6 +10,7 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/roster.dart';
@@ -41,19 +42,27 @@ class ContactsService {
final Map<String, String?> _contactDisplayNames = {};
Future<void> initialize() async {
if (await _canUseContactIntegration()) {
enableDatabaseListener();
await enable(shouldScan: false);
}
/// Enable listening to contact database events. If [shouldScan] is true, also
/// performs a scan of the contacts database, if we're allowed.
Future<void> enable({bool shouldScan = true}) async {
FlutterContacts.addListener(_onContactsDatabaseUpdate);
if (shouldScan && await _canUseContactIntegration()) {
unawaited(scanContacts());
}
}
/// Enable listening to contact database events
void enableDatabaseListener() {
FlutterContacts.addListener(_onContactsDatabaseUpdate);
}
/// Disable listening to contact database events
void disableDatabaseListener() {
/// Disable listening to contact database events. Also removes all roster items
/// that are pseudo roster items.
Future<void> disable() async {
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
await GetIt.I.get<RosterService>().removePseudoRosterItems(
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
Future<void> _onContactsDatabaseUpdate() async {
@@ -123,7 +132,6 @@ class ContactsService {
Future<Map<String, String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!;
// TODO(Unknown): Can we just .cast<String, String>() here?
_contactIds = Map<String, String>.fromEntries(
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
(item) => MapEntry(
@@ -185,6 +193,7 @@ class ContactsService {
final modifiedRosterItems = List<RosterItem>.empty(growable: true);
final addedRosterItems = List<RosterItem>.empty(growable: true);
final removedRosterItems = List<String>.empty(growable: true);
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
for (final id in List<String>.from(knownContactIds.values)) {
final index = contacts.indexWhere((c) => c.id == id);
@@ -209,9 +218,11 @@ class ContactsService {
// Remove the contact attributes from the conversation, if it existed
final conversation = await cs.createOrUpdateConversation(
jid,
accountJid!,
update: (c) async {
return cs.updateConversation(
jid,
accountJid,
contactId: null,
contactAvatarPath: null,
contactDisplayName: null,
@@ -227,15 +238,16 @@ class ContactsService {
}
// Remove the contact attributes from the roster item, if it existed
final r = await rs.getRosterItemByJid(jid);
final r = await rs.getRosterItemByJid(jid, accountJid);
if (r != null) {
if (r.pseudoRosterItem) {
_log.finest('Removing pseudo roster item $jid');
await rs.removeRosterItem(r.id);
await rs.removeRosterItem(r.jid, accountJid);
removedRosterItems.add(jid);
} else {
final newRosterItem = await rs.updateRosterItem(
r.id,
r.jid,
accountJid,
contactId: null,
contactAvatarPath: null,
contactDisplayName: null,
@@ -272,11 +284,14 @@ class ContactsService {
// Update a possibly existing conversation
final conversation = await cs.createOrUpdateConversation(
contact.jid,
accountJid!,
update: (c) async {
return cs.updateConversation(
contact.jid,
accountJid,
contactId: contact.id,
contactAvatarPath: contactAvatarPath,
contactAvatarPath:
contact.thumbnail != null ? contactAvatarPath : null,
contactDisplayName: contact.displayName,
);
},
@@ -290,10 +305,11 @@ class ContactsService {
}
// Update a possibly existing roster item
final r = await rs.getRosterItemByJid(contact.jid);
final r = await rs.getRosterItemByJid(contact.jid, accountJid);
if (r != null) {
final newRosterItem = await rs.updateRosterItem(
r.id,
r.jid,
accountJid,
contactId: contact.id,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contact.displayName,
@@ -301,6 +317,7 @@ class ContactsService {
modifiedRosterItems.add(newRosterItem);
} else {
final newRosterItem = await rs.addRosterItemFromData(
accountJid,
'',
'',
contact.jid,

View File

@@ -1,13 +1,17 @@
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/groupchat.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/groupchat.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:synchronized/synchronized.dart';
@@ -26,19 +30,31 @@ class ConversationService {
/// The lock for accessing _conversationCache
final Lock _lock = Lock();
final Logger _log = Logger('ConversationService');
String? _activeConversationJid;
String? get activeConversationJid => _activeConversationJid;
set activeConversationJid(String? jid) {
_log.finest('Setting activeConversationJid to $jid');
_activeConversationJid = jid;
}
/// When called with a JID [jid], then first, if non-null, [preRun] is
/// executed.
/// Next, if a conversation with JID [jid] exists, [update] is called with
/// the conversation as its argument. If not, then [create] is executed.
/// Returns either the result of [create], [update] or null.
Future<Conversation?> createOrUpdateConversation(
String jid, {
String jid,
String accountJid, {
CreateConversationCallback? create,
UpdateConversationCallback? update,
PreRunConversationCallback? preRun,
}) async {
return _lock.synchronized(() async {
final conversation = await _getConversationByJid(jid);
final conversation = await _getConversationByJid(jid, accountJid);
// Pre run
if (preRun != null) {
@@ -62,34 +78,46 @@ class ConversationService {
}
/// Loads all conversations from the database and adds them to the state and cache.
Future<List<Conversation>> loadConversations() async {
Future<List<Conversation>> loadConversations(String accountJid) async {
final db = GetIt.I.get<DatabaseService>().database;
final gs = GetIt.I.get<GroupchatService>();
final conversationsRaw = await db.query(
conversationsTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
orderBy: 'lastChangeTimestamp DESC',
);
final tmp = List<Conversation>.empty(growable: true);
for (final c in conversationsRaw) {
final jid = c['jid']! as String;
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final rosterItem = await GetIt.I
.get<RosterService>()
.getRosterItemByJid(jid, accountJid);
Message? lastMessage;
if (c['lastMessageId'] != null) {
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
c['lastMessageId']! as int,
jid,
c['lastMessageId']! as String,
accountJid,
queryReactionPreview: false,
);
}
GroupchatDetails? groupchatDetails;
if (c['type'] == ConversationType.groupchat.value) {
groupchatDetails = await gs.getGroupchatDetailsByJid(
c['jid']! as String,
accountJid,
);
}
tmp.add(
Conversation.fromDatabaseJson(
c,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
groupchatDetails,
),
);
}
@@ -99,25 +127,32 @@ class ConversationService {
/// Wrapper around DatabaseService's loadConversations that adds the loaded
/// to the cache.
Future<void> _loadConversationsIfNeeded() async {
Future<void> _loadConversationsIfNeeded(String accountJid) async {
if (_conversationCache != null) return;
final conversations = await loadConversations();
final conversations = await loadConversations(accountJid);
_conversationCache = Map<String, Conversation>.fromEntries(
conversations.map((c) => MapEntry(c.jid, c)),
);
}
/// Returns the conversation with jid [jid] or null if not found.
Future<Conversation?> _getConversationByJid(String jid) async {
await _loadConversationsIfNeeded();
Future<Conversation?> _getConversationByJid(
String jid,
String accountJid,
) async {
await _loadConversationsIfNeeded(accountJid);
return _conversationCache![jid];
}
/// Wrapper around [ConversationService._getConversationByJid] that aquires
/// the lock for the cache.
Future<Conversation?> getConversationByJid(String jid) async {
return _lock.synchronized(() async => _getConversationByJid(jid));
Future<Conversation?> getConversationByJid(
String jid,
String accountJid,
) async {
return _lock
.synchronized(() async => _getConversationByJid(jid, accountJid));
}
/// For modifying the cache without writing it to disk. Useful, for example, when
@@ -131,20 +166,23 @@ class ConversationService {
/// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> updateConversation(
String jid, {
String jid,
String accountJid, {
int? lastChangeTimestamp,
Message? lastMessage,
bool? open,
int? unreadCounter,
String? avatarUrl,
String? avatarPath,
Object? avatarHash = notSpecified,
ChatState? chatState,
bool? muted,
bool? encrypted,
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
GroupchatDetails? groupchatDetails,
}) async {
final conversation = (await _getConversationByJid(jid))!;
final conversation = (await _getConversationByJid(jid, accountJid))!;
final c = <String, dynamic>{};
@@ -160,8 +198,11 @@ class ConversationService {
if (unreadCounter != null) {
c['unreadCounter'] = unreadCounter;
}
if (avatarUrl != null) {
c['avatarUrl'] = avatarUrl;
if (avatarPath != null) {
c['avatarPath'] = avatarPath;
}
if (avatarHash != notSpecified) {
c['avatarHash'] = avatarHash as String?;
}
if (muted != null) {
c['muted'] = boolToInt(muted);
@@ -183,17 +224,17 @@ class ConversationService {
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
conversationsTable,
c,
where: 'jid = ?',
whereArgs: [jid],
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
await GetIt.I.get<RosterService>().getRosterItemByJid(jid, accountJid);
var newConversation = Conversation.fromDatabaseJson(
result,
rosterItem != null,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
groupchatDetails,
);
// Copy over the old lastMessage if a new one was not set
@@ -212,10 +253,11 @@ class ConversationService {
/// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> addConversationFromData(
String accountJid,
String title,
Message? lastMessage,
ConversationType type,
String avatarUrl,
String avatarPath,
String jid,
int unreadCounter,
int lastChangeTimestamp,
@@ -225,20 +267,24 @@ class ConversationService {
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
GroupchatDetails? groupchatDetails,
) async {
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
await GetIt.I.get<RosterService>().getRosterItemByJid(jid, accountJid);
final gs = GetIt.I.get<GroupchatService>();
final newConversation = Conversation(
accountJid,
title,
lastMessage,
avatarUrl,
avatarPath,
null,
jid,
groupchatDetails,
unreadCounter,
type,
lastChangeTimestamp,
open,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
muted,
encrypted,
ChatState.gone,
@@ -255,6 +301,14 @@ class ConversationService {
_conversationCache![newConversation.jid] = newConversation;
}
if (type == ConversationType.groupchat && groupchatDetails != null) {
await gs.addGroupchatDetailsFromData(
jid,
accountJid,
groupchatDetails.nick,
);
}
return newConversation;
}
@@ -263,9 +317,41 @@ class ConversationService {
///
/// If the conversation does not exist, then the value of the preference for
/// enableOmemoByDefault is used.
Future<bool> shouldEncryptForConversation(JID jid) async {
Future<bool> shouldEncryptForConversation(JID jid, String accountJid) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final conversation = await getConversationByJid(jid.toString());
final conversation = await getConversationByJid(jid.toString(), accountJid);
return conversation?.encrypted ?? prefs.enableOmemoByDefault;
}
/// Send a chat state [state] to [jid], if certain pre-conditions are met:
/// - We have a network connection
/// - Sending chat markers/states are enabled
/// - [jid] != '' (not the self-chat)
/// [type] is the type of chat the chat state should be sent within.
Future<void> sendChatState(
ConversationType type,
String jid,
ChatState state,
) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Only send chat states if the users wants to send them
if (!prefs.sendChatMarkers) return;
// Only send chat states when we're connected
// TODO(Unknown): Maybe queue it up intelligently
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
final conn = GetIt.I.get<XmppConnection>();
if (jid != '') {
await conn
.getManagerById<ChatStateManager>(chatStateManager)!
.sendChatState(
state,
jid,
messageType: type.toMessageType(),
);
}
}
}

View File

@@ -3,7 +3,6 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/types.dart';

View File

@@ -3,13 +3,6 @@ const messagesTable = 'Messages';
const rosterTable = 'RosterItems';
const mediaTable = 'SharedMedia';
const preferenceTable = 'Preferences';
const omemoDeviceTable = 'OmemoDevices';
const omemoDeviceListTable = 'OmemoDeviceList';
const omemoRatchetsTable = 'OmemoSessions';
const omemoTrustCacheTable = 'OmemoTrustCacheList';
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
const omemoFingerprintCache = 'OmemoFingerprintCache';
const xmppStateTable = 'XmppState';
const contactsTable = 'Contacts';
const stickersTable = 'Stickers';
@@ -19,6 +12,12 @@ const subscriptionsTable = 'SubscriptionRequests';
const fileMetadataTable = 'FileMetadata';
const fileMetadataHashesTable = 'FileMetadataHashes';
const reactionsTable = 'Reactions';
const omemoDevicesTable = 'OmemoDevices';
const omemoDeviceListTable = 'OmemoDeviceList';
const omemoRatchetsTable = 'OmemoRatchets';
const omemoTrustTable = 'OmemoTrustTable';
const notificationsTable = 'Notifications';
const groupchatTable = 'Groupchat';
const typeString = 0;
const typeInt = 1;

View File

@@ -12,61 +12,98 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $xmppStateTable (
key TEXT PRIMARY KEY,
value TEXT
key TEXT NOT NULL,
accountJid TEXT NOT NULL,
value TEXT,
PRIMARY KEY (key, accountJid)
)''',
);
// Messages
await db.execute('''
await db.execute(
'''
CREATE TABLE $messagesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
body TEXT,
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
id TEXT NOT NULL PRIMARY KEY,
accountJid TEXT NOT NULL,
sender TEXT NOT NULL,
body TEXT,
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id INTEGER,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''');
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id TEXT,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
occupantId TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote
FOREIGN KEY (quote_id)
REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata
FOREIGN KEY (file_metadata_id)
REFERENCES $fileMetadataTable (id)
)''',
);
await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
);
// Reactions
await db.execute('''
await db.execute(
'''
CREATE TABLE $reactionsTable (
accountJid TEXT NOT NULL,
message_id TEXT NOT NULL,
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
PRIMARY KEY (accountJid, senderJid, emoji, message_id),
CONSTRAINT fk_message
FOREIGN KEY (message_id)
REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
)''',
);
await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, accountJid, senderJid)',
);
// Notifications
await db.execute(
'''
CREATE TABLE $notificationsTable (
id INTEGER NOT NULL,
conversationJid TEXT NOT NULL,
accountJid TEXT NOT NULL,
sender TEXT,
senderJid TEXT,
avatarPath TEXT,
body TEXT NOT NULL,
mime TEXT,
path TEXT,
timestamp INTEGER NOT NULL,
PRIMARY KEY (id, conversationJid, senderJid, timestamp, accountJid)
)''',
);
await db.execute(
'CREATE INDEX idx_notifications ON $notificationsTable (conversationJid, accountJid)',
);
// File metadata
await db.execute('''
await db.execute(
'''
CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY,
path TEXT,
@@ -83,8 +120,10 @@ Future<void> createDatabase(Database db, int version) async {
cipherTextHashes TEXT,
filename TEXT NOT NULL,
size INTEGER
)''');
await db.execute('''
)''',
);
await db.execute(
'''
CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL,
value TEXT NOT NULL,
@@ -92,7 +131,8 @@ Future<void> createDatabase(Database db, int version) async {
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE
)''');
)''',
);
await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
);
@@ -101,51 +141,62 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $conversationsTable (
jid TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
type TEXT NOT NULL,
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
title TEXT NOT NULL,
avatarPath TEXT NOT NULL,
avatarHash TEXT,
type TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId TEXT,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
PRIMARY KEY (jid, accountJid),
CONSTRAINT fk_last_message
FOREIGN KEY (lastMessageId)
REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id
FOREIGN KEY (contactId)
REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid, accountJid)',
);
// Contacts
await db.execute('''
await db.execute(
'''
CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY,
jid TEXT NOT NULL
)''');
)''',
);
// Roster
await db.execute(
'''
CREATE TABLE $rosterTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL,
contactId TEXT,
contactAvatarPath TEXT,
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
title TEXT NOT NULL,
avatarPath TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
pseudoRosterItem INTEGER NOT NULL,
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
pseudoRosterItem INTEGER NOT NULL,
CONSTRAINT fk_contact_id
FOREIGN KEY (contactId)
REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
@@ -172,7 +223,8 @@ Future<void> createDatabase(Database db, int version) async {
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
restricted INTEGER NOT NULL
restricted INTEGER NOT NULL,
addedTimestamp INTEGER NOT NULL
)''',
);
@@ -180,86 +232,72 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $blocklistTable (
jid TEXT PRIMARY KEY
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
PRIMARY KEY (accountJid, jid)
);
''',
);
// Subscription requests
await db.execute('''
CREATE TABLE $subscriptionsTable(
jid TEXT PRIMARY KEY
)''');
// OMEMO
await db.execute(
'''
CREATE TABLE $omemoRatchetsTable (
id INTEGER NOT NULL,
CREATE TABLE $omemoDevicesTable (
jid TEXT NOT NULL PRIMARY KEY,
id INTEGER NOT NULL,
ikPub TEXT NOT NULL,
ik TEXT NOT NULL,
spkPub TEXT NOT NULL,
spk TEXT NOT NULL,
spkId INTEGER NOT NULL,
spkSig TEXT NOT NULL,
oldSpkPub TEXT,
oldSpk TEXT,
oldSpkId INTEGER,
opks TEXT NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
devices TEXT NOT NULL,
PRIMARY KEY (accountJid, jid)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoRatchetsTable (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
device INTEGER NOT NULL,
dhsPub TEXT NOT NULL,
dhs TEXT NOT NULL,
dhs_pub TEXT NOT NULL,
dhr TEXT,
dhrPub TEXT,
rk TEXT NOT NULL,
cks TEXT,
ckr TEXT,
ns INTEGER NOT NULL,
nr INTEGER NOT NULL,
pn INTEGER NOT NULL,
ik_pub TEXT NOT NULL,
session_ad TEXT NOT NULL,
acknowledged INTEGER NOT NULL,
mkskipped TEXT NOT NULL,
kex_timestamp INTEGER NOT NULL,
kex TEXT,
PRIMARY KEY (jid, id)
ik TEXT NOT NULL,
ad TEXT NOT NULL,
skipped TEXT NOT NULL,
kex TEXT NOT NULL,
acked INTEGER NOT NULL,
PRIMARY KEY (accountJid, jid, device)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustCacheTable (
key TEXT PRIMARY KEY NOT NULL,
trust INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustDeviceListTable (
jid TEXT NOT NULL,
device INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustEnableListTable (
key TEXT PRIMARY KEY NOT NULL,
enabled INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceTable (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (jid, id)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
PRIMARY KEY (jid, id)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoFingerprintCache (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
fingerprint TEXT NOT NULL,
PRIMARY KEY (jid, id)
CREATE TABLE $omemoTrustTable (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
device INTEGER NOT NULL,
trust INTEGER NOT NULL,
enabled INTEGER NOT NULL,
PRIMARY KEY (accountJid, jid, device)
)''',
);
@@ -272,6 +310,22 @@ Future<void> createDatabase(Database db, int version) async {
value TEXT NOT NULL
)''',
);
// Groupchat
await db.execute(
'''
CREATE TABLE $groupchatTable (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
nick TEXT NOT NULL,
PRIMARY KEY (jid, accountJid),
CONSTRAINT fk_groupchat
FOREIGN KEY (jid, accountJid)
REFERENCES $conversationsTable (jid, accountJid)
ON DELETE CASCADE
)''',
);
await db.insert(
preferenceTable,
Preference(

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:moxxyv2/service/database/creation.dart';
@@ -41,13 +40,123 @@ import 'package:moxxyv2/service/database/migrations/0002_reactions.dart';
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
import 'package:moxxyv2/service/database/migrations/0003_file_transfer_error_to_warning.dart';
import 'package:moxxyv2/service/database/migrations/0003_groupchat_table.dart';
import 'package:moxxyv2/service/database/migrations/0003_jid_attribute.dart';
import 'package:moxxyv2/service/database/migrations/0003_new_omemo.dart';
import 'package:moxxyv2/service/database/migrations/0003_new_omemo_pseudo_messages.dart';
import 'package:moxxyv2/service/database/migrations/0003_notifications.dart';
import 'package:moxxyv2/service/database/migrations/0003_occupant_id.dart';
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart';
// ignore: implementation_imports
import 'package:sqflite_common/src/sql_builder.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
const databasePasswordKey = 'database_encryption_password';
@internal
const List<Migration<Database>> migrations = [
Migration(2, upgradeFromV1ToV2),
Migration(3, upgradeFromV2ToV3),
Migration(4, upgradeFromV3ToV4),
Migration(5, upgradeFromV4ToV5),
Migration(6, upgradeFromV5ToV6),
Migration(7, upgradeFromV6ToV7),
Migration(8, upgradeFromV7ToV8),
Migration(9, upgradeFromV8ToV9),
Migration(10, upgradeFromV9ToV10),
Migration(11, upgradeFromV10ToV11),
Migration(12, upgradeFromV11ToV12),
Migration(13, upgradeFromV12ToV13),
Migration(14, upgradeFromV13ToV14),
Migration(15, upgradeFromV14ToV15),
Migration(16, upgradeFromV15ToV16),
Migration(17, upgradeFromV16ToV17),
Migration(18, upgradeFromV17ToV18),
Migration(19, upgradeFromV18ToV19),
Migration(20, upgradeFromV19ToV20),
Migration(21, upgradeFromV20ToV21),
Migration(22, upgradeFromV21ToV22),
Migration(23, upgradeFromV22ToV23),
Migration(24, upgradeFromV23ToV24),
Migration(25, upgradeFromV24ToV25),
Migration(26, upgradeFromV25ToV26),
Migration(27, upgradeFromV26ToV27),
Migration(28, upgradeFromV27ToV28),
Migration(29, upgradeFromV28ToV29),
Migration(30, upgradeFromV29ToV30),
Migration(31, upgradeFromV30ToV31),
Migration(32, upgradeFromV31ToV32),
Migration(33, upgradeFromV32ToV33),
Migration(34, upgradeFromV33ToV34),
Migration(35, upgradeFromV34ToV35),
Migration(36, upgradeFromV35ToV36),
Migration(37, upgradeFromV36ToV37),
Migration(38, upgradeFromV37ToV38),
Migration(39, upgradeFromV38ToV39),
Migration(40, upgradeFromV39ToV40),
Migration(41, upgradeFromV40ToV41),
Migration(42, upgradeFromV41ToV42),
Migration(43, upgradeFromV42ToV43),
Migration(44, upgradeFromV43ToV44),
Migration(45, upgradeFromV44ToV45),
Migration(46, upgradeFromV45ToV46),
Migration(47, upgradeFromV46ToV47),
];
class DatabaseService {
/// Logger.
final Logger _log = Logger('DatabaseService');
/// The database.
late Database database;
Future<void> initialize() async {
final dbPath = path.join(
await getDatabasesPath(),
'moxxy.db',
);
final dbPassword =
await GetIt.I.get<XmppStateService>().getOrCreateDatabaseKey();
// Just some sanity checks
final version = migrations.last.version;
assert(
migrations.every((migration) => migration.version <= version),
"Every migration's version must be smaller or equal to the last version",
);
assert(
migrations
.sublist(0, migrations.length - 1)
.every((migration) => migration.version < version),
'The last migration must have the largest version',
);
database = await openDatabase(
dbPath,
password: dbPassword,
version: version,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
// keys in the onConfigure phase, but re-enable them here.
// See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
// for the "solution".
await db.execute('PRAGMA foreign_keys = OFF');
},
onOpen: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onUpgrade: (db, oldVersion, newVersion) async {
await runMigrations(_log, db, migrations, oldVersion, 'database');
},
);
_log.finest('Database setup done');
}
}
extension DatabaseHelpers on Database {
/// Count the number of rows in [table] where [where] with the arguments [whereArgs]
@@ -65,6 +174,24 @@ extension DatabaseHelpers on Database {
)!;
}
/// Like insert but returns the affected row.
Future<Map<String, Object?>> insertAndReturn(
String table,
Map<String, Object?> values,
) async {
final q = SqlBuilder.insert(
table,
values,
);
final result = await rawQuery(
'${q.sql} RETURNING *',
q.arguments,
);
assert(result.length == 1, 'Only one row must be returned');
return result.first;
}
/// Like update but returns the affected row.
Future<Map<String, Object?>> updateAndReturn(
String table,
@@ -86,119 +213,4 @@ extension DatabaseHelpers on Database {
assert(result.length == 1, 'Only one row must be returned');
return result.first;
}
/// Like insert but returns the affected row.
Future<Map<String, Object?>> insertAndReturn(
String table,
Map<String, Object?> values,
) async {
final q = SqlBuilder.insert(
table,
values,
);
final result = await rawQuery(
'${q.sql} RETURNING *',
q.arguments,
);
assert(result.length == 1, 'Only one row must be returned');
return result.first;
}
}
@internal
const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(2, upgradeFromV1ToV2),
DatabaseMigration(3, upgradeFromV2ToV3),
DatabaseMigration(4, upgradeFromV3ToV4),
DatabaseMigration(5, upgradeFromV4ToV5),
DatabaseMigration(6, upgradeFromV5ToV6),
DatabaseMigration(7, upgradeFromV6ToV7),
DatabaseMigration(8, upgradeFromV7ToV8),
DatabaseMigration(9, upgradeFromV8ToV9),
DatabaseMigration(10, upgradeFromV9ToV10),
DatabaseMigration(11, upgradeFromV10ToV11),
DatabaseMigration(12, upgradeFromV11ToV12),
DatabaseMigration(13, upgradeFromV12ToV13),
DatabaseMigration(14, upgradeFromV13ToV14),
DatabaseMigration(15, upgradeFromV14ToV15),
DatabaseMigration(16, upgradeFromV15ToV16),
DatabaseMigration(17, upgradeFromV16ToV17),
DatabaseMigration(18, upgradeFromV17ToV18),
DatabaseMigration(19, upgradeFromV18ToV19),
DatabaseMigration(20, upgradeFromV19ToV20),
DatabaseMigration(21, upgradeFromV20ToV21),
DatabaseMigration(22, upgradeFromV21ToV22),
DatabaseMigration(23, upgradeFromV22ToV23),
DatabaseMigration(24, upgradeFromV23ToV24),
DatabaseMigration(25, upgradeFromV24ToV25),
DatabaseMigration(26, upgradeFromV25ToV26),
DatabaseMigration(27, upgradeFromV26ToV27),
DatabaseMigration(28, upgradeFromV27ToV28),
DatabaseMigration(29, upgradeFromV28ToV29),
DatabaseMigration(30, upgradeFromV29ToV30),
DatabaseMigration(31, upgradeFromV30ToV31),
DatabaseMigration(32, upgradeFromV31ToV32),
DatabaseMigration(33, upgradeFromV32ToV33),
DatabaseMigration(34, upgradeFromV33ToV34),
DatabaseMigration(35, upgradeFromV34ToV35),
DatabaseMigration(36, upgradeFromV35ToV36),
DatabaseMigration(37, upgradeFromV36ToV37),
];
class DatabaseService {
/// Secure storage for accesing the database encryption key.
final FlutterSecureStorage _storage = const FlutterSecureStorage(
// TODO(Unknown): Set other options
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
/// Logger.
final Logger _log = Logger('DatabaseService');
/// The database.
late Database database;
Future<void> initialize() async {
final dbPath = path.join(
await getDatabasesPath(),
'moxxy.db',
);
String key;
if (await _storage.containsKey(key: databasePasswordKey)) {
_log.finest('Database encryption key found');
key = (await _storage.read(key: databasePasswordKey))!;
} else {
_log.finest('Database encryption not key found. Generating it...');
key = randomAlphaNumeric(
40,
provider: CoreRandomProvider.from(Random.secure()),
);
await _storage.write(key: databasePasswordKey, value: key);
_log.finest('Key generation done...');
}
database = await openDatabase(
dbPath,
password: key,
version: 37,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
// keys in the onConfigure phase, but re-enable them here.
// See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
// for the "solution".
await db.execute('PRAGMA foreign_keys = OFF');
},
onOpen: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onUpgrade: (db, oldVersion, newVersion) async {
await runMigrations(_log, db, migrations, oldVersion);
},
);
_log.finest('Database setup done');
}
}

View File

@@ -1,5 +1,3 @@
import 'package:moxxyv2/shared/models/conversation.dart';
/// Conversion helpers for bool <-> int as sqlite has no "real" booleans
int boolToInt(bool b) => b ? 1 : 0;
bool intToBool(int i) => i == 0 ? false : true;
@@ -10,32 +8,6 @@ bool stringToBool(String s) => s == 'true' ? true : false;
String intToString(int i) => '$i';
int stringToInt(String s) => int.parse(s);
String conversationTypeToString(ConversationType type) {
switch (type) {
case ConversationType.chat:
{
return 'chat';
}
case ConversationType.note:
{
return 'note';
}
}
}
ConversationType stringToConversationType(String type) {
switch (type) {
case 'chat':
{
return ConversationType.chat;
}
default:
{
return ConversationType.note;
}
}
}
/// Given a map [map], extract all key-value pairs from [map] where the key starts with
/// [prefix]. Combine those key-value pairs into a new map, where the leading [prefix]
/// is removed from all key names.

View File

@@ -1,20 +1,21 @@
import 'package:logging/logging.dart';
/// A function to be called when a migration should be performed.
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
typedef MigrationCallback<T> = Future<void> Function(T);
/// This class represents a single database migration.
class DatabaseMigration<T> {
const DatabaseMigration(this.version, this.migration);
class Migration<T> {
const Migration(this.version, this.migration);
/// The version this migration upgrades the database to.
final int version;
/// The migration callback. Called the the database version is less than [version].
final DatabaseMigrationCallback<T> migration;
final MigrationCallback<T> migration;
}
/// Given the database [db] with the current version [version], goes through the list of
/// Given the migration [param], which is passed to every migration, with the current version
/// [version], goes through the list of
/// migrations [migrations] and applies all migrations with a version greater than
/// [version]. [migrations] is sorted before usage.
///
@@ -23,22 +24,32 @@ class DatabaseMigration<T> {
/// database argument, just pass in whatever (the tests use an integer).
Future<void> runMigrations<T>(
Logger log,
T db,
List<DatabaseMigration<T>> migrations,
T param,
List<Migration<T>> migrations,
int version,
) async {
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
String typeName, {
Future<void> Function(int)? commitVersion,
}) async {
final sortedMigrations = List<Migration<T>>.from(migrations)
..sort(
(a, b) => a.version.compareTo(b.version),
);
var currentVersion = version;
var hasRunMigration = false;
for (final migration in sortedMigrations) {
if (version < migration.version) {
log.info(
'Running database migration $currentVersion -> ${migration.version}',
'Running $typeName migration $currentVersion -> ${migration.version}',
);
await migration.migration(db);
await migration.migration(param);
currentVersion = migration.version;
hasRunMigration = true;
}
}
// Commit the version, if specified.
if (commitVersion != null && hasRunMigration) {
log.info('Committing migration version $currentVersion');
await commitVersion(currentVersion);
}
}

View File

@@ -1,10 +1,9 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV12ToV13(Database db) async {
await db.execute(
'''
CREATE TABLE $omemoFingerprintCache (
CREATE TABLE OmemoFingerprintCache (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
fingerprint TEXT NOT NULL,

View File

@@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV37ToV38(Database db) async {
await db
.execute('ALTER TABLE $conversationsTable ADD COLUMN avatarHash TEXT');
await db.execute(
'ALTER TABLE $conversationsTable RENAME COLUMN avatarUrl TO avatarPath',
);
await db.execute(
'ALTER TABLE $rosterTable RENAME COLUMN avatarUrl TO avatarPath',
);
}

View File

@@ -0,0 +1,16 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV44ToV45(Database db) async {
await db.update(
messagesTable,
{
'errorType': null,
'warningType': MessageWarningType.chatEncryptedButFilePlaintext.value,
},
where: 'errorType = ?',
// NOTE: 10 is the old id of this error
whereArgs: [10],
);
}

View File

@@ -0,0 +1,12 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV42ToV43(Database db) async {
await db.execute(
'''
CREATE TABLE $groupchatTable (
jid TEXT PRIMARY KEY,
nick TEXT NOT NULL
)''',
);
}

View File

@@ -0,0 +1,428 @@
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:uuid/uuid.dart';
extension MaybeGet<K, V> on Map<K, V> {
V? maybeGet(K? key) {
if (key == null) return null;
return this[key];
}
}
Future<void> upgradeFromV45ToV46(Database db) async {
// Migrate everything to the tuple of (account JID, <old pk>)
// Things we do not migrate to this scheme:
// - Stickers: Technically, makes no sense
// - File metadata: We want to aggresively cache, so we keep it
// Get the account JID
final rawJid = await db.query(
xmppStateTable,
where: 'key = ?',
whereArgs: ['jid'],
limit: 1,
);
// [migrateRows] indicates whether we can move the data to the new JID-annotated format.
// It's false if we don't have a "logged in" JID. If we have one, it's true and we can
// move data.
final migrateRows = rawJid.isNotEmpty;
final accountJid = migrateRows ? rawJid.first['value']! as String : null;
// Store the account JID in the secure storage.
if (migrateRows) {
await GetIt.I.get<XmppStateService>().setAccountJid(accountJid!);
}
// Migrate the XMPP state
await db.execute(
'''
CREATE TABLE ${xmppStateTable}_new (
key TEXT NOT NULL,
accountJid TEXT NOT NULL,
value TEXT,
PRIMARY KEY (key, accountJid)
)''',
);
if (migrateRows) {
for (final statePair in await db.query(xmppStateTable)) {
await db.insert(
'${xmppStateTable}_new',
{
...statePair,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $xmppStateTable');
await db
.execute('ALTER TABLE ${xmppStateTable}_new RENAME TO $xmppStateTable');
// Migrate messages
await db.execute(
'''
CREATE TABLE ${messagesTable}_new (
id TEXT NOT NULL PRIMARY KEY,
accountJid TEXT NOT NULL,
sender TEXT NOT NULL,
body TEXT,
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id TEXT,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote
FOREIGN KEY (quote_id)
REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata
FOREIGN KEY (file_metadata_id)
REFERENCES $fileMetadataTable (id)
)''',
);
// Build up the message map
/// Message's old id attribute -> Message's new UUID attribute.
const uuid = Uuid();
final messageMap = <int, String>{};
if (migrateRows) {
final messages = await db.query(messagesTable);
for (final message in messages) {
messageMap[message['id']! as int] = uuid.v4();
}
// Then migrate messages
for (final message in messages) {
await db.insert('${messagesTable}_new', {
...Map.from(message)
..remove('id')
..remove('quote_id'),
'accountJid': accountJid,
'quote_id': messageMap.maybeGet(message['quote_id'] as int?),
'id': messageMap[message['id']! as int],
});
}
}
await db.execute('DROP TABLE $messagesTable');
await db.execute('ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable');
await db.execute(
'CREATE INDEX idx_messages_sid ON $messagesTable (accountJid, sid)',
);
await db.execute(
'CREATE INDEX idx_messages_origin_sid ON $messagesTable (accountJid, originId, sid)',
);
// Migrate conversations
await db.execute(
'''
CREATE TABLE ${conversationsTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
title TEXT NOT NULL,
avatarPath TEXT NOT NULL,
avatarHash TEXT,
type TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId TEXT,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
PRIMARY KEY (jid, accountJid),
CONSTRAINT fk_last_message
FOREIGN KEY (lastMessageId)
REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id
FOREIGN KEY (contactId)
REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
if (migrateRows) {
for (final conversation in await db.query(conversationsTable)) {
await db.insert(
'${conversationsTable}_new',
{
...Map.from(conversation)..remove('lastMessageId'),
'lastMessageId':
messageMap.maybeGet(conversation['lastMessageId'] as int?),
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $conversationsTable');
await db.execute(
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable',
);
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (accountJid, jid)',
);
// Migrate groupchat details
await db.execute(
'''
CREATE TABLE ${groupchatTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
nick TEXT NOT NULL,
PRIMARY KEY (jid, accountJid),
CONSTRAINT fk_groupchat
FOREIGN KEY (jid, accountJid)
REFERENCES $conversationsTable (jid, accountJid)
ON DELETE CASCADE
)''',
);
if (migrateRows) {
for (final g in await db.query(groupchatTable)) {
await db.insert(
'${groupchatTable}_new',
{
...g,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $groupchatTable');
await db
.execute('ALTER TABLE ${groupchatTable}_new RENAME TO $groupchatTable');
// Migrate reactions
await db.execute(
'''
CREATE TABLE ${reactionsTable}_new (
accountJid TEXT NOT NULL,
message_id TEXT NOT NULL,
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
PRIMARY KEY (accountJid, senderJid, emoji, message_id),
CONSTRAINT fk_message
FOREIGN KEY (message_id)
REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''',
);
if (migrateRows) {
for (final reaction in await db.query(reactionsTable)) {
await db.insert(
'${reactionsTable}_new',
{
...Map.from(reaction)..remove('message_id'),
'message_id': messageMap.maybeGet(reaction['message_id']! as int),
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $reactionsTable');
await db
.execute('ALTER TABLE ${reactionsTable}_new RENAME TO $reactionsTable');
// Migrate the roster
await db.execute(
'''
CREATE TABLE ${rosterTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
title TEXT NOT NULL,
avatarPath TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
pseudoRosterItem INTEGER NOT NULL,
CONSTRAINT fk_contact_id
FOREIGN KEY (contactId)
REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
if (migrateRows) {
for (final rosterItem in await db.query(rosterTable)) {
await db.insert(
'${rosterTable}_new',
{
...Map.from(rosterItem)..remove('id'),
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $rosterTable');
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable');
// Migrate the blocklist
await db.execute(
'''
CREATE TABLE ${blocklistTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
PRIMARY KEY (accountJid, jid)
);
''',
);
if (migrateRows) {
for (final blocklistItem in await db.query(blocklistTable)) {
await db.insert(
'${blocklistTable}_new',
{
...blocklistItem,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $blocklistTable');
await db
.execute('ALTER TABLE ${blocklistTable}_new RENAME TO $blocklistTable');
// Migrate the notifications list
await db.execute(
'''
CREATE TABLE ${notificationsTable}_new (
id INTEGER NOT NULL,
conversationJid TEXT NOT NULL,
accountJid TEXT NOT NULL,
sender TEXT,
senderJid TEXT,
avatarPath TEXT,
body TEXT NOT NULL,
mime TEXT,
path TEXT,
timestamp INTEGER NOT NULL,
PRIMARY KEY (id, conversationJid, senderJid, timestamp, accountJid)
)''',
);
if (migrateRows) {
for (final notification in await db.query(notificationsTable)) {
await db.insert(
'${notificationsTable}_new',
{
...notification,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $notificationsTable');
await db.execute(
'ALTER TABLE ${notificationsTable}_new RENAME TO $notificationsTable',
);
// Migrate OMEMO device list
await db.execute(
'''
CREATE TABLE ${omemoDeviceListTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
devices TEXT NOT NULL,
PRIMARY KEY (accountJid, jid)
)''',
);
{
for (final deviceListEntry in await db.query(omemoDeviceListTable)) {
await db.insert(
'${omemoDeviceListTable}_new',
{
...deviceListEntry,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $omemoDeviceListTable');
await db.execute(
'ALTER TABLE ${omemoDeviceListTable}_new RENAME TO $omemoDeviceListTable',
);
// Migrate OMEMO trust
await db.execute(
'''
CREATE TABLE ${omemoTrustTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
device INTEGER NOT NULL,
trust INTEGER NOT NULL,
enabled INTEGER NOT NULL,
PRIMARY KEY (accountJid, jid, device)
)''',
);
if (migrateRows) {
for (final trustItem in await db.query(omemoTrustTable)) {
await db.insert(
'${omemoTrustTable}_new',
{
...trustItem,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $omemoTrustTable');
await db
.execute('ALTER TABLE ${omemoTrustTable}_new RENAME TO $omemoTrustTable');
// Migrate OMEMO ratchets
await db.execute(
'''
CREATE TABLE ${omemoRatchetsTable}_new (
jid TEXT NOT NULL,
accountJid TEXT NOT NULL,
device INTEGER NOT NULL,
dhsPub TEXT NOT NULL,
dhs TEXT NOT NULL,
dhrPub TEXT,
rk TEXT NOT NULL,
cks TEXT,
ckr TEXT,
ns INTEGER NOT NULL,
nr INTEGER NOT NULL,
pn INTEGER NOT NULL,
ik TEXT NOT NULL,
ad TEXT NOT NULL,
skipped TEXT NOT NULL,
kex TEXT NOT NULL,
acked INTEGER NOT NULL,
PRIMARY KEY (accountJid, jid, device)
)''',
);
if (migrateRows) {
for (final ratchet in await db.query(omemoRatchetsTable)) {
await db.insert(
'${omemoRatchetsTable}_new',
{
...ratchet,
'accountJid': accountJid,
},
);
}
}
await db.execute('DROP TABLE $omemoRatchetsTable');
await db.execute(
'ALTER TABLE ${omemoRatchetsTable}_new RENAME TO $omemoRatchetsTable',
);
}

View File

@@ -0,0 +1,72 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV39ToV40(Database db) async {
// Remove the old tables
await db.execute('DROP TABLE OmemoDevices');
await db.execute('DROP TABLE OmemoDeviceList');
await db.execute('DROP TABLE OmemoTrustCacheList');
await db.execute('DROP TABLE OmemoTrustDeviceList');
await db.execute('DROP TABLE OmemoTrustEnableList');
await db.execute('DROP TABLE OmemoFingerprintCache');
// Create the new tables
await db.execute(
'''
CREATE TABLE $omemoDevicesTable (
jid TEXT NOT NULL PRIMARY KEY,
id INTEGER NOT NULL,
ikPub TEXT NOT NULL,
ik TEXT NOT NULL,
spkPub TEXT NOT NULL,
spk TEXT NOT NULL,
spkId INTEGER NOT NULL,
spkSig TEXT NOT NULL,
oldSpkPub TEXT,
oldSpk TEXT,
oldSpkId INTEGER,
opks TEXT NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL PRIMARY KEY,
devices TEXT NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoRatchetsTable (
jid TEXT NOT NULL,
device INTEGER NOT NULL,
dhsPub TEXT NOT NULL,
dhs TEXT NOT NULL,
dhrPub TEXT,
rk TEXT NOT NULL,
cks TEXT,
ckr TEXT,
ns INTEGER NOT NULL,
nr INTEGER NOT NULL,
pn INTEGER NOT NULL,
ik TEXT NOT NULL,
ad TEXT NOT NULL,
skipped TEXT NOT NULL,
kex TEXT NOT NULL,
acked INTEGER NOT NULL,
PRIMARY KEY (jid, device)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustTable (
jid TEXT NOT NULL,
device INTEGER NOT NULL,
trust INTEGER NOT NULL,
enabled INTEGER NOT NULL,
PRIMARY KEY (jid, device)
)''',
);
}

View File

@@ -0,0 +1,25 @@
import 'dart:convert';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV40ToV41(Database db) async {
final messages = await db.query(
messagesTable,
where: 'pseudoMessageType IS NOT NULL',
);
for (final message in messages) {
await db.insert(
messagesTable,
{
...message,
'pseudoMessageData': jsonEncode({
'ratchetsAdded': 1,
'ratchetsReplaced': 0,
}),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV43ToV44(Database db) async {
await db.execute(
'''
CREATE TABLE $notificationsTable (
id INTEGER NOT NULL,
conversationJid TEXT NOT NULL,
sender TEXT,
senderJid TEXT,
avatarPath TEXT,
body TEXT NOT NULL,
mime TEXT,
path TEXT,
timestamp INTEGER NOT NULL,
PRIMARY KEY (id, conversationJid, senderJid, timestamp)
)''',
);
await db.execute(
'CREATE INDEX idx_notifications ON $notificationsTable (conversationJid)',
);
}

View File

@@ -0,0 +1,8 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV46ToV47(Database db) async {
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN occupantId TEXT',
);
}

View File

@@ -0,0 +1,6 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV38ToV39(Database db) async {
await db.execute('DROP TABLE $subscriptionsTable');
}

View File

@@ -0,0 +1,30 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV41ToV42(Database db) async {
/// Add the new column
await db.execute(
'''
ALTER TABLE $stickerPacksTable ADD COLUMN addedTimestamp INTEGER NOT NULL DEFAULT 0;
''',
);
/// Ensure that the sticker packs are sorted (albeit randomly)
final stickerPackIds = await db.query(
stickerPacksTable,
columns: ['id'],
);
var counter = 0;
for (final id in stickerPackIds) {
await db.update(
stickerPacksTable,
{
'addedTimestamp': counter,
},
where: 'id = ?',
whereArgs: [id],
);
counter++;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:ui';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/constants.dart';
@@ -10,8 +11,9 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common/sql.dart';
/// A class for returning whether a file metadata element was just created or retrieved.
class FileMetadataWrapper {
@@ -51,7 +53,7 @@ Future<String> computeCachedPathForFile(
Map<HashFunction, String>? hashes,
) async {
final basePath = path.join(
(await getApplicationDocumentsDirectory()).path,
await MoxplatformPlugin.platform.getPersistentDataPath(),
'media',
);
final baseDir = Directory(basePath);
@@ -67,7 +69,8 @@ Future<String> computeCachedPathForFile(
return path.join(
basePath,
hash != null
? '$hash.$ext'
// NOTE: [ext] already includes a leading "."
? '$hash$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
);
}
@@ -89,6 +92,10 @@ class FilesService {
'value': hash.value,
'id': metadataId,
},
// TODO(Unknown): I would like to get rid of this. In events.dart, when processing
// a request to manually download a file, we should check if we already
// have hash pointers for a file metadata item.
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
}
@@ -317,6 +324,20 @@ class FilesService {
} catch (ex) {
_log.warning('Failed to remove file ${metadata.path!}: $ex');
}
if (metadata.mimeType?.startsWith('video/') ?? false) {
final thumbnailPath = await getVideoThumbnailPath(metadata.path!);
final thumbnailFile = File(thumbnailPath);
if (thumbnailFile.existsSync()) {
try {
await thumbnailFile.delete();
} catch (ex) {
_log.warning(
'Failed to remove thumbnail file $thumbnailPath: $ex',
);
}
}
}
} else {
_log.info('Not removing file as there is no path associated with it');
}

104
lib/service/groupchat.dart Normal file
View File

@@ -0,0 +1,104 @@
import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/models/groupchat.dart';
class GroupchatService {
/// Retrieves the information about a group chat room specified by the given
/// JID.
/// Returns a [Future] that resolves to a [RoomInformation] object containing
/// details about the room.
Future<Result<RoomInformation, MUCError>> getRoomInformation(
JID roomJID,
) async {
final conn = GetIt.I.get<XmppConnection>();
final mm = conn.getManagerById<MUCManager>(mucManager)!;
final result = await mm.queryRoomInformation(roomJID);
return result;
}
/// Joins a group chat room specified by the given MUC JID and a nickname.
/// Returns a [Future] that resolves to a [GroupchatDetails] object
/// representing the details of the joined room.
/// Throws an exception of type [GroupchatErrorType.roomPasswordProtected]
/// if the room requires a password for entry.
Future<Result<GroupchatDetails, GroupchatErrorType>> joinRoom(
JID muc,
String accountJid,
String nick,
) async {
final conn = GetIt.I.get<XmppConnection>();
final mm = conn.getManagerById<MUCManager>(mucManager)!;
final roomInformationResult = await getRoomInformation(muc);
if (roomInformationResult.isType<RoomInformation>()) {
final roomPasswordProtected = roomInformationResult
.get<RoomInformation>()
.features
.contains('muc_passwordprotected');
if (roomPasswordProtected) {
return const Result(GroupchatErrorType.roomPasswordProtected);
}
final result = await mm.joinRoom(muc, nick);
if (result.isType<MUCError>()) {
return Result(
GroupchatErrorType.fromException(
result.get<MUCError>(),
),
);
} else {
return Result(
GroupchatDetails(
muc.toBare().toString(),
accountJid,
nick,
),
);
}
} else {
return Result(
GroupchatErrorType.fromException(
roomInformationResult.get<MUCError>(),
),
);
}
}
/// Creates and adds group chat details to the database based on the provided
/// JID, nickname, and title.
/// Returns a [Future] that resolves to a [GroupchatDetails] object
/// representing the added group chat details.
Future<GroupchatDetails> addGroupchatDetailsFromData(
String jid,
String accountJid,
String nick,
) async {
final groupchatDetails = GroupchatDetails(jid, accountJid, nick);
await GetIt.I.get<DatabaseService>().database.insert(
groupchatTable,
groupchatDetails.toJson(),
);
return groupchatDetails;
}
/// Retrieves group chat details from the database based on the provided JID.
///
/// Returns a [Future] that resolves to a [GroupchatDetails] object if found,
/// or `null` if no matching details are found.
Future<GroupchatDetails?> getGroupchatDetailsByJid(
String jid,
String accountJid,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final groupchatDetailsRaw = await db.query(
groupchatTable,
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
if (groupchatDetailsRaw.isEmpty) return null;
return GroupchatDetails.fromDatabaseJson(groupchatDetailsRaw[0]);
}
}

View File

@@ -124,11 +124,7 @@ String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
/// This information is complemented either the srcUrl or if unavailable
/// by the body of the quoted message. For non-media messages, we always use
/// the body as fallback.
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
if (quotedMessage == null) {
return null;
}
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored
String quoteMessageSize;

View File

@@ -6,7 +6,6 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/conversation.dart';
@@ -23,6 +22,7 @@ import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';
@@ -127,9 +127,6 @@ class HttpFileTransferService {
) async {
if (!File(to).existsSync()) {
await File(job.path).copy(to);
// Let the media scanner index the file
MoxplatformPlugin.media.scanFile(to);
} else {
_log.finest(
'Skipping file copy on upload as file is already at media location',
@@ -137,21 +134,27 @@ class HttpFileTransferService {
}
}
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
Future<void> _fileUploadFailed(
FileUploadJob job,
MessageErrorType error,
) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
// Notify UI of upload failure
for (final recipient in job.recipients) {
final m = job.messageMap[recipient]!;
final msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
m.id,
job.accountJid,
errorType: error,
isUploading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation list
final conversation = await cs.getConversationByJid(recipient);
final conversation =
await cs.getConversationByJid(recipient, job.accountJid);
if (conversation?.lastMessage?.id == msg.id) {
final newConversation = conversation!.copyWith(
lastMessage: msg,
@@ -190,7 +193,7 @@ class HttpFileTransferService {
);
} catch (ex) {
_log.warning('Encrypting ${job.path} failed: $ex');
await _fileUploadFailed(job, messageFailedToEncryptFile);
await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
return;
}
}
@@ -209,7 +212,7 @@ class HttpFileTransferService {
if (slotResult.isType<HttpFileUploadError>()) {
_log.severe('Failed to request upload slot for ${job.path}!');
await _fileUploadFailed(job, fileUploadFailedError);
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
return;
}
final slot = slotResult.get<HttpFileUploadSlot>();
@@ -225,7 +228,7 @@ class HttpFileTransferService {
final progress = current.toDouble() / total.toDouble();
sendEvent(
ProgressEvent(
id: job.messageMap.values.first.id,
id: job.messageMap[job.recipients.first]!.id,
progress: progress == 1 ? 0.99 : progress,
),
);
@@ -236,7 +239,7 @@ class HttpFileTransferService {
final ms = GetIt.I.get<MessageService>();
if (!isRequestOkay(uploadStatusCode)) {
_log.severe('Upload failed due to status code $uploadStatusCode');
await _fileUploadFailed(job, fileUploadFailedError);
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
return;
} else {
_log.fine('Upload was successful');
@@ -322,9 +325,11 @@ class HttpFileTransferService {
const uuid = Uuid();
for (final recipient in job.recipients) {
// Notify UI of upload completion
final m = job.messageMap[recipient]!;
var msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
errorType: noError,
m.id,
job.accountJid,
errorType: null,
isUploading: false,
fileMetadata: metadata,
);
@@ -332,20 +337,20 @@ class HttpFileTransferService {
final oldSid = msg.sid;
msg = await ms.updateMessage(
msg.id,
job.accountJid,
sid: uuid.v4(),
originId: uuid.v4(),
);
sendEvent(MessageUpdatedEvent(message: msg));
// Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: slot.getUrl,
requestDeliveryReceipt: true,
id: msg.sid,
originId: msg.originId,
sfs: StatelessFileSharingData(
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(slot.getUrl),
const MessageDeliveryReceiptData(true),
StableIdData(msg.originId, null),
StatelessFileSharingData(
FileMetadataData(
mediaType: job.mime,
size: stat.size,
@@ -353,11 +358,12 @@ class HttpFileTransferService {
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
<StatelessFileSharingSource>[source],
[source],
includeOOBFallback: true,
),
shouldEncrypt: job.encryptMap[recipient]!,
funReplacement: oldSid,
),
FileUploadNotificationReplacementData(oldSid),
MessageIdData(msg.sid),
]),
);
_log.finest(
'Sent message with file upload for ${job.path} to $recipient',
@@ -390,12 +396,16 @@ class HttpFileTransferService {
});
}
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
Future<void> _fileDownloadFailed(
FileDownloadJob job,
MessageErrorType error,
) async {
final ms = GetIt.I.get<MessageService>();
// Notify UI of download failure
final msg = await ms.updateMessage(
job.mId,
job.messageId,
job.accountJid,
errorType: error,
isDownloading: false,
);
@@ -436,7 +446,7 @@ class HttpFileTransferService {
final progress = current.toDouble() / total.toDouble();
sendEvent(
ProgressEvent(
id: job.mId,
id: job.messageId,
progress: progress == 1 ? 0.99 : progress,
),
);
@@ -451,7 +461,7 @@ class HttpFileTransferService {
_log.warning(
'HTTP GET of $downloadUrl returned $downloadStatusCode',
);
await _fileDownloadFailed(job, fileDownloadFailedError);
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
return;
}
@@ -462,7 +472,7 @@ class HttpFileTransferService {
// The file was downloaded and is now being decrypted
sendEvent(
ProgressEvent(
id: job.mId,
id: job.messageId,
),
);
@@ -479,7 +489,7 @@ class HttpFileTransferService {
if (!result.decryptionOkay) {
_log.warning('Failed to decrypt $downloadPath');
await _fileDownloadFailed(job, messageFailedToDecryptFile);
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
return;
}
@@ -488,7 +498,7 @@ class HttpFileTransferService {
_log.warning(
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
);
await _fileDownloadFailed(job, messageFailedToDecryptFile);
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
return;
}
@@ -527,8 +537,6 @@ class HttpFileTransferService {
int? mediaHeight;
if (mime != null) {
if (mime.startsWith('image/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
// Find out the dimensions
final imageSize = await getImageSizeFromPath(downloadedPath);
if (imageSize == null) {
@@ -538,25 +546,33 @@ class HttpFileTransferService {
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();
} else if (mime.startsWith('video/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
if (canGenerateVideoThumbnail(mime)) {
try {
// Generate thumbnail
final thumbnailPath = await maybeGenerateVideoThumbnail(
downloadedPath,
);
/*
// Generate thumbnail
final thumbnailPath = await getVideoThumbnailPath(
downloadedPath,
job.conversationJid,
);
if (thumbnailPath != null) {
// Find out the dimensions
final imageSize = await getImageSizeFromPath(thumbnailPath);
if (imageSize == null) {
_log.warning(
'Failed to get image size for $downloadedPath ($thumbnailPath)',
);
}
// Find out the dimensions
final imageSize = await getImageSizeFromPath(thumbnailPath);
if (imageSize == null) {
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();
}
} catch (ex) {
_log.warning('Failed to generate thumbnail for $downloadedPath');
}
} else {
_log.info(
'Not generating thumbnail for $downloadedPath because canGenerateVideoThumbnail returned false',
);
}
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();*/
} else if (mime.startsWith('audio/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
}
}
@@ -571,45 +587,61 @@ class HttpFileTransferService {
);
// Only add the hash pointers if the file hashes match what was sent
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
if (integrityCheckPassed) {
await fs.createMetadataHashEntries(
job.location.plaintextHashes!,
job.metadataId,
);
} else {
_log.warning('Integrity check failed for file');
}
if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
integrityCheckPassed &&
job.createMetadataHashes) {
await fs.createMetadataHashEntries(
job.location.plaintextHashes!,
job.metadataId,
);
}
final cs = GetIt.I.get<ConversationService>();
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
final conversation = (await cs.getConversationByJid(
job.conversationJid,
job.accountJid,
))!;
// Figure out if we should show a warning
MessageWarningType? warning;
if (!integrityCheckPassed) {
warning = MessageWarningType.fileIntegrityCheckFailed;
} else if (conversation.encrypted && !decryptionKeysAvailable) {
warning = MessageWarningType.chatEncryptedButFilePlaintext;
}
final msg = await GetIt.I.get<MessageService>().updateMessage(
job.mId,
job.messageId,
job.accountJid,
fileMetadata: metadata,
isFileUploadNotification: false,
warningType:
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
errorType: conversation.encrypted && !decryptionKeysAvailable
? messageChatEncryptedButFileNot
: null,
warningType: warning,
isDownloading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
final updatedConversation = conversation.copyWith(
lastMessage: conversation.lastMessage?.id == job.mId
lastMessage: conversation.lastMessage?.id == job.messageId
? msg
: conversation.lastMessage,
);
cs.setConversation(updatedConversation);
// Show a notification
if (notification.shouldShowNotification(msg.conversationJid) &&
job.shouldShowNotification) {
final shouldShowNotification =
notification.shouldShowNotification(msg.conversationJid);
if (shouldShowNotification && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(updatedConversation, msg, '');
await notification.updateOrShowNotification(
updatedConversation,
msg,
job.accountJid,
);
} else {
_log.finest(
'Not creating or updating notification for $downloadedPath: notification.shouldShowNotification=$shouldShowNotification, job.shouldShowNotification=${job.shouldShowNotification}',
);
}
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));

View File

@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/message.dart';
@immutable
class FileUploadJob {
const FileUploadJob(
this.accountJid,
this.recipients,
this.path,
this.mime,
@@ -23,11 +24,13 @@ class FileUploadJob {
// Recipient -> Message
final Map<String, Message> messageMap;
final String metadataId;
final List<Thumbnail> thumbnails;
final String accountJid;
final List<JingleContentThumbnail> thumbnails;
@override
bool operator ==(Object other) {
return other is FileUploadJob &&
accountJid == other.accountJid &&
recipients == other.recipients &&
path == other.path &&
messageMap == other.messageMap &&
@@ -52,37 +55,59 @@ class FileUploadJob {
@immutable
class FileDownloadJob {
const FileDownloadJob(
this.location,
this.mId,
this.metadataId,
this.messageId,
this.conversationJid,
this.accountJid,
this.location,
this.metadataId,
this.createMetadataHashes,
this.mimeGuess, {
this.shouldShowNotification = true,
});
final MediaFileLocation location;
final int mId;
final String metadataId;
/// The message id.
final String messageId;
/// The JID of the conversation we're downloading the file in.
final String conversationJid;
/// The associated account.
final String accountJid;
/// The location where the file can be found.
final MediaFileLocation location;
/// The id of the file metadata describing the file.
final String metadataId;
/// Flag indicating whether we should create hash pointers to the file metadata
/// object.
final bool createMetadataHashes;
/// A guess to the files's MIME type.
final String? mimeGuess;
/// Flag indicating whether a notification should be shown after successful download.
final bool shouldShowNotification;
@override
bool operator ==(Object other) {
return other is FileDownloadJob &&
location == other.location &&
mId == other.mId &&
metadataId == other.metadataId &&
messageId == other.messageId &&
conversationJid == other.conversationJid &&
location == other.location &&
accountJid == other.accountJid &&
metadataId == other.metadataId &&
mimeGuess == other.mimeGuess &&
shouldShowNotification == other.shouldShowNotification;
}
@override
int get hashCode =>
location.hashCode ^
mId.hashCode ^
metadataId.hashCode ^
conversationJid.hashCode ^
messageId.hashCode ^
location.hashCode ^
metadataId.hashCode ^
mimeGuess.hashCode ^
shouldShowNotification.hashCode;
}

View File

@@ -0,0 +1,16 @@
import 'package:logging/logging.dart';
/// Service which provides other services with information about the state of
/// the app, i.e. if it's in the foreground, minimized, ...
class LifecycleService {
final Logger _log = Logger('LifecycleService');
/// Flag indicating whether the app is currently active, i.e. in the foreground (true),
/// or inactive (false).
bool _active = false;
bool get isActive => _active;
set isActive(bool flag) {
_log.finest('Setting isActive to $flag');
_active = flag;
}
}

View File

@@ -10,47 +10,34 @@ import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:synchronized/synchronized.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:uuid/uuid.dart';
class MessageService {
/// Logger
final Logger _log = Logger('MessageService');
final LRUCache<String, List<Message>> _messageCache =
LRUCache(conversationMessagePageCacheSize);
final Lock _cacheLock = Lock();
/// UUID instance for message ids.
final _uuid = const Uuid();
Future<Message?> getMessageById(
int id,
String conversationJid, {
bool queryReactionPreview = true,
}) async {
Future<Message> _parseMessage(
Map<String, Object?> rawMessage,
String accountJid,
bool queryReactionPreview,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesRaw = await db.query(
messagesTable,
where: 'id = ? AND conversationJid = ?',
whereArgs: [id, conversationJid],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
// Load the file metadata, if available
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
if (rawMessage['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
whereArgs: [rawMessage['file_metadata_id']],
limit: 1,
))
.first;
@@ -58,32 +45,65 @@ class MessageService {
}
return Message.fromDatabaseJson(
msg,
rawMessage,
null,
fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
? await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
rawMessage['id']! as String,
accountJid,
)
: [],
);
}
Future<Message?> getMessageByXmppId(
/// Queries the database for a message with a stanza id of [id] inside
/// the conversation [conversationJid], if specified, in the context of the account
/// [accountJid].
Future<Message?> getMessageById(
String id,
String conversationJid, {
bool includeOriginId = true,
String accountJid, {
String? conversationJid,
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
final messagesRaw = await db.query(
messagesTable,
where: 'conversationJid = ? AND $idQuery',
where: conversationJid != null
? 'id = ? AND accountJid = ? AND conversationJid = ?'
: 'id = ? AND accountJid = ?',
whereArgs: [
conversationJid,
if (includeOriginId) id,
id,
accountJid,
if (conversationJid != null) conversationJid,
],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
}
/// Queries the database for a message with a stanza id of [originId] inside
/// the conversation [conversationJid], if specified, in the context of the account
/// [accountJid].
Future<Message?> getMessageByOriginId(
String originId,
String accountJid, {
String? conversationJid,
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesRaw = await db.query(
messagesTable,
where: conversationJid != null
? 'accountJid = ? AND originId = ? AND conversationJid = ?'
: 'accountJid = ? AND originId = ?',
whereArgs: [
accountJid,
originId,
if (conversationJid != null) conversationJid,
],
limit: 1,
);
@@ -91,30 +111,36 @@ class MessageService {
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
}
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
limit: 1,
))
.first;
fm = FileMetadata.fromDatabaseJson(rawFm);
}
return Message.fromDatabaseJson(
msg,
null,
fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
: [],
/// Query the database for the message with a stanza id of [sid] in the context of [accountJid].
/// If [conversationJid] is specified, then the message must also be within the conversation with
/// [conversationJid].
Future<Message?> getMessageByStanzaId(
String sid,
String accountJid, {
String? conversationJid,
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesRaw = await db.query(
messagesTable,
where: conversationJid != null
? 'accountJid = ? AND sid = ? AND conversationJid = ?'
: 'accountJid = ? AND sid = ?',
whereArgs: [
accountJid,
sid,
if (conversationJid != null) conversationJid,
],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
}
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
@@ -122,27 +148,20 @@ class MessageService {
/// than [oldestTimestamp], or the newest messages are returned if null.
Future<List<Message>> getPaginatedMessagesForJid(
String jid,
String accountJid,
bool olderThan,
int? oldestTimestamp,
) async {
if (olderThan && oldestTimestamp == null) {
final result = await _cacheLock.synchronized<List<Message>?>(() {
return _messageCache.getValue(jid);
});
if (result != null) return result;
}
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = oldestTimestamp != null
? 'conversationJid = ? AND timestamp $comparator ?'
: 'conversationJid = ?';
? 'conversationJid = ? AND accountJid = ? AND timestamp $comparator ?'
: 'conversationJid = ? AND accountJid = ?';
final rawMessages = await db.rawQuery(
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
'''
SELECT
msg.*,
quote.id AS quote_id,
quote.sender AS quote_sender,
quote.body AS quote_body,
quote.timestamp AS quote_timestamp,
@@ -187,6 +206,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
''',
[
jid,
accountJid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
@@ -228,38 +248,35 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
m,
quotes,
fm,
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
m['id']! as String,
accountJid,
),
),
);
}
if (olderThan && oldestTimestamp == null) {
await _cacheLock.synchronized(() {
_messageCache.cache(
jid,
page,
);
});
}
return page;
}
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
/// metadata attached. This method bypasses the cache and does not load the message's
/// quoted message, if it exists.
/// quoted message, if it exists. If [jid] is set to null, then the media messages for
/// all conversations are queried.
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
String jid,
String? jid,
String accountJid,
bool olderThan,
int? oldestTimestamp,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final queryPrefix = jid != null
? 'conversationJid = ? AND accountJid = ? AND'
: 'accountJid = ? AND';
final query = oldestTimestamp != null
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
: 'file_metadata_id IS NOT NULL';
final rawMessages = await db.rawQuery(
'''
SELECT
@@ -279,11 +296,27 @@ SELECT
fm.cipherTextHashes as fm_cipherTextHashes,
fm.filename as fm_filename,
fm.size as fm_size
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
FROM
(SELECT
*
FROM
$messagesTable
WHERE
$queryPrefix $query
ORDER BY timestamp
DESC LIMIT $sharedMediaPaginationSize
) AS msg
LEFT JOIN
$fileMetadataTable fm
ON
msg.file_metadata_id = fm.id
WHERE
fm_path IS NOT NULL
AND NOT EXISTS (SELECT id FROM $stickersTable WHERE file_metadata_id = fm.id);
''',
[
jid,
if (jid != null) jid,
accountJid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
@@ -301,9 +334,10 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(m, 'fm_'),
),
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
m['id']! as String,
accountJid,
),
),
);
}
@@ -313,6 +347,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
Future<Message> addMessageFromData(
String accountJid,
String body,
int timestamp,
String sender,
@@ -324,23 +359,25 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
String? originId,
String? quoteId,
FileMetadata? fileMetadata,
int? errorType,
int? warningType,
MessageErrorType? errorType,
MessageWarningType? warningType,
bool isDownloading = false,
bool isUploading = false,
String? stickerPackId,
int? pseudoMessageType,
String? occupantId,
PseudoMessageType? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
bool received = false,
bool displayed = false,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
var m = Message(
var message = Message(
_uuid.v4(),
accountJid,
sender,
body,
timestamp,
sid,
-1,
conversationJid,
isFileUploadNotification,
encrypted,
@@ -355,64 +392,29 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
isUploading: isUploading,
isDownloading: isDownloading,
stickerPackId: stickerPackId,
occupantId: occupantId,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
);
if (quoteId != null) {
final quotes = await getMessageByXmppId(quoteId, conversationJid);
final quotes = await getMessageById(quoteId, accountJid);
if (quotes == null) {
_log.warning('Failed to add quote for message with id $quoteId');
} else {
m = m.copyWith(quotes: quotes);
message = message.copyWith(quotes: quotes);
}
}
m = m.copyWith(
id: await db.insert(messagesTable, m.toDatabaseJson()),
);
await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(conversationJid);
if (cachedList != null) {
_messageCache.replaceValue(
conversationJid,
clampedListPrepend(
cachedList,
m,
messagePaginationSize,
),
);
}
});
return m;
}
Future<Message?> getMessageByStanzaId(
String conversationJid,
String stanzaId,
) async {
return getMessageByXmppId(
stanzaId,
conversationJid,
includeOriginId: false,
);
}
Future<Message?> getMessageByStanzaOrOriginId(
String conversationJid,
String id,
) async {
return getMessageByXmppId(
id,
conversationJid,
);
await db.insert(messagesTable, message.toDatabaseJson());
return message;
}
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage(
int id, {
String id,
String accountJid, {
String? sid,
Object? body = notSpecified,
bool? received,
bool? displayed,
@@ -424,9 +426,9 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
bool? isUploading,
bool? isDownloading,
Object? originId = notSpecified,
Object? sid = notSpecified,
bool? isRetracted,
bool? isEdited,
String? occupantId,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final m = <String, dynamic>{};
@@ -444,7 +446,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
m['acked'] = boolToInt(acked);
}
if (errorType != notSpecified) {
m['errorType'] = errorType as int?;
m['errorType'] = (errorType as MessageErrorType?)?.value;
}
if (warningType != notSpecified) {
m['warningType'] = warningType as int?;
@@ -458,9 +460,6 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
if (isUploading != null) {
m['isUploading'] = boolToInt(isUploading);
}
if (sid != notSpecified) {
m['sid'] = sid as String?;
}
if (originId != notSpecified) {
m['originId'] = originId as String?;
}
@@ -473,19 +472,25 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
if (isEdited != null) {
m['isEdited'] = boolToInt(isEdited);
}
if (sid != null) {
m['sid'] = sid;
}
if (occupantId != null) {
m['occupantId'] = occupantId;
}
final updatedMessage = await db.updateAndReturn(
messagesTable,
m,
where: 'id = ?',
whereArgs: [id],
where: 'id = ? AND accountJid = ?',
whereArgs: [id, accountJid],
);
Message? quotes;
if (updatedMessage['quote_id'] != null) {
quotes = await getMessageById(
updatedMessage['quote_id']! as int,
updatedMessage['conversationJid']! as String,
updatedMessage['quote_id']! as String,
accountJid,
queryReactionPreview: false,
);
}
@@ -508,25 +513,11 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
updatedMessage,
quotes,
metadata,
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(id, accountJid),
);
await _cacheLock.synchronized(() {
final page = _messageCache.getValue(msg.conversationJid);
if (page != null) {
_messageCache.replaceValue(
msg.conversationJid,
page.map((m) {
if (m.id == msg.id) {
return msg;
}
return m;
}).toList(),
);
}
});
return msg;
}
@@ -543,13 +534,15 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
/// the UI already verifies it.
Future<void> retractMessage(
String conversationJid,
String accountJid,
String originId,
String bareSender,
bool selfRetract,
) async {
final msg = await getMessageByXmppId(
final msg = await getMessageByOriginId(
originId,
conversationJid,
accountJid,
queryReactionPreview: false,
);
if (msg == null) {
@@ -572,6 +565,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
final isMedia = msg.isMedia;
final retractedMessage = await updateMessage(
msg.id,
accountJid,
warningType: null,
errorType: null,
isRetracted: true,
@@ -581,7 +575,8 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
sendEvent(MessageUpdatedEvent(message: retractedMessage));
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(conversationJid);
final conversation =
await cs.getConversationByJid(conversationJid, accountJid);
if (conversation != null) {
if (conversation.lastMessage?.id == msg.id) {
final newConversation = conversation.copyWith(
@@ -609,21 +604,32 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
}
}
Future<void> replaceMessageInCache(Message message) async {
await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(message.conversationJid);
if (cachedList != null) {
_messageCache.replaceValue(
message.conversationJid,
cachedList.map((m) {
if (m.id == message.id) {
return message;
}
/// Marks the message with the message id [id] as displayed and sends an
/// [MessageUpdatedEvent] to the UI. if [sendChatMarker] is true, then
/// a Chat Marker with <displayed /> is sent to the message's
/// conversationJid attribute.
Future<Message> markMessageAsRead(
String id,
String accountJid,
bool sendChatMarker,
) async {
final newMessage = await updateMessage(
id,
accountJid,
displayed: true,
);
return m;
}).toList(),
);
}
});
// Tell the UI
sendEvent(MessageUpdatedEvent(message: newMessage));
if (sendChatMarker) {
await GetIt.I.get<XmppService>().sendReadMarker(
// TODO(Unknown): This is wrong once groupchats are implemented
newMessage.conversationJid,
newMessage.originId ?? newMessage.sid,
);
}
return newMessage;
}
}

View File

@@ -1,49 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:omemo_dart/omemo_dart.dart';
class MoxxyOmemoManager extends BaseOmemoManager {
MoxxyOmemoManager() : super();
@override
Future<OmemoManager> getOmemoManager() async {
final os = GetIt.I.get<OmemoService>();
await os.ensureInitialized();
return os.omemoManager;
}
@override
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
// Never encrypt stanzas that contain PubSub elements
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null ||
stanza.firstTagByXmlns(carbonsXmlns) != null ||
stanza.firstTagByXmlns(rosterXmlns) != null) {
return false;
}
// Encrypt when the conversation is set to use OMEMO.
return GetIt.I
.get<ConversationService>()
.shouldEncryptForConversation(toJid);
}
}
class MoxxyBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager {
MoxxyBTBVTrustManager(
Map<RatchetMapKey, BTBVTrustState> trustCache,
Map<RatchetMapKey, bool> enablementCache,
Map<String, List<int>> devices,
) : super(
trustCache: trustCache,
enablementCache: enablementCache,
devices: devices,
);
@override
Future<void> commitState() async {
await GetIt.I.get<OmemoService>().commitTrustManager(await toJson());
}
}

View File

@@ -1,19 +1,46 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
/// [showAddToRoster], if the conversation exists.
Future<void> updateConversation(
String jid,
String accountJid,
bool showAddToRoster,
) async {
final cs = GetIt.I.get<ConversationService>();
final newConversation = await cs.createOrUpdateConversation(
jid,
accountJid,
update: (conversation) async {
final c = conversation.copyWith(
showAddToRoster: showAddToRoster,
);
cs.setConversation(c);
return c;
},
);
if (newConversation != null) {
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
}
class MoxxyRosterStateManager extends BaseRosterStateManager {
@override
Future<RosterCacheLoadResult> loadRosterCache() async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final rs = GetIt.I.get<RosterService>();
final state = await GetIt.I.get<XmppStateService>().state;
return RosterCacheLoadResult(
(await GetIt.I.get<XmppStateService>().getXmppState()).lastRosterVersion,
(await rs.getRoster())
state.lastRosterVersion,
(await rs.getRoster(accountJid!))
.map(
(item) => XmppRosterItem(
jid: item.jid,
@@ -35,6 +62,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
List<XmppRosterItem> added,
) async {
final rs = GetIt.I.get<RosterService>();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final xss = GetIt.I.get<XmppStateService>();
await xss.modifyXmppState(
(state) => state.copyWith(
@@ -44,50 +72,65 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Remove stale items
for (final jid in removed) {
await rs.removeRosterItemByJid(jid);
await rs.removeRosterItem(jid, accountJid!);
await updateConversation(jid, accountJid, true);
}
// Create new roster items
final rosterAdded = List<RosterItem>.empty(growable: true);
for (final item in added) {
final exists = await rs.getRosterItemByJid(item.jid) != null;
final exists = await rs.getRosterItemByJid(item.jid, accountJid!) != null;
// Skip adding items twice
if (exists) continue;
rosterAdded.add(
await rs.addRosterItemFromData(
'',
'',
item.jid,
item.name ?? item.jid.split('@').first,
item.subscription,
item.ask ?? '',
false,
null,
null,
null,
groups: item.groups,
),
final newRosterItem = await rs.addRosterItemFromData(
accountJid,
'',
'',
item.jid,
item.name ?? item.jid.split('@').first,
item.subscription,
item.ask ?? '',
false,
null,
null,
null,
groups: item.groups,
);
rosterAdded.add(newRosterItem);
// Update the cached conversation item
await updateConversation(
item.jid,
accountJid,
newRosterItem.showAddToRosterButton,
);
}
// Update modified items
final rosterModified = List<RosterItem>.empty(growable: true);
for (final item in modified) {
final ritem = await rs.getRosterItemByJid(item.jid);
final ritem = await rs.getRosterItemByJid(item.jid, accountJid!);
if (ritem == null) {
//_log.warning('Could not find roster item with JID $jid during update');
continue;
}
rosterModified.add(
await rs.updateRosterItem(
ritem.id,
title: item.name,
subscription: item.subscription,
ask: item.ask,
groups: item.groups,
),
final newRosterItem = await rs.updateRosterItem(
ritem.jid,
accountJid,
title: item.name,
subscription: item.subscription,
ask: item.ask,
groups: item.groups,
);
rosterModified.add(newRosterItem);
// Update the cached conversation item
await updateConversation(
item.jid,
accountJid,
newRosterItem.showAddToRosterButton,
);
}

View File

@@ -30,7 +30,8 @@ class MoxxyStreamManagementManager extends StreamManagementManager {
@override
Future<void> loadState() async {
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
if (state.smState != null) {
await setState(state.smState!);
}

View File

@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:moxxy_native/moxxy_native.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
/// Recreate all notification channels to apply settings that cannot be applied after the notification
/// channel has been created.
Future<void> upgradeV1ToV2NonDb(int _) async {
// Ensure that we can use the device locale
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocale();
final api = MoxxyNotificationsApi();
// Remove all notification channels, so that we can recreate them
await api.deleteNotificationChannels([
'FOREGROUND_DEFAULT',
'message_channel',
'warning_channel',
// Not sure where this one comes from
'warning',
]);
// Set up notification groups
await api.createNotificationGroups(
[
NotificationGroup(
id: messageNotificationGroupId,
description: 'Chat messages',
),
NotificationGroup(
id: warningNotificationChannelId,
description: 'Warnings',
),
NotificationGroup(
id: foregroundServiceNotificationGroupId,
description: 'Foreground service',
),
],
);
// Set up the notitifcation channels.
await api.createNotificationChannels([
NotificationChannel(
title: t.notifications.channels.messagesChannelName,
description: t.notifications.channels.messagesChannelDescription,
id: messageNotificationChannelId,
importance: NotificationChannelImportance.HIGH,
showBadge: true,
vibration: true,
enableLights: true,
),
NotificationChannel(
title: t.notifications.channels.warningChannelName,
description: t.notifications.channels.warningChannelDescription,
id: warningNotificationChannelId,
importance: NotificationChannelImportance.DEFAULT,
showBadge: false,
vibration: true,
enableLights: false,
),
// The foreground notification channel is only required on Android
if (Platform.isAndroid)
NotificationChannel(
title: t.notifications.channels.serviceChannelName,
description: t.notifications.channels.serviceChannelDescription,
id: foregroundServiceNotificationChannelId,
importance: NotificationChannelImportance.MIN,
showBadge: false,
vibration: false,
enableLights: false,
),
]);
}

View File

@@ -1,99 +1,256 @@
import 'dart:io';
import 'dart:math';
import 'package:awesome_notifications/awesome_notifications.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxy_native/moxxy_native.dart' as native;
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/events.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
import 'package:moxxyv2/shared/models/message.dart' as modelm;
import 'package:moxxyv2/shared/models/notification.dart' as modeln;
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
const _maxNotificationId = 2147483647;
const _messageChannelKey = 'message_channel';
const _warningChannelKey = 'warning_channel';
const _notificationActionKeyRead = 'markAsRead';
const _notificationActionKeyReply = 'reply';
// TODO(Unknown): Add resolution dependent drawables for the notification icon
/// Message payload keys.
const _conversationJidKey = 'conversationJid';
const _messageIdKey = 'message_id';
const _conversationTitleKey = 'title';
const _conversationAvatarKey = 'avatarPath';
class NotificationsService {
NotificationsService() : _log = Logger('NotificationsService');
// ignore: unused_field
final Logger _log;
NotificationsService() {
_eventStream = _channel
.receiveBroadcastStream()
.cast<Object>()
.map(native.NotificationEvent.decode);
}
@pragma('vm:entry-point')
static Future<void> onReceivedAction(ReceivedAction action) async {
final logger = Logger('NotificationHandler');
/// Logging.
final Logger _log = Logger('NotificationsService');
if (action.buttonKeyPressed.isEmpty && action.buttonKeyInput.isEmpty) {
/// The Pigeon channel to the native side
final native.MoxxyNotificationsApi _api = native.MoxxyNotificationsApi();
final EventChannel _channel =
const EventChannel('org.moxxy.moxxyv2/notification_stream');
late final Stream<native.NotificationEvent> _eventStream;
/// Called when something happens to the notification, i.e. the actions are triggered or
/// the notification has been tapped.
Future<void> onNotificationEvent(native.NotificationEvent event) async {
final conversationJid = event.extra![_conversationJidKey]!;
if (event.type == native.NotificationEventType.open) {
// The notification has been tapped
sendEvent(
MessageNotificationTappedEvent(
conversationJid: action.payload!['conversationJid']!,
title: action.payload!['title']!,
avatarUrl: action.payload!['avatarUrl']!,
conversationJid: conversationJid,
title: event.extra![_conversationTitleKey]!,
avatarPath: event.extra![_conversationAvatarKey]!,
),
);
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
// TODO(Unknown): Maybe refactor this call such that we don't have to use
// a command.
await performMarkMessageAsRead(
MarkMessageAsReadCommand(
conversationJid: action.payload!['conversationJid']!,
sid: action.payload!['sid']!,
newUnreadCounter: 0,
),
} else if (event.type == native.NotificationEventType.markAsRead) {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
// Mark the message as read
await GetIt.I.get<MessageService>().markMessageAsRead(
event.extra![_messageIdKey]!,
accountJid!,
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
// the marker, i.e. if the privacy settings allow it.
true,
);
// Update the conversation
final cs = GetIt.I.get<ConversationService>();
await cs.createOrUpdateConversation(
conversationJid,
accountJid,
update: (conversation) async {
final newConversation = await cs.updateConversation(
conversationJid,
accountJid,
unreadCounter: 0,
);
// Notify the UI
sendEvent(
ConversationUpdatedEvent(
conversation: newConversation,
),
);
return newConversation;
},
);
} else {
logger.warning(
'Received unknown notification action key ${action.buttonKeyPressed}',
// Clear notifications
await dismissNotificationsByJid(conversationJid, accountJid);
} else if (event.type == native.NotificationEventType.reply) {
// Save this as a notification so that we can display it later
assert(
event.payload != null,
'Reply payload must be not null',
);
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final notification = modeln.Notification(
event.id,
conversationJid,
accountJid!,
null,
null,
null,
event.payload!,
null,
null,
DateTime.now().millisecondsSinceEpoch,
);
await GetIt.I.get<DatabaseService>().database.insert(
notificationsTable,
notification.toJson(),
);
// Send the actual reply
await GetIt.I.get<XmppService>().sendMessage(
accountJid: accountJid,
body: event.payload!,
recipients: [conversationJid],
);
}
}
/// Configures the translatable strings on the native side
/// using locale is currently configured.
Future<void> configureNotificationI18n() async {
await _api.setNotificationI18n(
native.NotificationI18nData(
reply: t.notifications.message.reply,
markAsRead: t.notifications.message.markAsRead,
you: t.messages.you,
),
);
}
Future<void> initialize() async {
final an = AwesomeNotifications();
await an.initialize(
'resource://drawable/ic_service_icon',
// Set up notification groups
await _api.createNotificationGroups(
[
NotificationChannel(
channelKey: _messageChannelKey,
channelName: t.notifications.channels.messagesChannelName,
channelDescription:
t.notifications.channels.messagesChannelDescription,
native.NotificationGroup(
id: messageNotificationGroupId,
description: 'Chat messages',
),
NotificationChannel(
channelKey: _warningChannelKey,
channelName: t.notifications.channels.warningChannelName,
channelDescription:
t.notifications.channels.warningChannelDescription,
native.NotificationGroup(
id: warningNotificationChannelId,
description: 'Warnings',
),
native.NotificationGroup(
id: foregroundServiceNotificationGroupId,
description: 'Foreground service',
),
],
debug: kDebugMode,
);
await an.setListeners(
onActionReceivedMethod: onReceivedAction,
);
// Set up the notitifcation channels.
await _api.createNotificationChannels([
native.NotificationChannel(
title: t.notifications.channels.messagesChannelName,
description: t.notifications.channels.messagesChannelDescription,
id: messageNotificationChannelId,
importance: native.NotificationChannelImportance.HIGH,
showBadge: true,
vibration: true,
enableLights: true,
),
native.NotificationChannel(
title: t.notifications.channels.warningChannelName,
description: t.notifications.channels.warningChannelDescription,
id: warningNotificationChannelId,
importance: native.NotificationChannelImportance.DEFAULT,
showBadge: false,
vibration: true,
enableLights: false,
),
// The foreground notification channel is only required on Android
if (Platform.isAndroid)
native.NotificationChannel(
title: t.notifications.channels.serviceChannelName,
description: t.notifications.channels.serviceChannelDescription,
id: foregroundServiceNotificationChannelId,
importance: native.NotificationChannelImportance.MIN,
showBadge: false,
vibration: false,
enableLights: false,
),
]);
// Configure i18n
await configureNotificationI18n();
// Listen to notification events
_eventStream.listen(onNotificationEvent);
}
/// Returns true if a notification should be shown. false otherwise.
bool shouldShowNotification(String jid) {
return GetIt.I.get<XmppService>().getCurrentlyOpenedChatJid() != jid;
return GetIt.I.get<ConversationService>().activeConversationJid != jid ||
!GetIt.I.get<LifecycleService>().isActive;
}
/// Show a notification for a message [m] grouped by its conversationJid
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
/// then Android's BigPicture will be used.
Future<void> showNotification(
/// Queries the notifications for the conversation [jid] from the database.
Future<List<modeln.Notification>> _getNotificationsForJid(
String jid,
String accountJid,
) async {
final rawNotifications =
await GetIt.I.get<DatabaseService>().database.query(
notificationsTable,
where: 'conversationJid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
return rawNotifications.map(modeln.Notification.fromJson).toList();
}
Future<int?> _clearNotificationsForJid(String jid, String accountJid) async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.query(
notificationsTable,
where: 'conversationJid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
limit: 1,
);
// Assumption that all rows with the same conversationJid have the same id.
final id = result.isNotEmpty ? result.first['id']! as int : null;
await db.delete(
notificationsTable,
where: 'conversationJid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
return id;
}
Future<modeln.Notification> _createNotification(
modelc.Conversation c,
modelm.Message m,
String title, {
String? body,
String accountJid,
String? avatarPath,
int id, {
bool shouldOverride = false,
}) async {
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
String body;
@@ -105,67 +262,269 @@ class NotificationsService {
body = m.body;
}
final css = GetIt.I.get<ContactsService>();
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
final title =
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
final avatarPath = contactIntegrationEnabled
? c.contactAvatarPath ?? c.avatarUrl
: c.avatarUrl;
assert(
implies(m.fileMetadata?.path != null, m.fileMetadata?.mimeType != null),
'File metadata has path but no mime type',
);
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: m.id,
groupKey: c.jid,
channelKey: _messageChannelKey,
summary: title,
title: title,
body: body,
largeIcon: avatarPath.isNotEmpty ? 'file://$avatarPath' : null,
notificationLayout: m.isThumbnailable
? NotificationLayout.BigPicture
: NotificationLayout.Messaging,
category: NotificationCategory.Message,
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
payload: <String, String>{
'conversationJid': c.jid,
'sid': m.sid,
'title': title,
'avatarUrl': avatarPath,
// Use the resource (nick) when the chat is a groupchat
final senderJid = m.senderJid;
final senderTitle = c.isGroupchat
? senderJid.resource
: await c.titleWithOptionalContactService;
// If the file is a video, use its thumbnail, if available
var filePath = m.fileMetadata?.path;
var fileMime = m.fileMetadata?.mimeType;
// Thumbnail workaround for Android
if (Platform.isAndroid &&
(m.fileMetadata?.mimeType?.startsWith('video/') ?? false) &&
m.fileMetadata?.path != null) {
final thumbnailPath = await getVideoThumbnailPath(m.fileMetadata!.path!);
if (File(thumbnailPath).existsSync()) {
// Workaround for Android to show the thumbnail in the notification
filePath = thumbnailPath;
fileMime = 'image/jpeg';
}
}
// Add to the database
final newNotification = modeln.Notification(
id,
c.jid,
accountJid,
senderTitle,
senderJid.toString(),
(avatarPath?.isEmpty ?? false) ? null : avatarPath,
body,
fileMime,
filePath,
m.timestamp,
);
await GetIt.I.get<DatabaseService>().database.insert(
notificationsTable,
newNotification.toJson(),
conflictAlgorithm: shouldOverride ? ConflictAlgorithm.replace : null,
);
return newNotification;
}
/// Indicates whether we're allowed to show notifications on devices >= Android 13.
Future<bool> _canDoNotifications() async {
return Permission.notification.isGranted;
}
/// When a notification is already visible, then build a new notification based on [c] and [m],
/// update the database state and tell the OS to show the notification again.
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
Future<void> updateOrShowNotification(
modelc.Conversation c,
modelm.Message m,
String accountJid,
) async {
if (!(await _canDoNotifications())) {
_log.warning(
'updateNotification: Notifications permission not granted. Doing nothing.',
);
return;
}
final notifications = await _getNotificationsForJid(c.jid, accountJid);
final id = notifications.isNotEmpty
? notifications.first.id
: Random().nextInt(_maxNotificationId);
// TODO(Unknown): Handle groupchat member avatars
final notification = await _createNotification(
c,
m,
accountJid,
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
id,
shouldOverride: true,
);
await _api.showMessagingNotification(
native.MessagingNotification(
title: await c.titleWithOptionalContactService,
id: id,
channelId: messageNotificationChannelId,
jid: c.jid,
messages: notifications.map((n) {
// Based on the table's composite primary key
if (n.id == notification.id &&
n.conversationJid == notification.conversationJid &&
n.senderJid == notification.senderJid &&
n.timestamp == notification.timestamp) {
return notification.toNotificationMessage();
}
return n.toNotificationMessage();
}).toList(),
isGroupchat: c.isGroupchat,
groupId: messageNotificationGroupId,
extra: {
_conversationJidKey: c.jid,
_messageIdKey: m.id,
_conversationTitleKey: await c.titleWithOptionalContactService,
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
},
),
);
}
/// Show a notification for a message [m] grouped by its conversationJid
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
/// then Android's BigPicture will be used.
Future<void> showNotification(
modelc.Conversation c,
modelm.Message m,
String accountJid,
String title, {
String? body,
}) async {
if (!(await _canDoNotifications())) {
_log.warning(
'showNotification: Notifications permission not granted. Doing nothing.',
);
return;
}
final notifications = await _getNotificationsForJid(c.jid, accountJid);
final id = notifications.isNotEmpty
? notifications.first.id
: Random().nextInt(_maxNotificationId);
await _api.showMessagingNotification(
native.MessagingNotification(
title: title,
id: id,
channelId: messageNotificationChannelId,
jid: c.jid,
messages: [
...notifications.map((n) => n.toNotificationMessage()),
// TODO(Unknown): Handle groupchat member avatars
(await _createNotification(
c,
m,
accountJid,
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
id,
))
.toNotificationMessage(),
],
isGroupchat: c.isGroupchat,
groupId: messageNotificationGroupId,
extra: {
_conversationJidKey: c.jid,
_messageIdKey: m.id,
_conversationTitleKey: await c.titleWithOptionalContactService,
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
},
),
actionButtons: [
NotificationActionButton(
key: _notificationActionKeyReply,
label: t.notifications.message.reply,
requireInputText: true,
autoDismissible: false,
),
NotificationActionButton(
key: _notificationActionKeyRead,
label: t.notifications.message.markAsRead,
)
],
);
}
/// Show a notification with the highest priority that uses [title] as the title
/// and [body] as the body.
// TODO(Unknown): Use the warning icon as the notification icon
Future<void> showWarningNotification(String title, String body) async {
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: Random().nextInt(_maxNotificationId),
if (!(await _canDoNotifications())) {
_log.warning(
'showWarningNotification: Notifications permission not granted. Doing nothing.',
);
return;
}
await _api.showNotification(
native.RegularNotification(
title: title,
body: body,
channelKey: _warningChannelKey,
channelId: warningNotificationChannelId,
id: Random().nextInt(_maxNotificationId),
icon: native.NotificationIcon.warning,
groupId: warningNotificationGroupId,
),
);
}
/// Show a notification for a bounced message with erorr [type] for a
/// message in the chat with [jid].
Future<void> showMessageErrorNotification(
String jid,
String accountJid,
MessageErrorType type,
) async {
if (!(await _canDoNotifications())) {
_log.warning(
'showMessageErrorNotification: Notifications permission not granted. Doing nothing.',
);
return;
}
// Only show the notification for certain errors
if (![
MessageErrorType.remoteServerTimeout,
MessageErrorType.remoteServerNotFound,
MessageErrorType.serviceUnavailable
].contains(type)) {
return;
}
final conversation = await GetIt.I
.get<ConversationService>()
.getConversationByJid(jid, accountJid);
await _api.showNotification(
native.RegularNotification(
title: t.notifications.errors.messageError.title,
body: t.notifications.errors.messageError
.body(conversationTitle: conversation!.title),
channelId: warningNotificationChannelId,
id: Random().nextInt(_maxNotificationId),
icon: native.NotificationIcon.error,
groupId: warningNotificationGroupId,
),
);
}
/// Since all notifications are grouped by the conversation's JID, this function
/// clears all notifications for [jid].
Future<void> dismissNotificationsByJid(String jid) async {
await AwesomeNotifications().dismissNotificationsByGroupKey(jid);
Future<void> dismissNotificationsByJid(String jid, String accountJid) async {
final id = await _clearNotificationsForJid(jid, accountJid);
if (id != null) {
await _api.dismissNotification(id);
}
}
/// Dismisses all notifications for the context of [accountJid].
Future<void> dismissAllNotifications(String accountJid) async {
final db = GetIt.I.get<DatabaseService>().database;
final ids = await db.query(
notificationsTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
columns: ['id'],
distinct: true,
);
// Dismiss the notification
for (final idRaw in ids) {
await _api.dismissNotification(idRaw['id']! as int);
}
// Remove database entries
await db.delete(
notificationsTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
);
}
/// Requests the avatar path from [XmppStateService] and configures the notification plugin
/// accordingly, if the avatar path is not null. If it is null, this method does nothing.
Future<void> maybeSetAvatarFromState() async {
final xss = GetIt.I.get<XmppStateService>();
final avatarPath = (await xss.state).avatarUrl;
if (avatarPath.isNotEmpty) {
await _api.setNotificationSelfAvatar(avatarPath);
}
}
}

View File

@@ -1,213 +1,43 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/omemo/implementations.dart';
import 'package:moxxyv2/service/omemo/types.dart';
import 'package:moxxyv2/service/omemo/persistence.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
class OmemoDoubleRatchetWrapper {
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
final OmemoDoubleRatchet ratchet;
final int id;
final String jid;
}
class OmemoService {
/// Logger.
final Logger _log = Logger('OmemoService');
/// Flag indicating whether we are initialized.
bool _initialized = false;
/// Flag indicating whether the initialization is currently running.
bool _running = false;
/// Lock guarding access to [_waitingForInitialization], [_running], and [_initialized].
final Lock _lock = Lock();
/// Queue for code that is waiting on the service initialization.
final Queue<Completer<void>> _waitingForInitialization =
Queue<Completer<void>>();
final Map<String, Map<int, String>> _fingerprintCache = {};
late OmemoManager omemoManager;
/// The manager to use for OMEMO.
late OmemoManager _omemoManager;
Future<void> initializeIfNeeded(String jid) async {
final done = await _lock.synchronized(() => _initialized);
if (done) return;
final device = await _loadOmemoDevice(jid);
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
final deviceList = <String, List<int>>{};
if (device == null) {
_log.info('No OMEMO marker found. Generating OMEMO identity...');
} else {
_log.info('OMEMO marker found. Restoring OMEMO state...');
for (final ratchet in await _loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet;
}
deviceList.addAll(await _loadOmemoDeviceList());
}
final om = GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
omemoManager = OmemoManager(
device ?? await compute(generateNewIdentityImpl, jid),
await loadTrustManager(),
om.sendEmptyMessageImpl,
om.fetchDeviceList,
om.fetchDeviceBundle,
om.subscribeToDeviceListImpl,
);
if (device == null) {
await commitDevice(await omemoManager.getDevice());
await commitDeviceMap(<String, List<int>>{});
await commitTrustManager(await omemoManager.trustManager.toJson());
}
omemoManager.initialize(
ratchetMap,
deviceList,
);
omemoManager.eventStream.listen((event) async {
if (event is RatchetModifiedEvent) {
await _saveRatchet(
OmemoDoubleRatchetWrapper(
event.ratchet,
event.deviceId,
event.jid,
),
);
if (event.added) {
// Cache the fingerprint
final fingerprint = await event.ratchet.getOmemoFingerprint();
await _addFingerprintsToCache([
OmemoCacheTriple(
event.jid,
event.deviceId,
fingerprint,
),
]);
if (_fingerprintCache.containsKey(event.jid)) {
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
}
await addNewDeviceMessage(event.jid, event.deviceId);
}
} else if (event is DeviceListModifiedEvent) {
await commitDeviceMap(event.list);
} else if (event is DeviceModifiedEvent) {
await commitDevice(event.device);
// Publish it
await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
.publishBundle(await event.device.toBundle());
}
});
await _lock.synchronized(() {
_initialized = true;
for (final c in _waitingForInitialization) {
c.complete();
}
_waitingForInitialization.clear();
});
}
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
/// If, however, [jid] is our own JID, then nothing is done.
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
// Add a pseudo message if it is not about our own devices
final xmppState = await GetIt.I.get<XmppStateService>().getXmppState();
if (jid == xmppState.jid) return;
final ms = GetIt.I.get<MessageService>();
final message = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
'',
jid,
'',
false,
false,
false,
pseudoMessageType: pseudoMessageTypeNewDevice,
pseudoMessageData: <String, dynamic>{
'deviceId': deviceId,
'jid': jid,
},
);
sendEvent(
MessageAddedEvent(
message: message,
),
);
}
Future<model.OmemoDevice> regenerateDevice(String jid) async {
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
await _lock.synchronized(() {
_initialized = false;
});
_log.info('No OMEMO marker found. Generating OMEMO identity...');
final oldId = await omemoManager.getDeviceId();
// Clear the database
await _emptyOmemoSessionTables();
// Regenerate the identity in the background
final device = await compute(generateNewIdentityImpl, jid);
await omemoManager.replaceDevice(device);
await commitDevice(device);
await commitDeviceMap(<String, List<int>>{});
await commitTrustManager(await omemoManager.trustManager.toJson());
// Remove the old device
final omemo = GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
await omemo.deleteDevice(oldId);
// Publish the new one
await omemo.publishBundle(await omemoManager.getDeviceBundle());
// Allow access again
await _lock.synchronized(() {
_initialized = true;
for (final c in _waitingForInitialization) {
c.complete();
}
_waitingForInitialization.clear();
});
// Return the OmemoDevice
return model.OmemoDevice(
await getDeviceFingerprint(),
true,
true,
true,
await getDeviceId(),
);
/// Access the underlying [OmemoManager].
Future<OmemoManager> getOmemoManager() async {
await ensureInitialized();
return _omemoManager;
}
/// Ensures that the code following this *AWAITED* call can access every method
@@ -228,27 +58,79 @@ class OmemoService {
}
}
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
await _saveOmemoDeviceList(deviceMap);
/// Creates or loads the [OmemoManager] for the JID [jid].
Future<void> initializeIfNeeded(String jid) async {
final done = await _lock.synchronized(() {
// Do nothing if we're already initialized
if (_initialized) {
return true;
}
// Lock the execution if we're not yet running.
if (_running) {
return true;
}
_running = true;
return false;
});
if (done) return;
final device = await loadOmemoDevice(jid);
if (device == null) {
_log.info('No OMEMO marker found. Generating OMEMO identity...');
} else {
_log.info('OMEMO marker found. Restoring OMEMO state...');
}
final om = GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
_omemoManager = OmemoManager(
device ?? await compute(generateNewIdentityImpl, jid),
BlindTrustBeforeVerificationTrustManager(
commit: commitTrust,
loadData: loadTrust,
removeTrust: removeTrust,
),
om.sendEmptyMessageImpl,
om.fetchDeviceList,
om.fetchDeviceBundle,
om.subscribeToDeviceListImpl,
om.publishDeviceImpl,
commitDevice: commitDevice,
commitRatchets: commitRatchets,
commitDeviceList: commitDeviceList,
removeRatchets: removeRatchets,
loadRatchets: loadRatchets,
);
if (device == null) {
await commitDevice(await _omemoManager.getDevice());
}
await _lock.synchronized(() {
_running = false;
_initialized = true;
for (final c in _waitingForInitialization) {
c.complete();
}
_waitingForInitialization.clear();
});
}
Future<void> commitDevice(OmemoDevice device) async {
await _saveOmemoDevice(device);
}
/// Requests our device list and checks if the current device is in it. If not, then
/// it will be published.
Future<Object?> publishDeviceIfNeeded() async {
Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() async {
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
await ensureInitialized();
_log.finest('publishDeviceIfNeeded: Done');
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
final omemo =
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
final bareJid = conn.connectionSettings.jid.toBare();
final device = await omemoManager.getDevice();
final device = await _omemoManager.getDevice();
final bundlesRaw = await dm.discoItemsQuery(
bareJid,
@@ -256,7 +138,7 @@ class OmemoService {
);
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
await omemo.publishBundle(await device.toBundle());
return bundlesRaw.get<moxxmpp.DiscoError>();
return null;
}
final bundleIds = bundlesRaw
@@ -285,469 +167,116 @@ class OmemoService {
return null;
}
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
final bareJid = jid.toBare().toString();
final allDevicesRaw = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
.retrieveDeviceBundles(jid);
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
final map = <int, String>{};
final items = List<OmemoCacheTriple>.empty(growable: true);
for (final device in allDevices) {
final curveIk = await device.ik.toCurve25519();
final fingerprint = HEX.encode(await curveIk.getBytes());
map[device.id] = fingerprint;
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
}
// Cache them in memory
_fingerprintCache[bareJid] = map;
// Cache them in the database
await _addFingerprintsToCache(items);
}
}
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
final bareJid = jid.toBare().toString();
if (!_fingerprintCache.containsKey(bareJid)) {
// First try to load it from the database
final triples = await _getFingerprintsFromCache(bareJid);
if (triples.isEmpty) {
// We found no fingerprints in the database, so try to fetch them
await _fetchFingerprintsAndCache(jid);
} else {
// We have fetched fingerprints from the database
_fingerprintCache[bareJid] = Map<int, String>.fromEntries(
triples.map((triple) {
return MapEntry<int, String>(
triple.deviceId,
triple.fingerprint,
);
}),
);
}
}
}
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
Future<void> onNewConnection() async {
await ensureInitialized();
// Get finger prints if we have to
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
final keys = List<model.OmemoDevice>.empty(growable: true);
final tm =
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
final trustMap = await tm.getDevicesTrust(jid);
if (!_fingerprintCache.containsKey(jid)) return [];
for (final deviceId in _fingerprintCache[jid]!.keys) {
keys.add(
model.OmemoDevice(
_fingerprintCache[jid]![deviceId]!,
await tm.isTrusted(jid, deviceId),
trustMap[deviceId] == BTBVTrustState.verified,
await tm.isEnabled(jid, deviceId),
deviceId,
),
);
}
return keys;
await _omemoManager.onNewConnection();
}
Future<void> commitTrustManager(Map<String, dynamic> json) async {
await _saveTrustCache(
json['trust']! as Map<String, int>,
);
await _saveTrustEnablementList(
json['enable']! as Map<String, bool>,
);
await _saveTrustDeviceList(
json['devices']! as Map<String, List<int>>,
);
}
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
return MoxxyBTBVTrustManager(
await _loadTrustCache(),
await _loadTrustEnablementList(),
await _loadTrustDeviceList(),
);
}
Future<void> setOmemoKeyEnabled(
String jid,
int deviceId,
bool enabled,
) async {
Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) async {
await ensureInitialized();
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
}
final fingerprints = await _omemoManager.getFingerprintsForJid(jid) ?? [];
var trust = <int, BTBVTrustData>{};
Future<void> removeAllSessions(String jid) async {
await ensureInitialized();
await omemoManager.removeAllRatchets(jid);
}
Future<int> getDeviceId() async {
await ensureInitialized();
return omemoManager.getDeviceId();
}
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
/// published on [ownJid]'s devices PubSub node.
/// Note that the list is made so that the current device is excluded.
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
final ownId = await getDeviceId();
final keys = List<model.OmemoDevice>.from(
await getOmemoKeysForJid(ownJid.toString()),
);
final bareJid = ownJid.toBare().toString();
// Get fingerprints if we have to
await _loadOrFetchFingerprints(ownJid);
final tm =
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
final trustMap = await tm.getDevicesTrust(bareJid);
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
if (deviceId == ownId) continue;
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
keys.add(
model.OmemoDevice(
fingerprint,
await tm.isTrusted(bareJid, deviceId),
trustMap[deviceId] == BTBVTrustState.verified,
await tm.isEnabled(bareJid, deviceId),
deviceId,
hasSessionWith: false,
),
);
}
return keys;
}
Future<void> verifyDevice(int deviceId, String jid) async {
final tm =
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
await tm.setDeviceTrust(
await _omemoManager.withTrustManager(
jid,
deviceId,
BTBVTrustState.verified,
(tm) async {
trust = await (tm as BlindTrustBeforeVerificationTrustManager)
.getDevicesTrust(jid);
},
);
}
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
void onNewConnection() {
if (_initialized) {
omemoManager.onNewConnection();
}
}
/// Database methods
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
final results =
await GetIt.I.get<DatabaseService>().database.query(omemoRatchetsTable);
return results.map((ratchet) {
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
for (final i in json) {
final element = i as Map<String, dynamic>;
mkskipped.add({
'key': element['key']! as String,
'public': element['public']! as String,
'n': element['n']! as int,
});
}
return OmemoDoubleRatchetWrapper(
OmemoDoubleRatchet.fromJson(
{
...ratchet,
'acknowledged': intToBool(ratchet['acknowledged']! as int),
'mkskipped': mkskipped,
},
),
ratchet['id']! as int,
ratchet['jid']! as String,
return fingerprints.map((fp) {
return model.OmemoDevice(
fp.fingerprint,
trust[fp.deviceId]?.trusted ?? false,
trust[fp.deviceId]?.state == BTBVTrustState.verified,
trust[fp.deviceId]?.enabled ?? false,
fp.deviceId,
);
}).toList();
}
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
final json = await ratchet.ratchet.toJson();
await GetIt.I.get<DatabaseService>().database.insert(
omemoRatchetsTable,
{
...json,
'mkskipped': jsonEncode(json['mkskipped']),
'acknowledged': boolToInt(json['acknowledged']! as bool),
'jid': ratchet.jid,
'id': ratchet.id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustCacheTable);
final mapEntries =
entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
// TODO(PapaTutuWawa): Expose this from omemo_dart
BTBVTrustState state;
final value = entry['trust']! as int;
if (value == 1) {
state = BTBVTrustState.notTrusted;
} else if (value == 2) {
state = BTBVTrustState.blindTrust;
} else if (value == 3) {
state = BTBVTrustState.verified;
} else {
state = BTBVTrustState.notTrusted;
}
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
state,
);
Future<void> setDeviceEnablement(String jid, int device, bool state) async {
await ensureInitialized();
await _omemoManager.withTrustManager(jid, (tm) async {
await (tm as BlindTrustBeforeVerificationTrustManager)
.setEnabled(jid, device, state);
});
return Map.fromEntries(mapEntries);
}
Future<void> _saveTrustCache(Map<String, int> cache) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustCacheTable);
for (final entry in cache.entries) {
batch.insert(
omemoTrustCacheTable,
{
'key': entry.key,
'trust': entry.value,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<Map<RatchetMapKey, bool>> _loadTrustEnablementList() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustEnableListTable);
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
intToBool(entry['enabled']! as int),
);
Future<void> setDeviceVerified(String jid, int device) async {
await ensureInitialized();
await _omemoManager.withTrustManager(jid, (tm) async {
await (tm as BlindTrustBeforeVerificationTrustManager)
.setDeviceTrust(jid, device, BTBVTrustState.verified);
});
return Map.fromEntries(mapEntries);
}
Future<void> _saveTrustEnablementList(Map<String, bool> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustEnableListTable);
for (final entry in list.entries) {
batch.insert(
omemoTrustEnableListTable,
{
'key': entry.key,
'enabled': boolToInt(entry.value),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
Future<void> removeAllRatchets(String jid) async {
await ensureInitialized();
await _omemoManager.removeAllRatchets(jid);
}
Future<Map<String, List<int>>> _loadTrustDeviceList() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustDeviceListTable);
final map = <String, List<int>>{};
for (final entry in entries) {
final key = entry['jid']! as String;
final device = entry['device']! as int;
if (map.containsKey(key)) {
map[key]!.add(device);
} else {
map[key] = [device];
}
}
return map;
Future<OmemoDevice> getDevice() async {
await ensureInitialized();
return _omemoManager.getDevice();
}
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
Future<model.OmemoDevice> regenerateDevice() async {
await ensureInitialized();
// ignore: cascade_invocations
batch.delete(omemoTrustDeviceListTable);
for (final entry in list.entries) {
for (final device in entry.value) {
batch.insert(
omemoTrustDeviceListTable,
{
'jid': entry.key,
'device': device,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
final oldDeviceId = (await getDevice()).id;
await batch.commit();
}
// Generate the new device
final newDevice = await _omemoManager.regenerateDevice();
Future<void> _saveOmemoDevice(OmemoDevice device) async {
await GetIt.I.get<DatabaseService>().database.insert(
omemoDeviceTable,
{
'jid': device.jid,
'id': device.id,
'data': jsonEncode(await device.toJson()),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
final data = await GetIt.I.get<DatabaseService>().database.query(
omemoDeviceTable,
where: 'jid = ?',
whereArgs: [jid],
limit: 1,
);
if (data.isEmpty) return null;
final deviceJson =
jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
// NOTE: We need to do this because Dart otherwise complains about not being able
// to cast dynamic to List<int>.
final opks = List<Map<String, dynamic>>.empty(growable: true);
final opksIter = deviceJson['opks']! as List<dynamic>;
for (final tmpOpk in opksIter) {
final opk = tmpOpk as Map<String, dynamic>;
opks.add(<String, dynamic>{
'id': opk['id']! as int,
'public': opk['public']! as String,
'private': opk['private']! as String,
});
}
deviceJson['opks'] = opks;
return OmemoDevice.fromJson(deviceJson);
}
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
final list = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoDeviceListTable);
final map = <String, List<int>>{};
for (final entry in list) {
final key = entry['jid']! as String;
final id = entry['id']! as int;
if (map.containsKey(key)) {
map[key]!.add(id);
} else {
map[key] = [id];
}
}
return map;
}
Future<void> _saveOmemoDeviceList(Map<String, List<int>> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoDeviceListTable);
for (final entry in list.entries) {
for (final id in entry.value) {
batch.insert(
omemoDeviceListTable,
{
'jid': entry.key,
'id': id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit();
}
Future<void> _emptyOmemoSessionTables() async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch
..delete(omemoRatchetsTable)
..delete(omemoTrustCacheTable)
..delete(omemoTrustEnableListTable);
await batch.commit();
}
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final item in items) {
batch.insert(
omemoFingerprintCache,
<String, dynamic>{
'jid': item.jid,
'id': item.deviceId,
'fingerprint': item.fingerprint,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
omemoFingerprintCache,
where: 'jid = ?',
whereArgs: [jid],
// Remove the old device
unawaited(
GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
.deleteDevice(oldDeviceId),
);
return rawItems.map((item) {
return OmemoCacheTriple(
jid,
item['id']! as int,
item['fingerprint']! as String,
);
}).toList();
return model.OmemoDevice(
await newDevice.getFingerprint(),
true,
true,
true,
newDevice.id,
);
}
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
/// Also sends an event to the UI.
Future<void> addPseudoMessage(
String conversationJid,
String accountJid,
PseudoMessageType type,
int ratchetsAdded,
int ratchetsReplaced,
) async {
final ms = GetIt.I.get<MessageService>();
final message = await ms.addMessageFromData(
accountJid,
'',
DateTime.now().millisecondsSinceEpoch,
'',
conversationJid,
'',
false,
false,
false,
pseudoMessageType: type,
pseudoMessageData: {
'ratchetsAdded': ratchetsAdded,
'ratchetsReplaced': ratchetsReplaced,
},
);
sendEvent(
MessageAddedEvent(
message: message,
),
);
}
}

View File

@@ -0,0 +1,318 @@
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:omemo_dart/omemo_dart.dart';
import 'package:sqflite_common/sql.dart';
extension ByteListHelpers on List<int> {
String toBase64() {
return base64Encode(this);
}
OmemoPublicKey toPublicKey(KeyPairType type) {
return OmemoPublicKey.fromBytes(this, type);
}
}
Future<void> commitDevice(OmemoDevice device) async {
final db = GetIt.I.get<DatabaseService>().database;
final serializedOpks = <String, Map<String, String>>{};
for (final entry in device.opks.entries) {
serializedOpks[entry.key.toString()] = {
'public': base64Encode(await entry.value.pk.getBytes()),
'private': base64Encode(await entry.value.sk.getBytes()),
};
}
await db.insert(
omemoDevicesTable,
{
'jid': device.jid,
'id': device.id,
'ikPub': base64Encode(await device.ik.pk.getBytes()),
'ik': base64Encode(await device.ik.sk.getBytes()),
'spkPub': base64Encode(await device.spk.pk.getBytes()),
'spk': base64Encode(await device.spk.sk.getBytes()),
'spkId': device.spkId,
'spkSig': base64Encode(device.spkSignature),
'oldSpkPub': (await device.oldSpk?.pk.getBytes())?.toBase64(),
'oldSpk': (await device.oldSpk?.sk.getBytes())?.toBase64(),
'oldSpkId': device.oldSpkId,
'opks': jsonEncode(serializedOpks),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
final db = GetIt.I.get<DatabaseService>().database;
final rawDevice = await db.query(
omemoDevicesTable,
where: 'jid = ?',
whereArgs: [jid],
limit: 1,
);
if (rawDevice.isEmpty) {
return null;
}
final deviceJson = rawDevice.first;
// Deserialize the OPKs first
final deserializedOpks = <int, OmemoKeyPair>{};
final opks =
(jsonDecode(rawDevice.first['opks']! as String) as Map<dynamic, dynamic>)
.cast<String, dynamic>();
for (final opk in opks.entries) {
final opkValue = (opk.value as Map<String, dynamic>).cast<String, String>();
deserializedOpks[int.parse(opk.key)] = OmemoKeyPair.fromBytes(
base64Decode(opkValue['public']!),
base64Decode(opkValue['private']!),
KeyPairType.x25519,
);
}
OmemoKeyPair? oldSpk;
if (deviceJson['oldSpkPub'] != null && deviceJson['oldSpk'] != null) {
oldSpk = OmemoKeyPair.fromBytes(
base64Decode(deviceJson['oldSpkPub']! as String),
base64Decode(deviceJson['oldSpk']! as String),
KeyPairType.x25519,
);
}
return OmemoDevice(
jid,
deviceJson['id']! as int,
OmemoKeyPair.fromBytes(
base64Decode(deviceJson['ikPub']! as String),
base64Decode(deviceJson['ik']! as String),
KeyPairType.ed25519,
),
OmemoKeyPair.fromBytes(
base64Decode(deviceJson['spkPub']! as String),
base64Decode(deviceJson['spk']! as String),
KeyPairType.x25519,
),
deviceJson['spkId']! as int,
base64Decode(deviceJson['spkSig']! as String),
oldSpk,
deviceJson['oldSpkId'] as int?,
deserializedOpks,
);
}
Future<void> commitRatchets(List<OmemoRatchetData> ratchets) async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final db = GetIt.I.get<DatabaseService>().database;
final batch = db.batch();
for (final ratchet in ratchets) {
// Serialize the skipped keys
final serializedSkippedKeys = <Map<String, Object>>[];
for (final sk in ratchet.ratchet.mkSkipped.entries) {
serializedSkippedKeys.add({
'dhPub': (await sk.key.dh.getBytes()).toBase64(),
'n': sk.key.n,
'mk': sk.value.toBase64(),
});
}
// Serialize the KEX
final kex = {
'pkId': ratchet.ratchet.kex.pkId,
'spkId': ratchet.ratchet.kex.spkId,
'ek': (await ratchet.ratchet.kex.ek.getBytes()).toBase64(),
'ik': (await ratchet.ratchet.kex.ik.getBytes()).toBase64(),
};
batch.insert(
omemoRatchetsTable,
{
'jid': ratchet.jid,
'device': ratchet.id,
'dhsPub': base64Encode(await ratchet.ratchet.dhs.pk.getBytes()),
'dhs': base64Encode(await ratchet.ratchet.dhs.sk.getBytes()),
'dhrPub': (await ratchet.ratchet.dhr?.getBytes())?.toBase64(),
'rk': base64Encode(ratchet.ratchet.rk),
'cks': ratchet.ratchet.cks?.toBase64(),
'ckr': ratchet.ratchet.ckr?.toBase64(),
'ns': ratchet.ratchet.ns,
'nr': ratchet.ratchet.nr,
'pn': ratchet.ratchet.pn,
'ik': (await ratchet.ratchet.ik.getBytes()).toBase64(),
'ad': ratchet.ratchet.sessionAd.toBase64(),
'skipped': jsonEncode(serializedSkippedKeys),
'kex': jsonEncode(kex),
'acked': boolToInt(ratchet.ratchet.acknowledged),
'accountJid': accountJid,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<void> commitDeviceList(String jid, List<int> devices) async {
final db = GetIt.I.get<DatabaseService>().database;
await db.insert(
omemoDeviceListTable,
{
'jid': jid,
'devices': jsonEncode(devices),
'accountJid': await GetIt.I.get<XmppStateService>().getAccountJid(),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> removeRatchets(List<RatchetMapKey> ratchets) async {
final db = GetIt.I.get<DatabaseService>().database;
final batch = db.batch();
for (final key in ratchets) {
batch.delete(
omemoRatchetsTable,
where: 'jid = ? AND device = ? AND accountJid = ?',
whereArgs: [
key.jid,
key.deviceId,
await GetIt.I.get<XmppStateService>().getAccountJid(),
],
);
}
await batch.commit();
}
Future<OmemoDataPackage?> loadRatchets(String jid) async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final db = GetIt.I.get<DatabaseService>().database;
final ratchetsRaw = await db.query(
omemoRatchetsTable,
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
final deviceListRaw = await db.query(
omemoDeviceListTable,
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
limit: 1,
);
if (ratchetsRaw.isEmpty || deviceListRaw.isEmpty) {
return null;
}
// Deserialize the ratchets
final ratchets = <RatchetMapKey, OmemoDoubleRatchet>{};
for (final ratchetRaw in ratchetsRaw) {
final key = RatchetMapKey(
jid,
ratchetRaw['device']! as int,
);
// Deserialize skipped keys
final mkSkipped = <SkippedKey, List<int>>{};
final skippedKeysRaw =
(jsonDecode(ratchetRaw['skipped']! as String) as List<dynamic>)
.cast<Map<dynamic, dynamic>>();
for (final skippedRaw in skippedKeysRaw) {
final key = SkippedKey(
(skippedRaw['dhPub']! as String)
.fromBase64()
.toPublicKey(KeyPairType.x25519),
skippedRaw['n']! as int,
);
mkSkipped[key] = (skippedRaw['mk']! as String).fromBase64();
}
// Deserialize the KEX
final kexRaw =
(jsonDecode(ratchetRaw['kex']! as String) as Map<dynamic, dynamic>)
.cast<String, Object>();
final kex = KeyExchangeData(
kexRaw['pkId']! as int,
kexRaw['spkId']! as int,
(kexRaw['ek']! as String).fromBase64().toPublicKey(KeyPairType.x25519),
(kexRaw['ik']! as String).fromBase64().toPublicKey(KeyPairType.ed25519),
);
// Deserialize the entire ratchet
ratchets[key] = OmemoDoubleRatchet(
OmemoKeyPair.fromBytes(
base64Decode(ratchetRaw['dhsPub']! as String),
base64Decode(ratchetRaw['dhs']! as String),
KeyPairType.x25519,
),
(ratchetRaw['dhrPub'] as String?)
?.fromBase64()
.toPublicKey(KeyPairType.x25519),
base64Decode(ratchetRaw['rk']! as String),
(ratchetRaw['cks'] as String?)?.fromBase64(),
(ratchetRaw['ckr'] as String?)?.fromBase64(),
ratchetRaw['ns']! as int,
ratchetRaw['nr']! as int,
ratchetRaw['pn']! as int,
(ratchetRaw['ik']! as String)
.fromBase64()
.toPublicKey(KeyPairType.ed25519),
(ratchetRaw['ad']! as String).fromBase64(),
mkSkipped,
intToBool(ratchetRaw['acked']! as int),
kex,
);
}
return OmemoDataPackage(
(jsonDecode(deviceListRaw.first['devices']! as String) as List<dynamic>)
.cast<int>(),
ratchets,
);
}
Future<void> commitTrust(BTBVTrustData trust) async {
final db = GetIt.I.get<DatabaseService>().database;
await db.insert(
omemoTrustTable,
{
'jid': trust.jid,
'device': trust.device,
'trust': trust.state.value,
'enabled': boolToInt(trust.enabled),
'accountJid': await GetIt.I.get<XmppStateService>().getAccountJid(),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<BTBVTrustData>> loadTrust(String jid) async {
final db = GetIt.I.get<DatabaseService>().database;
final rawTrust = await db.query(
omemoTrustTable,
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, await GetIt.I.get<XmppStateService>().getAccountJid()],
);
return rawTrust.map((trust) {
return BTBVTrustData(
jid,
trust['device']! as int,
BTBVTrustState.fromInt(trust['trust']! as int),
intToBool(trust['enabled']! as int),
false,
);
}).toList();
}
Future<void> removeTrust(String jid) async {
final db = GetIt.I.get<DatabaseService>().database;
await db.delete(
omemoTrustTable,
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, await GetIt.I.get<XmppStateService>().getAccountJid()],
);
}

View File

@@ -0,0 +1,41 @@
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
class PermissionsService {
/// Returns true if the UI should request the notification permission. If not,
/// returns false.
/// If the permission should be requested, this method also sets the `XmppState`'s
/// `askedNotificationPermission` to true.
Future<bool> shouldRequestNotificationPermission() async {
final xss = GetIt.I.get<XmppStateService>();
final retValue = !(await xss.state).askedNotificationPermission;
if (retValue) {
await xss.modifyXmppState(
(state) => state.copyWith(askedNotificationPermission: true),
);
}
return retValue;
}
/// Returns true if the UI should request to not be battery-optimised. If not,
/// returns false. Also returns false if the app is already ignoring battery optimisations.
/// If the excemption should be requested, this method also sets the `XmppState`'s
/// `askedBatteryOptimizationExcemption` to true.
Future<bool> shouldRequestBatteryOptimisationExcemption() async {
if (await MoxplatformPlugin.platform.isIgnoringBatteryOptimizations()) {
return false;
}
final xss = GetIt.I.get<XmppStateService>();
final retValue = !(await xss.state).askedBatteryOptimizationExcemption;
if (retValue) {
await xss.modifyXmppState(
(state) => state.copyWith(askedBatteryOptimizationExcemption: true),
);
}
return retValue;
}
}

View File

@@ -23,11 +23,14 @@ class ReactionsService {
/// Query the database for 6 distinct emoji reactions associated with the message id
/// [id].
Future<List<String>> getPreviewReactionsForMessage(int id) async {
Future<List<String>> getPreviewReactionsForMessage(
String id,
String accountJid,
) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
where: 'message_id = ? AND accountJid = ?',
whereArgs: [id, accountJid],
columns: ['emoji'],
distinct: true,
limit: 6,
@@ -36,135 +39,134 @@ class ReactionsService {
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<List<Reaction>> getReactionsForMessage(int id) async {
Future<List<Reaction>> getReactionsForMessage(
String id,
String accountJid,
) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
where: 'message_id = ? AND accountJid = ?',
whereArgs: [id, accountJid],
);
return reactions.map(Reaction.fromJson).toList();
}
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
Future<List<String>> getReactionsForMessageByJid(
String id,
String accountJid,
String jid,
) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ? AND senderJid = ?',
whereArgs: [id, jid],
where: 'message_id = ? AND accountJid = ? AND senderJid = ?',
whereArgs: [id, accountJid, jid],
);
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<int> _countReactions(int messageId, String emoji) async {
Future<int> _countReactions(
String id,
String accountJid,
String emoji,
) async {
return GetIt.I.get<DatabaseService>().database.count(
reactionsTable,
'message_id = ? AND emoji = ?',
[messageId, emoji],
'message_id = ? AND accountJid = ? AND emoji = ?',
[id, accountJid, emoji],
);
}
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
/// Adds a new reaction [emoji], if possible, to the message with id [id] and returns the
/// new message reaction preview.
Future<Message?> addNewReaction(
int messageId,
String conversationJid,
String id,
String accountJid,
String senderJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
var msg = await ms.getMessageById(id, accountJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
_log.warning(
'Failed to get message ($id, $accountJid)',
);
return null;
}
if (!msg.reactionsPreview.contains(emoji) &&
msg.reactionsPreview.length < 6) {
final newPreview = [
...msg.reactionsPreview,
emoji,
];
try {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
await GetIt.I.get<DatabaseService>().database.insert(
reactionsTable,
Reaction(
messageId,
jid,
emoji,
).toJson(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
final newMsg = msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
_log.finest('Message reaction preview: ${msg.reactionsPreview}');
await GetIt.I.get<DatabaseService>().database.insert(
reactionsTable,
Reaction(
id,
accountJid,
senderJid,
emoji,
).toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
return newMsg;
} catch (ex) {
// The reaction already exists
return msg;
}
if (msg.reactionsPreview.length < 6 &&
!msg.reactionsPreview.contains(emoji)) {
msg = msg.copyWith(
reactionsPreview: [
...msg.reactionsPreview,
emoji,
],
);
}
return msg;
}
Future<Message?> removeReaction(
int messageId,
String conversationJid,
String id,
String accountJid,
String senderJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
final msg = await ms.getMessageById(id, accountJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
_log.warning(
'Failed to get message ($id, $accountJid)',
);
return null;
}
final xss = GetIt.I.get<XmppStateService>();
await GetIt.I.get<DatabaseService>().database.delete(
reactionsTable,
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
where:
'message_id = ? AND accountJid = ? AND emoji = ? AND senderJid = ?',
whereArgs: [
messageId,
id,
accountJid,
emoji,
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
(await xss.state).jid,
],
);
final count = await _countReactions(messageId, emoji);
final count = await _countReactions(id, accountJid, emoji);
if (count > 0) {
return msg;
}
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
final newMsg = msg.copyWith(
return msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
);
return newMsg;
}
Future<void> processNewReactions(
Message msg,
String accountJid,
String senderJid,
List<String> emojis,
) async {
// Get all reactions know for this message
final allReactions = await getReactionsForMessage(msg.id);
final allReactions = await getReactionsForMessage(msg.id, accountJid);
final userEmojis =
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
@@ -175,8 +177,9 @@ class ReactionsService {
for (final emoji in removedReactions) {
final rows = await db.delete(
reactionsTable,
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
whereArgs: [msg.id, senderJid, emoji],
where:
'message_id = ? AND accountJid = ? AND senderJid = ? AND emoji = ?',
whereArgs: [msg.id, accountJid, senderJid, emoji],
);
assert(rows == 1, 'Only one row should be removed');
}
@@ -186,6 +189,7 @@ class ReactionsService {
reactionsTable,
Reaction(
msg.id,
accountJid,
senderJid,
emoji,
).toJson(),
@@ -193,11 +197,11 @@ class ReactionsService {
}
final newMessage = msg.copyWith(
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
reactionsPreview: await getPreviewReactionsForMessage(
msg.id,
accountJid,
),
);
await GetIt.I.get<MessageService>().replaceMessageInCache(
newMessage,
);
sendEvent(MessageUpdatedEvent(message: newMessage));
}
}

View File

@@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
@@ -19,20 +18,21 @@ class RosterService {
/// Logger.
final Logger _log = Logger('RosterService');
Future<void> _loadRosterIfNeeded() async {
Future<void> _loadRosterIfNeeded(String accountJid) async {
if (_rosterCache == null) {
await loadRosterFromDatabase();
await loadRosterFromDatabase(accountJid);
}
}
Future<bool> isInRoster(String jid) async {
await _loadRosterIfNeeded();
Future<bool> isInRoster(String jid, String accountJid) async {
await _loadRosterIfNeeded(accountJid);
return _rosterCache!.containsKey(jid);
}
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
Future<RosterItem> addRosterItemFromData(
String avatarUrl,
String accountJid,
String avatarPath,
String avatarHash,
String jid,
String title,
@@ -45,9 +45,9 @@ class RosterService {
List<String> groups = const [],
}) async {
// TODO(PapaTutuWawa): Handle groups
final i = RosterItem(
-1,
avatarUrl,
final item = RosterItem(
accountJid,
avatarPath,
avatarHash,
jid,
title,
@@ -59,13 +59,10 @@ class RosterService {
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
);
final item = i.copyWith(
id: await GetIt.I
.get<DatabaseService>()
.database
.insert(rosterTable, i.toDatabaseJson()),
);
await GetIt.I
.get<DatabaseService>()
.database
.insert(rosterTable, item.toDatabaseJson());
// Update the cache
_rosterCache![item.jid] = item;
@@ -75,8 +72,9 @@ class RosterService {
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
Future<RosterItem> updateRosterItem(
int id, {
String? avatarUrl,
String jid,
String accountJid, {
String? avatarPath,
String? avatarHash,
String? title,
String? subscription,
@@ -89,8 +87,8 @@ class RosterService {
}) async {
final i = <String, dynamic>{};
if (avatarUrl != null) {
i['avatarUrl'] = avatarUrl;
if (avatarPath != null) {
i['avatarPath'] = avatarPath;
}
if (avatarHash != null) {
i['avatarHash'] = avatarHash;
@@ -126,8 +124,8 @@ class RosterService {
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
rosterTable,
i,
where: 'id = ?',
whereArgs: [id],
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
final newItem = RosterItem.fromDatabaseJson(result);
@@ -138,40 +136,28 @@ class RosterService {
}
/// Removes a roster item from the database and cache
Future<void> removeRosterItem(int id) async {
Future<void> removeRosterItem(String jid, String accountJid) async {
// NOTE: This call ensures that _rosterCache != null
await GetIt.I.get<DatabaseService>().database.delete(
rosterTable,
where: 'id = ?',
whereArgs: [id],
where: 'jid = ? AND accountJid = ?',
whereArgs: [jid, accountJid],
);
assert(_rosterCache != null, '_rosterCache must be non-null');
/// Update cache
_rosterCache!.removeWhere((_, value) => value.id == id);
}
/// Removes a roster item from the database based on its JID.
Future<void> removeRosterItemByJid(String jid) async {
await _loadRosterIfNeeded();
for (final item in _rosterCache!.values) {
if (item.jid == jid) {
await removeRosterItem(item.id);
return;
}
}
_rosterCache!.removeWhere((_, value) => value.jid == jid);
}
/// Returns the entire roster
Future<List<RosterItem>> getRoster() async {
await _loadRosterIfNeeded();
Future<List<RosterItem>> getRoster(String accountJid) async {
await _loadRosterIfNeeded(accountJid);
return _rosterCache!.values.toList();
}
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
Future<RosterItem?> getRosterItemByJid(String jid) async {
if (await isInRoster(jid)) {
Future<RosterItem?> getRosterItemByJid(String jid, String accountJid) async {
if (await isInRoster(jid, accountJid)) {
return _rosterCache![jid];
}
@@ -180,9 +166,12 @@ class RosterService {
/// Load the roster from the database. This function is guarded against loading the
/// roster multiple times and thus creating too many "RosterDiff" actions.
Future<List<RosterItem>> loadRosterFromDatabase() async {
final itemsRaw =
await GetIt.I.get<DatabaseService>().database.query(rosterTable);
Future<List<RosterItem>> loadRosterFromDatabase(String accountJid) async {
final itemsRaw = await GetIt.I.get<DatabaseService>().database.query(
rosterTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
);
final items = itemsRaw.map(RosterItem.fromDatabaseJson);
_rosterCache = <String, RosterItem>{};
@@ -197,7 +186,8 @@ class RosterService {
/// and, if it was successful, create the database entry. Returns the
/// [RosterItem] model object.
Future<RosterItem> addToRosterWrapper(
String avatarUrl,
String accountJid,
String avatarPath,
String avatarHash,
String jid,
String title,
@@ -205,7 +195,8 @@ class RosterService {
final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid);
final item = await addRosterItemFromData(
avatarUrl,
accountJid,
avatarPath,
avatarHash,
jid,
title,
@@ -217,14 +208,19 @@ class RosterService {
await css.getContactDisplayName(contactId),
);
final result = await GetIt.I
.get<XmppConnection>()
.getRosterManager()!
.addToRoster(jid, title);
final conn = GetIt.I.get<XmppConnection>();
final result = await conn.getRosterManager()!.addToRoster(jid, title);
if (!result) {
// TODO(Unknown): Signal error?
}
final to = JID.fromString(jid);
final preApproval =
await conn.getPresenceManager()!.preApproveSubscription(to);
if (!preApproval) {
await conn.getPresenceManager()!.requestSubscription(to);
}
sendEvent(RosterDiffEvent(added: [item]));
return item;
}
@@ -233,24 +229,46 @@ class RosterService {
/// successful, from the database. If [unsubscribe] is true, then [jid] won't receive
/// our presence anymore.
Future<bool> removeFromRosterWrapper(
String jid, {
String jid,
String accountJid, {
bool unsubscribe = true,
}) async {
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
final conn = GetIt.I.get<XmppConnection>();
final roster = conn.getRosterManager()!;
final pm = conn.getManagerById<PresenceManager>(presenceManager)!;
final result = await roster.removeFromRoster(jid);
if (result == RosterRemovalResult.okay ||
result == RosterRemovalResult.itemNotFound) {
if (unsubscribe) {
GetIt.I
.get<SubscriptionRequestService>()
.sendUnsubscriptionRequest(jid);
await pm.unsubscribe(JID.fromString(jid));
}
_log.finest('Removing from roster maybe worked. Removing from database');
await removeRosterItemByJid(jid);
await removeRosterItem(jid, accountJid);
return true;
}
return false;
}
/// Removes all roster items that are pseudo roster items.
Future<void> removePseudoRosterItems(String accountJid) async {
final items = await getRoster(accountJid);
final removed = List<String>.empty(growable: true);
for (final item in items) {
if (!item.pseudoRosterItem) continue;
assert(
item.contactId != null,
'Only pseudo roster items that are for the contact integration should ge removed',
);
removed.add(item.jid);
await removeRosterItem(item.jid, accountJid);
}
sendEvent(
RosterDiffEvent(removed: removed),
);
}
}

View File

@@ -2,11 +2,11 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
@@ -17,23 +17,28 @@ import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/migration.dart';
import 'package:moxxyv2/service/events.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/groupchat.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/moxxmpp/roster.dart';
import 'package:moxxyv2/service/moxxmpp/socket.dart';
import 'package:moxxyv2/service/moxxmpp/stream.dart';
import 'package:moxxyv2/service/non_database_migrations/0000_notification_channels.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/permissions.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/share.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/storage.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/commands.dart';
@@ -69,10 +74,30 @@ Future<void> initializeServiceIfNeeded() async {
);
} else {
logger.info('Service is not running. Initializing service... ');
// Run non-db migrations
const storage = FlutterSecureStorage();
const versionKey = 'non_database_migrations_version';
final currentVersion = int.parse(
await storage.read(key: versionKey) ?? '0',
);
await runMigrations(
logger,
42,
const [
Migration(2, upgradeV1ToV2NonDb),
],
currentVersion,
'non-database',
commitVersion: (version) async =>
storage.write(key: versionKey, value: version.toString()),
);
await handler.start(
entrypoint,
receiveUIEvent,
ui_events.handleIsolateEvent,
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
);
}
}
@@ -144,7 +169,7 @@ Future<void> initUDPLogger() async {
/// The entrypoint for all platforms after the platform specific initilization is done.
@pragma('vm:entry-point')
Future<void> entrypoint() async {
Future<void> entrypoint(String initialLocale) async {
setupLogging();
setupBackgroundEventHandler();
@@ -154,9 +179,13 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<LanguageService>(LanguageService());
// Initialize the database
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
await GetIt.I.get<DatabaseService>().initialize();
// Initialize the account state
await GetIt.I.get<XmppStateService>().initializeXmppState();
// Initialize services
GetIt.I.registerSingleton<ConnectivityWatcherService>(
ConnectivityWatcherService(),
@@ -174,15 +203,33 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
GetIt.I.registerSingleton<ContactsService>(ContactsService());
GetIt.I.registerSingleton<StickersService>(StickersService());
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
GetIt.I.registerSingleton<SubscriptionRequestService>(
SubscriptionRequestService(),
);
GetIt.I.registerSingleton<FilesService>(FilesService());
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
GetIt.I.registerSingleton<GroupchatService>(GroupchatService());
GetIt.I.registerSingleton<StorageService>(StorageService());
GetIt.I.registerSingleton<ShareService>(ShareService());
GetIt.I.registerSingleton<PermissionsService>(PermissionsService());
GetIt.I.registerSingleton<LifecycleService>(LifecycleService());
final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp);
// Set the locale before we initialize the notigications service to ensure
// the correct locale is used for the notification channels.
final preferredLocale =
(await GetIt.I.get<PreferencesService>().getPreferences())
.languageLocaleCode;
if (preferredLocale == 'default') {
LocaleSettings.setLocaleRaw(initialLocale);
GetIt.I.get<Logger>().finest(
'Setting locale to system locale ($initialLocale) per preferences',
);
} else {
LocaleSettings.setLocaleRaw(preferredLocale);
GetIt.I.get<Logger>().finest(
'Setting locale to configured locale ($preferredLocale) per preferences',
);
}
await GetIt.I.get<NotificationsService>().initialize();
await GetIt.I.get<ContactsService>().initialize();
await GetIt.I.get<ConnectivityService>().initialize();
@@ -211,10 +258,14 @@ Future<void> entrypoint() async {
StreamManagementNegotiator(),
CSINegotiator(),
RosterFeatureNegotiator(),
PresenceNegotiator(),
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
SaslPlainNegotiator(),
Sasl2Negotiator(),
Bind2Negotiator(),
FASTSaslNegotiator(),
]);
await connection.registerManagers([
MoxxyStreamManagementManager(),
@@ -222,7 +273,14 @@ Future<void> entrypoint() async {
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
]),
RosterManager(MoxxyRosterStateManager()),
MoxxyOmemoManager(),
OmemoManager(
GetIt.I.get<OmemoService>().getOmemoManager,
(toJid, _) async =>
GetIt.I.get<ConversationService>().shouldEncryptForConversation(
toJid,
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
),
),
PingManager(const Duration(minutes: 3)),
MessageManager(),
PresenceManager(),
@@ -230,7 +288,6 @@ Future<void> entrypoint() async {
CSIManager(),
CarbonsManager(),
PubSubManager(),
VCardManager(),
UserAvatarManager(),
StableIdManager(),
MessageDeliveryReceiptManager(),
@@ -249,13 +306,24 @@ Future<void> entrypoint() async {
LastMessageCorrectionManager(),
MessageReactionsManager(),
StickersManager(),
MessageProcessingHintManager(),
MUCManager(),
OccupantIdManager(),
]);
GetIt.I.registerSingleton<XmppConnection>(connection);
GetIt.I.get<Logger>().finest('Done with xmpp');
final settings = await xmpp.getConnectionSettings();
// Ensure our data directory exists
final dir = Directory(
await MoxplatformPlugin.platform.getPersistentDataPath(),
);
if (!dir.existsSync()) {
GetIt.I
.get<Logger>()
.finest('Data dir ${dir.path} does not exist. Creating...');
await dir.create(recursive: true);
GetIt.I.get<Logger>().finest('Done');
}
// Ensure we can access translations here
// TODO(Unknown): This does *NOT* allow us to get the system's locale as we have no
@@ -263,6 +331,7 @@ Future<void> entrypoint() async {
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocale();
final settings = await xmpp.getConnectionSettings();
GetIt.I.get<Logger>().finest('Got settings');
if (settings != null) {
unawaited(
@@ -271,6 +340,9 @@ Future<void> entrypoint() async {
.initializeIfNeeded(settings.jid.toBare().toString()),
);
// Potentially set the notification avatar
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
// The title of the notification will be changed as soon as the connection state
// of [XmppConnection] changes.
await connection

55
lib/service/share.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
/// The service responsible for handling the direct share feature.
class ShareService {
/// Logging.
final Logger _log = Logger('ShareService');
/// Updates the share shortcuts for [conversation]. If a message was received or
/// sent in [conversation], this method should be called.
Future<void> recordSentMessage(
Conversation conversation,
) async {
assert(
implies(!conversation.isSelfChat, conversation.jid.isNotEmpty),
'Only self-chats can have an empty JID',
);
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Use the correct title if we share to the note-to-self chat.
final conversationName = conversation.isSelfChat
? t.pages.conversations.speeddialAddNoteToSelf
: conversation.getTitleWithOptionalContact(
prefs.enableContactIntegration,
);
final conversationImageFilePath =
conversation.getAvatarPathWithOptionalContact(
prefs.enableContactIntegration,
);
// Prevent empty JIDs as that messes with share_handler
final conversationJid =
conversation.isSelfChat ? selfChatShareFakeJid : conversation.jid;
_log.finest(
'Creating direct share target "$conversationName" (jid=$conversationJid, avatarPath=$conversationImageFilePath)',
);
// Tell the system to create a direct share shortcut
await MoxplatformPlugin.contacts.recordSentMessage(
conversationName,
conversationJid,
avatarPath:
conversationImageFilePath.isEmpty ? null : conversationImageFilePath,
fallbackIcon: conversation.isSelfChat
? FallbackIconType.notes
: FallbackIconType.person,
);
}
}

View File

@@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:archive/archive.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
@@ -16,20 +17,48 @@ import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p;
class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService');
Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
/// Computes the total amount of storage occupied by the stickers in the sticker
/// pack identified by id [id].
/// NOTE that if a sticker does not indicate a file size, i.e. the "size" column is
/// NULL, then a size of 0 is assumed.
Future<int> getStickerPackSizeById(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.rawQuery(
'''
SELECT
SUM(size) AS size
FROM
$fileMetadataTable as fmt
WHERE
path IS NOT NULL AND
EXISTS (
SELECT
id
FROM
$stickersTable
WHERE
file_metadata_id = fmt.id AND
stickerPackId = ?
)
''',
[id],
);
_log.finest('Cumulative size for $id: $result');
return result.first['size'] as int? ?? 0;
}
Future<StickerPack?> getStickerPackById(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query(
stickerPacksTable,
@@ -59,13 +88,23 @@ SELECT
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
FROM
(SELECT
*
FROM
$stickersTable
WHERE
stickerPackId = ?
) AS sticker
JOIN
$fileMetadataTable fm
ON
sticker.file_metadata_id = fm.id;
''',
[id],
);
_stickerPacks[id] = StickerPack.fromDatabaseJson(
final stickerPack = StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers.map((sticker) {
return Sticker.fromDatabaseJson(
@@ -75,28 +114,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
),
);
}).toList(),
).copyWith(
size: await getStickerPackSizeById(id),
);
return _stickerPacks[id]!;
}
Future<List<StickerPack>> getStickerPacks() async {
if (_stickerPacks.isEmpty) {
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
stickerPacksTable,
columns: ['id'],
);
for (final rawPack in rawPackIds) {
final id = rawPack['id']! as String;
await getStickerPackById(id);
}
}
_log.finest('Got ${_stickerPacks.length} sticker packs');
return _stickerPacks.values.toList();
return stickerPack;
}
Future<void> removeStickerPack(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist');
@@ -117,17 +143,20 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
}
// Remove from the database
await GetIt.I.get<DatabaseService>().database.delete(
await db.delete(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
await db.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
@@ -140,7 +169,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
@@ -238,34 +268,35 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
);
// Get file metadata
final fileMetadataRaw =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
MediaFileLocation(
sticker.fileMetadata.sourceUrls!,
p.basename(stickerPath),
null,
null,
null,
sticker.fileMetadata.plaintextHashes,
null,
sticker.fileMetadata.size,
),
sticker.fileMetadata.mimeType,
sticker.fileMetadata.size,
sticker.fileMetadata.width != null &&
sticker.fileMetadata.height != null
? Size(
sticker.fileMetadata.width!.toDouble(),
sticker.fileMetadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
final fs = GetIt.I.get<FilesService>();
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
MediaFileLocation(
sticker.fileMetadata.sourceUrls!,
p.basename(stickerPath),
null,
null,
null,
sticker.fileMetadata.plaintextHashes,
null,
sticker.fileMetadata.size,
),
sticker.fileMetadata.mimeType,
sticker.fileMetadata.size,
sticker.fileMetadata.width != null &&
sticker.fileMetadata.height != null
? Size(
sticker.fileMetadata.width!.toDouble(),
sticker.fileMetadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
if (!fileMetadataRaw.retrieved) {
if (!fileMetadataRaw.retrieved &&
fileMetadataRaw.fileMetadata.path == null) {
final downloadStatusCode = await downloadFile(
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
stickerPath,
@@ -279,13 +310,22 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
}
}
var fm = fileMetadataRaw.fileMetadata;
if (fileMetadataRaw.fileMetadata.size == null) {
// Determine the file size of the sticker.
fm = await fs.updateFileMetadata(
fileMetadataRaw.fileMetadata.id,
size: File(stickerPath).lengthSync(),
);
}
stickers[i] = await _addStickerFromData(
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
remotePack.hashValue,
sticker.desc,
sticker.suggests,
fileMetadataRaw.fileMetadata,
fm,
);
}
@@ -370,9 +410,10 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
return null;
}
final stickerDirPath = await getStickerPackPath(
pack.hashAlgorithm.toName(),
pack.hashValue,
final stickerDirPath = p.join(
await MoxplatformPlugin.platform.getPersistentDataPath(),
'stickers',
'${pack.hashAlgorithm.toName()}_${pack.hashValue}',
);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
@@ -387,11 +428,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
pack.hashValue,
pack.restricted,
true,
DateTime.now().millisecondsSinceEpoch,
0,
);
await _addStickerPackFromData(stickerPack);
// Add all stickers
var size = 0;
final stickers = List<Sticker>.empty(growable: true);
final fs = GetIt.I.get<FilesService>();
for (final sticker in pack.stickers) {
// Get the "path" to the sticker
final stickerPath = await computeCachedPathForFile(
@@ -404,39 +449,69 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
final fileMetadataRaw = await GetIt.I
.get<FilesService>()
.createFileMetadataIfRequired(
MediaFileLocation(
urlSources,
p.basename(stickerPath),
null,
null,
null,
sticker.metadata.hashes,
null,
sticker.metadata.size,
),
sticker.metadata.mediaType,
sticker.metadata.size,
sticker.metadata.width != null && sticker.metadata.height != null
? Size(
sticker.metadata.width!.toDouble(),
sticker.metadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
MediaFileLocation(
urlSources,
p.basename(stickerPath),
null,
null,
null,
sticker.metadata.hashes,
null,
sticker.metadata.size,
),
sticker.metadata.mediaType,
sticker.metadata.size,
sticker.metadata.width != null && sticker.metadata.height != null
? Size(
sticker.metadata.width!.toDouble(),
sticker.metadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
// Only copy the sticker to storage if we don't already have it
if (!fileMetadataRaw.retrieved) {
var fm = fileMetadataRaw.fileMetadata;
if (!fileMetadataRaw.retrieved ||
fileMetadataRaw.fileMetadata.path == null) {
_log.finest(
'Copying sticker ${sticker.metadata.name!} to media storage',
);
final stickerFile = archive.findFile(sticker.metadata.name!)!;
await File(stickerPath).writeAsBytes(
final file = File(stickerPath);
await file.writeAsBytes(
stickerFile.content as List<int>,
);
// Update the File Metadata entry
fm = await fs.updateFileMetadata(
fm.id,
size: file.lengthSync(),
path: stickerPath,
);
size += file.lengthSync();
} else {
_log.finest(
'Not copying sticker ${sticker.metadata.name!} as we already have it',
);
}
// Check if the sticker has size
if (fm.size == null) {
_log.finest(
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
);
// Update the File Metadata entry
fm = await fs.updateFileMetadata(
fm.id,
size: File(stickerPath).lengthSync(),
);
size += fm.size!;
}
stickers.add(
@@ -446,18 +521,16 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
pack.hashValue,
sticker.metadata.desc!,
sticker.suggests,
fileMetadataRaw.fileMetadata,
fm,
),
);
}
final stickerPackWithStickers = stickerPack.copyWith(
stickers: stickers,
size: size,
);
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info(
'Sticker pack ${stickerPack.id} successfully added to the database',
);
@@ -466,4 +539,110 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
unawaited(_publishStickerPack(pack));
return stickerPackWithStickers;
}
/// Returns a paginated list of sticker packs.
/// [includeStickers] controls whether the stickers for a given sticker pack are
/// fetched from the database. Setting this to false, i.e. not loading the stickers,
/// can be useful, for example, when we're only interested in listing the sticker
/// packs without the stickers being visible.
Future<List<StickerPack>> getPaginatedStickerPacks(
bool olderThan,
int? timestamp,
bool includeStickers,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = timestamp != null ? 'addedTimestamp $comparator ?' : null;
final stickerPacksRaw = await db.query(
stickerPacksTable,
where: query,
orderBy: 'addedTimestamp DESC',
limit: stickerPackPaginationSize,
);
final stickerPacks = List<StickerPack>.empty(growable: true);
for (final pack in stickerPacksRaw) {
// Query the stickers
List<Map<String, Object?>> stickersRaw;
if (includeStickers) {
stickersRaw = await db.rawQuery(
'''
SELECT
st.*,
fm.id AS fm_id,
fm.path AS fm_path,
fm.sourceUrls AS fm_sourceUrls,
fm.mimeType AS fm_mimeType,
fm.thumbnailType AS fm_thumbnailType,
fm.thumbnailData AS fm_thumbnailData,
fm.width AS fm_width,
fm.height AS fm_height,
fm.plaintextHashes AS fm_plaintextHashes,
fm.encryptionKey AS fm_encryptionKey,
fm.encryptionIv AS fm_encryptionIv,
fm.encryptionScheme AS fm_encryptionScheme,
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM
$stickersTable AS st,
$fileMetadataTable AS fm
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[
pack['id']! as String,
],
);
} else {
stickersRaw = List<Map<String, Object?>>.empty();
}
final stickerPack = StickerPack.fromDatabaseJson(
pack,
stickersRaw.map((sticker) {
return Sticker.fromDatabaseJson(
sticker,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(sticker, 'fm_'),
),
);
}).toList(),
);
/// If stickers were not requested, we still have to get the size of the
/// sticker pack anyway.
int size;
if (includeStickers && stickerPack.stickers.isNotEmpty) {
size = stickerPack.stickers
.map((sticker) => sticker.fileMetadata.size ?? 0)
.reduce((value, element) => value + element);
} else {
final sizeResult = await db.rawQuery(
'''
SELECT
SUM(fm.size) as size
FROM
$fileMetadataTable as fm,
$stickersTable as st
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[pack['id']! as String],
);
size = sizeResult.first['size'] as int? ?? 0;
}
stickerPacks.add(
stickerPack.copyWith(
size: size,
),
);
}
return stickerPacks;
}
}

94
lib/service/storage.dart Normal file
View File

@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/files.dart';
/// Service responsible for handling storage related queries, like how much storage
/// are we currently using.
class StorageService {
/// Logger.
final Logger _log = Logger('StorageService');
/// Compute the amount of storage all FileMetadata objects take, that both have
/// their file size and path set to something other than null.
/// Note that this usage does not include file metadata items that are stickers.
Future<int> computeUsedMediaStorage() async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.rawQuery(
'''
SELECT SUM(size) AS size FROM $fileMetadataTable AS fmt
WHERE path IS NOT NULL
AND size IS NOT NULL
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
''',
);
_log.finest('computeUsedMediaStorage: SQL:: $result');
return result.first['size'] as int? ?? 0;
}
Future<int> computeUsedStickerStorage() async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.rawQuery(
'''
SELECT SUM(size) AS size FROM $fileMetadataTable as fmt
WHERE path IS NOT NULL
AND size IS NOT NULL
AND EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
''',
);
_log.finest('computeUsedStickerStorage: SQL:: $result');
return result.first['size'] as int? ?? 0;
}
/// Deletes shared media files for which the age of the newest attached message
/// is at least [timeOffsetMilliseconds] milliseconds in the past from the moment
/// of calling.
Future<void> deleteOldMediaFiles(int timeOffsetMilliseconds) async {
// The timestamp of the newest message referencing this
final maxAge =
DateTime.now().millisecondsSinceEpoch - timeOffsetMilliseconds;
// The database
final db = GetIt.I.get<DatabaseService>().database;
// The query is pretty complicated because:
// - We deduplicate media files, meaning that there may be > 1 messages that use a given
// file metadata entry. To prevent deleting too many files, we have to find the newest
// message that references the file metadata item and check if that message's timestamp
// puts it in deletion range.
// - We don't want to delete files that belong to a sticker pack because the storage of those
// is managed differently.
// - In case we have file metadata items that are dangling, we also remove those.
// TODO(Unknown): It might be nice to merge the two subqueries
final results = await db.rawQuery(
'''
SELECT
path,
id
FROM
$fileMetadataTable AS fmt
WHERE (
(SELECT MAX(timestamp) FROM $messagesTable WHERE file_metadata_id = fmt.id) <= $maxAge
OR NOT EXISTS (SELECT id FROM $messagesTable WHERE file_metadata_id = fmt.id)
)
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
AND path IS NOT NULL
''',
);
_log.finest('Found ${results.length} matching files for deletion');
for (final result in results) {
// Update the entry
await GetIt.I.get<FilesService>().updateFileMetadata(
result['id']! as String,
path: null,
);
final file = File(result['path']! as String);
if (file.existsSync()) await file.delete();
}
}
}

View File

@@ -1,95 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
class SubscriptionRequestService {
List<String>? _subscriptionRequests;
final Lock _lock = Lock();
/// Only load data from the database into
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
/// been loaded.
Future<void> _loadSubscriptionRequestsIfNeeded() async {
await _lock.synchronized(() async {
_subscriptionRequests ??= List<String>.from(
(await GetIt.I
.get<DatabaseService>()
.database
.query(subscriptionsTable))
.map((m) => m['jid']! as String)
.toList(),
);
});
}
Future<List<String>> getSubscriptionRequests() async {
await _loadSubscriptionRequestsIfNeeded();
return _subscriptionRequests!;
}
Future<void> addSubscriptionRequest(String jid) async {
await _loadSubscriptionRequestsIfNeeded();
await _lock.synchronized(() async {
if (!_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.add(jid);
await GetIt.I.get<DatabaseService>().database.insert(
subscriptionsTable,
{
'jid': jid,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
});
}
Future<void> removeSubscriptionRequest(String jid) async {
await _loadSubscriptionRequestsIfNeeded();
await _lock.synchronized(() async {
if (_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.remove(jid);
await GetIt.I.get<DatabaseService>().database.delete(
subscriptionsTable,
where: 'jid = ?',
whereArgs: [jid],
);
}
});
}
Future<bool> hasPendingSubscriptionRequest(String jid) async {
return (await getSubscriptionRequests()).contains(jid);
}
PresenceManager get _presence =>
GetIt.I.get<XmppConnection>().getPresenceManager()!;
/// Accept a subscription request from [jid].
Future<void> acceptSubscriptionRequest(String jid) async {
_presence.sendSubscriptionRequestApproval(jid);
await removeSubscriptionRequest(jid);
}
/// Reject a subscription request from [jid].
Future<void> rejectSubscriptionRequest(String jid) async {
_presence.sendSubscriptionRequestRejection(jid);
await removeSubscriptionRequest(jid);
}
/// Send a subscription request to [jid].
void sendSubscriptionRequest(String jid) {
_presence.sendSubscriptionRequest(jid);
}
/// Remove a presence subscription with [jid].
void sendUnsubscriptionRequest(String jid) {
_presence.sendUnsubscriptionRequest(jid);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,242 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:random_string/random_string.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
import 'package:uuid/uuid.dart';
extension UserAgentJson on UserAgent {
Map<String, String?> toJson() => {
'id': id,
'software': software,
'device': device,
};
}
const _userAgentKey = 'userAgent';
const _databasePasswordKey = 'database_encryption_password';
const _accountJidKey = 'account_jid';
class XmppStateService {
/// Persistent state around the connection, like the SM token, etc.
XmppState? _state;
/// Logger
final Logger _log = Logger('XmppStateService');
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
/// Persistent state around the connection, like the SM token, etc.
late XmppState _state;
final Lock _stateLock = Lock();
Future<XmppState> get state => _stateLock.synchronized(() => _state);
/// Cached account JID.
String? _accountJid;
/// Cache the user agent
UserAgent? _userAgent;
final Lock _userAgentLock = Lock();
/// Secure storage for data we must have before the database is up.
final FlutterSecureStorage _storage = const FlutterSecureStorage(
// TODO(Unknown): Set other options
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
/// Either returns the database password from the secure storage or
/// generates a new one and writes it to the secure storage.
Future<String> getOrCreateDatabaseKey() async {
final key = await _storage.read(key: _databasePasswordKey);
if (key != null) {
return key;
}
// We have no database key yet, so generate, save, and return.
_log.info('Found no database encryption password. Generating a new one...');
final newKey = randomAlphaNumeric(
40,
provider: CoreRandomProvider.from(Random.secure()),
);
await _storage.write(key: _databasePasswordKey, value: newKey);
_log.info('Key generation done');
return newKey;
}
/// The user agent used for SASL2 authentication. If cached, returns from cache.
/// If not cached, loads from the database. If not in the database, creates a
/// user agent and writes it to the database.
Future<UserAgent> get userAgent async {
return _userAgentLock.synchronized(() async {
if (_userAgent != null) return _userAgent!;
final db = GetIt.I.get<DatabaseService>().database;
final rowsRaw = await db.database.query(
xmppStateTable,
where: 'key = ?',
whereArgs: [_userAgentKey],
);
if (rowsRaw.isEmpty) {
// Generate a new user agent
_userAgent = UserAgent(
software: 'Moxxy',
id: const Uuid().v4(),
);
// Write it to the database
await db.insert(
xmppStateTable,
{
'accountJid': _accountJid,
'key': _userAgentKey,
'value': jsonEncode(_userAgent!.toJson()),
},
);
return _userAgent!;
}
assert(rowsRaw.length == 1, 'Only one row must exist');
final data = rowsRaw.first['value']! as String;
final json =
(jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String?>();
final userAgent = UserAgent(
device: json['device'],
software: json['software'],
id: json['id'],
);
_userAgent = userAgent;
return _userAgent!;
});
}
Future<void> initializeXmppState() async {
// NOTE: Called only once at the start so we don't have to worry about aquiring a lock
await _loadAccountJid();
final state = await _loadXmppState(_accountJid);
if (_accountJid == null || state == null) {
_log.finest(
'No account JID or account state available. Creating default value',
);
_state = XmppState(jid: _accountJid);
return;
}
_state = state;
}
Future<XmppState?> _loadXmppState(String? accountJid) async {
if (accountJid == null) {
return null;
}
final json = <String, String?>{};
final rowsRaw =
await GetIt.I.get<DatabaseService>().database.query(xmppStateTable);
final rowsRaw = await GetIt.I.get<DatabaseService>().database.query(
xmppStateTable,
where: 'accountJid = ?',
whereArgs: [accountJid],
columns: ['key', 'value'],
);
if (rowsRaw.isEmpty) {
return null;
}
for (final row in rowsRaw) {
json[row['key']! as String] = row['value'] as String?;
}
_state = XmppState.fromDatabaseTuples(json);
return _state!;
return XmppState.fromDatabaseTuples(json);
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
/// The same as [commitXmppState] but without aquiring [_stateLock].
Future<void> _commitXmppState(String accountJid) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final tuple in _state!.toDatabaseTuples().entries) {
for (final tuple in _state.toDatabaseTuples().entries) {
batch.insert(
xmppStateTable,
<String, String?>{'key': tuple.key, 'value': tuple.value},
<String, String?>{
'key': tuple.key,
'value': tuple.value,
'accountJid': accountJid
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<void> commitXmppState(String accountJid) async {
await _stateLock.synchronized(
() => _commitXmppState(accountJid),
);
}
Future<void> setXmppState(XmppState state, String accountJid) async {
await _stateLock.synchronized(
() async {
_state = state;
await _commitXmppState(accountJid);
},
);
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(
XmppState Function(XmppState) func, {
bool commit = true,
}) async {
final accountJid = await getAccountJid();
assert(
accountJid != null,
'The accountJid must be not empty',
);
await _stateLock.synchronized(
() async {
_state = func(_state);
if (commit) {
await _commitXmppState(accountJid!);
}
},
);
}
/// Resets the current account JID to null.
Future<void> resetAccountJid() async {
_accountJid = null;
await _storage.delete(key: _accountJidKey);
}
/// Sets the current account JID to [jid] and stores it in the secure storage.
Future<void> setAccountJid(String jid, {bool commit = true}) async {
_accountJid = jid;
if (commit) {
await _storage.write(key: _accountJidKey, value: jid);
}
}
Future<String?> _loadAccountJid() async {
return _accountJid ??= await _storage.read(key: _accountJidKey);
}
/// Gets the current account JID from the cache or from the secure storage.
Future<String?> getAccountJid() async {
return _accountJid ?? await _loadAccountJid();
}
Future<bool> isLoggedIn(String? accountJid) async {
final s = await state;
if (accountJid == null || s.jid == null || s.password == null) {
return false;
}
return await GetIt.I.get<XmppService>().getConnectionSettings() != null;
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:moxplatform/moxplatform.dart';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';
/// Save the bytes [bytes] that represent the user's avatar under
/// the [cache directory]/users/[jid]/avatar_[hash].png.
@@ -11,7 +11,7 @@ Future<String> saveAvatarInCache(
String jid,
String oldPath,
) async {
final cacheDir = (await getApplicationDocumentsDirectory()).path;
final cacheDir = await MoxplatformPlugin.platform.getPersistentDataPath();
final avatarsDir = Directory(pathlib.join(cacheDir, 'avatars'));
await avatarsDir.create(recursive: true);
@@ -29,6 +29,6 @@ Future<String> saveAvatarInCache(
/// Returns the path where a user's avatar is saved. Note that this does not imply
/// the existence of an avatar.
Future<String> getAvatarPath(String jid, String hash) async {
final cacheDir = (await getApplicationDocumentsDirectory()).path;
final cacheDir = await MoxplatformPlugin.platform.getPersistentDataPath();
return pathlib.join(cacheDir, 'avatars', '$hash.png');
}

View File

@@ -1,4 +1,4 @@
import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart';

View File

@@ -14,3 +14,23 @@ const int maxSharedMediaPages = 3;
/// The amount of conversations for which we cache the first page.
const int conversationMessagePageCacheSize = 4;
/// The amount of sticker packs we fetch per paginated request
const stickerPackPaginationSize = 10;
/// The amount of sticker packs we can cache in memory.
const maxStickerPackPages = 2;
/// An "invalid" fake JID to make share_handler happy when adding the self-chat
/// to the direct share list.
const selfChatShareFakeJid = '{{ self-chat }}';
/// Keys for grouping notifications
const messageNotificationGroupId = 'message';
const warningNotificationGroupId = 'warning';
const foregroundServiceNotificationGroupId = 'service';
/// Notification channel ids
const foregroundServiceNotificationChannelId = 'foreground_service';
const messageNotificationChannelId = 'messages';
const warningNotificationChannelId = 'warnings';

11
lib/shared/debug.dart Normal file
View File

@@ -0,0 +1,11 @@
enum DebugCommand {
/// Clear the stream resumption state so that the next connection is fresh.
clearStreamResumption(0),
requestRoster(1),
logAvailableMediaFiles(2);
const DebugCommand(this.id);
/// The id of the command
final int id;
}

View File

@@ -1,86 +1,253 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:omemo_dart/omemo_dart.dart';
const unspecifiedError = -1;
const noError = 0;
const fileUploadFailedError = 1;
const messageNotEncryptedForDevice = 2;
const messageInvalidHMAC = 3;
const messageNoDecryptionKey = 4;
const messageInvalidAffixElements = 5;
// const messageInvalidNumber = 6;
const messageFailedToEncrypt = 7;
const messageFailedToDecryptFile = 8;
const messageContactDoesNotSupportOmemo = 9;
const messageChatEncryptedButFileNot = 10;
const messageFailedToEncryptFile = 11;
const fileDownloadFailedError = 12;
const messageServiceUnavailable = 13;
const messageRemoteServerTimeout = 14;
const messageRemoteServerNotFound = 15;
enum ErrorType {
unknown(-1),
remoteServerNotFound(0),
remoteServerTimeout(1);
int errorTypeFromException(dynamic exception) {
if (exception == null) {
return noError;
const ErrorType(this.value);
factory ErrorType.fromValue(int value) {
switch (value) {
case 0:
return ErrorType.remoteServerNotFound;
case 1:
return ErrorType.remoteServerTimeout;
default:
return ErrorType.unknown;
}
}
if (exception is NoDecryptionKeyException) {
return messageNoDecryptionKey;
} else if (exception is InvalidMessageHMACException) {
return messageInvalidHMAC;
} else if (exception is NotEncryptedForDeviceException) {
return messageNoDecryptionKey;
} else if (exception is InvalidAffixElementsException) {
return messageInvalidAffixElements;
} else if (exception is EncryptionFailedException) {
return messageFailedToEncrypt;
} else if (exception is OmemoNotSupportedForContactException) {
return messageContactDoesNotSupportOmemo;
}
return unspecifiedError;
/// The identifier value of this error type.
final int value;
}
String errorToTranslatableString(int error) {
assert(
error != noError,
'Calling errorToTranslatableString with noError makes no sense',
);
enum MessageErrorType {
unspecified(-1),
// TODO(Unknown): Maybe remove
noError(0),
switch (error) {
case messageNotEncryptedForDevice:
return t.errors.omemo.notEncryptedForDevice;
case messageInvalidHMAC:
return t.errors.omemo.invalidHmac;
case messageNoDecryptionKey:
return t.errors.omemo.noDecryptionKey;
case messageInvalidAffixElements:
return t.errors.omemo.messageInvalidAfixElement;
case fileUploadFailedError:
return t.errors.message.fileUploadFailed;
case messageContactDoesNotSupportOmemo:
return t.errors.message.contactDoesntSupportOmemo;
case fileDownloadFailedError:
return t.errors.message.fileDownloadFailed;
case messageServiceUnavailable:
return t.errors.message.serviceUnavailable;
case messageRemoteServerTimeout:
return t.errors.message.remoteServerTimeout;
case messageRemoteServerNotFound:
return t.errors.message.remoteServerNotFound;
case messageFailedToEncrypt:
return t.errors.message.failedToEncrypt;
case messageFailedToDecryptFile:
return t.errors.message.failedToDecryptFile;
case messageChatEncryptedButFileNot:
return t.errors.message.fileNotEncrypted;
case messageFailedToEncryptFile:
return t.errors.message.failedToEncryptFile;
case unspecifiedError:
return t.errors.message.unspecified;
/// The file upload failed.
fileUploadFailed(1),
/// The received message was not encrypted for this device.
notEncryptedForDevice(2),
/// The HMAC of the encrypted message is wrong.
invalidHMAC(3),
/// We have no key available to decrypt the message.
noDecryptionKey(4),
/// The sanity-checks on the included affix elements failed.
invalidAffixElements(5),
/// The encryption of the message somehow failed.
failedToEncrypt(7),
/// The decryption of the file failed.
failedToDecryptFile(8),
/// The contact does not support OMEMO:2.
omemoNotSupported(9),
/// The chat is set to use OMEMO, but the received file was sent in plaintext.
chatEncryptedButPlaintextFile(10),
/// The encryption of the file somehow failed.
failedToEncryptFile(11),
/// We were unable to download the file.
fileDownloadFailed(12),
/// The message was bounced with a <service-unavailable />.
serviceUnavailable(13),
/// The message was bounced with a <remote-server-timeout />.
remoteServerTimeout(14),
/// The message was bounced with a <remote-server-not-found />.
remoteServerNotFound(15);
const MessageErrorType(this.value);
static MessageErrorType? fromInt(int? value) {
if (value == null) {
return null;
}
if (value == MessageErrorType.unspecified.value) {
return MessageErrorType.unspecified;
} else if (value == MessageErrorType.noError.value) {
return MessageErrorType.noError;
} else if (value == MessageErrorType.fileUploadFailed.value) {
return MessageErrorType.fileUploadFailed;
} else if (value == MessageErrorType.notEncryptedForDevice.value) {
return MessageErrorType.notEncryptedForDevice;
} else if (value == MessageErrorType.invalidHMAC.value) {
return MessageErrorType.invalidHMAC;
} else if (value == MessageErrorType.noDecryptionKey.value) {
return MessageErrorType.noDecryptionKey;
} else if (value == MessageErrorType.invalidAffixElements.value) {
return MessageErrorType.invalidAffixElements;
} else if (value == MessageErrorType.failedToEncrypt.value) {
return MessageErrorType.failedToEncrypt;
} else if (value == MessageErrorType.failedToDecryptFile.value) {
return MessageErrorType.failedToDecryptFile;
} else if (value == MessageErrorType.omemoNotSupported.value) {
return MessageErrorType.omemoNotSupported;
} else if (value == MessageErrorType.chatEncryptedButPlaintextFile.value) {
return MessageErrorType.chatEncryptedButPlaintextFile;
} else if (value == MessageErrorType.failedToEncryptFile.value) {
return MessageErrorType.failedToEncryptFile;
} else if (value == MessageErrorType.fileDownloadFailed.value) {
return MessageErrorType.fileDownloadFailed;
} else if (value == MessageErrorType.serviceUnavailable.value) {
return MessageErrorType.serviceUnavailable;
} else if (value == MessageErrorType.remoteServerTimeout.value) {
return MessageErrorType.remoteServerTimeout;
} else if (value == MessageErrorType.remoteServerNotFound.value) {
return MessageErrorType.remoteServerNotFound;
}
return null;
}
assert(false, 'Invalid error code $error used');
return '';
static MessageErrorType? fromException(dynamic exception) {
if (exception == null) {
return null;
}
if (exception is InvalidMessageHMACError) {
return MessageErrorType.invalidHMAC;
} else if (exception is NotEncryptedForDeviceError) {
return MessageErrorType.noDecryptionKey;
} else if (exception is InvalidAffixElementsException) {
return MessageErrorType.invalidAffixElements;
} else if (exception is EncryptionFailedException) {
return MessageErrorType.failedToEncrypt;
} else if (exception is OmemoNotSupportedForContactException) {
return MessageErrorType.omemoNotSupported;
}
return MessageErrorType.unspecified;
}
/// The identifier representing the error.
final int value;
String get translatableString {
assert(
this != MessageErrorType.noError,
'Calling errorToTranslatableString with noError makes no sense',
);
switch (this) {
case MessageErrorType.notEncryptedForDevice:
return t.errors.omemo.notEncryptedForDevice;
case MessageErrorType.invalidHMAC:
return t.errors.omemo.invalidHmac;
case MessageErrorType.noDecryptionKey:
return t.errors.omemo.noDecryptionKey;
case MessageErrorType.invalidAffixElements:
return t.errors.omemo.messageInvalidAfixElement;
case MessageErrorType.fileUploadFailed:
return t.errors.message.fileUploadFailed;
case MessageErrorType.omemoNotSupported:
return t.errors.message.contactDoesntSupportOmemo;
case MessageErrorType.fileDownloadFailed:
return t.errors.message.fileDownloadFailed;
case MessageErrorType.serviceUnavailable:
return t.errors.message.serviceUnavailable;
case MessageErrorType.remoteServerTimeout:
return t.errors.message.remoteServerTimeout;
case MessageErrorType.remoteServerNotFound:
return t.errors.message.remoteServerNotFound;
case MessageErrorType.failedToEncrypt:
return t.errors.message.failedToEncrypt;
case MessageErrorType.failedToDecryptFile:
return t.errors.message.failedToDecryptFile;
case MessageErrorType.chatEncryptedButPlaintextFile:
return t.errors.message.fileNotEncrypted;
case MessageErrorType.failedToEncryptFile:
return t.errors.message.failedToEncryptFile;
// NOTE: This fallthrough is just here to make Dart happy
case MessageErrorType.noError:
case MessageErrorType.unspecified:
return t.errors.message.unspecified;
}
}
}
/// A converter for converting between [MessageErrorType] and [int].
class MessageErrorTypeConverter
implements JsonConverter<MessageErrorType, int> {
const MessageErrorTypeConverter();
@override
MessageErrorType fromJson(int json) {
return MessageErrorType.fromInt(json)!;
}
@override
int toJson(MessageErrorType data) => data.value;
}
enum GroupchatErrorType {
unspecified(0),
roomPasswordProtected(1),
unableToJoinRoom(2),
noNicknameSpecified(3),
invalidStanzaFormat(4),
invalidDiscoInfoResponse(5),
roomNotJoinedError(6);
const GroupchatErrorType(this.value);
static GroupchatErrorType? fromInt(int? value) {
if (value == null) {
return null;
}
if (value == GroupchatErrorType.unspecified.value) {
return GroupchatErrorType.unspecified;
} else if (value == GroupchatErrorType.roomPasswordProtected.value) {
return GroupchatErrorType.roomPasswordProtected;
} else if (value == GroupchatErrorType.unableToJoinRoom.value) {
return GroupchatErrorType.unableToJoinRoom;
} else if (value == GroupchatErrorType.noNicknameSpecified.value) {
return GroupchatErrorType.noNicknameSpecified;
} else if (value == GroupchatErrorType.invalidStanzaFormat.value) {
return GroupchatErrorType.invalidStanzaFormat;
} else if (value == GroupchatErrorType.invalidDiscoInfoResponse.value) {
return GroupchatErrorType.invalidDiscoInfoResponse;
} else if (value == GroupchatErrorType.roomNotJoinedError.value) {
return GroupchatErrorType.roomNotJoinedError;
}
return null;
}
static GroupchatErrorType? fromException(dynamic exception) {
if (exception == null) {
return null;
}
if (exception is NoNicknameSpecified) {
return GroupchatErrorType.noNicknameSpecified;
} else if (exception is InvalidStanzaFormat) {
return GroupchatErrorType.invalidStanzaFormat;
} else if (exception is InvalidDiscoInfoResponse) {
return GroupchatErrorType.invalidDiscoInfoResponse;
} else if (exception is RoomNotJoinedError) {
return GroupchatErrorType.roomNotJoinedError;
}
return GroupchatErrorType.unspecified;
}
/// The identifier representing the error.
final int value;
}

View File

@@ -1,4 +1,4 @@
import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction_group.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
part 'events.moxxy.dart';

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:core';
import 'dart:io';
import 'dart:typed_data';
@@ -7,7 +8,6 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:synchronized/synchronized.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
/// Add a leading zero, if required, to ensure that an integer is rendered
/// as a two "digit" string.
@@ -365,49 +365,13 @@ Future<Size?> getImageSizeFromData(Uint8List bytes) async {
}
}
/// Generate a thumbnail file (JPEG) for the video at [path]. [conversationJid] refers
/// to the JID of the conversation the file comes from.
/// If the thumbnail already exists, then just its path is returned. If not, then
/// it gets generated first.
Future<String?> getVideoThumbnailPath(
String path,
String conversationJid,
String mime,
) async {
//print('getVideoThumbnailPath: Mime type: $mime');
// Ignore mime types that may be wacky
if (mime == 'video/webm') return null;
final tempDir = await getTemporaryDirectory();
final thumbnailFilenameNoExtension = p.withoutExtension(
p.basename(path),
);
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
final thumbnailDirectory = p.join(
tempDir.path,
'thumbnails',
conversationJid,
);
final thumbnailPath = p.join(thumbnailDirectory, thumbnailFilename);
final dir = Directory(thumbnailDirectory);
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File(thumbnailPath);
if (file.existsSync()) return thumbnailPath;
final r = await VideoThumbnail.thumbnailFile(
video: path,
thumbnailPath: thumbnailDirectory,
imageFormat: ImageFormat.JPEG,
quality: 75,
);
assert(
r == thumbnailPath,
'The generated video thumbnail has a different path than we expected: $r vs. $thumbnailPath',
);
return thumbnailPath;
/// Returns true if we can generate a video thumbnail of mime type [mime]. If not, returns
/// false.
bool canGenerateVideoThumbnail(String mime) {
return ![
// Ignore mime types that may be wacky
'video/webm',
].contains(mime);
}
Future<String> getContactProfilePicturePath(String id) async {
@@ -423,15 +387,6 @@ Future<String> getContactProfilePicturePath(String id) async {
return p.join(avatarDir, id);
}
Future<String> getStickerPackPath(String hashFunction, String hashValue) async {
final appDir = await getApplicationDocumentsDirectory();
return p.join(
appDir.path,
'stickers',
'${hashFunction}_$hashValue',
);
}
/// Prepend [item] to [list], but ensure that the resulting list's size is
/// smaller than or equal to [maxSize].
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
@@ -461,3 +416,15 @@ List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
...list,
].sublist(0, maxSize);
}
extension StringJsonHelper on String {
/// Converts the Map into a JSON-encoded String. Helper function for working with nullable maps.
Map<String, dynamic> fromJson() {
return (jsonDecode(this) as Map<dynamic, dynamic>).cast<String, dynamic>();
}
}
extension MapJsonHelper on Map<String, dynamic> {
/// Converts the map into a String. Helper function for working with nullable Strings.
String toJson() => jsonEncode(this);
}

View File

@@ -2,6 +2,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/models/groupchat.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
@@ -14,11 +16,11 @@ class ConversationChatStateConverter
@override
ChatState fromJson(Map<String, dynamic> json) =>
chatStateFromString(json['chatState'] as String);
ChatState.fromName(json['chatState'] as String);
@override
Map<String, dynamic> toJson(ChatState state) => <String, String>{
'chatState': chatStateToString(state),
'chatState': state.toName(),
};
}
@@ -40,39 +42,137 @@ class ConversationMessageConverter
}
enum ConversationType {
@JsonValue('chat')
chat,
@JsonValue('note')
note
chat('chat'),
note('note'),
groupchat('groupchat');
const ConversationType(this.value);
final String value;
static ConversationType fromString(String value) {
switch (value) {
case 'groupchat':
return ConversationType.groupchat;
case 'note':
return ConversationType.note;
case 'chat':
return ConversationType.chat;
default:
// Should ideally never happen
throw Exception();
}
}
/// Returns the "type" attribute value for a message within the enum value's
/// context.
String toMessageType() {
assert(
this != ConversationType.note,
'Chat states should not be sent to the self-chat',
);
switch (this) {
case ConversationType.note:
case ConversationType.chat:
return 'chat';
case ConversationType.groupchat:
return 'groupchat';
}
}
}
class ConversationTypeConverter
extends JsonConverter<ConversationType, String> {
const ConversationTypeConverter();
@override
ConversationType fromJson(String json) {
return ConversationType.fromString(json);
}
@override
String toJson(ConversationType object) {
return object.value;
}
}
class GroupchatDetailsConverter
extends JsonConverter<GroupchatDetails, Map<String, dynamic>> {
const GroupchatDetailsConverter();
@override
GroupchatDetails fromJson(Map<String, dynamic> json) {
return GroupchatDetails(
json['jid']! as String,
json['accountJid']! as String,
json['nick']! as String,
);
}
@override
Map<String, dynamic> toJson(GroupchatDetails object) {
return {
'jid': object.jid,
'accountJid': object.accountJid,
'nick': object.nick,
};
}
}
@freezed
class Conversation with _$Conversation {
factory Conversation(
/// The account that has the conversation.
String accountJid,
/// The title of the chat.
String title,
// The newest message in the chat.
@ConversationMessageConverter() Message? lastMessage,
String avatarUrl,
// The path to the avatar.
String avatarPath,
// The hash of the avatar.
String? avatarHash,
// The JID of the entity we're having a chat with...
String jid,
// The nick with which the MUC is joined...
@GroupchatDetailsConverter() GroupchatDetails? groupchatDetails,
// The number of unread messages.
int unreadCounter,
ConversationType type,
// The kind of chat this conversation is representing.
@ConversationTypeConverter() ConversationType type,
// The timestamp the conversation was last changed.
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
int lastChangeTimestamp,
// Indicates if the conversation should be shown on the homescreen
// Indicates if the conversation should be shown on the homescreen.
bool open,
// Indicates, if [jid] is a regular user, if the user is in the roster.
bool inRoster,
// The subscription state of the roster item
String subscription,
/// Flag indicating whether the "add to roster" button should be shown.
bool showAddToRoster,
// Whether the chat is muted (true = muted, false = not muted)
bool muted,
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
bool encrypted,
// The current chat state
@ConversationChatStateConverter() ChatState chatState, {
// The id of the contact in the device's phonebook if it exists
String? contactId,
// The path to the contact avatar, if available
String? contactAvatarPath,
// The contact's display name, if it exists
String? contactDisplayName,
}) = _Conversation;
@@ -85,21 +185,21 @@ class Conversation with _$Conversation {
factory Conversation.fromDatabaseJson(
Map<String, dynamic> json,
bool inRoster,
String subscription,
bool showAddToRoster,
Message? lastMessage,
GroupchatDetails? groupchatDetails,
) {
return Conversation.fromJson({
...json,
'muted': intToBool(json['muted']! as int),
'open': intToBool(json['open']! as int),
'inRoster': inRoster,
'subscription': subscription,
'showAddToRoster': showAddToRoster,
'encrypted': intToBool(json['encrypted']! as int),
'chatState':
const ConversationChatStateConverter().toJson(ChatState.gone),
}).copyWith(
lastMessage: lastMessage,
groupchatDetails: groupchatDetails,
);
}
@@ -107,9 +207,9 @@ class Conversation with _$Conversation {
final map = toJson()
..remove('id')
..remove('chatState')
..remove('inRoster')
..remove('subscription')
..remove('lastMessage');
..remove('showAddToRoster')
..remove('lastMessage')
..remove('groupchatDetails');
return {
...map,
@@ -123,30 +223,68 @@ class Conversation with _$Conversation {
/// True, when the chat state of the conversation indicates typing. False, if not.
bool get isTyping => chatState == ChatState.composing;
/// The path to the avatar. This returns, if enabled, first the contact's avatar
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
/// The path to the avatar. This returns, if [contactIntegration] is true, first the contact's avatar
/// path, then the XMPP avatar's path. If [contactIntegration] is false, just returns the regular
/// XMPP avatar's path.
String? get avatarPathWithOptionalContact {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
return contactAvatarPath ?? avatarUrl;
String getAvatarPathWithOptionalContact(bool contactIntegration) {
if (contactIntegration) {
return contactAvatarPath ?? avatarPath;
}
return avatarUrl;
return avatarPath;
}
/// The title of the chat. This returns, if enabled, first the contact's display
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesBloc].
/// NOTE: This method only works in the UI.
String? get avatarPathWithOptionalContact => getAvatarPathWithOptionalContact(
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
);
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesService].
/// NOTE: This method only works in the background isolate.
Future<String?> get avatarPathWithOptionalContactService async =>
getAvatarPathWithOptionalContact(
(await GetIt.I.get<PreferencesService>().getPreferences())
.enableContactIntegration,
);
/// The title of the chat. This returns, if [contactIntegration] is true, first the contact's display
/// name, then the XMPP chat title. If [contactIntegration] is false, just returns the XMPP chat
/// title.
String get titleWithOptionalContact {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
String getTitleWithOptionalContact(bool contactIntegration) {
if (contactIntegration) {
return contactDisplayName ?? title;
}
return title;
}
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesBloc].
/// NOTE: This method only works in the UI.
String get titleWithOptionalContact => getTitleWithOptionalContact(
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
);
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesService].
/// NOTE: This method only works in the background isolate.
Future<String> get titleWithOptionalContactService async =>
getTitleWithOptionalContact(
(await GetIt.I.get<PreferencesService>().getPreferences())
.enableContactIntegration,
);
/// The amount of items that are shown in the context menu.
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
/// True, if the conversation is a self-chat. False, if not.
bool get isSelfChat => type == ConversationType.note;
/// True, if the conversation is a groupchat. False, if not.
bool get isGroupchat => type == ConversationType.groupchat;
}
/// Sorts conversations in descending order by their last change timestamp.

View File

@@ -0,0 +1,30 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'groupchat.freezed.dart';
part 'groupchat.g.dart';
@freezed
class GroupchatDetails with _$GroupchatDetails {
factory GroupchatDetails(
/// The JID of the groupchat.
String jid,
/// The associated account JID.
String accountJid,
/// The nick to join as.
String nick,
) = _GroupchatDetails;
const GroupchatDetails._();
/// JSON
factory GroupchatDetails.fromJson(Map<String, dynamic> json) =>
_$GroupchatDetailsFromJson(json);
factory GroupchatDetails.fromDatabaseJson(
Map<String, dynamic> json,
) {
return GroupchatDetails.fromJson(json);
}
}

View File

@@ -1,5 +1,5 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart';
@@ -9,50 +9,130 @@ import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart';
part 'message.g.dart';
const pseudoMessageTypeNewDevice = 1;
enum PseudoMessageType {
/// Indicates that a new device was created in the chat.
newDevice(1),
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
if (data == null) return <String, dynamic>{};
/// Indicates that an existing device has been replaced.
changedDevice(2);
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
const PseudoMessageType(this.value);
/// The identifier for the type of pseudo message.
final int value;
static PseudoMessageType? fromInt(int value) {
switch (value) {
case 1:
return PseudoMessageType.newDevice;
case 2:
return PseudoMessageType.changedDevice;
}
return null;
}
}
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
if (data == null) return null;
if (data.isEmpty) return null;
/// A converter for converting between [PseudoMessageType] and [int].
class PseudoMessageTypeConverter extends JsonConverter<PseudoMessageType, int> {
const PseudoMessageTypeConverter();
return jsonEncode(data);
@override
PseudoMessageType fromJson(int json) {
return PseudoMessageType.fromInt(json)!;
}
@override
int toJson(PseudoMessageType object) {
return object.value;
}
}
@freezed
class Message with _$Message {
factory Message(
// The message id (Moxxy-generated UUID).
String id,
/// The JID of the account that sent or received the message.
String accountJid,
/// The full JID of the sender
String sender,
/// The content of the <body /> tag
String body,
/// The timestamp the message was received
int timestamp,
/// The "id" attribute of the message stanza.
String sid,
// The database-internal identifier of the message
int id,
/// The JID of the conversation this message was received/sent in.
String conversationJid,
/// Flag indicating whether the message is a file upload notification.
bool isFileUploadNotification,
/// Flag indicating whether the message was sent/received encrypted.
bool encrypted,
// True if the message contains a <no-store> Message Processing Hint. False if not
/// True if the message contains a <no-store> Message Processing Hint. False if not
bool containsNoStore, {
int? errorType,
int? warningType,
/// A message's associated error, if applicable (e.g. crypto error, file upload failure, ...).
@MessageErrorTypeConverter() MessageErrorType? errorType,
/// A message's associated warning, if applicable.
@MessageWarningTypeConverter() MessageWarningType? warningType,
/// If a file is attached, this is a reference to the file metadata.
FileMetadata? fileMetadata,
/// Flag indicating whether the message's file is currently being downloaded.
@Default(false) bool isDownloading,
/// Flag indicating whether the message's file is currently being uploaded.
@Default(false) bool isUploading,
/// Flag indicating whether the message was marked as received.
@Default(false) bool received,
/// If the message was sent by us, this means that the recipient has displayed the message.
/// If we received the message, then this means that we sent a read marker for that message.
@Default(false) bool displayed,
/// Specified whether the message has been acked using stream management, i.e. it was successfully sent to
/// the server.
@Default(false) bool acked,
/// Indicates whether the message has been retracted.
@Default(false) bool isRetracted,
/// Indicates whether the message has been edited.
@Default(false) bool isEdited,
/// An optional origin id attached to the message
String? originId,
/// The message this message quotes using XEP-0461
Message? quotes,
/// A short summary of reactions, if available
@Default([]) List<String> reactionsPreview,
/// The ID of the sticker pack the sticker belongs to, if the message
/// contains a sticker.
String? stickerPackId,
int? pseudoMessageType,
/// The occupant-id of the sender, when the message was received in a groupchat.
String? occupantId,
/// If the message is not a real message, then this field indicates
/// the type of "pseudo message" we should display.
@PseudoMessageTypeConverter() PseudoMessageType? pseudoMessageType,
/// The associated data for "pseudo messages".
Map<String, dynamic>? pseudoMessageData,
}) = _Message;
@@ -82,8 +162,7 @@ class Message with _$Message {
'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactionsPreview': reactionsPreview,
'pseudoMessageData':
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
'pseudoMessageData': (json['pseudoMessageData'] as String?)?.fromJson(),
}).copyWith(
quotes: quotes,
fileMetadata: fileMetadata,
@@ -92,7 +171,6 @@ class Message with _$Message {
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('id')
..remove('quotes')
..remove('reactionsPreview')
..remove('fileMetadata')
@@ -113,15 +191,29 @@ class Message with _$Message {
'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited),
'containsNoStore': boolToInt(containsNoStore),
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
'pseudoMessageData': pseudoMessageData?.toJson(),
};
}
/// True if the [errorType] describes an error related to OMEMO.
bool get isOmemoError => [
MessageErrorType.notEncryptedForDevice,
MessageErrorType.invalidHMAC,
MessageErrorType.noDecryptionKey,
MessageErrorType.invalidAffixElements,
MessageErrorType.failedToEncrypt,
MessageErrorType.failedToDecryptFile,
MessageErrorType.omemoNotSupported,
MessageErrorType.failedToEncryptFile,
].contains(errorType);
/// Returns true if the message is an error. If not, then returns false.
bool get hasError => errorType != null && errorType != noError;
bool get hasError =>
errorType != null && errorType != MessageErrorType.noError;
/// Returns true if the message is a warning. If not, then returns false.
bool get hasWarning => warningType != null && warningType != noWarning;
bool get hasWarning =>
warningType != null && warningType != MessageWarningType.noWarning;
/// Returns a representative emoji for a message. Its primary purpose is
/// to provide a universal fallback for quoted media messages.
@@ -181,11 +273,7 @@ class Message with _$Message {
/// Returns true if the menu item to show the error should be shown in the
/// longpress menu.
bool get errorMenuVisible {
return hasError &&
(errorType! < messageNotEncryptedForDevice ||
errorType! > messageInvalidAffixElements);
}
bool get errorMenuVisible => hasError && !isOmemoError;
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
/// images.
@@ -201,9 +289,12 @@ class Message with _$Message {
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker
/// Returns true if the message is a sticker.
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
/// True if the message is a media message
/// True if the message is a media message.
bool get isMedia => fileMetadata != null;
/// The JID of the sender in moxxmpp's format.
JID get senderJid => JID.fromString(sender);
}

View File

@@ -0,0 +1,60 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxy_native/moxxy_native.dart' as native;
part 'notification.freezed.dart';
part 'notification.g.dart';
@freezed
class Notification with _$Notification {
factory Notification(
// The notification id.
int id,
// The JID of the conversation the notification belongs to.
String conversationJid,
/// The JID of the account that the conversation belongs to.
String accountJid,
// The sender title.
String? sender,
// The JID of the sender.
String? senderJid,
// The path to use as the avatar.
String? avatarPath,
// The body text.
String body,
// The optional mime type of the media attachment.
String? mime,
// The optional mime type of the path attachment.
String? path,
// The timestamp of the notification.
int timestamp,
) = _Notification;
const Notification._();
/// JSON
factory Notification.fromJson(Map<String, dynamic> json) =>
_$NotificationFromJson(json);
native.NotificationMessage toNotificationMessage() {
return native.NotificationMessage(
sender: sender,
jid: senderJid,
avatarPath: avatarPath,
content: native.NotificationMessageContent(
body: body,
mime: mime,
path: path,
),
timestamp: timestamp,
);
}
}

View File

@@ -11,9 +11,8 @@ class OmemoDevice with _$OmemoDevice {
bool trusted,
bool verified,
bool enabled,
int deviceId, {
@Default(true) bool hasSessionWith,
}) = _OmemoDevice;
int deviceId,
) = _OmemoDevice;
/// JSON
factory OmemoDevice.fromJson(Map<String, dynamic> json) =>

View File

@@ -8,8 +8,15 @@ class Reaction with _$Reaction {
factory Reaction(
// This is valid in combination with freezed
// ignore: invalid_annotation_target
@JsonKey(name: 'message_id') int messageId,
@JsonKey(name: 'message_id') String messageId,
// The account JID of the attached message.
String accountJid,
// The sender of the reaction.
String senderJid,
// The emoji reaction.
String emoji,
) = _Reaction;

View File

@@ -1,5 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
part 'roster.freezed.dart';
part 'roster.g.dart';
@@ -7,21 +10,39 @@ part 'roster.g.dart';
@freezed
class RosterItem with _$RosterItem {
factory RosterItem(
int id,
String avatarUrl,
// The the JID of the account this roster belongs to.
String accountJid,
// Path to the roster avatar.
String avatarPath,
// The SHA-1 hash of the roster avatar.
String avatarHash,
// The JID of the roster item.
String jid,
// The title of the roster item.
String title,
// The subscription state of the roster item.
String subscription,
// The ask attribute of the roster item.
String ask,
// Indicates whether the "roster item" really exists on the roster and is not just there
// for the contact integration
bool pseudoRosterItem,
// A list of groups the roster item is in.
List<String> groups, {
// The id of the contact in the device's phonebook, if it exists
String? contactId,
// The path to the profile picture of the contact, if it exists
String? contactAvatarPath,
// The contact's display name, if it exists
String? contactDisplayName,
}) = _RosterItem;
@@ -43,7 +64,6 @@ class RosterItem with _$RosterItem {
Map<String, dynamic> toDatabaseJson() {
final json = toJson()
..remove('id')
// TODO(PapaTutuWawa): Fix
..remove('groups')
..remove('pseudoRosterItem');
@@ -53,4 +73,51 @@ class RosterItem with _$RosterItem {
'pseudoRosterItem': boolToInt(pseudoRosterItem),
};
}
/// Whether a conversation with this roster item should display the "Add to roster" button.
bool get showAddToRosterButton {
// Those chats are not dealt with on the roster
if (pseudoRosterItem) {
return false;
}
// A full presence subscription is already achieved. Nothing to do
if (subscription == 'both') {
return false;
}
// We are not yet waiting for a response to the presence request
if (ask == 'subscribe' && ['none', 'from', 'to'].contains(subscription)) {
return false;
}
return true;
}
/// The title of the roster item. This returns, if [contactIntegration] is true, first the contact's display
/// name, then the XMPP roster title. If [contactIntegration] is false, just returns the XMPP roster
/// title.
String getTitleWithOptionalContact(bool contactIntegration) {
if (contactIntegration) {
return contactDisplayName ?? title;
}
return title;
}
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesBloc].
/// NOTE: This method only works in the UI.
String get titleWithOptionalContact => getTitleWithOptionalContact(
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
);
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
/// contact integration enablement status extracted from the [PreferencesService].
/// NOTE: This method only works in the background isolate.
Future<String> get titleWithOptionalContactService async =>
getTitleWithOptionalContact(
(await GetIt.I.get<PreferencesService>().getPreferences())
.enableContactIntegration,
);
}

View File

@@ -17,6 +17,12 @@ class StickerPack with _$StickerPack {
String hashValue,
bool restricted,
bool local,
/// The timestamp (milliseconds since epoch) when the sticker pack was added
int addedTimestamp,
/// The size in bytes
int size,
) = _StickerPack;
const StickerPack._();
@@ -34,6 +40,8 @@ class StickerPack with _$StickerPack {
pack.hashValue,
pack.restricted,
local,
0,
0,
);
/// JSON
@@ -49,6 +57,7 @@ class StickerPack with _$StickerPack {
'local': true,
'restricted': intToBool(json['restricted']! as int),
'stickers': <Sticker>[],
'size': 0,
});
return pack.copyWith(stickers: stickers);
@@ -57,7 +66,8 @@ class StickerPack with _$StickerPack {
Map<String, dynamic> toDatabaseJson() {
final json = toJson()
..remove('local')
..remove('stickers');
..remove('stickers')
..remove('size');
return {
...json,

View File

@@ -5,13 +5,27 @@ import 'package:moxxmpp/moxxmpp.dart';
part 'xmpp_state.freezed.dart';
part 'xmpp_state.g.dart';
extension StreamManagementStateToJson on StreamManagementState {
Map<String, dynamic> toJson() => {
'c2s': c2s,
's2c': s2c,
'streamResumptionLocation': streamResumptionLocation,
'streamResumptionId': streamResumptionId,
};
}
class StreamManagementStateConverter
implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
const StreamManagementStateConverter();
@override
StreamManagementState fromJson(Map<String, dynamic> json) =>
StreamManagementState.fromJson(json);
StreamManagementState(
json['c2s']! as int,
json['s2c']! as int,
streamResumptionLocation: json['streamResumptionLocation'] as String?,
streamResumptionId: json['streamResumptionId'] as String?,
);
@override
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
@@ -27,9 +41,12 @@ class XmppState with _$XmppState {
String? displayName,
String? password,
String? lastRosterVersion,
String? fastToken,
@Default('') String avatarUrl,
@Default('') String avatarHash,
@Default(false) bool askedStoragePermission,
@Default(false) bool askedNotificationPermission,
@Default(false) bool askedBatteryOptimizationExcemption,
}) = _XmppState;
const XmppState._();
@@ -54,6 +71,10 @@ class XmppState with _$XmppState {
'avatarUrl': tuples['avatarUrl'],
'avatarHash': tuples['avatarHash'],
'askedStoragePermission': tuples['askedStoragePermission'] == 'true',
'askedNotificationPermission':
tuples['askedNotificationPermission'] == 'true',
'askedBatteryOptimizationExcemption':
tuples['askedBatteryOptimizationExcemption'] == 'true',
};
return XmppState.fromJson(json);
@@ -62,12 +83,20 @@ class XmppState with _$XmppState {
Map<String, String?> toDatabaseTuples() {
final json = toJson()
..remove('smState')
..remove('askedStoragePermission');
..remove('askedStoragePermission')
..remove('askedNotificationPermission')
..remove('askedBatteryOptimizationExcemption');
return {
...json.cast<String, String?>(),
'smState': jsonEncode(smState?.toJson()),
'askedStoragePermission': askedStoragePermission ? 'true' : 'false',
'askedNotificationPermission':
askedNotificationPermission ? 'true' : 'false',
'askedBatteryOptimizationExcemption':
askedBatteryOptimizationExcemption ? 'true' : 'false',
};
}
bool get canLogIn => jid != null && password != null;
}

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