136 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
147 changed files with 7338 additions and 3668 deletions

View File

@@ -3,6 +3,11 @@
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
on the Moxxy codebase. 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 ## Prerequisites
Before building or working on Moxxy, please make sure that your development environment is correctly set up. Before building or working on Moxxy, please make sure that your development environment is correctly set up.
@@ -51,7 +56,22 @@ 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 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. can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
### 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 ### 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
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file 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. 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) [<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`. 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 ## A Bit of History
This project is the successor of moxxyv1, which was written in *React Native* and abandoned This project is the successor of moxxyv1, which was written in *React Native* and abandoned

View File

@@ -27,7 +27,7 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Allow receiving share intents for all kinds of things --> <!-- Allow receiving share intents for all kinds of things -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -38,6 +38,14 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </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> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

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

View File

@@ -0,0 +1,412 @@
{
"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"
},
"titles": {
"error": "Foutmelding"
}
},
"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"
},
"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"
}
}
}
}

View File

@@ -0,0 +1,379 @@
{
"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": "Ошибка"
}
},
"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": "Ошибка при импорте стикерпаков"
},
"settings": {
"title": "Настройки",
"conversationsSection": "Чаты",
"accountSection": "Учётная запись",
"signOut": "Выйти",
"signOutConfirmTitle": "Выйти",
"signOutConfirmBody": "Вы хотите выйти, продолжить?",
"miscellaneousSection": "Другое",
"debuggingSection": "Отладка",
"general": "Основные"
},
"appearance": {
"title": "Внешний вид",
"languageSection": "Язык",
"language": "Язык в приложении",
"systemLanguage": "Как в системе",
"languageSubtext": "Выбранный язык: ${selectedLanguage}"
},
"licenses": {
"title": "Открытые лицензии",
"licensedUnder": "Лицензировано под ${license}"
}
},
"sharedMedia": {
"empty": {
"chat": "Нет общих медиафайлов для этого чата",
"general": "Нет доступных медиаустройств"
}
}
},
"language": "Русский",
"warnings": {
"message": {
"integrityCheckFailed": "Не удалось проверить целостность файла"
},
"conversation": {
"holdForLonger": "Удерживайте для записи"
}
}
}

View File

@@ -5,3 +5,5 @@ targets:
options: options:
input_directory: assets/i18n input_directory: assets/i18n
output_directory: lib/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,6 @@
- (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, Japanese, 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.

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

123
flake.lock generated
View File

@@ -1,6 +1,66 @@
{ {
"nodes": { "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"
}
},
"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": { "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": {
"locked": { "locked": {
"lastModified": 1667395993, "lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
@@ -17,24 +77,71 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1676076353, "lastModified": 1689679375,
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=", "narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
"owner": "AtaraxiaSjel", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6", "rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "AtaraxiaSjel", "owner": "NixOS",
"ref": "update/flutter", "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "android-nixpkgs": "android-nixpkgs",
"nixpkgs": "nixpkgs" "flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
}
},
"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"
} }
} }
}, },

View File

@@ -1,34 +1,47 @@
{ {
description = "Moxxy v2"; description = "Moxxy v2";
inputs = { inputs = {
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
android-nixpkgs.url = "github:tadfisher/android-nixpkgs";
}; };
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let outputs = { self, nixpkgs, flake-utils, android-nixpkgs }: flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
config = { config = {
android_sdk.accept_license = true; android_sdk.accept_license = true;
allowUnfree = 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 { # Everything to make Flutter happy
# TODO: Find a way to pin these sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
#toolsVersion = "26.1.1"; cmdline-tools-latest
#platformToolsVersion = "31.0.3"; build-tools-30-0-3
#buildToolsVersions = [ "31.0.0" ]; build-tools-33-0-2
#includeEmulator = true; build-tools-34-0-0
#emulatorVersion = "30.6.3"; platform-tools
platformVersions = [ "28" ]; emulator
includeSources = false; patcher-v4
includeSystemImages = true; platforms-android-28
systemImageTypes = [ "default" ]; platforms-android-29
abiVersions = [ "x86_64" ]; platforms-android-30
includeNDK = false; platforms-android-31
useGoogleAPIs = false; platforms-android-33
useGoogleTVAddOns = false;
}; # For flutter_zxing
cmake-3-18-1
#ndk-21-4-7075529
(ndk-21-4-7075529.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.python27 ];
}))
]);
pinnedJDK = pkgs.jdk17; pinnedJDK = pkgs.jdk17;
pythonEnv = pkgs.python3.withPackages (ps: with ps; [ pythonEnv = pkgs.python3.withPackages (ps: with ps; [
@@ -38,13 +51,27 @@
in { in {
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android # Android
pythonEnv gnumake # Build scripts pinnedJDK sdk
gitlint jq # Code hygiene scrcpy
ripgrep # General utilities
# Flutter
flutter37
# Build scripts
pythonEnv gnumake
# Code hygiene
gitlint jq
]; ];
ANDROID_SDK_ROOT = "${sdk}/share/android-sdk";
ANDROID_HOME = "${sdk}/share/android-sdk";
JAVA_HOME = pinnedJDK; 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";
}; };
}); });
} }

View File

@@ -36,9 +36,6 @@ files:
roster: roster:
type: List<RosterItem>? type: List<RosterItem>?
deserialise: true deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Triggered if a conversation has been added. # Triggered if a conversation has been added.
# Also returned by [AddConversationCommand] # Also returned by [AddConversationCommand]
- name: ConversationAddedEvent - name: ConversationAddedEvent
@@ -208,7 +205,7 @@ files:
attributes: attributes:
conversationJid: String conversationJid: String
title: String title: String
avatarUrl: String avatarPath: String
- name: StickerPackImportSuccessEvent - name: StickerPackImportSuccessEvent
extends: BackgroundEvent extends: BackgroundEvent
implements: implements:
@@ -274,6 +271,70 @@ files:
reactions: reactions:
type: List<ReactionGroup> type: List<ReactionGroup>
deserialise: true 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
# Returned when an error occured
- name: ErrorEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
errorId: int
# 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
generate_builder: true generate_builder: true
builder_name: "Event" builder_name: "Event"
builder_baseclass: "BackgroundEvent" builder_baseclass: "BackgroundEvent"
@@ -484,9 +545,8 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
conversationJid: String id: int
sid: String sendMarker: bool
newUnreadCounter: int
- name: AddReactionToMessageCommand - name: AddReactionToMessageCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -567,7 +627,7 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
conversationJid: String conversationJid: String?
olderThan: bool olderThan: bool
timestamp: int? timestamp: int?
- name: GetReactionsForMessageCommand - name: GetReactionsForMessageCommand
@@ -576,6 +636,46 @@ files:
- JsonImplementation - JsonImplementation
attributes: attributes:
messageId: int messageId: int
- 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
generate_builder: true generate_builder: true
# get${builder_Name}FromJson # get${builder_Name}FromJson
builder_name: "Command" builder_name: "Command"

View File

@@ -10,7 +10,6 @@ import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/synchronized_queue.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/blocklist_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
@@ -26,6 +25,7 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart'; import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_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/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/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart'; import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
@@ -35,7 +35,6 @@ import 'package:moxxyv2/ui/events.dart';
import "package:moxxyv2/ui/pages/register/register.dart"; import "package:moxxyv2/ui/pages/register/register.dart";
import "package:moxxyv2/ui/pages/postregister/postregister.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/blocklist.dart';
import 'package:moxxyv2/ui/pages/conversation/conversation.dart'; import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
import 'package:moxxyv2/ui/pages/conversations.dart'; import 'package:moxxyv2/ui/pages/conversations.dart';
@@ -57,14 +56,21 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
import 'package:moxxyv2/ui/pages/settings/network.dart'; import 'package:moxxyv2/ui/pages/settings/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart'; import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.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/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/share_selection.dart';
//import 'package:moxxyv2/ui/pages/sharedmedia.dart'; //import 'package:moxxyv2/ui/pages/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart'; import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/pages/startchat.dart';
import 'package:moxxyv2/ui/pages/sticker_pack.dart'; import 'package:moxxyv2/ui/pages/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.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/data.dart';
import 'package:moxxyv2/ui/service/progress.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/service/sharing.dart';
import 'package:moxxyv2/ui/theme.dart'; import 'package:moxxyv2/ui/theme.dart';
import 'package:page_transition/page_transition.dart'; import 'package:page_transition/page_transition.dart';
@@ -83,7 +89,13 @@ void setupLogging() {
Future<void> setupUIServices() async { Future<void> setupUIServices() async {
GetIt.I.registerSingleton<UIProgressService>(UIProgressService()); GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
GetIt.I.registerSingleton<UIDataService>(UIDataService()); GetIt.I.registerSingleton<UIDataService>(UIDataService());
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
GetIt.I.registerSingleton<UISharingService>(UISharingService()); 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) { void setupBlocs(GlobalKey<NavigatorState> navKey) {
@@ -95,7 +107,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc()); GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc()); GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
GetIt.I.registerSingleton<CropBloc>(CropBloc()); GetIt.I.registerSingleton<CropBloc>(CropBloc());
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc()); GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc()); GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
@@ -147,8 +159,8 @@ void main() async {
BlocProvider<PreferencesBloc>( BlocProvider<PreferencesBloc>(
create: (_) => GetIt.I.get<PreferencesBloc>(), create: (_) => GetIt.I.get<PreferencesBloc>(),
), ),
BlocProvider<AddContactBloc>( BlocProvider<StartChatBloc>(
create: (_) => GetIt.I.get<AddContactBloc>(), create: (_) => GetIt.I.get<StartChatBloc>(),
), ),
BlocProvider<CropBloc>( BlocProvider<CropBloc>(
create: (_) => GetIt.I.get<CropBloc>(), create: (_) => GetIt.I.get<CropBloc>(),
@@ -268,11 +280,13 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case newConversationRoute: case newConversationRoute:
return NewConversationPage.route; return NewConversationPage.route;
case conversationRoute: case conversationRoute:
final args = settings.arguments! as ConversationPageArguments;
return PageTransition<dynamic>( return PageTransition<dynamic>(
type: PageTransitionType.rightToLeft, type: PageTransitionType.rightToLeft,
settings: settings, settings: settings,
child: ConversationPage( child: ConversationPage(
conversationJid: settings.arguments! as String, conversationJid: args.conversationJid,
initialText: args.initialText,
), ),
); );
// case sharedMediaRoute: // case sharedMediaRoute:
@@ -298,7 +312,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case debuggingRoute: case debuggingRoute:
return DebuggingPage.route; return DebuggingPage.route;
case addContactRoute: case addContactRoute:
return AddContactPage.route; return StartChatPage.route;
case cropRoute: case cropRoute:
return CropPage.route; return CropPage.route;
case sendFilesRoute: case sendFilesRoute:
@@ -323,8 +337,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
); );
case stickersRoute: case stickersRoute:
return StickersSettingsPage.route; return StickersSettingsPage.route;
case stickerPacksRoute:
return StickerPacksSettingsPage.route;
case stickerPackRoute: case stickerPackRoute:
return StickerPackPage.route; return StickerPackPage.route;
case storageSettingsRoute:
return StorageSettingsPage.route;
case storageSharedMediaSettingsRoute:
return StorageSharedMediaPage.route;
} }
return null; return null;

View File

@@ -1,8 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
@@ -14,60 +12,100 @@ import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.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 { class AvatarService {
final Logger _log = Logger('AvatarService'); final Logger _log = Logger('AvatarService');
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async { /// List of JIDs for which we have already requested the avatar in the current stream.
await updateAvatarForJid( final List<JID> _requestedInStream = [];
event.jid,
event.hash, void resetCache() {
base64Decode(_cleanBase64String(event.base64)), _requestedInStream.clear();
);
} }
Future<void> updateAvatarForJid( Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
String jid, 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, String hash,
List<int> data, List<int> data,
) async { ) async {
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
final originalConversation = await cs.getConversationByJid(jid); final originalConversation = await cs.getConversationByJid(jid.toString());
final originalRoster = await rs.getRosterItemByJid(jid); final originalRoster = await rs.getRosterItemByJid(jid.toString());
if (originalConversation == null && originalRoster == null) return; if (originalConversation == null && originalRoster == null) return;
final avatarPath = await saveAvatarInCache( final avatarPath = await saveAvatarInCache(
data, data,
hash, hash,
jid, jid.toString(),
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!, (originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
); );
if (originalConversation != null) { if (originalConversation != null) {
final conversation = await cs.createOrUpdateConversation( final conversation = await cs.createOrUpdateConversation(
jid, jid.toString(),
update: (c) async { update: (c) async {
return cs.updateConversation( return cs.updateConversation(
jid, jid.toString(),
avatarUrl: avatarPath, avatarPath: avatarPath,
avatarHash: hash,
); );
}, },
); );
@@ -81,88 +119,21 @@ class AvatarService {
if (originalRoster != null) { if (originalRoster != null) {
final roster = await rs.updateRosterItem( final roster = await rs.updateRosterItem(
originalRoster.id, originalRoster.id,
avatarUrl: avatarPath, avatarPath: avatarPath,
avatarHash: hash, avatarHash: hash,
); );
sendEvent(RosterDiffEvent(modified: [roster])); sendEvent(RosterDiffEvent(modified: [roster]));
} }
}
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async { sendEvent(
final am = GetIt.I AvatarUpdatedEvent(
.get<XmppConnection>() jid: jid.toString(),
.getManagerById<UserAvatarManager>(userAvatarManager)!; path: avatarPath,
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,
); );
} }
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 /// Publishes the data at [path] as an avatar with PubSub ID
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash /// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
/// of the avatar data. /// of the avatar data.
@@ -201,6 +172,7 @@ class AvatarService {
imageSize.height.toInt(), imageSize.height.toInt(),
// TODO(PapaTutuWawa): Maybe do a check here // TODO(PapaTutuWawa): Maybe do a check here
'image/png', 'image/png',
null,
), ),
public, public,
); );
@@ -213,38 +185,44 @@ class AvatarService {
return true; return true;
} }
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
Future<void> requestOwnAvatar() async { Future<void> requestOwnAvatar() async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final jid = JID.fromString(state.jid!);
if (_requestedInStream.contains(jid)) {
return;
}
_requestedInStream.add(jid);
final am = GetIt.I final am = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!; .getManagerById<UserAvatarManager>(userAvatarManager)!;
final xss = GetIt.I.get<XmppStateService>(); final rawId = await am.getAvatarId(jid);
final state = await xss.getXmppState(); if (rawId.isType<AvatarError>()) {
final jid = state.jid!; _log.finest(
final idResult = await am.getAvatarId(JID.fromString(jid)); 'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
if (idResult.isType<AvatarError>()) { );
_log.info('Error while getting latest avatar id for own avatar');
return; return;
} }
final id = idResult.get<String>(); final id = rawId.get<String>();
if (id == state.avatarHash) return; if (id == state.avatarHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
_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');
return; 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( final avatarPath = await saveAvatarInCache(
base64Decode(_cleanBase64String(avatarData.base64)), avatarData.data,
avatarData.hash, avatarData.hash,
jid, jid.toString(),
state.avatarUrl, state.avatarUrl,
); );
await xss.modifyXmppState( await xss.modifyXmppState(

View File

@@ -41,19 +41,25 @@ class ContactsService {
final Map<String, String?> _contactDisplayNames = {}; final Map<String, String?> _contactDisplayNames = {};
Future<void> initialize() async { Future<void> initialize() async {
if (await _canUseContactIntegration()) { await enable(shouldScan: false);
enableDatabaseListener(); }
/// 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 /// Disable listening to contact database events. Also removes all roster items
void enableDatabaseListener() { /// that are pseudo roster items.
FlutterContacts.addListener(_onContactsDatabaseUpdate); Future<void> disable() async {
}
/// Disable listening to contact database events
void disableDatabaseListener() {
FlutterContacts.removeListener(_onContactsDatabaseUpdate); FlutterContacts.removeListener(_onContactsDatabaseUpdate);
await GetIt.I.get<RosterService>().removePseudoRosterItems();
} }
Future<void> _onContactsDatabaseUpdate() async { Future<void> _onContactsDatabaseUpdate() async {
@@ -123,7 +129,6 @@ class ContactsService {
Future<Map<String, String>> _getContactIds() async { Future<Map<String, String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!; if (_contactIds != null) return _contactIds!;
// TODO(Unknown): Can we just .cast<String, String>() here?
_contactIds = Map<String, String>.fromEntries( _contactIds = Map<String, String>.fromEntries(
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map( (await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
(item) => MapEntry( (item) => MapEntry(
@@ -276,7 +281,8 @@ class ContactsService {
return cs.updateConversation( return cs.updateConversation(
contact.jid, contact.jid,
contactId: contact.id, contactId: contact.id,
contactAvatarPath: contactAvatarPath, contactAvatarPath:
contact.thumbnail != null ? contactAvatarPath : null,
contactDisplayName: contact.displayName, contactDisplayName: contact.displayName,
); );
}, },

View File

@@ -87,8 +87,7 @@ class ConversationService {
tmp.add( tmp.add(
Conversation.fromDatabaseJson( Conversation.fromDatabaseJson(
c, c,
rosterItem != null && !rosterItem.pseudoRosterItem, rosterItem?.showAddToRosterButton ?? true,
rosterItem?.subscription ?? 'none',
lastMessage, lastMessage,
), ),
); );
@@ -136,7 +135,8 @@ class ConversationService {
Message? lastMessage, Message? lastMessage,
bool? open, bool? open,
int? unreadCounter, int? unreadCounter,
String? avatarUrl, String? avatarPath,
Object? avatarHash = notSpecified,
ChatState? chatState, ChatState? chatState,
bool? muted, bool? muted,
bool? encrypted, bool? encrypted,
@@ -160,8 +160,11 @@ class ConversationService {
if (unreadCounter != null) { if (unreadCounter != null) {
c['unreadCounter'] = unreadCounter; c['unreadCounter'] = unreadCounter;
} }
if (avatarUrl != null) { if (avatarPath != null) {
c['avatarUrl'] = avatarUrl; c['avatarPath'] = avatarPath;
}
if (avatarHash != notSpecified) {
c['avatarHash'] = avatarHash as String?;
} }
if (muted != null) { if (muted != null) {
c['muted'] = boolToInt(muted); c['muted'] = boolToInt(muted);
@@ -191,8 +194,7 @@ class ConversationService {
await GetIt.I.get<RosterService>().getRosterItemByJid(jid); await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
var newConversation = Conversation.fromDatabaseJson( var newConversation = Conversation.fromDatabaseJson(
result, result,
rosterItem != null, rosterItem?.showAddToRosterButton ?? true,
rosterItem?.subscription ?? 'none',
lastMessage, lastMessage,
); );
@@ -215,7 +217,7 @@ class ConversationService {
String title, String title,
Message? lastMessage, Message? lastMessage,
ConversationType type, ConversationType type,
String avatarUrl, String avatarPath,
String jid, String jid,
int unreadCounter, int unreadCounter,
int lastChangeTimestamp, int lastChangeTimestamp,
@@ -231,14 +233,14 @@ class ConversationService {
final newConversation = Conversation( final newConversation = Conversation(
title, title,
lastMessage, lastMessage,
avatarUrl, avatarPath,
null,
jid, jid,
unreadCounter, unreadCounter,
type, type,
lastChangeTimestamp, lastChangeTimestamp,
open, open,
rosterItem != null && !rosterItem.pseudoRosterItem, rosterItem?.showAddToRosterButton ?? true,
rosterItem?.subscription ?? 'none',
muted, muted,
encrypted, encrypted,
ChatState.gone, ChatState.gone,

View File

@@ -3,13 +3,6 @@ const messagesTable = 'Messages';
const rosterTable = 'RosterItems'; const rosterTable = 'RosterItems';
const mediaTable = 'SharedMedia'; const mediaTable = 'SharedMedia';
const preferenceTable = 'Preferences'; 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 xmppStateTable = 'XmppState';
const contactsTable = 'Contacts'; const contactsTable = 'Contacts';
const stickersTable = 'Stickers'; const stickersTable = 'Stickers';
@@ -19,6 +12,10 @@ const subscriptionsTable = 'SubscriptionRequests';
const fileMetadataTable = 'FileMetadata'; const fileMetadataTable = 'FileMetadata';
const fileMetadataHashesTable = 'FileMetadataHashes'; const fileMetadataHashesTable = 'FileMetadataHashes';
const reactionsTable = 'Reactions'; const reactionsTable = 'Reactions';
const omemoDevicesTable = 'OmemoDevices';
const omemoDeviceListTable = 'OmemoDeviceList';
const omemoRatchetsTable = 'OmemoRatchets';
const omemoTrustTable = 'OmemoTrustTable';
const typeString = 0; const typeString = 0;
const typeInt = 1; const typeInt = 1;

View File

@@ -18,7 +18,8 @@ Future<void> createDatabase(Database db, int version) async {
); );
// Messages // Messages
await db.execute(''' await db.execute(
'''
CREATE TABLE $messagesTable ( CREATE TABLE $messagesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL, sender TEXT NOT NULL,
@@ -46,13 +47,15 @@ Future<void> createDatabase(Database db, int version) async {
pseudoMessageData TEXT, pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id) CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id) CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)'''); )''',
);
await db.execute( await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)', 'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
); );
// Reactions // Reactions
await db.execute(''' await db.execute(
'''
CREATE TABLE $reactionsTable ( CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL, senderJid TEXT NOT NULL,
emoji TEXT NOT NULL, emoji TEXT NOT NULL,
@@ -60,13 +63,15 @@ Future<void> createDatabase(Database db, int version) async {
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id), CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id) CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE ON DELETE CASCADE
)'''); )''',
);
await db.execute( await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)', 'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
); );
// File metadata // File metadata
await db.execute(''' await db.execute(
'''
CREATE TABLE $fileMetadataTable ( CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
path TEXT, path TEXT,
@@ -83,8 +88,10 @@ Future<void> createDatabase(Database db, int version) async {
cipherTextHashes TEXT, cipherTextHashes TEXT,
filename TEXT NOT NULL, filename TEXT NOT NULL,
size INTEGER size INTEGER
)'''); )''',
await db.execute(''' );
await db.execute(
'''
CREATE TABLE $fileMetadataHashesTable ( CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL, algorithm TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
@@ -92,7 +99,8 @@ Future<void> createDatabase(Database db, int version) async {
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value), CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id) CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE ON DELETE CASCADE
)'''); )''',
);
await db.execute( await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)', 'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
); );
@@ -101,19 +109,20 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute( await db.execute(
''' '''
CREATE TABLE $conversationsTable ( CREATE TABLE $conversationsTable (
jid TEXT NOT NULL PRIMARY KEY, jid TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
avatarUrl TEXT NOT NULL, avatarPath TEXT NOT NULL,
type TEXT NOT NULL, avatarHash TEXT,
type TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL, lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL, unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL, open INTEGER NOT NULL,
muted INTEGER NOT NULL, muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL, encrypted INTEGER NOT NULL,
lastMessageId INTEGER, lastMessageId INTEGER,
contactId TEXT, contactId TEXT,
contactAvatarPath TEXT, contactAvatarPath TEXT,
contactDisplayName TEXT, contactDisplayName TEXT,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id), CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id) CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL ON DELETE SET NULL
@@ -124,11 +133,13 @@ Future<void> createDatabase(Database db, int version) async {
); );
// Contacts // Contacts
await db.execute(''' await db.execute(
'''
CREATE TABLE $contactsTable ( CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
jid TEXT NOT NULL jid TEXT NOT NULL
)'''); )''',
);
// Roster // Roster
await db.execute( await db.execute(
@@ -137,7 +148,7 @@ Future<void> createDatabase(Database db, int version) async {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL, jid TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
avatarUrl TEXT NOT NULL, avatarPath TEXT NOT NULL,
avatarHash TEXT NOT NULL, avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL, subscription TEXT NOT NULL,
ask TEXT NOT NULL, ask TEXT NOT NULL,
@@ -172,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
description TEXT NOT NULL, description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL, hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL, hashValue TEXT NOT NULL,
restricted INTEGER NOT NULL restricted INTEGER NOT NULL,
addedTimestamp INTEGER NOT NULL
)''', )''',
); );
@@ -185,81 +197,61 @@ Future<void> createDatabase(Database db, int version) async {
''', ''',
); );
// Subscription requests
await db.execute('''
CREATE TABLE $subscriptionsTable(
jid TEXT PRIMARY KEY
)''');
// OMEMO // OMEMO
await db.execute( await db.execute(
''' '''
CREATE TABLE $omemoRatchetsTable ( CREATE TABLE $omemoDevicesTable (
id INTEGER NOT NULL, jid TEXT NOT NULL PRIMARY KEY,
jid TEXT NOT NULL, id INTEGER NOT NULL,
dhs TEXT NOT NULL, ikPub TEXT NOT NULL,
dhs_pub TEXT NOT NULL, ik TEXT NOT NULL,
dhr TEXT, spkPub TEXT NOT NULL,
rk TEXT NOT NULL, spk TEXT NOT NULL,
cks TEXT, spkId INTEGER NOT NULL,
ckr TEXT, spkSig TEXT NOT NULL,
ns INTEGER NOT NULL, oldSpkPub TEXT,
nr INTEGER NOT NULL, oldSpk TEXT,
pn INTEGER NOT NULL, oldSpkId INTEGER,
ik_pub TEXT NOT NULL, opks 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)
)''',
);
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( await db.execute(
''' '''
CREATE TABLE $omemoDeviceListTable ( CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL, jid TEXT NOT NULL PRIMARY KEY,
id INTEGER NOT NULL, devices TEXT NOT NULL
PRIMARY KEY (jid, id)
)''', )''',
); );
await db.execute( await db.execute(
''' '''
CREATE TABLE $omemoFingerprintCache ( CREATE TABLE $omemoRatchetsTable (
jid TEXT NOT NULL, jid TEXT NOT NULL,
id INTEGER NOT NULL, device INTEGER NOT NULL,
fingerprint TEXT NOT NULL, dhsPub TEXT NOT NULL,
PRIMARY KEY (jid, id) 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

@@ -41,6 +41,11 @@ 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_reactions_2.dart';
import 'package:moxxyv2/service/database/migrations/0002_shared_media.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/0002_sticker_metadata.dart';
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.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_remove_subscriptions.dart';
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart'; import 'package:random_string/random_string.dart';
// ignore: implementation_imports // ignore: implementation_imports
@@ -144,6 +149,11 @@ const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(35, upgradeFromV34ToV35), DatabaseMigration(35, upgradeFromV34ToV35),
DatabaseMigration(36, upgradeFromV35ToV36), DatabaseMigration(36, upgradeFromV35ToV36),
DatabaseMigration(37, upgradeFromV36ToV37), DatabaseMigration(37, upgradeFromV36ToV37),
DatabaseMigration(38, upgradeFromV37ToV38),
DatabaseMigration(39, upgradeFromV38ToV39),
DatabaseMigration(40, upgradeFromV39ToV40),
DatabaseMigration(41, upgradeFromV40ToV41),
DatabaseMigration(42, upgradeFromV41ToV42),
]; ];
class DatabaseService { class DatabaseService {
@@ -179,10 +189,23 @@ class DatabaseService {
_log.finest('Key generation done...'); _log.finest('Key generation done...');
} }
// 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( database = await openDatabase(
dbPath, dbPath,
password: key, password: key,
version: 37, version: version,
onCreate: createDatabase, onCreate: createDatabase,
onConfigure: (db) async { onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign // In order to do schema changes during database upgrades, we disable foreign

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -10,6 +11,8 @@ import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart'; import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/contacts.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.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/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
@@ -25,10 +28,12 @@ import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/stickers.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.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/debug.dart' as debug;
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart'; import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
@@ -100,6 +105,12 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages), EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia), EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions), EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
EventTypeMatcher<RequestAvatarForJidCommand>(performRequestAvatarForJid),
EventTypeMatcher<GetStorageUsageCommand>(performGetStorageUsage),
EventTypeMatcher<DeleteOldMediaFilesCommand>(performOldMediaFileDeletion),
EventTypeMatcher<GetPagedStickerPackCommand>(performGetPagedStickerPacks),
EventTypeMatcher<GetStickerPackByIdCommand>(performGetStickerPackById),
EventTypeMatcher<DebugCommand>(performDebugCommand),
]); ]);
GetIt.I.registerSingleton<EventHandler>(handler); GetIt.I.registerSingleton<EventHandler>(handler);
@@ -180,7 +191,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
.where((c) => c.open) .where((c) => c.open)
.toList(), .toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(), roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
); );
} }
@@ -318,7 +328,7 @@ Future<void> performSendMessage(
command.editSid!, command.editSid!,
command.recipients.first, command.recipients.first,
command.chatState.isNotEmpty command.chatState.isNotEmpty
? chatStateFromString(command.chatState) ? ChatState.fromName(command.chatState)
: null, : null,
); );
return; return;
@@ -328,7 +338,7 @@ Future<void> performSendMessage(
body: command.body, body: command.body,
recipients: command.recipients, recipients: command.recipients,
chatState: command.chatState.isNotEmpty chatState: command.chatState.isNotEmpty
? chatStateFromString(command.chatState) ? ChatState.fromName(command.chatState)
: null, : null,
quotedMessage: command.quotedMessage, quotedMessage: command.quotedMessage,
currentConversationJid: command.currentConversationJid, currentConversationJid: command.currentConversationJid,
@@ -392,13 +402,13 @@ Future<void> performSetPreferences(
final css = GetIt.I.get<ContactsService>(); final css = GetIt.I.get<ContactsService>();
if (command.preferences.enableContactIntegration) { if (command.preferences.enableContactIntegration) {
if (!oldPrefs.enableContactIntegration) { if (!oldPrefs.enableContactIntegration) {
css.enableDatabaseListener(); await css.enable();
} }
unawaited(css.scanContacts()); unawaited(css.scanContacts());
} else { } else {
if (oldPrefs.enableContactIntegration) { if (oldPrefs.enableContactIntegration) {
css.disableDatabaseListener(); await css.disable();
} }
} }
@@ -448,6 +458,43 @@ Future<void> performSetPreferences(
); );
} }
/// Attempts to achieve a "both" subscription with [jid].
Future<void> _maybeAchieveBothSubscription(String jid) async {
final roster = GetIt.I.get<RosterService>();
final item = await roster.getRosterItemByJid(jid);
if (item != null) {
GetIt.I.get<Logger>().finest(
'Roster item for $jid has subscription "${item.subscription}" with ask "${item.ask}"',
);
// Nothing more to do
if (item.subscription == 'both') {
return;
}
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
switch (item.subscription) {
case 'both':
return;
case 'none':
case 'from':
if (item.ask != 'subscribe') {
// Try to move from "from"/"none" to "both", by going over "From + Pending Out"
await pm.requestSubscription(JID.fromString(item.jid));
}
break;
case 'to':
// Move from "to" to "both"
await pm.acceptSubscriptionRequest(JID.fromString(item.jid));
break;
}
} else {
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
}
}
Future<void> performAddContact( Future<void> performAddContact(
AddContactCommand command, { AddContactCommand command, {
dynamic extra, dynamic extra,
@@ -459,76 +506,108 @@ Future<void> performAddContact(
final inRoster = await roster.isInRoster(jid); final inRoster = await roster.isInRoster(jid);
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
await cs.createOrUpdateConversation( final conversation = await cs.getConversationByJid(jid);
jid, if (conversation != null) {
create: () async { await cs.createOrUpdateConversation(
// Create jid,
final css = GetIt.I.get<ContactsService>(); update: (c) async {
final contactId = await css.getContactIdForJid(jid); final newConversation = await cs.updateConversation(
final prefs = await GetIt.I.get<PreferencesService>().getPreferences(); jid,
final newConversation = await cs.addConversationFromData( open: true,
jid.split('@')[0], lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
null, );
ConversationType.chat,
'',
jid,
0,
DateTime.now().millisecondsSinceEpoch,
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
contactId,
await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId),
);
sendEvent( sendEvent(
AddContactResultEvent(conversation: newConversation, added: !inRoster), AddContactResultEvent(
id: id, conversation: newConversation,
); added: !inRoster,
),
id: id,
);
return newConversation; return newConversation;
}, },
update: (c) async { );
final newConversation = await cs.updateConversation(
jid,
open: true,
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
);
sendEvent( // Add to roster, if needed
AddContactResultEvent(conversation: newConversation, added: !inRoster), await _maybeAchieveBothSubscription(jid);
id: id,
);
return newConversation;
},
);
// Manage subscription requests
final srs = GetIt.I.get<SubscriptionRequestService>();
final hasSubscriptionRequest = await srs.hasPendingSubscriptionRequest(jid);
if (hasSubscriptionRequest) {
await srs.acceptSubscriptionRequest(jid);
}
// Add to roster, if needed
final item = await roster.getRosterItemByJid(jid);
if (item != null) {
if (item.subscription != 'from' && item.subscription != 'both') {
GetIt.I.get<Logger>().finest(
'Roster item already exists with no presence subscription from them. Sending subscription request',
);
srs.sendSubscriptionRequest(jid);
}
} else { } else {
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]); // We did not have a conversation with that JID.
} final info = await GetIt.I
.get<XmppConnection>()
.getDiscoManager()!
.discoInfoQuery(JID.fromString(jid));
var isGroupchat = false;
if (info.isType<DiscoInfo>()) {
isGroupchat = info.get<DiscoInfo>().identities.firstWhereOrNull(
(identity) => identity.category == 'conference',
) !=
null;
} else if (info.isType<RemoteServerNotFoundError>()) {
sendEvent(
ErrorEvent(
errorId: ErrorType.remoteServerNotFound.value,
),
id: id,
);
return;
} else if (info.isType<RemoteServerTimeoutError>()) {
sendEvent(
ErrorEvent(
errorId: ErrorType.remoteServerTimeout.value,
),
id: id,
);
return;
}
// Try to figure out an avatar if (isGroupchat) {
// TODO(Unknown): Don't do that here. Do it more intelligently. // The JID points to a groupchat. Handle that on the UI side
await GetIt.I.get<AvatarService>().subscribeJid(jid); sendEvent(
await GetIt.I.get<AvatarService>().fetchAndUpdateAvatarForJid(jid, ''); JidIsGroupchatEvent(),
id: id,
);
} else {
await cs.createOrUpdateConversation(
jid,
create: () async {
// Create
final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid);
final prefs =
await GetIt.I.get<PreferencesService>().getPreferences();
final newConversation = await cs.addConversationFromData(
jid.split('@')[0],
null,
ConversationType.chat,
'',
jid,
0,
DateTime.now().millisecondsSinceEpoch,
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
contactId,
await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId),
);
sendEvent(
AddContactResultEvent(
conversation: newConversation,
added: !inRoster,
),
id: id,
);
return newConversation;
},
);
// Add to roster, if required
await _maybeAchieveBothSubscription(jid);
}
}
} }
Future<void> performRemoveContact( Future<void> performRemoveContact(
@@ -547,7 +626,7 @@ Future<void> performRemoveContact(
sendEvent( sendEvent(
ConversationUpdatedEvent( ConversationUpdatedEvent(
conversation: conversation.copyWith( conversation: conversation.copyWith(
inRoster: false, showAddToRoster: true,
), ),
), ),
); );
@@ -594,6 +673,7 @@ Future<void> performRequestDownload(
), ),
message.id, message.id,
message.fileMetadata!.id, message.fileMetadata!.id,
message.fileMetadata!.plaintextHashes?.isNotEmpty ?? false,
message.conversationJid, message.conversationJid,
mimeGuess, mimeGuess,
), ),
@@ -615,23 +695,32 @@ Future<void> performSetShareOnlineStatus(
dynamic extra, dynamic extra,
}) async { }) async {
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
final srs = GetIt.I.get<SubscriptionRequestService>();
final item = await rs.getRosterItemByJid(command.jid); final item = await rs.getRosterItemByJid(command.jid);
// TODO(Unknown): Maybe log // TODO(Unknown): Maybe log
if (item == null) return; if (item == null) return;
final jid = JID.fromString(command.jid);
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
if (command.share) { if (command.share) {
if (item.ask == 'subscribe') { switch (item.subscription) {
await srs.acceptSubscriptionRequest(command.jid); case 'to':
} else { await pm.acceptSubscriptionRequest(jid);
srs.sendSubscriptionRequest(command.jid); break;
case 'none':
case 'from':
await pm.requestSubscription(jid);
break;
} }
} else { } else {
if (item.ask == 'subscribe') { switch (item.subscription) {
await srs.rejectSubscriptionRequest(command.jid); case 'both':
} else { case 'from':
srs.sendUnsubscriptionRequest(command.jid); case 'to':
await pm.unsubscribe(jid);
break;
} }
} }
} }
@@ -673,9 +762,9 @@ Future<void> performSendChatState(
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
if (command.jid != '') { if (command.jid != '') {
conn await conn
.getManagerById<ChatStateManager>(chatStateManager)! .getManagerById<ChatStateManager>(chatStateManager)!
.sendChatState(chatStateFromString(command.state), command.jid); .sendChatState(ChatState.fromName(command.state), command.jid);
} }
} }
@@ -712,7 +801,9 @@ Future<void> performSignOut(SignOutCommand command, {dynamic extra}) async {
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
final xss = GetIt.I.get<XmppStateService>(); final xss = GetIt.I.get<XmppStateService>();
unawaited(conn.disconnect()); unawaited(conn.disconnect());
await xss.modifyXmppState((state) => XmppState()); await xss.modifyXmppState(
(state) => XmppState(),
);
sendEvent( sendEvent(
SignedOutEvent(), SignedOutEvent(),
@@ -754,7 +845,7 @@ Future<void> performGetOmemoFingerprints(
final omemo = GetIt.I.get<OmemoService>(); final omemo = GetIt.I.get<OmemoService>();
sendEvent( sendEvent(
GetConversationOmemoFingerprintsResult( GetConversationOmemoFingerprintsResult(
fingerprints: await omemo.getOmemoKeysForJid(command.jid), fingerprints: await omemo.getFingerprintsForJid(command.jid),
), ),
id: id, id: id,
); );
@@ -767,7 +858,7 @@ Future<void> performEnableOmemoKey(
final id = extra as String; final id = extra as String;
final omemo = GetIt.I.get<OmemoService>(); final omemo = GetIt.I.get<OmemoService>();
await omemo.setOmemoKeyEnabled( await omemo.setDeviceEnablement(
command.jid, command.jid,
command.deviceId, command.deviceId,
command.enabled, command.enabled,
@@ -783,10 +874,14 @@ Future<void> performRecreateSessions(
RecreateSessionsCommand command, { RecreateSessionsCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid); // Remove all ratchets
await GetIt.I.get<OmemoService>().removeAllRatchets(command.jid);
final conn = GetIt.I.get<XmppConnection>(); // And force the creation of new ones
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat( await GetIt.I
.get<XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)!
.sendOmemoHeartbeat(
command.jid, command.jid,
); );
} }
@@ -815,14 +910,14 @@ Future<void> performGetOwnOmemoFingerprints(
final id = extra as String; final id = extra as String;
final os = GetIt.I.get<OmemoService>(); final os = GetIt.I.get<OmemoService>();
final xs = GetIt.I.get<XmppService>(); final xs = GetIt.I.get<XmppService>();
await os.ensureInitialized();
final jid = (await xs.getConnectionSettings())!.jid; final jid = (await xs.getConnectionSettings())!.jid;
final device = await os.getDevice();
sendEvent( sendEvent(
GetOwnOmemoFingerprintsResult( GetOwnOmemoFingerprintsResult(
ownDeviceFingerprint: await os.getDeviceFingerprint(), ownDeviceFingerprint: await device.getFingerprint(),
ownDeviceId: await os.getDeviceId(), ownDeviceId: device.id,
fingerprints: await os.getOwnFingerprints(jid), fingerprints: await os.getFingerprintsForJid(jid.toString()),
), ),
id: id, id: id,
); );
@@ -834,7 +929,7 @@ Future<void> performRemoveOwnDevice(
}) async { }) async {
await GetIt.I await GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BaseOmemoManager>(omemoManager)! .getManagerById<OmemoManager>(omemoManager)!
.deleteDevice(command.deviceId); .deleteDevice(command.deviceId);
} }
@@ -843,9 +938,7 @@ Future<void> performRegenerateOwnDevice(
dynamic extra, dynamic extra,
}) async { }) async {
final id = extra as String; final id = extra as String;
final jid = final device = await GetIt.I.get<OmemoService>().regenerateDevice();
GetIt.I.get<XmppConnection>().connectionSettings.jid.toBare().toString();
final device = await GetIt.I.get<OmemoService>().regenerateDevice(jid);
sendEvent( sendEvent(
RegenerateOwnDeviceResult(device: device), RegenerateOwnDeviceResult(device: device),
@@ -864,17 +957,14 @@ Future<void> performMessageRetraction(
true, true,
); );
if (command.conversationJid != '') { if (command.conversationJid != '') {
(GetIt.I final manager = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!) .getManagerById<MessageManager>(messageManager)!;
.sendMessage( await manager.sendMessage(
MessageDetails( JID.fromString(command.conversationJid),
to: command.conversationJid, TypedMap<StanzaHandlerExtension>.fromList([
messageRetraction: MessageRetractionData( MessageRetractionData(command.originId, t.messages.retractedFallback),
command.originId, ]),
t.messages.retractedFallback,
),
),
); );
} }
} }
@@ -899,9 +989,9 @@ Future<void> performMarkConversationAsRead(
sendEvent(ConversationUpdatedEvent(conversation: conversation)); sendEvent(ConversationUpdatedEvent(conversation: conversation));
if (conversation.lastMessage != null) { if (conversation.lastMessage != null) {
await GetIt.I.get<XmppService>().sendReadMarker( await GetIt.I.get<MessageService>().markMessageAsRead(
command.conversationJid, conversation.lastMessage!.id,
conversation.lastMessage!.sid, conversation.type != ConversationType.note,
); );
} }
} }
@@ -916,26 +1006,10 @@ Future<void> performMarkMessageAsRead(
MarkMessageAsReadCommand command, { MarkMessageAsReadCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
final cs = GetIt.I.get<ConversationService>(); await GetIt.I.get<MessageService>().markMessageAsRead(
command.id,
final conversation = await cs.createOrUpdateConversation( command.sendMarker,
command.conversationJid,
update: (c) async {
return cs.updateConversation(
command.conversationJid,
unreadCounter: command.newUnreadCounter,
); );
},
);
if (conversation != null) {
sendEvent(ConversationUpdatedEvent(conversation: conversation));
await GetIt.I.get<XmppService>().sendReadMarker(
command.conversationJid,
command.sid,
);
}
} }
Future<void> performAddMessageReaction( Future<void> performAddMessageReaction(
@@ -956,24 +1030,25 @@ Future<void> performAddMessageReaction(
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!; final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction // Send the reaction
GetIt.I final manager = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)! .getManagerById<MessageManager>(messageManager)!;
.sendMessage( await manager.sendMessage(
MessageDetails( JID.fromString(command.conversationJid),
to: command.conversationJid, TypedMap<StanzaHandlerExtension>.fromList([
messageReactions: MessageReactions( MessageReactionsData(
msg.originId ?? msg.sid, msg.originId ?? msg.sid,
await rs.getReactionsForMessageByJid( await rs.getReactionsForMessageByJid(
command.messageId, command.messageId,
jid, jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
), ),
); ),
const MarkableData(false),
MessageProcessingHintData([
if (!msg.containsNoStore) MessageProcessingHint.store,
]),
]),
);
} }
} }
@@ -995,24 +1070,25 @@ Future<void> performRemoveMessageReaction(
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!; final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction // Send the reaction
GetIt.I final manager = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)! .getManagerById<MessageManager>(messageManager)!;
.sendMessage( await manager.sendMessage(
MessageDetails( JID.fromString(command.conversationJid),
to: command.conversationJid, TypedMap<StanzaHandlerExtension>.fromList([
messageReactions: MessageReactions( MessageReactionsData(
msg.originId ?? msg.sid, msg.originId ?? msg.sid,
await rs.getReactionsForMessageByJid( await rs.getReactionsForMessageByJid(
command.messageId, command.messageId,
jid, jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
), ),
); ),
const MarkableData(false),
MessageProcessingHintData([
if (!msg.containsNoStore) MessageProcessingHint.store,
]),
]),
);
} }
} }
@@ -1020,9 +1096,9 @@ Future<void> performMarkDeviceVerified(
MarkOmemoDeviceAsVerifiedCommand command, { MarkOmemoDeviceAsVerifiedCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
await GetIt.I.get<OmemoService>().verifyDevice( await GetIt.I.get<OmemoService>().setDeviceVerified(
command.deviceId,
command.jid, command.jid,
command.deviceId,
); );
} }
@@ -1129,6 +1205,8 @@ Future<void> performFetchStickerPack(
stickerPack.hashValue, stickerPack.hashValue,
stickerPack.restricted, stickerPack.restricted,
false, false,
0,
0,
), ),
), ),
id: id, id: id,
@@ -1248,3 +1326,125 @@ Future<void> performGetReactions(
id: id, id: id,
); );
} }
Future<void> performRequestAvatarForJid(
RequestAvatarForJidCommand command, {
dynamic extra,
}) async {
final as = GetIt.I.get<AvatarService>();
Future<void> future;
if (command.ownAvatar) {
future = as.requestOwnAvatar();
} else {
future = as.requestAvatar(
JID.fromString(command.jid),
command.hash,
);
}
unawaited(future);
}
Future<void> performGetStorageUsage(
GetStorageUsageCommand command, {
dynamic extra,
}) async {
sendEvent(
GetStorageUsageEvent(
mediaUsage: await GetIt.I.get<StorageService>().computeUsedMediaStorage(),
stickerUsage:
await GetIt.I.get<StorageService>().computeUsedStickerStorage(),
),
id: extra as String,
);
}
Future<void> performOldMediaFileDeletion(
DeleteOldMediaFilesCommand command, {
dynamic extra,
}) async {
await GetIt.I.get<StorageService>().deleteOldMediaFiles(command.timeOffset);
sendEvent(
DeleteOldMediaFilesDoneEvent(
newUsage: await GetIt.I.get<StorageService>().computeUsedMediaStorage(),
conversations:
(await GetIt.I.get<ConversationService>().loadConversations())
.where((c) => c.open)
.toList(),
),
id: extra as String,
);
}
Future<void> performGetPagedStickerPacks(
GetPagedStickerPackCommand command, {
dynamic extra,
}) async {
final result = await GetIt.I.get<StickersService>().getPaginatedStickerPacks(
command.olderThan,
command.timestamp,
command.includeStickers,
);
sendEvent(
PagedStickerPackResult(
stickerPacks: result,
),
id: extra as String,
);
}
Future<void> performGetStickerPackById(
GetStickerPackByIdCommand command, {
dynamic extra,
}) async {
sendEvent(
GetStickerPackByIdResult(
stickerPack: await GetIt.I.get<StickersService>().getStickerPackById(
command.id,
),
),
id: extra as String,
);
}
Future<void> performDebugCommand(
DebugCommand command, {
dynamic extra,
}) async {
final conn = GetIt.I.get<XmppConnection>();
if (command.id == debug.DebugCommand.clearStreamResumption.id) {
// Disconnect
await conn.disconnect();
// Reset stream management
await conn.getManagerById<StreamManagementManager>(smManager)!.resetState();
// Reconnect
await conn.connect(
shouldReconnect: true,
waitForConnection: true,
);
} else if (command.id == debug.DebugCommand.requestRoster.id) {
await conn
.getManagerById<RosterManager>(rosterManager)!
.requestRoster(useRosterVersion: false);
} else if (command.id == debug.DebugCommand.logAvailableMediaFiles.id) {
final db = GetIt.I.get<DatabaseService>().database;
final results = await db.rawQuery(
'''
SELECT
path,
id
FROM
$fileMetadataTable AS fmt
WHERE
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
AND path IS NOT NULL
''',
);
Logger.root.finest(results);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; 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. /// A class for returning whether a file metadata element was just created or retrieved.
class FileMetadataWrapper { class FileMetadataWrapper {
@@ -67,7 +68,8 @@ Future<String> computeCachedPathForFile(
return path.join( return path.join(
basePath, basePath,
hash != null hash != null
? '$hash.$ext' // NOTE: [ext] already includes a leading "."
? '$hash$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext', : '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
); );
} }
@@ -89,6 +91,10 @@ class FilesService {
'value': hash.value, 'value': hash.value,
'id': metadataId, '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,
); );
} }
} }

View File

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

View File

@@ -137,7 +137,10 @@ 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 ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
@@ -190,7 +193,7 @@ class HttpFileTransferService {
); );
} catch (ex) { } catch (ex) {
_log.warning('Encrypting ${job.path} failed: $ex'); _log.warning('Encrypting ${job.path} failed: $ex');
await _fileUploadFailed(job, messageFailedToEncryptFile); await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
return; return;
} }
} }
@@ -209,7 +212,7 @@ class HttpFileTransferService {
if (slotResult.isType<HttpFileUploadError>()) { if (slotResult.isType<HttpFileUploadError>()) {
_log.severe('Failed to request upload slot for ${job.path}!'); _log.severe('Failed to request upload slot for ${job.path}!');
await _fileUploadFailed(job, fileUploadFailedError); await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
return; return;
} }
final slot = slotResult.get<HttpFileUploadSlot>(); final slot = slotResult.get<HttpFileUploadSlot>();
@@ -236,7 +239,7 @@ class HttpFileTransferService {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
if (!isRequestOkay(uploadStatusCode)) { if (!isRequestOkay(uploadStatusCode)) {
_log.severe('Upload failed due to status code $uploadStatusCode'); _log.severe('Upload failed due to status code $uploadStatusCode');
await _fileUploadFailed(job, fileUploadFailedError); await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
return; return;
} else { } else {
_log.fine('Upload was successful'); _log.fine('Upload was successful');
@@ -324,7 +327,7 @@ class HttpFileTransferService {
// Notify UI of upload completion // Notify UI of upload completion
var msg = await ms.updateMessage( var msg = await ms.updateMessage(
job.messageMap[recipient]!.id, job.messageMap[recipient]!.id,
errorType: noError, errorType: null,
isUploading: false, isUploading: false,
fileMetadata: metadata, fileMetadata: metadata,
); );
@@ -338,14 +341,13 @@ class HttpFileTransferService {
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
// Send the message to the recipient // Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails( JID.fromString(recipient),
to: recipient, TypedMap<StanzaHandlerExtension>.fromList([
body: slot.getUrl, MessageBodyData(slot.getUrl),
requestDeliveryReceipt: true, const MessageDeliveryReceiptData(true),
id: msg.sid, StableIdData(msg.originId, null),
originId: msg.originId, StatelessFileSharingData(
sfs: StatelessFileSharingData(
FileMetadataData( FileMetadataData(
mediaType: job.mime, mediaType: job.mime,
size: stat.size, size: stat.size,
@@ -353,11 +355,12 @@ class HttpFileTransferService {
thumbnails: job.thumbnails, thumbnails: job.thumbnails,
hashes: plaintextHashes, hashes: plaintextHashes,
), ),
<StatelessFileSharingSource>[source], [source],
includeOOBFallback: true,
), ),
shouldEncrypt: job.encryptMap[recipient]!, FileUploadNotificationReplacementData(oldSid),
funReplacement: oldSid, MessageIdData(msg.sid),
), ]),
); );
_log.finest( _log.finest(
'Sent message with file upload for ${job.path} to $recipient', 'Sent message with file upload for ${job.path} to $recipient',
@@ -390,7 +393,10 @@ class HttpFileTransferService {
}); });
} }
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async { Future<void> _fileDownloadFailed(
FileDownloadJob job,
MessageErrorType error,
) async {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
// Notify UI of download failure // Notify UI of download failure
@@ -451,7 +457,7 @@ class HttpFileTransferService {
_log.warning( _log.warning(
'HTTP GET of $downloadUrl returned $downloadStatusCode', 'HTTP GET of $downloadUrl returned $downloadStatusCode',
); );
await _fileDownloadFailed(job, fileDownloadFailedError); await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
return; return;
} }
@@ -479,7 +485,7 @@ class HttpFileTransferService {
if (!result.decryptionOkay) { if (!result.decryptionOkay) {
_log.warning('Failed to decrypt $downloadPath'); _log.warning('Failed to decrypt $downloadPath');
await _fileDownloadFailed(job, messageFailedToDecryptFile); await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
return; return;
} }
@@ -488,7 +494,7 @@ class HttpFileTransferService {
_log.warning( _log.warning(
'Decryption of $downloadPath ($downloadedPath) failed: $ex', 'Decryption of $downloadPath ($downloadedPath) failed: $ex',
); );
await _fileDownloadFailed(job, messageFailedToDecryptFile); await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
return; return;
} }
@@ -571,15 +577,13 @@ class HttpFileTransferService {
); );
// Only add the hash pointers if the file hashes match what was sent // Only add the hash pointers if the file hashes match what was sent
if (job.location.plaintextHashes?.isNotEmpty ?? false) { if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
if (integrityCheckPassed) { integrityCheckPassed &&
await fs.createMetadataHashEntries( job.createMetadataHashes) {
job.location.plaintextHashes!, await fs.createMetadataHashEntries(
job.metadataId, job.location.plaintextHashes!,
); job.metadataId,
} else { );
_log.warning('Integrity check failed for file');
}
} }
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
@@ -591,7 +595,7 @@ class HttpFileTransferService {
warningType: warningType:
integrityCheckPassed ? null : warningFileIntegrityCheckFailed, integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
errorType: conversation.encrypted && !decryptionKeysAvailable errorType: conversation.encrypted && !decryptionKeysAvailable
? messageChatEncryptedButFileNot ? MessageErrorType.chatEncryptedButPlaintextFile
: null, : null,
isDownloading: false, isDownloading: false,
); );

View File

@@ -55,15 +55,32 @@ class FileDownloadJob {
this.location, this.location,
this.mId, this.mId,
this.metadataId, this.metadataId,
this.createMetadataHashes,
this.conversationJid, this.conversationJid,
this.mimeGuess, { this.mimeGuess, {
this.shouldShowNotification = true, this.shouldShowNotification = true,
}); });
/// The location where the file can be found.
final MediaFileLocation location; final MediaFileLocation location;
/// The id of the message associated with the download.
final int mId; final int mId;
/// The id of the file metadata describing the file.
final String metadataId; final String metadataId;
/// Flag indicating whether we should create hash pointers to the file metadata
/// object.
final bool createMetadataHashes;
/// The JID of the conversation this message was received in.
final String conversationJid; final String conversationJid;
/// A guess to the files's MIME type.
final String? mimeGuess; final String? mimeGuess;
/// Flag indicating whether a notification should be shown after successful download.
final bool shouldShowNotification; final bool shouldShowNotification;
@override @override

View File

@@ -10,8 +10,10 @@ import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/reactions.dart'; import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/cache.dart'; import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
@@ -249,17 +251,19 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file /// Like getPaginatedMessagesForJid, but instead only returns messages that have file
/// metadata attached. This method bypasses the cache and does not load the message's /// 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( Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
String jid, String? jid,
bool olderThan, bool olderThan,
int? oldestTimestamp, int? oldestTimestamp,
) async { ) async {
final db = GetIt.I.get<DatabaseService>().database; final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>'; final comparator = olderThan ? '<' : '>';
final queryPrefix = jid != null ? 'conversationJid = ? AND' : '';
final query = oldestTimestamp != null final query = oldestTimestamp != null
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?' ? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
: 'conversationJid = ? AND file_metadata_id IS NOT NULL'; : 'file_metadata_id IS NOT NULL';
final rawMessages = await db.rawQuery( final rawMessages = await db.rawQuery(
''' '''
SELECT SELECT
@@ -279,11 +283,26 @@ SELECT
fm.cipherTextHashes as fm_cipherTextHashes, fm.cipherTextHashes as fm_cipherTextHashes,
fm.filename as fm_filename, fm.filename as fm_filename,
fm.size as fm_size fm.size as fm_size
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg FROM
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id; (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,
if (oldestTimestamp != null) oldestTimestamp, if (oldestTimestamp != null) oldestTimestamp,
], ],
); );
@@ -324,12 +343,12 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
String? originId, String? originId,
String? quoteId, String? quoteId,
FileMetadata? fileMetadata, FileMetadata? fileMetadata,
int? errorType, MessageErrorType? errorType,
int? warningType, int? warningType,
bool isDownloading = false, bool isDownloading = false,
bool isUploading = false, bool isUploading = false,
String? stickerPackId, String? stickerPackId,
int? pseudoMessageType, PseudoMessageType? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData, Map<String, dynamic>? pseudoMessageData,
bool received = false, bool received = false,
bool displayed = false, bool displayed = false,
@@ -444,7 +463,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
m['acked'] = boolToInt(acked); m['acked'] = boolToInt(acked);
} }
if (errorType != notSpecified) { if (errorType != notSpecified) {
m['errorType'] = errorType as int?; m['errorType'] = (errorType as MessageErrorType?)?.value;
} }
if (warningType != notSpecified) { if (warningType != notSpecified) {
m['warningType'] = warningType as int?; m['warningType'] = warningType as int?;
@@ -626,4 +645,43 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
} }
}); });
} }
/// Marks the message with the database 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(int id, bool sendChatMarker) async {
final newMessage = await updateMessage(
id,
displayed: true,
);
// 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;
}
/// Evicts all cached message pages for [jid], if any were cached, from the
/// cache.
Future<void> evictFromCache(String jid) async {
return _cacheLock.synchronized(() => _messageCache.remove(jid));
}
/// Like [evictFromCache], but for multiple JIDs [jids].
Future<void> evictMultipleFromCache(List<String> jids) async {
return _cacheLock.synchronized(() {
for (final jid in jids) {
_messageCache.remove(jid);
}
});
}
} }

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,12 +1,32 @@
import 'dart:async'; import 'dart:async';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.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, bool showAddToRoster) async {
final cs = GetIt.I.get<ConversationService>();
final newConversation = await cs.createOrUpdateConversation(
jid,
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 { class MoxxyRosterStateManager extends BaseRosterStateManager {
@override @override
Future<RosterCacheLoadResult> loadRosterCache() async { Future<RosterCacheLoadResult> loadRosterCache() async {
@@ -45,6 +65,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Remove stale items // Remove stale items
for (final jid in removed) { for (final jid in removed) {
await rs.removeRosterItemByJid(jid); await rs.removeRosterItemByJid(jid);
await updateConversation(jid, true);
} }
// Create new roster items // Create new roster items
@@ -54,21 +75,23 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Skip adding items twice // Skip adding items twice
if (exists) continue; if (exists) continue;
rosterAdded.add( final newRosterItem = await rs.addRosterItemFromData(
await rs.addRosterItemFromData( '',
'', '',
'', item.jid,
item.jid, item.name ?? item.jid.split('@').first,
item.name ?? item.jid.split('@').first, item.subscription,
item.subscription, item.ask ?? '',
item.ask ?? '', false,
false, null,
null, null,
null, null,
null, groups: item.groups,
groups: item.groups,
),
); );
rosterAdded.add(newRosterItem);
// Update the cached conversation item
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
} }
// Update modified items // Update modified items
@@ -80,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
continue; continue;
} }
rosterModified.add( final newRosterItem = await rs.updateRosterItem(
await rs.updateRosterItem( ritem.id,
ritem.id, title: item.name,
title: item.name, subscription: item.subscription,
subscription: item.subscription, ask: item.ask,
ask: item.ask, groups: item.groups,
groups: item.groups,
),
); );
rosterModified.add(newRosterItem);
// Update the cached conversation item
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
} }
// Tell the UI // Tell the UI

View File

@@ -5,10 +5,9 @@ import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/contacts.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/events.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart'; import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart' as modelc; import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
@@ -36,19 +35,16 @@ class NotificationsService {
MessageNotificationTappedEvent( MessageNotificationTappedEvent(
conversationJid: action.payload!['conversationJid']!, conversationJid: action.payload!['conversationJid']!,
title: action.payload!['title']!, title: action.payload!['title']!,
avatarUrl: action.payload!['avatarUrl']!, avatarPath: action.payload!['avatarPath']!,
), ),
); );
} else if (action.buttonKeyPressed == _notificationActionKeyRead) { } else if (action.buttonKeyPressed == _notificationActionKeyRead) {
// TODO(Unknown): Maybe refactor this call such that we don't have to use await GetIt.I.get<MessageService>().markMessageAsRead(
// a command. int.parse(action.payload!['id']!),
await performMarkMessageAsRead( // [XmppService.sendReadMarker] will check whether the *SHOULD* send
MarkMessageAsReadCommand( // the marker, i.e. if the privacy settings allow it.
conversationJid: action.payload!['conversationJid']!, true,
sid: action.payload!['sid']!, );
newUnreadCounter: 0,
),
);
} else { } else {
logger.warning( logger.warning(
'Received unknown notification action key ${action.buttonKeyPressed}', 'Received unknown notification action key ${action.buttonKeyPressed}',
@@ -110,8 +106,8 @@ class NotificationsService {
final title = final title =
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title; contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
final avatarPath = contactIntegrationEnabled final avatarPath = contactIntegrationEnabled
? c.contactAvatarPath ?? c.avatarUrl ? c.contactAvatarPath ?? c.avatarPath
: c.avatarUrl; : c.avatarPath;
await AwesomeNotifications().createNotification( await AwesomeNotifications().createNotification(
content: NotificationContent( content: NotificationContent(
@@ -131,7 +127,8 @@ class NotificationsService {
'conversationJid': c.jid, 'conversationJid': c.jid,
'sid': m.sid, 'sid': m.sid,
'title': title, 'title': title,
'avatarUrl': avatarPath, 'avatarPath': avatarPath,
'messageId': m.id.toString(),
}, },
), ),
actionButtons: [ actionButtons: [

View File

@@ -1,213 +1,43 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp; 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/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/omemo/implementations.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/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart' as model; import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.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 { class OmemoService {
/// Logger.
final Logger _log = Logger('OmemoService'); final Logger _log = Logger('OmemoService');
/// Flag indicating whether we are initialized.
bool _initialized = false; 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(); final Lock _lock = Lock();
/// Queue for code that is waiting on the service initialization.
final Queue<Completer<void>> _waitingForInitialization = final Queue<Completer<void>> _waitingForInitialization =
Queue<Completer<void>>(); 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 { /// Access the underlying [OmemoManager].
final done = await _lock.synchronized(() => _initialized); Future<OmemoManager> getOmemoManager() async {
if (done) return; await ensureInitialized();
return _omemoManager;
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(),
);
} }
/// Ensures that the code following this *AWAITED* call can access every method /// 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 { /// Creates or loads the [OmemoManager] for the JID [jid].
await _saveOmemoDeviceList(deviceMap); 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 { Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() 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 {
_log.finest('publishDeviceIfNeeded: Waiting for initialization...'); _log.finest('publishDeviceIfNeeded: Waiting for initialization...');
await ensureInitialized(); await ensureInitialized();
_log.finest('publishDeviceIfNeeded: Done'); _log.finest('publishDeviceIfNeeded: Done');
final conn = GetIt.I.get<moxxmpp.XmppConnection>(); final conn = GetIt.I.get<moxxmpp.XmppConnection>();
final omemo = final omemo =
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!; conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!; final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
final bareJid = conn.connectionSettings.jid.toBare(); final bareJid = conn.connectionSettings.jid.toBare();
final device = await omemoManager.getDevice(); final device = await _omemoManager.getDevice();
final bundlesRaw = await dm.discoItemsQuery( final bundlesRaw = await dm.discoItemsQuery(
bareJid, bareJid,
@@ -256,7 +138,7 @@ class OmemoService {
); );
if (bundlesRaw.isType<moxxmpp.DiscoError>()) { if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
await omemo.publishBundle(await device.toBundle()); await omemo.publishBundle(await device.toBundle());
return bundlesRaw.get<moxxmpp.DiscoError>(); return null;
} }
final bundleIds = bundlesRaw final bundleIds = bundlesRaw
@@ -285,469 +167,114 @@ class OmemoService {
return null; return null;
} }
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async { Future<void> onNewConnection() 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 {
await ensureInitialized(); await ensureInitialized();
await _omemoManager.onNewConnection();
// 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;
} }
Future<void> commitTrustManager(Map<String, dynamic> json) async { Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) 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 {
await ensureInitialized(); 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 _omemoManager.withTrustManager(
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(
jid, jid,
deviceId, (tm) async {
BTBVTrustState.verified, trust = await (tm as BlindTrustBeforeVerificationTrustManager)
.getDevicesTrust(jid);
},
); );
}
/// Tells omemo_dart, that certain caches are to be seen as invalidated. return fingerprints.map((fp) {
void onNewConnection() { return model.OmemoDevice(
if (_initialized) { fp.fingerprint,
omemoManager.onNewConnection(); trust[fp.deviceId]?.trusted ?? false,
} trust[fp.deviceId]?.state == BTBVTrustState.verified,
} trust[fp.deviceId]?.enabled ?? false,
fp.deviceId,
/// 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,
); );
}).toList(); }).toList();
} }
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async { Future<void> setDeviceEnablement(String jid, int device, bool state) async {
final json = await ratchet.ratchet.toJson(); await ensureInitialized();
await GetIt.I.get<DatabaseService>().database.insert( await _omemoManager.withTrustManager(jid, (tm) async {
omemoRatchetsTable, await (tm as BlindTrustBeforeVerificationTrustManager)
{ .setEnabled(jid, device, state);
...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,
);
}); });
return Map.fromEntries(mapEntries);
} }
Future<void> _saveTrustCache(Map<String, int> cache) async { Future<void> setDeviceVerified(String jid, int device) async {
final batch = GetIt.I.get<DatabaseService>().database.batch(); await ensureInitialized();
await _omemoManager.withTrustManager(jid, (tm) async {
// ignore: cascade_invocations await (tm as BlindTrustBeforeVerificationTrustManager)
batch.delete(omemoTrustCacheTable); .setDeviceTrust(jid, device, BTBVTrustState.verified);
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),
);
}); });
return Map.fromEntries(mapEntries);
} }
Future<void> _saveTrustEnablementList(Map<String, bool> list) async { Future<void> removeAllRatchets(String jid) async {
final batch = GetIt.I.get<DatabaseService>().database.batch(); await ensureInitialized();
await _omemoManager.removeAllRatchets(jid);
// 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<Map<String, List<int>>> _loadTrustDeviceList() async { Future<OmemoDevice> getDevice() async {
final entries = await GetIt.I await ensureInitialized();
.get<DatabaseService>() return _omemoManager.getDevice();
.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<void> _saveTrustDeviceList(Map<String, List<int>> list) async { Future<model.OmemoDevice> regenerateDevice() async {
final batch = GetIt.I.get<DatabaseService>().database.batch(); await ensureInitialized();
// ignore: cascade_invocations final oldDeviceId = (await getDevice()).id;
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,
);
}
}
await batch.commit(); // Generate the new device
} final newDevice = await _omemoManager.regenerateDevice();
Future<void> _saveOmemoDevice(OmemoDevice device) async { // Remove the old device
await GetIt.I.get<DatabaseService>().database.insert( unawaited(
omemoDeviceTable, GetIt.I
{ .get<moxxmpp.XmppConnection>()
'jid': device.jid, .getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
'id': device.id, .deleteDevice(oldDeviceId),
'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],
); );
return rawItems.map((item) { return model.OmemoDevice(
return OmemoCacheTriple( await newDevice.getFingerprint(),
jid, true,
item['id']! as int, true,
item['fingerprint']! as String, true,
); newDevice.id,
}).toList(); );
}
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
/// Also sends an event to the UI.
Future<void> addPseudoMessage(
String conversationJid,
PseudoMessageType type,
int ratchetsAdded,
int ratchetsReplaced,
) async {
final ms = GetIt.I.get<MessageService>();
final message = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
'',
conversationJid,
'',
false,
false,
false,
pseudoMessageType: type,
pseudoMessageData: {
'ratchetsAdded': ratchetsAdded,
'ratchetsReplaced': ratchetsReplaced,
},
);
sendEvent(
MessageAddedEvent(
message: message,
),
);
} }
} }

View File

@@ -0,0 +1,308 @@
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: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 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),
},
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),
},
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 = ?',
whereArgs: [key.jid, key.deviceId],
);
}
await batch.commit();
}
Future<OmemoDataPackage?> loadRatchets(String jid) async {
final db = GetIt.I.get<DatabaseService>().database;
final ratchetsRaw = await db.query(
omemoRatchetsTable,
where: 'jid = ?',
whereArgs: [jid],
);
final deviceListRaw = await db.query(
omemoDeviceListTable,
where: 'jid = ?',
whereArgs: [jid],
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),
},
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 = ?',
whereArgs: [jid],
);
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 = ?',
whereArgs: [jid],
);
}

View File

@@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
@@ -32,7 +31,7 @@ class RosterService {
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache. /// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
Future<RosterItem> addRosterItemFromData( Future<RosterItem> addRosterItemFromData(
String avatarUrl, String avatarPath,
String avatarHash, String avatarHash,
String jid, String jid,
String title, String title,
@@ -47,7 +46,7 @@ class RosterService {
// TODO(PapaTutuWawa): Handle groups // TODO(PapaTutuWawa): Handle groups
final i = RosterItem( final i = RosterItem(
-1, -1,
avatarUrl, avatarPath,
avatarHash, avatarHash,
jid, jid,
title, title,
@@ -76,7 +75,7 @@ class RosterService {
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache. /// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
Future<RosterItem> updateRosterItem( Future<RosterItem> updateRosterItem(
int id, { int id, {
String? avatarUrl, String? avatarPath,
String? avatarHash, String? avatarHash,
String? title, String? title,
String? subscription, String? subscription,
@@ -89,8 +88,8 @@ class RosterService {
}) async { }) async {
final i = <String, dynamic>{}; final i = <String, dynamic>{};
if (avatarUrl != null) { if (avatarPath != null) {
i['avatarUrl'] = avatarUrl; i['avatarPath'] = avatarPath;
} }
if (avatarHash != null) { if (avatarHash != null) {
i['avatarHash'] = avatarHash; i['avatarHash'] = avatarHash;
@@ -197,7 +196,7 @@ class RosterService {
/// and, if it was successful, create the database entry. Returns the /// and, if it was successful, create the database entry. Returns the
/// [RosterItem] model object. /// [RosterItem] model object.
Future<RosterItem> addToRosterWrapper( Future<RosterItem> addToRosterWrapper(
String avatarUrl, String avatarPath,
String avatarHash, String avatarHash,
String jid, String jid,
String title, String title,
@@ -205,7 +204,7 @@ class RosterService {
final css = GetIt.I.get<ContactsService>(); final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid); final contactId = await css.getContactIdForJid(jid);
final item = await addRosterItemFromData( final item = await addRosterItemFromData(
avatarUrl, avatarPath,
avatarHash, avatarHash,
jid, jid,
title, title,
@@ -217,14 +216,19 @@ class RosterService {
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
); );
final result = await GetIt.I final conn = GetIt.I.get<XmppConnection>();
.get<XmppConnection>() final result = await conn.getRosterManager()!.addToRoster(jid, title);
.getRosterManager()!
.addToRoster(jid, title);
if (!result) { if (!result) {
// TODO(Unknown): Signal error? // 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])); sendEvent(RosterDiffEvent(added: [item]));
return item; return item;
} }
@@ -236,14 +240,14 @@ class RosterService {
String jid, { String jid, {
bool unsubscribe = true, bool unsubscribe = true,
}) async { }) 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); final result = await roster.removeFromRoster(jid);
if (result == RosterRemovalResult.okay || if (result == RosterRemovalResult.okay ||
result == RosterRemovalResult.itemNotFound) { result == RosterRemovalResult.itemNotFound) {
if (unsubscribe) { if (unsubscribe) {
GetIt.I await pm.unsubscribe(JID.fromString(jid));
.get<SubscriptionRequestService>()
.sendUnsubscriptionRequest(jid);
} }
_log.finest('Removing from roster maybe worked. Removing from database'); _log.finest('Removing from roster maybe worked. Removing from database');
@@ -253,4 +257,25 @@ class RosterService {
return false; return false;
} }
/// Removes all roster items that are pseudo roster items.
Future<void> removePseudoRosterItems() async {
final items = await getRoster();
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.id);
}
sendEvent(
RosterDiffEvent(removed: removed),
);
}
} }

View File

@@ -23,7 +23,6 @@ import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/language.dart'; import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/connectivity.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/roster.dart';
import 'package:moxxyv2/service/moxxmpp/socket.dart'; import 'package:moxxyv2/service/moxxmpp/socket.dart';
import 'package:moxxyv2/service/moxxmpp/stream.dart'; import 'package:moxxyv2/service/moxxmpp/stream.dart';
@@ -32,8 +31,9 @@ import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart'; import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/share.dart';
import 'package:moxxyv2/service/stickers.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.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
@@ -175,11 +175,10 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<ContactsService>(ContactsService()); GetIt.I.registerSingleton<ContactsService>(ContactsService());
GetIt.I.registerSingleton<StickersService>(StickersService()); GetIt.I.registerSingleton<StickersService>(StickersService());
GetIt.I.registerSingleton<XmppStateService>(XmppStateService()); GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
GetIt.I.registerSingleton<SubscriptionRequestService>(
SubscriptionRequestService(),
);
GetIt.I.registerSingleton<FilesService>(FilesService()); GetIt.I.registerSingleton<FilesService>(FilesService());
GetIt.I.registerSingleton<ReactionsService>(ReactionsService()); GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
GetIt.I.registerSingleton<StorageService>(StorageService());
GetIt.I.registerSingleton<ShareService>(ShareService());
final xmpp = XmppService(); final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp); GetIt.I.registerSingleton<XmppService>(xmpp);
@@ -211,10 +210,14 @@ Future<void> entrypoint() async {
StreamManagementNegotiator(), StreamManagementNegotiator(),
CSINegotiator(), CSINegotiator(),
RosterFeatureNegotiator(), RosterFeatureNegotiator(),
PresenceNegotiator(),
SaslScramNegotiator(10, '', '', ScramHashType.sha512), SaslScramNegotiator(10, '', '', ScramHashType.sha512),
SaslScramNegotiator(9, '', '', ScramHashType.sha256), SaslScramNegotiator(9, '', '', ScramHashType.sha256),
SaslScramNegotiator(8, '', '', ScramHashType.sha1), SaslScramNegotiator(8, '', '', ScramHashType.sha1),
SaslPlainNegotiator(), SaslPlainNegotiator(),
Sasl2Negotiator(),
Bind2Negotiator(),
FASTSaslNegotiator(),
]); ]);
await connection.registerManagers([ await connection.registerManagers([
MoxxyStreamManagementManager(), MoxxyStreamManagementManager(),
@@ -222,7 +225,12 @@ Future<void> entrypoint() async {
const Identity(category: 'client', type: 'phone', name: 'Moxxy'), const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
]), ]),
RosterManager(MoxxyRosterStateManager()), RosterManager(MoxxyRosterStateManager()),
MoxxyOmemoManager(), OmemoManager(
GetIt.I.get<OmemoService>().getOmemoManager,
(toJid, _) async => GetIt.I
.get<ConversationService>()
.shouldEncryptForConversation(toJid),
),
PingManager(const Duration(minutes: 3)), PingManager(const Duration(minutes: 3)),
MessageManager(), MessageManager(),
PresenceManager(), PresenceManager(),
@@ -230,7 +238,6 @@ Future<void> entrypoint() async {
CSIManager(), CSIManager(),
CarbonsManager(), CarbonsManager(),
PubSubManager(), PubSubManager(),
VCardManager(),
UserAvatarManager(), UserAvatarManager(),
StableIdManager(), StableIdManager(),
MessageDeliveryReceiptManager(), MessageDeliveryReceiptManager(),
@@ -249,6 +256,7 @@ Future<void> entrypoint() async {
LastMessageCorrectionManager(), LastMessageCorrectionManager(),
MessageReactionsManager(), MessageReactionsManager(),
StickersManager(), StickersManager(),
MessageProcessingHintManager(),
]); ]);
GetIt.I.registerSingleton<XmppConnection>(connection); GetIt.I.registerSingleton<XmppConnection>(connection);

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

@@ -0,0 +1,53 @@
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: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

@@ -16,6 +16,7 @@ import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
@@ -24,12 +25,40 @@ import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class StickersService { class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService'); final Logger _log = Logger('StickersService');
Future<StickerPack?> getStickerPackById(String id) async { /// Computes the total amount of storage occupied by the stickers in the sticker
if (_stickerPacks.containsKey(id)) return _stickerPacks[id]; /// 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 db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query( final rawPack = await db.query(
stickerPacksTable, stickerPacksTable,
@@ -59,13 +88,23 @@ SELECT
fm.cipherTextHashes AS fm_cipherTextHashes, fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename, fm.filename AS fm_filename,
fm.size AS fm_size fm.size AS fm_size
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker FROM
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id; (SELECT
*
FROM
$stickersTable
WHERE
stickerPackId = ?
) AS sticker
JOIN
$fileMetadataTable fm
ON
sticker.file_metadata_id = fm.id;
''', ''',
[id], [id],
); );
_stickerPacks[id] = StickerPack.fromDatabaseJson( final stickerPack = StickerPack.fromDatabaseJson(
rawPack.first, rawPack.first,
rawStickers.map((sticker) { rawStickers.map((sticker) {
return Sticker.fromDatabaseJson( return Sticker.fromDatabaseJson(
@@ -75,28 +114,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
), ),
); );
}).toList(), }).toList(),
).copyWith(
size: await getStickerPackSizeById(id),
); );
return _stickerPacks[id]!; return stickerPack;
}
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();
} }
Future<void> removeStickerPack(String id) async { Future<void> removeStickerPack(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final pack = await getStickerPackById(id); final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist'); assert(pack != null, 'The sticker pack must exist');
@@ -117,15 +143,17 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
} }
// Remove from the database // Remove from the database
await GetIt.I.get<DatabaseService>().database.delete( await db.delete(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
await db.delete(
stickerPacksTable, stickerPacksTable,
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub // Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState(); final state = await GetIt.I.get<XmppStateService>().getXmppState();
final result = await GetIt.I final result = await GetIt.I
@@ -238,34 +266,35 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
); );
// Get file metadata // Get file metadata
final fileMetadataRaw = final fs = GetIt.I.get<FilesService>();
await GetIt.I.get<FilesService>().createFileMetadataIfRequired( final fileMetadataRaw = await fs.createFileMetadataIfRequired(
MediaFileLocation( MediaFileLocation(
sticker.fileMetadata.sourceUrls!, sticker.fileMetadata.sourceUrls!,
p.basename(stickerPath), p.basename(stickerPath),
null, null,
null, null,
null, null,
sticker.fileMetadata.plaintextHashes, sticker.fileMetadata.plaintextHashes,
null, null,
sticker.fileMetadata.size, sticker.fileMetadata.size,
), ),
sticker.fileMetadata.mimeType, sticker.fileMetadata.mimeType,
sticker.fileMetadata.size, sticker.fileMetadata.size,
sticker.fileMetadata.width != null && sticker.fileMetadata.width != null &&
sticker.fileMetadata.height != null sticker.fileMetadata.height != null
? Size( ? Size(
sticker.fileMetadata.width!.toDouble(), sticker.fileMetadata.width!.toDouble(),
sticker.fileMetadata.height!.toDouble(), sticker.fileMetadata.height!.toDouble(),
) )
: null, : null,
// TODO(Unknown): Maybe consider the thumbnails one day // TODO(Unknown): Maybe consider the thumbnails one day
null, null,
null, null,
path: stickerPath, path: stickerPath,
); );
if (!fileMetadataRaw.retrieved) { if (!fileMetadataRaw.retrieved &&
fileMetadataRaw.fileMetadata.path == null) {
final downloadStatusCode = await downloadFile( final downloadStatusCode = await downloadFile(
Uri.parse(sticker.fileMetadata.sourceUrls!.first), Uri.parse(sticker.fileMetadata.sourceUrls!.first),
stickerPath, stickerPath,
@@ -279,13 +308,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( stickers[i] = await _addStickerFromData(
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ?? getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(), DateTime.now().millisecondsSinceEpoch.toString(),
remotePack.hashValue, remotePack.hashValue,
sticker.desc, sticker.desc,
sticker.suggests, sticker.suggests,
fileMetadataRaw.fileMetadata, fm,
); );
} }
@@ -387,11 +425,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
pack.hashValue, pack.hashValue,
pack.restricted, pack.restricted,
true, true,
DateTime.now().millisecondsSinceEpoch,
0,
); );
await _addStickerPackFromData(stickerPack); await _addStickerPackFromData(stickerPack);
// Add all stickers // Add all stickers
var size = 0;
final stickers = List<Sticker>.empty(growable: true); final stickers = List<Sticker>.empty(growable: true);
final fs = GetIt.I.get<FilesService>();
for (final sticker in pack.stickers) { for (final sticker in pack.stickers) {
// Get the "path" to the sticker // Get the "path" to the sticker
final stickerPath = await computeCachedPathForFile( final stickerPath = await computeCachedPathForFile(
@@ -404,39 +446,69 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
.whereType<moxxmpp.StatelessFileSharingUrlSource>() .whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url) .map((src) => src.url)
.toList(); .toList();
final fileMetadataRaw = await GetIt.I final fileMetadataRaw = await fs.createFileMetadataIfRequired(
.get<FilesService>() MediaFileLocation(
.createFileMetadataIfRequired( urlSources,
MediaFileLocation( p.basename(stickerPath),
urlSources, null,
p.basename(stickerPath), null,
null, null,
null, sticker.metadata.hashes,
null, null,
sticker.metadata.hashes, sticker.metadata.size,
null, ),
sticker.metadata.size, sticker.metadata.mediaType,
), sticker.metadata.size,
sticker.metadata.mediaType, sticker.metadata.width != null && sticker.metadata.height != null
sticker.metadata.size, ? Size(
sticker.metadata.width != null && sticker.metadata.height != null sticker.metadata.width!.toDouble(),
? Size( sticker.metadata.height!.toDouble(),
sticker.metadata.width!.toDouble(), )
sticker.metadata.height!.toDouble(), : null,
) // TODO(Unknown): Maybe consider the thumbnails one day
: null, null,
// TODO(Unknown): Maybe consider the thumbnails one day null,
null, path: stickerPath,
null, );
path: stickerPath,
);
// Only copy the sticker to storage if we don't already have it // 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!)!; final stickerFile = archive.findFile(sticker.metadata.name!)!;
await File(stickerPath).writeAsBytes( final file = File(stickerPath);
await file.writeAsBytes(
stickerFile.content as List<int>, 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( stickers.add(
@@ -446,18 +518,16 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
pack.hashValue, pack.hashValue,
sticker.metadata.desc!, sticker.metadata.desc!,
sticker.suggests, sticker.suggests,
fileMetadataRaw.fileMetadata, fm,
), ),
); );
} }
final stickerPackWithStickers = stickerPack.copyWith( final stickerPackWithStickers = stickerPack.copyWith(
stickers: stickers, stickers: stickers,
size: size,
); );
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info( _log.info(
'Sticker pack ${stickerPack.id} successfully added to the database', 'Sticker pack ${stickerPack.id} successfully added to the database',
); );
@@ -466,4 +536,110 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
unawaited(_publishStickerPack(pack)); unawaited(_publishStickerPack(pack));
return stickerPackWithStickers; 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;
}
} }

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

@@ -0,0 +1,114 @@
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';
import 'package:moxxyv2/service/message.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();
}
// Empty the message caches for conversations where we just removed the file
final resultIdPlaceholders =
List<String>.filled(results.length, '?').join(', ');
final conversations = (await db.query(
messagesTable,
where: 'file_metadata_id IN ($resultIdPlaceholders)',
whereArgs: results.map((result) => result['id']! as String).toList(),
columns: ['conversationJid'],
distinct: true,
))
.map((item) => item['conversationJid']! as String);
// Evict the affected message pages from cache
_log.finest('Evicting conversations from cache: $conversations');
await GetIt.I
.get<MessageService>()
.evictMultipleFromCache(conversations.toList());
}
}

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,13 +1,78 @@
import 'dart:convert';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/shared/models/xmpp_state.dart'; import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:sqflite_sqlcipher/sqflite.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';
class XmppStateService { class XmppStateService {
/// Persistent state around the connection, like the SM token, etc. /// Persistent state around the connection, like the SM token, etc.
XmppState? _state; XmppState? _state;
/// Cache the user agent
UserAgent? _userAgent;
final Lock _userAgentLock = Lock();
/// 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,
{
'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<XmppState> getXmppState() async { Future<XmppState> getXmppState() async {
if (_state != null) return _state!; if (_state != null) return _state!;

View File

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

View File

@@ -14,3 +14,13 @@ const int maxSharedMediaPages = 3;
/// The amount of conversations for which we cache the first page. /// The amount of conversations for which we cache the first page.
const int conversationMessagePageCacheSize = 4; 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 String selfChatShareFakeJid = '{{ self-chat }}';

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,199 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
const unspecifiedError = -1; enum ErrorType {
const noError = 0; unknown(-1),
const fileUploadFailedError = 1; remoteServerNotFound(0),
const messageNotEncryptedForDevice = 2; remoteServerTimeout(1);
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;
int errorTypeFromException(dynamic exception) { const ErrorType(this.value);
if (exception == null) {
return noError; 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) { /// The identifier value of this error type.
return messageNoDecryptionKey; final int value;
} 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;
} }
String errorToTranslatableString(int error) { enum MessageErrorType {
assert( unspecified(-1),
error != noError, // TODO(Unknown): Maybe remove
'Calling errorToTranslatableString with noError makes no sense', noError(0),
);
switch (error) { /// The file upload failed.
case messageNotEncryptedForDevice: fileUploadFailed(1),
return t.errors.omemo.notEncryptedForDevice;
case messageInvalidHMAC: /// The received message was not encrypted for this device.
return t.errors.omemo.invalidHmac; notEncryptedForDevice(2),
case messageNoDecryptionKey:
return t.errors.omemo.noDecryptionKey; /// The HMAC of the encrypted message is wrong.
case messageInvalidAffixElements: invalidHMAC(3),
return t.errors.omemo.messageInvalidAfixElement;
case fileUploadFailedError: /// We have no key available to decrypt the message.
return t.errors.message.fileUploadFailed; noDecryptionKey(4),
case messageContactDoesNotSupportOmemo:
return t.errors.message.contactDoesntSupportOmemo; /// The sanity-checks on the included affix elements failed.
case fileDownloadFailedError: invalidAffixElements(5),
return t.errors.message.fileDownloadFailed;
case messageServiceUnavailable: /// The encryption of the message somehow failed.
return t.errors.message.serviceUnavailable; failedToEncrypt(7),
case messageRemoteServerTimeout:
return t.errors.message.remoteServerTimeout; /// The decryption of the file failed.
case messageRemoteServerNotFound: failedToDecryptFile(8),
return t.errors.message.remoteServerNotFound;
case messageFailedToEncrypt: /// The contact does not support OMEMO:2.
return t.errors.message.failedToEncrypt; omemoNotSupported(9),
case messageFailedToDecryptFile:
return t.errors.message.failedToDecryptFile; /// The chat is set to use OMEMO, but the received file was sent in plaintext.
case messageChatEncryptedButFileNot: chatEncryptedButPlaintextFile(10),
return t.errors.message.fileNotEncrypted;
case messageFailedToEncryptFile: /// The encryption of the file somehow failed.
return t.errors.message.failedToEncryptFile; failedToEncryptFile(11),
case unspecifiedError:
return t.errors.message.unspecified; /// 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.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'); static MessageErrorType? fromException(dynamic exception) {
return ''; 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;
} }

View File

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

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:core'; import 'dart:core';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -461,3 +462,15 @@ List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
...list, ...list,
].sublist(0, maxSize); ].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

@@ -14,11 +14,11 @@ class ConversationChatStateConverter
@override @override
ChatState fromJson(Map<String, dynamic> json) => ChatState fromJson(Map<String, dynamic> json) =>
chatStateFromString(json['chatState'] as String); ChatState.fromName(json['chatState'] as String);
@override @override
Map<String, dynamic> toJson(ChatState state) => <String, String>{ Map<String, dynamic> toJson(ChatState state) => <String, String>{
'chatState': chatStateToString(state), 'chatState': state.toName(),
}; };
} }
@@ -40,39 +40,90 @@ class ConversationMessageConverter
} }
enum ConversationType { enum ConversationType {
@JsonValue('chat') chat('chat'),
chat, note('note');
@JsonValue('note')
note const ConversationType(this.value);
/// The identifier of the enum value.
final String value;
static ConversationType? fromInt(String value) {
switch (value) {
case 'chat':
return ConversationType.chat;
case 'note':
return ConversationType.note;
}
return null;
}
}
class ConversationTypeConverter
extends JsonConverter<ConversationType, String> {
const ConversationTypeConverter();
@override
ConversationType fromJson(String json) {
return ConversationType.fromInt(json)!;
}
@override
String toJson(ConversationType object) {
return object.value;
}
} }
@freezed @freezed
class Conversation with _$Conversation { class Conversation with _$Conversation {
factory Conversation( factory Conversation(
/// The title of the chat.
String title, String title,
// The newest message in the chat.
@ConversationMessageConverter() Message? lastMessage, @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, String jid,
// The number of unread messages.
int unreadCounter, 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 // NOTE: In milliseconds since Epoch or -1 if none has ever happened
int lastChangeTimestamp, int lastChangeTimestamp,
// Indicates if the conversation should be shown on the homescreen
// Indicates if the conversation should be shown on the homescreen.
bool open, bool open,
// Indicates, if [jid] is a regular user, if the user is in the roster.
bool inRoster, /// Flag indicating whether the "add to roster" button should be shown.
// The subscription state of the roster item bool showAddToRoster,
String subscription,
// Whether the chat is muted (true = muted, false = not muted) // Whether the chat is muted (true = muted, false = not muted)
bool muted, bool muted,
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted) // Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
bool encrypted, bool encrypted,
// The current chat state // The current chat state
@ConversationChatStateConverter() ChatState chatState, { @ConversationChatStateConverter() ChatState chatState, {
// The id of the contact in the device's phonebook if it exists // The id of the contact in the device's phonebook if it exists
String? contactId, String? contactId,
// The path to the contact avatar, if available // The path to the contact avatar, if available
String? contactAvatarPath, String? contactAvatarPath,
// The contact's display name, if it exists // The contact's display name, if it exists
String? contactDisplayName, String? contactDisplayName,
}) = _Conversation; }) = _Conversation;
@@ -85,16 +136,14 @@ class Conversation with _$Conversation {
factory Conversation.fromDatabaseJson( factory Conversation.fromDatabaseJson(
Map<String, dynamic> json, Map<String, dynamic> json,
bool inRoster, bool showAddToRoster,
String subscription,
Message? lastMessage, Message? lastMessage,
) { ) {
return Conversation.fromJson({ return Conversation.fromJson({
...json, ...json,
'muted': intToBool(json['muted']! as int), 'muted': intToBool(json['muted']! as int),
'open': intToBool(json['open']! as int), 'open': intToBool(json['open']! as int),
'inRoster': inRoster, 'showAddToRoster': showAddToRoster,
'subscription': subscription,
'encrypted': intToBool(json['encrypted']! as int), 'encrypted': intToBool(json['encrypted']! as int),
'chatState': 'chatState':
const ConversationChatStateConverter().toJson(ChatState.gone), const ConversationChatStateConverter().toJson(ChatState.gone),
@@ -107,8 +156,7 @@ class Conversation with _$Conversation {
final map = toJson() final map = toJson()
..remove('id') ..remove('id')
..remove('chatState') ..remove('chatState')
..remove('inRoster') ..remove('showAddToRoster')
..remove('subscription')
..remove('lastMessage'); ..remove('lastMessage');
return { return {
@@ -123,30 +171,47 @@ class Conversation with _$Conversation {
/// True, when the chat state of the conversation indicates typing. False, if not. /// True, when the chat state of the conversation indicates typing. False, if not.
bool get isTyping => chatState == ChatState.composing; bool get isTyping => chatState == ChatState.composing;
/// The path to the avatar. This returns, if enabled, first the contact's avatar /// The path to the avatar. This returns, if [contactIntegration] is true, first the contact's avatar
/// path, then the XMPP avatar's path. If not enabled, just returns the regular /// path, then the XMPP avatar's path. If [contactIntegration] is false, just returns the regular
/// XMPP avatar's path. /// XMPP avatar's path.
String? get avatarPathWithOptionalContact { String getAvatarPathWithOptionalContact(bool contactIntegration) {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) { if (contactIntegration) {
return contactAvatarPath ?? avatarUrl; return contactAvatarPath ?? avatarPath;
} }
return avatarUrl; return avatarPath;
} }
/// The title of the chat. This returns, if enabled, first the contact's display /// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat /// 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,
);
/// 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. /// title.
String get titleWithOptionalContact { String getTitleWithOptionalContact(bool contactIntegration) {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) { if (contactIntegration) {
return contactDisplayName ?? title; return contactDisplayName ?? title;
} }
return 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,
);
/// The amount of items that are shown in the context menu. /// The amount of items that are shown in the context menu.
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0); 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;
} }
/// Sorts conversations in descending order by their last change timestamp. /// Sorts conversations in descending order by their last change timestamp.

View File

@@ -1,5 +1,5 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
@@ -9,19 +9,43 @@ import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart'; part 'message.freezed.dart';
part 'message.g.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) { /// Indicates that an existing device has been replaced.
if (data == null) return <String, dynamic>{}; 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) { /// A converter for converting between [PseudoMessageType] and [int].
if (data == null) return null; class PseudoMessageTypeConverter extends JsonConverter<PseudoMessageType, int> {
if (data.isEmpty) return null; const PseudoMessageTypeConverter();
return jsonEncode(data); @override
PseudoMessageType fromJson(int json) {
return PseudoMessageType.fromInt(json)!;
}
@override
int toJson(PseudoMessageType object) {
return object.value;
}
} }
@freezed @freezed
@@ -38,21 +62,31 @@ class Message with _$Message {
bool 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, { bool containsNoStore, {
int? errorType, @MessageErrorTypeConverter() MessageErrorType? errorType,
int? warningType, int? warningType,
FileMetadata? fileMetadata, FileMetadata? fileMetadata,
@Default(false) bool isDownloading, @Default(false) bool isDownloading,
@Default(false) bool isUploading, @Default(false) bool isUploading,
@Default(false) bool 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, @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, @Default(false) bool acked,
/// Indicates whether the message has been retracted.
@Default(false) bool isRetracted, @Default(false) bool isRetracted,
/// Indicates whether the message has been edited.
@Default(false) bool isEdited, @Default(false) bool isEdited,
String? originId, String? originId,
Message? quotes, Message? quotes,
@Default([]) List<String> reactionsPreview, @Default([]) List<String> reactionsPreview,
String? stickerPackId, String? stickerPackId,
int? pseudoMessageType, @PseudoMessageTypeConverter() PseudoMessageType? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData, Map<String, dynamic>? pseudoMessageData,
}) = _Message; }) = _Message;
@@ -82,8 +116,7 @@ class Message with _$Message {
'isEdited': intToBool(json['isEdited']! as int), 'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int), 'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactionsPreview': reactionsPreview, 'reactionsPreview': reactionsPreview,
'pseudoMessageData': 'pseudoMessageData': (json['pseudoMessageData'] as String?)?.fromJson(),
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
}).copyWith( }).copyWith(
quotes: quotes, quotes: quotes,
fileMetadata: fileMetadata, fileMetadata: fileMetadata,
@@ -113,12 +146,25 @@ class Message with _$Message {
'isRetracted': boolToInt(isRetracted), 'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited), 'isEdited': boolToInt(isEdited),
'containsNoStore': boolToInt(containsNoStore), '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. /// 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. /// 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 != noWarning;
@@ -181,11 +227,7 @@ class Message with _$Message {
/// Returns true if the menu item to show the error should be shown in the /// Returns true if the menu item to show the error should be shown in the
/// longpress menu. /// longpress menu.
bool get errorMenuVisible { bool get errorMenuVisible => hasError && !isOmemoError;
return hasError &&
(errorType! < messageNotEncryptedForDevice ||
errorType! > messageInvalidAffixElements);
}
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or /// Returns true if the message contains media that can be thumbnailed, i.e. videos or
/// images. /// images.
@@ -201,9 +243,12 @@ class Message with _$Message {
/// Returns true if the message can be copied to the clipboard. /// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage; 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; 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; bool get isMedia => fileMetadata != null;
/// The JID of the sender in moxxmpp's format.
JID get senderJid => JID.fromString(sender);
} }

View File

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

View File

@@ -8,7 +8,7 @@ part 'roster.g.dart';
class RosterItem with _$RosterItem { class RosterItem with _$RosterItem {
factory RosterItem( factory RosterItem(
int id, int id,
String avatarUrl, String avatarPath,
String avatarHash, String avatarHash,
String jid, String jid,
String title, String title,
@@ -53,4 +53,24 @@ class RosterItem with _$RosterItem {
'pseudoRosterItem': boolToInt(pseudoRosterItem), '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;
}
} }

View File

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

View File

@@ -5,13 +5,27 @@ import 'package:moxxmpp/moxxmpp.dart';
part 'xmpp_state.freezed.dart'; part 'xmpp_state.freezed.dart';
part 'xmpp_state.g.dart'; part 'xmpp_state.g.dart';
extension StreamManagementStateToJson on StreamManagementState {
Map<String, dynamic> toJson() => {
'c2s': c2s,
's2c': s2c,
'streamResumptionLocation': streamResumptionLocation,
'streamResumptionId': streamResumptionId,
};
}
class StreamManagementStateConverter class StreamManagementStateConverter
implements JsonConverter<StreamManagementState, Map<String, dynamic>> { implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
const StreamManagementStateConverter(); const StreamManagementStateConverter();
@override @override
StreamManagementState fromJson(Map<String, dynamic> json) => 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 @override
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson(); Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
@@ -27,6 +41,7 @@ class XmppState with _$XmppState {
String? displayName, String? displayName,
String? password, String? password,
String? lastRosterVersion, String? lastRosterVersion,
String? fastToken,
@Default('') String avatarUrl, @Default('') String avatarUrl,
@Default('') String avatarHash, @Default('') String avatarHash,
@Default(false) bool askedStoragePermission, @Default(false) bool askedStoragePermission,

View File

@@ -1,10 +0,0 @@
part of 'addcontact_bloc.dart';
@freezed
class AddContactState with _$AddContactState {
factory AddContactState({
@Default('') String jid,
@Default(null) String? jidError,
@Default(false) bool isWorking,
}) = _AddContactState;
}

View File

@@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
@@ -11,6 +11,7 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart'; import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
part 'conversation_bloc.freezed.dart'; part 'conversation_bloc.freezed.dart';
part 'conversation_event.dart'; part 'conversation_event.dart';
@@ -47,8 +48,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
) async { ) async {
final cb = GetIt.I.get<ConversationsBloc>(); final cb = GetIt.I.get<ConversationsBloc>();
await cb.waitUntilInitialized(); await cb.waitUntilInitialized();
final conversation = firstWhereOrNull( final conversation = cb.state.conversations.firstWhereOrNull(
cb.state.conversations,
(Conversation c) => c.jid == event.jid, (Conversation c) => c.jid == event.jid,
)!; )!;
emit( emit(
@@ -60,18 +60,22 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
), ),
); );
final arguments = ConversationPageArguments(
event.jid,
event.initialText,
);
final navEvent = event.removeUntilConversations final navEvent = event.removeUntilConversations
? (PushedNamedAndRemoveUntilEvent( ? (PushedNamedAndRemoveUntilEvent(
NavigationDestination( NavigationDestination(
conversationRoute, conversationRoute,
arguments: event.jid, arguments: arguments,
), ),
ModalRoute.withName(conversationsRoute), ModalRoute.withName(conversationsRoute),
)) ))
: (PushedNamedEvent( : (PushedNamedEvent(
NavigationDestination( NavigationDestination(
conversationRoute, conversationRoute,
arguments: event.jid, arguments: arguments,
), ),
)); ));
@@ -102,7 +106,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
emit( emit(
state.copyWith( state.copyWith(
conversation: state.conversation!.copyWith( conversation: state.conversation!.copyWith(
inRoster: true, showAddToRoster: false,
), ),
), ),
); );

View File

@@ -22,12 +22,17 @@ class RequestedConversationEvent extends ConversationEvent {
this.title, this.title,
this.avatarUrl, { this.avatarUrl, {
this.removeUntilConversations = false, this.removeUntilConversations = false,
this.initialText,
}); });
// These are placeholders in case we have to wait a bit longer
/// These are placeholders in case we have to wait a bit longer
final String jid; final String jid;
final String title; final String title;
final String avatarUrl; final String avatarUrl;
final bool removeUntilConversations; final bool removeUntilConversations;
/// Initial value to put in the input field.
final String? initialText;
} }
/// Triggered by the UI when a user should be blocked /// Triggered by the UI when a user should be blocked

View File

@@ -21,6 +21,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
on<AvatarChangedEvent>(_onAvatarChanged); on<AvatarChangedEvent>(_onAvatarChanged);
on<ConversationClosedEvent>(_onConversationClosed); on<ConversationClosedEvent>(_onConversationClosed);
on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead); on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead);
on<ConversationsSetEvent>(_onConversationsSet);
} }
// TODO(Unknown): This pattern is used so often that it should become its own thing in moxlib // TODO(Unknown): This pattern is used so often that it should become its own thing in moxlib
@@ -53,7 +54,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
state.copyWith( state.copyWith(
displayName: event.displayName, displayName: event.displayName,
jid: event.jid, jid: event.jid,
avatarUrl: event.avatarUrl ?? '', avatarPath: event.avatarUrl ?? '',
conversations: event.conversations..sort(compareConversation), conversations: event.conversations..sort(compareConversation),
), ),
); );
@@ -118,7 +119,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
) async { ) async {
return emit( return emit(
state.copyWith( state.copyWith(
avatarUrl: event.path, avatarPath: event.path,
), ),
); );
} }
@@ -154,4 +155,15 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
Conversation? getConversationByJid(String jid) { Conversation? getConversationByJid(String jid) {
return state.conversations.firstWhereOrNull((c) => c.jid == jid); return state.conversations.firstWhereOrNull((c) => c.jid == jid);
} }
Future<void> _onConversationsSet(
ConversationsSetEvent event,
Emitter<ConversationsState> emit,
) async {
emit(
state.copyWith(
conversations: event.conversations,
),
);
}
} }

View File

@@ -46,3 +46,10 @@ class ConversationMarkedAsReadEvent extends ConversationsEvent {
ConversationMarkedAsReadEvent(this.jid); ConversationMarkedAsReadEvent(this.jid);
final String jid; final String jid;
} }
/// Triggered by the UI when we received a fresh list of conversations, for example
/// after removing old media files.
class ConversationsSetEvent extends ConversationsEvent {
ConversationsSetEvent(this.conversations);
final List<Conversation> conversations;
}

View File

@@ -5,7 +5,7 @@ class ConversationsState with _$ConversationsState {
factory ConversationsState({ factory ConversationsState({
@Default(<Conversation>[]) List<Conversation> conversations, @Default(<Conversation>[]) List<Conversation> conversations,
@Default('') String displayName, @Default('') String displayName,
@Default('') String avatarUrl, @Default('') String avatarPath,
@Default('') String jid, @Default('') String jid,
}) = _ConversationsState; }) = _ConversationsState;
} }

View File

@@ -1,7 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
@@ -43,10 +43,11 @@ class NewConversationBloc
final conversations = GetIt.I.get<ConversationsBloc>(); final conversations = GetIt.I.get<ConversationsBloc>();
// Guard against an unneccessary roundtrip // Guard against an unneccessary roundtrip
if (listContains( final listContains = conversations.state.conversations.firstWhereOrNull(
conversations.state.conversations, (Conversation c) => c.jid == event.jid,
(Conversation c) => c.jid == event.jid, ) !=
)) { null;
if (listContains) {
GetIt.I.get<conversation.ConversationBloc>().add( GetIt.I.get<conversation.ConversationBloc>().add(
conversation.RequestedConversationEvent( conversation.RequestedConversationEvent(
event.jid, event.jid,
@@ -120,8 +121,7 @@ class NewConversationBloc
if (event.removed.contains(item.jid)) continue; if (event.removed.contains(item.jid)) continue;
// Handle modified items // Handle modified items
final modified = firstWhereOrNull( final modified = event.modified.firstWhereOrNull(
event.modified,
(RosterItem i) => i.id == item.id, (RosterItem i) => i.id == item.id,
); );
if (modified != null) { if (modified != null) {

View File

@@ -87,17 +87,6 @@ class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!), RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!),
awaitable: false, awaitable: false,
); );
emit(
state.copyWith(
keys: List.from(
state.keys.map(
(key) => key.copyWith(
hasSessionWith: false,
),
),
),
),
);
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent()); GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
} }

View File

@@ -90,16 +90,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
SetSubscriptionStateEvent event, SetSubscriptionStateEvent event,
Emitter<ProfileState> emit, Emitter<ProfileState> emit,
) async { ) async {
emit(
state.copyWith(
conversation: state.conversation!.copyWith(
// NOTE: This is wrong, but we just keep it like this until the real result comes
// in.
subscription: event.shareStatus ? 'to' : 'from',
),
),
);
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus), SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
awaitable: false, awaitable: false,

View File

@@ -7,6 +7,7 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
part 'sendfiles_bloc.freezed.dart'; part 'sendfiles_bloc.freezed.dart';
part 'sendfiles_event.dart'; part 'sendfiles_event.dart';
@@ -24,10 +25,9 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
/// Pick files. Returns either a list of paths to attach or null if the process has /// Pick files. Returns either a list of paths to attach or null if the process has
/// been cancelled. /// been cancelled.
Future<List<String>?> _pickFiles(SendFilesType type) async { Future<List<String>?> _pickFiles(SendFilesType type) async {
final fileType = final result = await safePickFiles(
type == SendFilesType.image ? FileType.image : FileType.any; type == SendFilesType.image ? FileType.image : FileType.any,
final result = await FilePicker.platform );
.pickFiles(type: fileType, allowMultiple: true);
if (result == null) return null; if (result == null) return null;

View File

@@ -28,9 +28,11 @@ enum ShareSelectionType {
class ShareListItem { class ShareListItem {
const ShareListItem( const ShareListItem(
this.avatarPath, this.avatarPath,
this.avatarHash,
this.jid, this.jid,
this.title, this.title,
this.isConversation, this.isConversation,
this.conversationType,
this.isEncrypted, this.isEncrypted,
this.pseudoRosterItem, this.pseudoRosterItem,
this.contactId, this.contactId,
@@ -38,9 +40,11 @@ class ShareListItem {
this.contactDisplayName, this.contactDisplayName,
); );
final String avatarPath; final String avatarPath;
final String? avatarHash;
final String jid; final String jid;
final String title; final String title;
final bool isConversation; final bool isConversation;
final ConversationType? conversationType;
final bool isEncrypted; final bool isEncrypted;
final bool pseudoRosterItem; final bool pseudoRosterItem;
final String? contactId; final String? contactId;
@@ -79,10 +83,12 @@ class ShareSelectionBloc
final items = List<ShareListItem>.from( final items = List<ShareListItem>.from(
conversations.map((c) { conversations.map((c) {
return ShareListItem( return ShareListItem(
c.avatarUrl, c.avatarPath,
c.avatarHash,
c.jid, c.jid,
c.title, c.title,
true, true,
c.type,
c.encrypted, c.encrypted,
false, false,
c.contactId, c.contactId,
@@ -100,10 +106,12 @@ class ShareSelectionBloc
if (index == -1) { if (index == -1) {
items.add( items.add(
ShareListItem( ShareListItem(
rosterItem.avatarUrl, rosterItem.avatarPath,
rosterItem.avatarHash,
rosterItem.jid, rosterItem.jid,
rosterItem.title, rosterItem.title,
false, false,
null,
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault, GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
rosterItem.pseudoRosterItem, rosterItem.pseudoRosterItem,
rosterItem.contactId, rosterItem.contactId,
@@ -113,10 +121,12 @@ class ShareSelectionBloc
); );
} else { } else {
items[index] = ShareListItem( items[index] = ShareListItem(
rosterItem.avatarUrl, rosterItem.avatarPath,
rosterItem.avatarHash,
rosterItem.jid, rosterItem.jid,
rosterItem.title, rosterItem.title,
false, false,
null,
items[index].isEncrypted, items[index].isEncrypted,
items[index].pseudoRosterItem, items[index].pseudoRosterItem,
items[index].contactId, items[index].contactId,
@@ -187,7 +197,7 @@ class ShareSelectionBloc
SendMessageCommand( SendMessageCommand(
recipients: _getRecipients(), recipients: _getRecipients(),
body: state.text!, body: state.text!,
chatState: chatStateToString(ChatState.gone), chatState: ChatState.gone.toName(),
), ),
); );

View File

@@ -2,18 +2,20 @@ import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
part 'addcontact_bloc.freezed.dart'; part 'startchat_bloc.freezed.dart';
part 'addcontact_event.dart'; part 'startchat_event.dart';
part 'addcontact_state.dart'; part 'startchat_state.dart';
class AddContactBloc extends Bloc<AddContactEvent, AddContactState> { class StartChatBloc extends Bloc<StartChatEvent, StartChatState> {
AddContactBloc() : super(AddContactState()) { StartChatBloc() : super(StartChatState()) {
on<AddedContactEvent>(_onContactAdded); on<AddedContactEvent>(_onContactAdded);
on<JidChangedEvent>(_onJidChanged); on<JidChangedEvent>(_onJidChanged);
on<PageResetEvent>(_onPageReset); on<PageResetEvent>(_onPageReset);
@@ -21,7 +23,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
Future<void> _onContactAdded( Future<void> _onContactAdded(
AddedContactEvent event, AddedContactEvent event,
Emitter<AddContactState> emit, Emitter<StartChatState> emit,
) async { ) async {
final validation = validateJidString(state.jid); final validation = validateJidString(state.jid);
if (validation != null) { if (validation != null) {
@@ -41,31 +43,54 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
AddContactCommand( AddContactCommand(
jid: state.jid, jid: state.jid,
), ),
) as AddContactResultEvent; );
if (result is ErrorEvent) {
final error = result.errorId == ErrorType.remoteServerNotFound.value ||
result.errorId == ErrorType.remoteServerTimeout.value
? t.errors.newChat.remoteServerError
: t.errors.newChat.unknown;
emit(
state.copyWith(
jidError: error,
isWorking: false,
),
);
return;
} else if (result is JidIsGroupchatEvent) {
emit(
state.copyWith(
jidError: t.errors.newChat.groupchatUnsupported,
isWorking: false,
),
);
return;
}
await _onPageReset(PageResetEvent(), emit); await _onPageReset(PageResetEvent(), emit);
if (result.conversation != null) { final addResult = result! as AddContactResultEvent;
if (result.added) { if (addResult.conversation != null) {
if (addResult.added) {
GetIt.I.get<ConversationsBloc>().add( GetIt.I.get<ConversationsBloc>().add(
ConversationsAddedEvent(result.conversation!), ConversationsAddedEvent(addResult.conversation!),
); );
} else { } else {
GetIt.I.get<ConversationsBloc>().add( GetIt.I.get<ConversationsBloc>().add(
ConversationsUpdatedEvent(result.conversation!), ConversationsUpdatedEvent(addResult.conversation!),
); );
} }
} }
assert( assert(
result.conversation != null, addResult.conversation != null,
'RequestedConversationEvent must contain a not null conversation', 'RequestedConversationEvent must contain a not null conversation',
); );
GetIt.I.get<ConversationBloc>().add( GetIt.I.get<ConversationBloc>().add(
RequestedConversationEvent( RequestedConversationEvent(
result.conversation!.jid, addResult.conversation!.jid,
result.conversation!.title, addResult.conversation!.title,
result.conversation!.avatarUrl, addResult.conversation!.avatarPath,
removeUntilConversations: true, removeUntilConversations: true,
), ),
); );
@@ -73,7 +98,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
Future<void> _onJidChanged( Future<void> _onJidChanged(
JidChangedEvent event, JidChangedEvent event,
Emitter<AddContactState> emit, Emitter<StartChatState> emit,
) async { ) async {
emit( emit(
state.copyWith( state.copyWith(
@@ -84,7 +109,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
Future<void> _onPageReset( Future<void> _onPageReset(
PageResetEvent event, PageResetEvent event,
Emitter<AddContactState> emit, Emitter<StartChatState> emit,
) async { ) async {
emit( emit(
state.copyWith( state.copyWith(

View File

@@ -1,15 +1,15 @@
part of 'addcontact_bloc.dart'; part of 'startchat_bloc.dart';
abstract class AddContactEvent {} abstract class StartChatEvent {}
/// Triggered when a new contact has been added by the UI /// Triggered when a new contact has been added by the UI
class AddedContactEvent extends AddContactEvent {} class AddedContactEvent extends StartChatEvent {}
/// Triggered by the UI when the JID input field is changed /// Triggered by the UI when the JID input field is changed
class JidChangedEvent extends AddContactEvent { class JidChangedEvent extends StartChatEvent {
JidChangedEvent(this.jid); JidChangedEvent(this.jid);
final String jid; final String jid;
} }
/// Triggered when the UI wants to reset its state /// Triggered when the UI wants to reset its state
class PageResetEvent extends AddContactEvent {} class PageResetEvent extends StartChatEvent {}

View File

@@ -0,0 +1,10 @@
part of 'startchat_bloc.dart';
@freezed
class StartChatState with _$StartChatState {
factory StartChatState({
@Default('') String jid,
@Default(null) String? jidError,
@Default(false) bool isWorking,
}) = _StartChatState;
}

View File

@@ -2,7 +2,6 @@ import 'package:bloc/bloc.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
@@ -44,15 +43,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
); );
// Apply // Apply
final stickerPack = firstWhereOrNull( final stickerPackResult =
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks, // ignore: cast_nullable_to_non_nullable
(StickerPack pack) => pack.id == event.stickerPackId, await MoxplatformPlugin.handler.getDataSender().sendData(
GetStickerPackByIdCommand(
id: event.stickerPackId,
),
) as GetStickerPackByIdResult;
assert(
stickerPackResult.stickerPack != null,
'The sticker pack must be found',
); );
assert(stickerPack != null, 'The sticker pack must be found');
emit( emit(
state.copyWith( state.copyWith(
isWorking: false, isWorking: false,
stickerPack: stickerPack, stickerPack: stickerPackResult.stickerPack,
), ),
); );
} }
@@ -149,21 +155,13 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
), ),
); );
if (result is StickerPackInstallSuccessEvent) { // Leave the page
GetIt.I.get<stickers.StickersBloc>().add( GetIt.I.get<NavigationBloc>().add(
stickers.StickerPackAddedEvent(result.stickerPack), PoppedRouteEvent(),
); );
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
// Notify on failure
if (result is! StickerPackInstallSuccessEvent) {
await Fluttertoast.showToast( await Fluttertoast.showToast(
msg: t.pages.stickerPack.fetchingFailure, msg: t.pages.stickerPack.fetchingFailure,
gravity: ToastGravity.SNACKBAR, gravity: ToastGravity.SNACKBAR,
@@ -176,13 +174,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
StickerPackRequested event, StickerPackRequested event,
Emitter<StickerPackState> emit, Emitter<StickerPackState> emit,
) async { ) async {
// Find out if the sticker pack is locally available or not emit(
final stickerPack = firstWhereOrNull( state.copyWith(
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks, isWorking: true,
(StickerPack pack) => pack.id == event.stickerPackId, ),
); );
if (stickerPack == null) { final stickerPackResult =
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
GetStickerPackByIdCommand(
id: event.stickerPackId,
),
) as GetStickerPackByIdResult;
// Find out if the sticker pack is locally available or not
if (stickerPackResult.stickerPack == null) {
await _onRemoteStickerPackRequested( await _onRemoteStickerPackRequested(
RemoteStickerPackRequested( RemoteStickerPackRequested(
event.stickerPackId, event.stickerPackId,
@@ -191,9 +198,11 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
emit, emit,
); );
} else { } else {
await _onLocalStickerPackRequested( emit(
LocallyAvailableStickerPackRequested(event.stickerPackId), state.copyWith(
emit, isWorking: false,
stickerPack: stickerPackResult.stickerPack,
),
); );
} }
} }

View File

@@ -1,17 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/painting.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker.dart'; import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart'; import 'package:moxxyv2/ui/helpers.dart';
part 'stickers_bloc.freezed.dart'; part 'stickers_bloc.freezed.dart';
part 'stickers_event.dart'; part 'stickers_event.dart';
@@ -19,60 +16,20 @@ part 'stickers_state.dart';
class StickersBloc extends Bloc<StickersEvent, StickersState> { class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickersBloc() : super(StickersState()) { StickersBloc() : super(StickersState()) {
on<StickersSetEvent>(_onStickersSet);
on<StickerPackRemovedEvent>(_onStickerPackRemoved); on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<StickerPackImportedEvent>(_onStickerPackImported); on<StickerPackImportedEvent>(_onStickerPackImported);
on<StickerPackAddedEvent>(_onStickerPackAdded);
}
Future<void> _onStickersSet(
StickersSetEvent event,
Emitter<StickersState> emit,
) async {
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
// of the sticker in the UI.
final map = <StickerKey, Sticker>{};
for (final pack in event.stickerPacks) {
for (final sticker in pack.stickers) {
if (!sticker.isImage) continue;
map[StickerKey(pack.id, sticker.id)] = sticker;
}
}
emit(
state.copyWith(
stickerPacks: event.stickerPacks,
stickerMap: map,
),
);
} }
Future<void> _onStickerPackRemoved( Future<void> _onStickerPackRemoved(
StickerPackRemovedEvent event, StickerPackRemovedEvent event,
Emitter<StickersState> emit, Emitter<StickersState> emit,
) async { ) async {
final stickerPack = firstWhereOrNull( // Remove from the UI
state.stickerPacks, BidirectionalStickerPackController.instance?.removeItem(
(StickerPack sp) => sp.id == event.stickerPackId, (stickerPack) => stickerPack.id == event.stickerPackId,
)!;
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in stickerPack.stickers) {
sm.remove(StickerKey(stickerPack.id, sticker.id));
// Evict stickers from the cache
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
}
emit(
state.copyWith(
stickerPacks: List.from(
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
),
stickerMap: sm,
),
); );
// Notify the backend
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveStickerPackCommand( RemoveStickerPackCommand(
stickerPackId: event.stickerPackId, stickerPackId: event.stickerPackId,
@@ -85,8 +42,11 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickerPackImportedEvent event, StickerPackImportedEvent event,
Emitter<StickersState> emit, Emitter<StickersState> emit,
) async { ) async {
final file = await FilePicker.platform.pickFiles(); final pickerResult = await safePickFiles(
if (file == null) return; FileType.any,
allowMultiple: false,
);
if (pickerResult == null) return;
emit( emit(
state.copyWith( state.copyWith(
@@ -96,40 +56,23 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
final result = await MoxplatformPlugin.handler.getDataSender().sendData( final result = await MoxplatformPlugin.handler.getDataSender().sendData(
ImportStickerPackCommand( ImportStickerPackCommand(
path: file.files.single.path!, path: pickerResult.files.single.path!,
), ),
); );
emit(
state.copyWith(
isImportRunning: false,
),
);
if (result is StickerPackImportSuccessEvent) { if (result is StickerPackImportSuccessEvent) {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in result.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
result.stickerPack,
]),
stickerMap: sm,
isImportRunning: false,
),
);
await Fluttertoast.showToast( await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importSuccess, msg: t.pages.settings.stickers.importSuccess,
gravity: ToastGravity.SNACKBAR, gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT, toastLength: Toast.LENGTH_SHORT,
); );
} else { } else {
emit(
state.copyWith(
isImportRunning: false,
),
);
await Fluttertoast.showToast( await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importFailure, msg: t.pages.settings.stickers.importFailure,
gravity: ToastGravity.SNACKBAR, gravity: ToastGravity.SNACKBAR,
@@ -137,26 +80,4 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
); );
} }
} }
Future<void> _onStickerPackAdded(
StickerPackAddedEvent event,
Emitter<StickersState> emit,
) async {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in event.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
event.stickerPack,
]),
stickerMap: sm,
),
);
}
} }

View File

@@ -2,13 +2,6 @@ part of 'stickers_bloc.dart';
abstract class StickersEvent {} abstract class StickersEvent {}
class StickersSetEvent extends StickersEvent {
StickersSetEvent(
this.stickerPacks,
);
final List<StickerPack> stickerPacks;
}
/// Triggered by the UI when a sticker pack has been removed /// Triggered by the UI when a sticker pack has been removed
class StickerPackRemovedEvent extends StickersEvent { class StickerPackRemovedEvent extends StickersEvent {
StickerPackRemovedEvent(this.stickerPackId); StickerPackRemovedEvent(this.stickerPackId);
@@ -17,9 +10,3 @@ class StickerPackRemovedEvent extends StickersEvent {
/// Triggered by the UI when a sticker pack has been imported /// Triggered by the UI when a sticker pack has been imported
class StickerPackImportedEvent extends StickersEvent {} class StickerPackImportedEvent extends StickersEvent {}
/// Triggered by the UI when a sticker pack has been imported
class StickerPackAddedEvent extends StickersEvent {
StickerPackAddedEvent(this.stickerPack);
final StickerPack stickerPack;
}

View File

@@ -20,8 +20,6 @@ class StickerKey {
@freezed @freezed
class StickersState with _$StickersState { class StickersState with _$StickersState {
factory StickersState({ factory StickersState({
@Default([]) List<StickerPack> stickerPacks,
@Default({}) Map<StickerKey, Sticker> stickerMap,
@Default(false) bool isImportRunning, @Default(false) bool isImportRunning,
}) = _StickersState; }) = _StickersState;
} }

View File

@@ -134,6 +134,9 @@ const Color reactionColorSent = Color(0xff2993FB);
/// The color of the skim when a message is highlighted. /// The color of the skim when a message is highlighted.
const Color highlightSkimColor = Color(0xff000000); const Color highlightSkimColor = Color(0xff000000);
/// The width of the bar used to indicate a legacy quote.
const double textMessageQuoteBarWidth = 3;
/// Navigation constants /// Navigation constants
const String cropRoute = '/crop'; const String cropRoute = '/crop';
const String introRoute = '/intro'; const String introRoute = '/intro';
@@ -157,6 +160,9 @@ const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String conversationSettingsRoute = '$settingsRoute/conversation'; const String conversationSettingsRoute = '$settingsRoute/conversation';
const String appearanceRoute = '$settingsRoute/appearance'; const String appearanceRoute = '$settingsRoute/appearance';
const String stickersRoute = '$settingsRoute/stickers'; const String stickersRoute = '$settingsRoute/stickers';
const String stickerPacksRoute = '$settingsRoute/stickers/sticker_packs';
const String storageSettingsRoute = '$settingsRoute/storage';
const String storageSharedMediaSettingsRoute = '$settingsRoute/storage/media';
const String blocklistRoute = '/blocklist'; const String blocklistRoute = '/blocklist';
const String shareSelectionRoute = '/share_selection'; const String shareSelectionRoute = '/share_selection';
const String serverInfoRoute = '$profileRoute/server_info'; const String serverInfoRoute = '$profileRoute/server_info';

View File

@@ -98,11 +98,7 @@ class BidirectionalController<T> {
hasOlderData = data.length >= pageSize; hasOlderData = data.length >= pageSize;
// Don't trigger an update if we fetched nothing // Don't trigger an update if we fetched nothing
if (data.isEmpty) { _setIsFetching(false);
_setIsFetching(false);
return;
}
_cache.insertAll(0, data); _cache.insertAll(0, data);
// Evict items from the cache if we overstep the maximum // Evict items from the cache if we overstep the maximum
@@ -217,6 +213,22 @@ class BidirectionalController<T> {
return found; return found;
} }
/// Removes the first item for which [test] returns true.
void removeItem(bool Function(T) test) {
var found = false;
for (var i = 0; i < _cache.length; i++) {
if (test(_cache[i])) {
_cache.removeAt(i);
found = true;
break;
}
}
if (found) {
_dataStreamController.add(_cache);
}
}
/// Animate to the bottom of the view. /// Animate to the bottom of the view.
void animateToBottom() { void animateToBottom() {
_controller.animateTo( _controller.animateTo(

View File

@@ -68,8 +68,11 @@ class RecordingData {
class BidirectionalConversationController class BidirectionalConversationController
extends BidirectionalController<Message> { extends BidirectionalController<Message> {
BidirectionalConversationController(this.conversationJid) BidirectionalConversationController(
: assert( this.conversationJid,
this.focusNode, {
String? initialText,
}) : assert(
BidirectionalConversationController.currentController == null, BidirectionalConversationController.currentController == null,
'There can only be one BidirectionalConversationController', 'There can only be one BidirectionalConversationController',
), ),
@@ -78,6 +81,9 @@ class BidirectionalConversationController
maxPageAmount: maxMessagePages, maxPageAmount: maxMessagePages,
) { ) {
_textController.addListener(_handleTextChanged); _textController.addListener(_handleTextChanged);
if (initialText != null) {
_textController.text = initialText;
}
BidirectionalConversationController.currentController = this; BidirectionalConversationController.currentController = this;
@@ -95,6 +101,10 @@ class BidirectionalConversationController
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
TextEditingController get textController => _textController; TextEditingController get textController => _textController;
/// The focus node of the textfield used for message text input. Useful for
/// forcing focus after selecting a message for editing.
final FocusNode focusNode;
/// Stream for SendButtonState updates /// Stream for SendButtonState updates
final StreamController<conversation.SendButtonState> final StreamController<conversation.SendButtonState>
_sendButtonStreamController = StreamController(); _sendButtonStreamController = StreamController();
@@ -313,10 +323,6 @@ class BidirectionalConversationController
assert(text.isNotEmpty, 'Cannot send empty text messages'); assert(text.isNotEmpty, 'Cannot send empty text messages');
_textController.text = ''; _textController.text = '';
// Reset the message editing state
final wasEditing = _messageEditingState != null;
_messageEditingState = null;
// Add message to the database and send it // Add message to the database and send it
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData( final result = await MoxplatformPlugin.handler.getDataSender().sendData(
@@ -324,7 +330,7 @@ class BidirectionalConversationController
recipients: [conversationJid], recipients: [conversationJid],
body: text, body: text,
quotedMessage: _quotedMessage, quotedMessage: _quotedMessage,
chatState: chatStateToString(ChatState.active), chatState: ChatState.active.toName(),
editId: _messageEditingState?.id, editId: _messageEditingState?.id,
editSid: _messageEditingState?.sid, editSid: _messageEditingState?.sid,
currentConversationJid: conversationJid, currentConversationJid: conversationJid,
@@ -332,6 +338,10 @@ class BidirectionalConversationController
awaitable: true, awaitable: true,
) as MessageAddedEvent; ) as MessageAddedEvent;
// Reset the message editing state
final wasEditing = _messageEditingState != null;
_messageEditingState = null;
// Reset the quote // Reset the quote
removeQuote(); removeQuote();
@@ -413,6 +423,8 @@ class BidirectionalConversationController
int id, int id,
String sid, String sid,
) { ) {
_log.fine('Beginning editing for id: $id, sid: $sid');
_messageEditingState = MessageEditingState( _messageEditingState = MessageEditingState(
id, id,
sid, sid,
@@ -426,6 +438,9 @@ class BidirectionalConversationController
_sendButtonStreamController _sendButtonStreamController
.add(conversation.SendButtonState.cancelCorrection); .add(conversation.SendButtonState.cancelCorrection);
// Focus the textfield.
focusNode.requestFocus();
} }
/// Exit the "edit mode" for a message. /// Exit the "edit mode" for a message.

View File

@@ -25,7 +25,7 @@ class BidirectionalSharedMediaController
static BidirectionalSharedMediaController? currentController; static BidirectionalSharedMediaController? currentController;
/// The JID of the conversation we want to get shared media of. /// The JID of the conversation we want to get shared media of.
final String conversationJid; final String? conversationJid;
@override @override
Future<List<Message>> fetchOlderDataImpl( Future<List<Message>> fetchOlderDataImpl(

View File

@@ -0,0 +1,66 @@
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
class BidirectionalStickerPackController
extends BidirectionalController<StickerPack> {
BidirectionalStickerPackController(this.includeStickers)
: assert(
instance == null,
'There can only be one BidirectionalStickerPackController',
),
super(
pageSize: stickerPackPaginationSize,
maxPageAmount: maxStickerPackPages,
) {
instance = this;
}
/// A flag telling the UI to also include stickers in the sticker pack requests.
final bool includeStickers;
/// Singleton instance access.
static BidirectionalStickerPackController? instance;
@override
void dispose() {
super.dispose();
instance = null;
}
@override
Future<List<StickerPack>> fetchOlderDataImpl(
StickerPack? oldestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetPagedStickerPackCommand(
olderThan: true,
timestamp: oldestElement?.addedTimestamp,
includeStickers: includeStickers,
),
) as PagedStickerPackResult;
return result.stickerPacks;
}
@override
Future<List<StickerPack>> fetchNewerDataImpl(
StickerPack? newestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetPagedStickerPackCommand(
olderThan: false,
timestamp: newestElement?.addedTimestamp,
includeStickers: includeStickers,
),
) as PagedStickerPackResult;
return result.stickerPacks;
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:async';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
class StorageState {
const StorageState(
this.mediaUsage,
this.stickersUsage,
);
/// The storage usage of sticker packs in bytes.
final int stickersUsage;
/// The storage usage of media files in bytes.
final int mediaUsage;
/// The total used storage.
int get totalUsage => stickersUsage + mediaUsage;
}
/// A controller class for managing requesting the storage usage and handling changes
/// to the storage usage induced by UI actions.
class StorageController {
StorageController()
: assert(
instance == null,
'Only one instance of StorageController can exist',
) {
StorageController.instance = this;
}
// ignore: prefer_final_fields
StorageState _state = const StorageState(
0,
0,
);
/// The stream controller.
final StreamController<StorageState> _controller =
StreamController<StorageState>.broadcast();
Stream<StorageState> get stream => _controller.stream;
/// Singleton instance
static StorageController? instance;
/// Fetches the total storage usage from the service and triggers an event on the
/// event stream.
Future<void> fetchStorageUsage() async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetStorageUsageCommand(),
) as GetStorageUsageEvent;
_state = StorageState(
result.mediaUsage,
result.stickerUsage,
);
_controller.add(_state);
}
/// Updates the state by replacing the storage usage with [newUsage].
void mediaUsageUpdated(int newUsage) {
_state = StorageState(
newUsage,
_state.stickersUsage,
);
_controller.add(_state);
}
/// Updates the state by subtracting [size] from the stickersUsage.
void stickerPackRemoved(int size) {
_state = StorageState(
_state.mediaUsage,
_state.stickersUsage - size,
);
_controller.add(_state);
}
/// Disposes of the singleton
void dispose() {
StorageController.instance = null;
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxlib/awaitabledatasender.dart'; import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart'; import 'package:moxxyv2/shared/eventhandler.dart';
@@ -14,9 +14,9 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation; import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile; import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/controller/conversation_controller.dart'; import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/prestart.dart'; import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
import 'package:moxxyv2/ui/service/progress.dart'; import 'package:moxxyv2/ui/service/progress.dart';
void setupEventHandler() { void setupEventHandler() {
@@ -33,7 +33,10 @@ void setupEventHandler() {
EventTypeMatcher<PreStartDoneEvent>(preStartDone), EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady), EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend), EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded), EventTypeMatcher<StreamNegotiationsCompletedEvent>(
onStreamNegotiationsDone,
),
EventTypeMatcher<AvatarUpdatedEvent>(onAvatarUpdated),
]); ]);
GetIt.I.registerSingleton<EventHandler>(handler); GetIt.I.registerSingleton<EventHandler>(handler);
@@ -173,16 +176,21 @@ Future<void> onNotificationTappend(
conversation.RequestedConversationEvent( conversation.RequestedConversationEvent(
event.conversationJid, event.conversationJid,
event.title, event.title,
event.avatarUrl, event.avatarPath,
), ),
); );
} }
Future<void> onStickerPackAdded( Future<void> onStreamNegotiationsDone(
StickerPackAddedEvent event, { StreamNegotiationsCompletedEvent event, {
dynamic extra, dynamic extra,
}) async { }) async {
GetIt.I.get<stickers.StickersBloc>().add( if (!event.resumed) {
stickers.StickerPackAddedEvent(event.stickerPack), GetIt.I.get<UIAvatarsService>().resetCache();
); }
} }
Future<void> onAvatarUpdated(
AvatarUpdatedEvent event, {
dynamic extra,
}) async {}

View File

@@ -18,17 +18,26 @@ import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart'; import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/redirects.dart'; import 'package:moxxyv2/ui/redirects.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
/// Shows a dialog asking the user if they are sure that they want to proceed with an /// Shows a dialog asking the user if they are sure that they want to proceed with an
/// action. Resolves to true if the user pressed the confirm button. Returns false if /// action. Resolves to true if the user pressed the confirm button. Returns false if
/// the cancel button was pressed. /// the cancel button was pressed.
///
/// If [affirmativeText] is given, then its value is used for the "OK" button. If not,
/// the i18n-defined "yes" value will be used.
///
/// If [destructive] is set to true, then the affirmative button's text color will be
/// set to red. If set to false, the default text color is used.
Future<bool> showConfirmationDialog( Future<bool> showConfirmationDialog(
String title, String title,
String body, String body,
BuildContext context, BuildContext context, {
) async { String? affirmativeText,
bool destructive = false,
}) async {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@@ -41,7 +50,10 @@ Future<bool> showConfirmationDialog(
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: Text(t.global.yes), child: Text(
affirmativeText ?? t.global.yes,
style: destructive ? const TextStyle(color: Colors.red) : null,
),
), ),
TextButton( TextButton(
onPressed: Navigator.of(context).pop, onPressed: Navigator.of(context).pop,
@@ -118,14 +130,49 @@ void dismissSoftKeyboard(BuildContext context) {
} }
} }
/// A wrapper around [FilePicker.platform.pickFiles] that first checks if we have the
/// appropriate permission. If not, tries to request the permission. If that failed,
/// show a toast to inform the user and return null.
///
/// [type] is the type of file to pick.
///
/// [allowMultiple] indicates whether the file picker should allow multiple files to be
/// selected. Defaults to true.
///
/// [withData] is equal to the withData parameter of [FilePicker.platform.pickFiles].
Future<FilePickerResult?> safePickFiles(
FileType type, {
bool allowMultiple = true,
bool withData = false,
}) async {
// If we have no storage permission, request it. If that also failed, show a toast
// telling the user that the storage permission is not available.
final status = await Permission.storage.status;
if (status.isDenied) {
final newStatus = await Permission.storage.request();
if (!newStatus.isGranted) {
await Fluttertoast.showToast(
msg: t.errors.filePicker.permissionDenied,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_LONG,
);
return null;
}
}
return FilePicker.platform.pickFiles(
type: type,
allowMultiple: allowMultiple,
withData: withData,
);
}
/// Open the file picker to pick an image and open the cropping tool. /// Open the file picker to pick an image and open the cropping tool.
/// The Future either resolves to null if the user cancels the action or /// The Future either resolves to null if the user cancels the action or
/// the actual image data. /// the actual image data.
Future<Uint8List?> pickAndCropImage(BuildContext context) async { Future<Uint8List?> pickAndCropImage(BuildContext context) async {
final result = await FilePicker.platform.pickFiles( final result =
type: FileType.image, await safePickFiles(FileType.image, allowMultiple: false, withData: true);
withData: true,
);
if (result != null) { if (result != null) {
return GetIt.I return GetIt.I
@@ -199,9 +246,15 @@ Color getTileColor(BuildContext context) {
String localeCodeToLanguageName(String localeCode) { String localeCodeToLanguageName(String localeCode) {
switch (localeCode) { switch (localeCode) {
case 'de': case 'de':
return 'Deutsch'; return AppLocale.de.build().language;
case 'en': case 'en':
return 'English'; return AppLocale.en.build().language;
case 'nl':
return AppLocale.nl.build().language;
case 'ja':
return AppLocale.ja.build().language;
case 'ru':
return AppLocale.ru.build().language;
case 'default': case 'default':
return t.pages.settings.appearance.systemLanguage; return t.pages.settings.appearance.systemLanguage;
} }

View File

@@ -22,13 +22,25 @@ import 'package:moxxyv2/ui/pages/conversation/selected_message.dart';
import 'package:moxxyv2/ui/pages/conversation/topbar.dart'; import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
import 'package:moxxyv2/ui/pages/conversation/typing_indicator.dart'; import 'package:moxxyv2/ui/pages/conversation/typing_indicator.dart';
import 'package:moxxyv2/ui/service/data.dart'; import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/read.dart';
import 'package:moxxyv2/ui/theme.dart'; import 'package:moxxyv2/ui/theme.dart';
import 'package:moxxyv2/ui/widgets/chat/bubbles/bubbles.dart';
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart'; import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
import 'package:moxxyv2/ui/widgets/chat/bubbles/new_device.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart'; import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
import 'package:moxxyv2/ui/widgets/combined_picker.dart'; import 'package:moxxyv2/ui/widgets/combined_picker.dart';
import 'package:moxxyv2/ui/widgets/context_menu.dart'; import 'package:moxxyv2/ui/widgets/context_menu.dart';
class ConversationPageArguments {
const ConversationPageArguments(
this.conversationJid,
this.initialText,
);
final String conversationJid;
final String? initialText;
}
int getMessageMenuOptionCount( int getMessageMenuOptionCount(
Message message, Message message,
Message? lastMessage, Message? lastMessage,
@@ -49,12 +61,16 @@ int getMessageMenuOptionCount(
class ConversationPage extends StatefulWidget { class ConversationPage extends StatefulWidget {
const ConversationPage({ const ConversationPage({
required this.conversationJid, required this.conversationJid,
this.initialText,
super.key, super.key,
}); });
/// The JID of the current conversation /// The JID of the current conversation
final String conversationJid; final String conversationJid;
/// The optional initial text to put in the input field.
final String? initialText;
@override @override
ConversationPageState createState() => ConversationPageState(); ConversationPageState createState() => ConversationPageState();
} }
@@ -88,6 +104,8 @@ class ConversationPageState extends State<ConversationPage>
// Setup message paging // Setup message paging
_conversationController = BidirectionalConversationController( _conversationController = BidirectionalConversationController(
widget.conversationJid, widget.conversationJid,
_textfieldFocusNode,
initialText: widget.initialText,
); );
_conversationController.fetchOlderData(); _conversationController.fetchOlderData();
@@ -232,10 +250,7 @@ class ConversationPageState extends State<ConversationPage>
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: maxWidth, maxWidth: maxWidth,
), ),
child: NewDeviceBubble( child: bubbleFromPseudoMessageType(context, item),
data: item.pseudoMessageData!,
title: state.conversation!.title,
),
), ),
], ],
); );
@@ -294,6 +309,9 @@ class ConversationPageState extends State<ConversationPage>
sentBySelf, sentBySelf,
); );
// Dismiss the soft-keyboard
dismissSoftKeyboard(context);
// Start the actual animation // Start the actual animation
_selectionController.selectMessage( _selectionController.selectMessage(
SelectedMessageData( SelectedMessageData(
@@ -319,6 +337,13 @@ class ConversationPageState extends State<ConversationPage>
), ),
); );
}, },
visibilityCallback: (info) {
if (sentBySelf) return;
if (info.visibleFraction >= 0) {
GetIt.I.get<UIReadMarkerService>().handleMarker(message);
}
},
); );
} }
@@ -337,6 +362,8 @@ class ConversationPageState extends State<ConversationPage>
return false; return false;
} }
// Clear the read marker cache
GetIt.I.get<UIReadMarkerService>().clear();
return true; return true;
}, },
child: KeyboardReplacerScaffold( child: KeyboardReplacerScaffold(
@@ -448,9 +475,12 @@ class ConversationPageState extends State<ConversationPage>
children: [ children: [
BlocBuilder<ConversationBloc, ConversationState>( BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => buildWhen: (prev, next) =>
prev.conversation?.inRoster != next.conversation?.inRoster, prev.conversation?.showAddToRoster !=
next.conversation?.showAddToRoster,
builder: (context, state) { builder: (context, state) {
if ((state.conversation?.inRoster ?? false) || final showAddToRoster =
state.conversation?.showAddToRoster ?? false;
if (!showAddToRoster ||
state.conversation?.type == ConversationType.note) { state.conversation?.type == ConversationType.note) {
return const SizedBox(); return const SizedBox();
} }

View File

@@ -1,14 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/shared/warning_types.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/controller/conversation_controller.dart'; import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart'; import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
@@ -213,7 +211,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.canRetract(sentBySelf)) if (message.canRetract(sentBySelf))
ContextMenuItem( ContextMenuItem(
icon: Icons.delete, icon: Icons.delete,
@@ -233,16 +230,7 @@ class SelectedMessageContextMenu extends StatelessWidget {
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.canEdit(sentBySelf))
// TODO(Unknown): Also allow correcting older messages
if (message.canEdit(sentBySelf) &&
GetIt.I
.get<ConversationBloc>()
.state
.conversation
?.lastMessage
?.id ==
message.id)
ContextMenuItem( ContextMenuItem(
icon: Icons.edit, icon: Icons.edit,
text: t.pages.conversation.edit, text: t.pages.conversation.edit,
@@ -256,7 +244,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.errorMenuVisible) if (message.errorMenuVisible)
ContextMenuItem( ContextMenuItem(
icon: Icons.info_outline, icon: Icons.info_outline,
@@ -264,22 +251,19 @@ class SelectedMessageContextMenu extends StatelessWidget {
onPressed: () { onPressed: () {
showInfoDialog( showInfoDialog(
t.errors.conversation.messageErrorDialogTitle, t.errors.conversation.messageErrorDialogTitle,
errorToTranslatableString( message.errorType!.translatableString,
message.errorType!,
),
context, context,
); );
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.hasWarning) if (message.hasWarning)
ContextMenuItem( ContextMenuItem(
icon: Icons.warning, icon: Icons.warning,
text: t.pages.conversation.showWarning, text: t.pages.conversation.showWarning,
onPressed: () { onPressed: () {
showInfoDialog( showInfoDialog(
'Warning', t.pages.conversation.warning,
warningToTranslatableString( warningToTranslatableString(
message.warningType!, message.warningType!,
), ),
@@ -288,22 +272,24 @@ class SelectedMessageContextMenu extends StatelessWidget {
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.isCopyable) if (message.isCopyable)
ContextMenuItem( ContextMenuItem(
icon: Icons.content_copy, icon: Icons.content_copy,
text: t.pages.conversation.copy, text: t.pages.conversation.copy,
onPressed: () { onPressed: () {
// TODO(Unknown): Show a toast saying the message has been copied
Clipboard.setData( Clipboard.setData(
ClipboardData( ClipboardData(
text: message.body, text: message.body,
), ),
); );
selectionController.dismiss(); selectionController.dismiss();
// Show an informative toast
Fluttertoast.showToast(
msg: t.pages.conversation.messageCopied,
);
}, },
), ),
if (message.isQuotable && message.conversationJid != '') if (message.isQuotable && message.conversationJid != '')
ContextMenuItem( ContextMenuItem(
icon: Icons.forward, icon: Icons.forward,
@@ -316,7 +302,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
selectionController.dismiss(); selectionController.dismiss();
}, },
), ),
if (message.isQuotable) if (message.isQuotable)
ContextMenuItem( ContextMenuItem(
icon: Icons.reply, icon: Icons.reply,

View File

@@ -49,7 +49,7 @@ class ConversationTopbar extends StatelessWidget
bool _shouldRebuild(ConversationState prev, ConversationState next) { bool _shouldRebuild(ConversationState prev, ConversationState next) {
return prev.conversation?.title != next.conversation?.title || return prev.conversation?.title != next.conversation?.title ||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl || prev.conversation?.avatarPath != next.conversation?.avatarPath ||
prev.conversation?.chatState != next.conversation?.chatState || prev.conversation?.chatState != next.conversation?.chatState ||
prev.conversation?.jid != next.conversation?.jid || prev.conversation?.jid != next.conversation?.jid ||
prev.conversation?.encrypted != next.conversation?.encrypted; prev.conversation?.encrypted != next.conversation?.encrypted;
@@ -110,14 +110,17 @@ class ConversationTopbar extends StatelessWidget
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: RebuildOnContactIntegrationChange( child: RebuildOnContactIntegrationChange(
builder: () => AvatarWrapper( builder: () => CachingXMPPAvatar(
jid: state.conversation?.jid ?? '',
radius: 25, radius: 25,
avatarUrl: state.conversation hasContactId:
?.avatarPathWithOptionalContact ?? state.conversation?.contactId != null,
'', hash: state.conversation?.avatarHash,
altText: state path: state.conversation?.avatarPath,
.conversation?.titleWithOptionalContact ?? shouldRequest: state.conversation != null,
'A', altIcon: state.conversation?.isSelfChat ?? false
? Icons.notes
: null,
), ),
), ),
), ),

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