358 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
0c42c117a0 chore(all): Bump flutter_secure_storage 2023-05-25 22:41:15 +02:00
d795cb717e feat(i18n): Translate missing profile strings 2023-05-25 21:41:34 +02:00
1d5d1fdf86 fix(ui): Fix keyboard dodging too much in certain situations 2023-05-25 21:34:07 +02:00
d795c34dab feat(service): Do not request storage permission 2023-05-25 14:43:37 +02:00
b38f5c139f chore(all): Bump moxxmpp 2023-05-25 12:47:34 +02:00
b623f32fbf feat(all): Bump moxxmpp
- Bump moxxmpp to allow queueing stanzas that are sent offline
- Should fix #75.
2023-05-24 22:53:24 +02:00
19fd079436 chore(all): Bump moxxmpp 2023-05-23 16:01:40 +02:00
7d70a96533 fix(ui): Add bottom padding to a sticker pack's name
Also replace the weird list with a GroupedListView.
2023-05-22 14:19:05 +02:00
dce6e34289 fix(ui): Fix padding in the new conversations page
Fixes #276.
2023-05-22 13:51:20 +02:00
881f080916 Merge pull request 'Rework the conversation page' (#275) from chore/conversation-rewrite into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/275
2023-05-21 21:56:41 +00:00
051687535b fix(ui): Fix weird padding in the conversation topbar 2023-05-21 23:56:30 +02:00
0b420933e0 fix(ui): Improve contrast in dark mode 2023-05-21 23:48:42 +02:00
0b3876c3f0 feat(ui): Use the same selection effect for the share selection 2023-05-21 23:40:19 +02:00
9711d45a7a chore(ui): Rename overview_menu.dart -> context_menu.dart 2023-05-21 23:36:31 +02:00
8dcba94de7 feat(ui): Improve the selection of conversation items 2023-05-21 23:34:09 +02:00
226dca8c1a feat(ui): Implement the new typing animation 2023-05-21 13:01:18 +02:00
ad01a7e3e3 feat(ui): Re-add audio messages 2023-05-20 15:56:24 +02:00
adde5a4134 feat(ui): Re-implement the sticker picker 2023-05-19 19:52:22 +02:00
9ae1807225 chore(ui): Clean-up the selection effect 2023-05-19 13:56:44 +02:00
e7f8446c02 feat(ui): Implement a prettier overview animation 2023-05-17 21:27:58 +02:00
7b05bf200c feat(ui): Implement tailor-made keyboard dodging 2023-05-16 23:42:19 +02:00
e992cb309f chore(all): Bump moxxmpp 2023-05-16 13:46:52 +02:00
0f138678ec fix(ui): Fix a sticker as the first message not appearing
Fixes #274.
2023-05-16 12:35:35 +02:00
35658e611a fix(ui): Do not clear the conversation on exit
Fixes #262.

Since we no longer query the BLoC whether we should send a chat state
notification, but instead ask the controller, we can safely remove
the clearing of the `conversation` field.
2023-05-16 12:18:46 +02:00
2a25cd44cf fix(service): Fix invalid hashes being sent with stickers
Fixes #273.

Also fixes:
- Weird (wrong) serialization of the hash maps
- An issue with migrations when passing a const list

NOTE: If you ran Moxxy between the merge of #267 and this commit, you
have to remove Moxxy's data and start anew.
2023-05-16 01:07:40 +02:00
29053df245 chore(ui): Completely rework the BorderlessTopbar
Fixes #249.
2023-05-15 14:39:28 +02:00
78ad02ec80 feat(ui,service): Allow replying with a sticker
Fixes #270.
2023-05-15 12:57:58 +02:00
e3f2ef22a6 fix(ui): Update the conversation list when an upload failed
Fixes #271.
2023-05-15 11:09:03 +02:00
f884e181e3 fix(ui): Sticker quotes now say "Sticker"
Fixes #268.
2023-05-15 00:07:51 +02:00
e69d7ed0a2 feat(ui): Make quote roundings better
Fixes #269.
2023-05-14 23:54:46 +02:00
d65e11a3ea feat(ui): Prepend a "You:" when the last message was sent by us
Fixes #242. Reference #258. Thanks!
2023-05-14 23:06:17 +02:00
294d0ee02c Merge pull request 'Improve the database' (#267) from feat/message-rework into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/267
2023-05-14 20:51:45 +00:00
6f4abebb32 feat(service): Make adding migrations less of a hassle 2023-05-14 18:07:08 +02:00
5d83796b37 chore(service): Move methods into their respective services 2023-05-14 17:37:02 +02:00
a06c697fe3 fix(service): Remove the 'reactions' column 2023-05-14 16:45:18 +02:00
5de2a8b6af fix(service): Fix migration 2023-05-14 15:07:40 +02:00
7234f67c42 chore(ui): Fix formatting issue 2023-05-14 14:19:41 +02:00
972f5079f9 feat(service): Create indices for common queries 2023-05-14 14:18:56 +02:00
27d4ed1781 feat(ui): Test that padding the reactions list works 2023-05-14 12:26:25 +02:00
5f074ef695 feat(ui): Make the reactions preview slightly transparent 2023-05-14 12:10:44 +02:00
d0f60519fd feat(ui): Fill in the reaction list with actual data 2023-05-14 12:02:38 +02:00
cd7c495cb7 feat(ui): Show 'You' if a reaction came from us 2023-05-14 00:46:54 +02:00
59317d45f9 chore(ui): Move the reaction bubble colors to the constants 2023-05-14 00:18:25 +02:00
7c2c9f978d chore(shared): Fix naming issue in the reaction model 2023-05-14 00:13:51 +02:00
d540f0c2f2 chore(ui): Add documentation 2023-05-14 00:07:59 +02:00
340bbb7ca8 feat(ui): Make reactions look prettier 2023-05-14 00:00:11 +02:00
0aaffd1249 fix(ui,service): Fix linter issues 2023-05-13 22:14:14 +02:00
04be2e8c88 fix(service): Fix removing a reaction 2023-05-13 22:12:05 +02:00
57dbe83901 fix(service): Make adding a reaction work 2023-05-13 22:07:17 +02:00
60c5328eb0 feat(ui,service): Fix linter issues 2023-05-13 21:58:24 +02:00
189d9ca9cd feat(ui): Allow picking a new reaction 2023-05-13 21:48:11 +02:00
5d797b1e66 fix(ui): Make only our own reactions clickable 2023-05-13 20:55:01 +02:00
2f1a40b4d9 feat(ui,service): Allow requesting the reactions on a given message 2023-05-13 20:50:46 +02:00
02c0cd5af0 feat(ui,service): Make reactions work again 2023-05-12 23:35:27 +02:00
f2a70cd137 feat(service): Remove shared media table and attribute 2023-05-11 21:12:36 +02:00
8d88c25f05 feat(ui): Make the shared media list look nicer 2023-05-11 21:06:23 +02:00
c1c5625441 chore(ui,service): Format and lint 2023-05-11 20:36:35 +02:00
462e800907 fix(service): Fix TODOs 2023-05-11 20:35:15 +02:00
faa5ee2c4f feat(ui,service): Implement paged shared media requests 2023-05-11 16:33:55 +02:00
5dad5730ce fix(ui): If an image has no size, decode it full 2023-05-10 13:43:58 +02:00
5017187927 fix(service): Fix file uploading 2023-05-10 13:41:12 +02:00
14e7f72bd3 feat(ui): SHow an error icon if the last message has an error 2023-05-10 12:27:19 +02:00
9ef67f5788 feat(service): Untested work on file sending 2023-05-10 12:26:37 +02:00
79226f6ca8 fix(service): Fix downloading a file again 2023-05-10 00:42:09 +02:00
c8c0239e36 feat(tests): Add tests for getPrefixedSubMap 2023-05-10 00:34:07 +02:00
f1be10bf8c fix(service): Fix database loading 2023-05-10 00:24:39 +02:00
18c3c9d324 feat(all): Use stickers as an extension of SFS 2023-05-09 21:24:20 +02:00
4825fe881d fix(ui): Fix not downloaded files having no background color 2023-05-08 23:57:57 +02:00
081d20fe50 fix(all): Format and lint 2023-05-08 23:57:37 +02:00
c1a66711db feat(service): Verify hash after download 2023-05-08 21:15:54 +02:00
b113e78423 feat(service): Create hash pointers only after integrity checks 2023-05-08 13:27:06 +02:00
470e8aac9c feat(service): Guard against empty SFS source lists 2023-05-08 13:11:03 +02:00
39babfbadd fix(service): Fix wrong type when querying file metadata 2023-05-08 13:08:25 +02:00
86f7e63f65 fix(service): Append the extension to the saved filename 2023-05-08 00:00:54 +02:00
ecd2a71981 feat(ui,service): Port UI and fix first bugs 2023-05-07 22:51:06 +02:00
2ece9e6209 feat(service): Try to bring over the service 2023-05-07 16:56:27 +02:00
9310b9c305 chore(all): Bump moxxmpp 2023-05-07 00:19:09 +02:00
abad9897b8 fix(service): Use database table constants 2023-04-09 13:27:16 +02:00
0cfffff94c chore(all): Bump moxxmpp 2023-04-09 13:21:47 +02:00
6c53103345 chore(service): Bump moxxmpp 2023-04-07 23:18:52 +02:00
346ef66bca chore(all): Update moxxmpp to 0.3.1 2023-04-06 15:30:50 +02:00
e092201030 feat(ui): If possible, decode shared images at a lower resolution 2023-03-21 22:45:04 +01:00
3c14521ca0 feat(service): Log the HTTP status when an upload fails 2023-03-21 20:17:08 +01:00
4b43427bf0 fix(ui): Fix color of block/add buttons 2023-03-21 20:03:40 +01:00
b7f39fe8ed chore(service): Format the database migrations again 2023-03-21 19:56:20 +01:00
1f64569bc2 fix(service): Fix locking the UI when importing broken sticker packs
Fixes #241.
2023-03-21 19:56:14 +01:00
7c56383601 chore(all): Lint and format tests and migrations 2023-03-21 16:32:34 +01:00
2de50b012b fix(service): Fix broken migration 2023-03-21 16:17:56 +01:00
1de90e3ce1 feat(docs): Explain the commit message format 2023-03-21 12:18:19 +01:00
64a175819f chore(meta): Bump moxxmpp 2023-03-21 12:02:36 +01:00
4cc507832c chore(service): Bump moxxmpp
Fixes #248.
2023-03-20 13:07:15 +01:00
fd1e14e4cd Merge pull request 'Note to self feature addition' (#256) from ikjot-2605/moxxy:feature_note_to_self into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/256
2023-03-20 11:52:25 +00:00
Ikjot Singh Dhody
a78db354ab feat(notes): Handle reactions correctly for notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-20 13:41:14 +05:30
Ikjot Singh Dhody
a86d83eeba feat(notes): Fix upload logger.
Consistent method for checking note vs chat conversation.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-19 00:56:45 +05:30
Ikjot Singh Dhody
02e73ade5e feat(notes): Utilize converation object for notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-18 15:33:17 +05:30
9d0a84b317 fix(ui): Fix text overflow when quoting a file
Fixes #261.
2023-03-17 21:28:32 +01:00
Ikjot Singh Dhody
0cf237914b feat(notes): Update read marker cleaner for notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-17 03:58:47 +05:30
Ikjot Singh Dhody
398c23fccb feat(notes): Remove unneccessary changes.
Remove unneccessary "sent" changes in file widgets.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-17 03:41:52 +05:30
8f68292dfd fix(ui): Fix UI crash when less than 8 shared media items are available 2023-03-16 17:09:58 +01:00
Ikjot Singh Dhody
8ef62e7ff1 feat(notes): Read/Encrypted Markers for Notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-16 00:58:41 +05:30
Ikjot Singh Dhody
99257f4b28 feat(notes): Disable Chat State/File Upload-Notes
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-15 23:51:48 +05:30
ikjot-2605
9f529a3a1c Merge branch 'master' into feature_note_to_self 2023-03-15 17:00:04 +00:00
8178a0dd8a fix(ui): Remove the quote *AFTER* sending the message 2023-03-15 12:36:36 +01:00
0f250b6eae fix(ui): Sending a message does not reset the quoted message 2023-03-15 12:34:04 +01:00
716579cc5e chore(ui): Remove unused import 2023-03-15 12:33:47 +01:00
25caf3f4a6 fix(ui): Use the filename attribute also in quotes 2023-03-15 12:29:02 +01:00
Ikjot Singh Dhody
1c1b598768 feat(notes): Fix quoted file widget.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-15 02:57:41 +05:30
ikjot-2605
7cbb56dc2c Merge branch 'master' into feature_note_to_self 2023-03-14 21:25:11 +00:00
7f41ec2aac fix(ui): Always directly use the filename attribute in media widgets 2023-03-14 21:13:20 +01:00
Ikjot Singh Dhody
ac5fc38de6 feat(notes): Formatting changes.
Update file.dart to work when unified filename is used.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-15 00:45:10 +05:30
1f3c568d0c chore(ui): Restructure the message widget directory
Fixes #257.
2023-03-14 00:28:20 +01:00
Ikjot Singh Dhody
2a186377df feat(notes): Add edit note capability.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 06:42:42 +05:30
Ikjot Singh Dhody
d529974cd9 feat(notes): Hide forward option for notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 06:32:28 +05:30
Ikjot Singh Dhody
f378c60bf5 feat(notes): Handle message/note retractions.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 06:28:40 +05:30
Ikjot Singh Dhody
e4523a2d33 feat(notes): Handle file upload for notes.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 06:24:55 +05:30
Ikjot Singh Dhody
4aacd36c59 feat(notes): Add switch case for helper.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 05:18:00 +05:30
Ikjot Singh Dhody
a291d9ab07 feat(notes): Database migration-Conversation type.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 04:55:52 +05:30
Ikjot Singh Dhody
9d73fc3a94 feat(notes): Miscellaneous fixes - Review 1.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 04:43:20 +05:30
Ikjot Singh Dhody
8a33d88e31 feat(notes): German translation - SpeedDialChild.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-13 00:03:48 +05:30
Ikjot Singh Dhody
6650686d48 feat(notes): Update ConversationTopBar for Notes.
For notes, don't show block user, add to contacts row.

For notes, don't give encryption options, and don't allow to block user.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-12 23:44:47 +05:30
Ikjot Singh Dhody
8570997cb0 feat(notes): Remove moxxmpp implementation - Note.
Message stanza not sent for Notes to Self.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-12 23:15:12 +05:30
Ikjot Singh Dhody
31ee7b919b feat(notes): Diasble Notification Service for Note
Format SpeedDialChild for Conversation.

Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-10 13:44:26 +05:30
Ikjot Singh Dhody
30f6ecd2f8 feat(notes): Fix enum decoding - ConversationType.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-10 13:32:19 +05:30
Ikjot Singh Dhody
9e3700001d feat(notes): Add 'type' argument to Conversation.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-08 17:50:55 +05:30
Ikjot Singh Dhody
2928602e8d feat(notes): Remove JAVA_HOME override from gradle
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-07 21:00:31 +05:30
Ikjot Singh Dhody
09fc55d2c7 Merge branch 'master' into feature_note_to_self
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-07 20:58:44 +05:30
b391425d48 fix(docs): Fix grammar 2023-03-07 12:32:45 +01:00
3b21486647 fix(style): Remove some comprehensions 2023-03-07 12:13:18 +01:00
641ac01b33 fix(docs): Fix formatting of checklist 2023-03-07 12:00:15 +01:00
233370b448 fix(style): Ensure commit message starts with uppercase 2023-03-07 11:55:44 +01:00
45bff04329 feat(docs): Add a contribution document (#252) 2023-03-07 11:54:46 +01:00
6d32387e6c feat(shared): Remove the Semaphore (#255) 2023-03-07 00:29:20 +01:00
4f51cf1f80 feat(ui): Remove empty files 2023-03-07 00:28:13 +01:00
46f7e5beaa fix(meta): Reformat code 2023-03-06 23:38:14 +01:00
fee39f56fa fix(meta): Fix linter warnings 2023-03-06 23:35:08 +01:00
a3e8758dbd Merge pull request 'feat: use dart format to format code' (#254) from coder-with-a-bushido/moxxy:dartformat-patch into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/254
2023-03-06 19:27:23 +00:00
Ikjot Singh Dhody
2b6ed19847 feat(notes): Make JID "" for notes conversation.
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-06 18:47:31 +05:30
Ikjot Singh Dhody
34971950ad feat(notes): Add notes to self SpeedDialChild
Signed-off-by: Ikjot Singh Dhody <ikjotsd@gmail.com>
2023-03-06 16:43:48 +05:30
Karthikeyan S
29b22b7dd9 feat: use dart format to format code 2023-03-06 10:19:10 +05:30
8bc4771345 fix(ui): "non-colored" bottom icons should always be white 2023-03-04 13:10:48 +01:00
314c8f8d18 fix(service): Fix custom update and return routine
Use sqflite's SqlBuilder to build the queries. This
is much less error prone that what I did before.
2023-03-03 13:58:31 +01:00
dd3e47e492 feat(service): Utilise Sqlite's RETURNING statement
Fixes #247.
2023-03-03 13:13:36 +01:00
7f90f3315a docs(meta): Add common build issues 2023-03-02 11:39:35 +01:00
ceb43c0f0f feat(ui): Use GridViews in more places 2023-03-01 22:00:05 +01:00
e225cab90a fix(ui): Only show the summary item when we have >= 8 shared media items 2023-03-01 18:09:17 +01:00
87793a032c feat(service): Cache the first message page for 5 conversations (#166) 2023-03-01 17:46:32 +01:00
b3227129d5 fix(ui): Fix unread badges 2023-03-01 17:22:17 +01:00
5861c7f8cb feat(meta): Update to Flutter 3.7.3 2023-03-01 13:29:20 +01:00
1181d1c526 Merge pull request 'Paginate messages and shared media' (#239) from feat/message-pagination into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/239
2023-02-26 21:13:12 +00:00
b3c02324aa feat(service): Remove the message cache 2023-02-26 18:54:20 +01:00
3664b5f8c5 fix(service): Fix the display of shared media 2023-02-23 21:09:55 +01:00
d58bf448ef feat(ui): Replace Wrap with a GridView 2023-02-23 17:09:09 +01:00
95d1e1ed38 feat(meta): Paginate shared media loading 2023-02-23 16:59:15 +01:00
bbaa41f389 feat(meta): Implement the last missing message pagination code 2023-02-22 11:46:29 +01:00
20bff17c74 chore(meta): Fix linter issues 2023-02-21 22:59:41 +01:00
31a7d18905 feat(ui): Move a lot of code into a generic controller 2023-02-21 22:47:03 +01:00
c4f04b73be feat(shared): Rename constant 2023-02-21 15:09:51 +01:00
188c6199c9 fix(ui): Show sent stickers 2023-02-20 15:56:12 +01:00
62413eb8e4 feat(ui): Decode images at a lower resolution 2023-02-19 20:57:05 +01:00
1c4697caa7 fix(ui): Sticker sending 2023-02-19 20:44:53 +01:00
785272ba21 feat(ui): Remove MessageUpdatedEvent 2023-02-19 20:41:48 +01:00
d28e669b5f fix(ui): Bubble corner radius 2023-02-19 20:39:28 +01:00
fe3b07aa2f fix(ui): Message reactions 2023-02-19 20:32:48 +01:00
a21ecf9bbf fix(ui): Retracted messages have emoji-only font size 2023-02-19 20:22:20 +01:00
55113543dd fix(ui): Message retraction 2023-02-19 20:20:06 +01:00
76041671eb fix(ui): Handle chat states 2023-02-19 20:16:07 +01:00
be2d4ec29f feat(ui): Implement message updating 2023-02-19 20:01:08 +01:00
dfa221768c feat(ui): Handle new messages 2023-02-19 19:52:48 +01:00
9b2278a0ff feat(ui): Move pickerVisible into the controller 2023-02-19 18:22:31 +01:00
24b0a0c7bb feat(ui): Remove obsolete code from the BLoC 2023-02-19 18:02:22 +01:00
023ad574a8 fix(ui): Fetch our own JID 2023-02-19 17:55:37 +01:00
74772dc6b5 fix(ui): Fix the microphone button not disappearing 2023-02-19 17:50:42 +01:00
8fc7734827 fix(ui): Implement quoting a message 2023-02-19 17:40:52 +01:00
43659b01bd feat(meta): Fix scrollToBottom and message sending 2023-02-19 17:27:12 +01:00
de2e2f3987 feat(meta): Paginate message requests 2023-02-18 21:02:55 +01:00
28591a6787 feat(ui): Render date bubbles in a less stupid way 2023-02-18 16:43:24 +01:00
e78dae0950 fix(service): Fix crash on startup 2023-02-10 19:52:34 +01:00
5b86f69444 fix(ui): Clamp text quotes to 2 lines maximum
Fixes #237.
2023-02-10 19:37:00 +01:00
92a7d30e43 feat(service): Improve and fix the API for ConversationService 2023-02-03 20:33:04 +01:00
fa311bfb95 feat(service): Remove autoAcceptSubscriptionRequests 2023-02-03 17:07:03 +01:00
c1988a9bcd chore(meta): Rework the conversation service
- Conversations are now uniquely identified by their JID instead of some
  ID
- The ConversationService's cache is now guarded by a lock
2023-02-03 12:15:49 +01:00
27185b21b5 feat(i18n): Devices -> Security 2023-02-01 13:57:14 +01:00
bad4295aec feat(ui): Show something when we have no sessions with a JID 2023-02-01 13:55:02 +01:00
b891f29e11 feat(meta): Remove explicit subscription management 2023-01-31 16:56:14 +01:00
35a752e565 feat(ui): Auto-accept subscription requests if the JID is in the roster 2023-01-31 12:33:39 +01:00
6c5189744a fix(ui): Fix off-by-one error 2023-01-31 12:20:56 +01:00
81e9a7d420 feat(meta): Integrate subscription requests 2023-01-30 21:27:05 +01:00
3a01025471 feat(service): Log why OMEMO publishing fails 2023-01-30 17:31:13 +01:00
e652ecca44 fix(ui): Make the first message appear at the top of the screen 2023-01-30 17:30:55 +01:00
c244d54d22 fix(ui): Keep the intro page behind the login page 2023-01-30 17:03:36 +01:00
cff9000d6b fix(ui): Back button's splash is behind the bar 2023-01-30 17:03:14 +01:00
dc8804de3a fix(ui): Fix logging out not navigating correctly 2023-01-30 16:59:04 +01:00
92467630cd feat(ui): Use flutter_list_view for the message list
This allows us to eventually jump to list indices.
Closes #232.
Also adds a missing date bubble at the top.
2023-01-29 16:23:46 +01:00
452734a433 chore(service): Rename init to initialize 2023-01-29 15:38:54 +01:00
49c7b18d57 fix(service): Stop no connection timer when we lost network connectivity 2023-01-29 15:36:20 +01:00
f7665403b9 chore(service): Move XmppState into its own service 2023-01-28 20:45:51 +01:00
9ae047b2d0 chore(service): Use direct initialization 2023-01-28 20:19:30 +01:00
4523d87028 chore(service): Move createFallbackBodyForQuotedMessage into helpers 2023-01-28 20:14:47 +01:00
c34c0ffd0f feat(service): Show notification on unrecoverable error 2023-01-28 20:12:27 +01:00
a179d0f6cc chore(meta): Use moxxmpp 0.2.0 2023-01-27 22:04:47 +01:00
6c1b7c54d0 feat(meta): Bump moxxmpp 2023-01-27 19:23:08 +01:00
bbb59ac2cc fix(service): Remove workaround 2023-01-27 19:22:27 +01:00
f16d33decd feat(service): Adapt to moxxmpp changes 2023-01-27 00:15:12 +01:00
c4e5504c1d feat(service): Exclude roster and Carbons from OMEMO 2023-01-23 13:03:31 +01:00
0fb8230e50 feat(service): Ignore key exchange errors
Fixes #228.
2023-01-23 11:54:21 +01:00
86be724246 feat(meta): Bump moxxmpp and omemo_dart
Should fix some OMEMO issues that have crept up.
2023-01-22 19:27:41 +01:00
27b3ad0da5 fix(meta): Mark XEP-0384 as complete 2023-01-21 21:17:02 +01:00
25167ed078 fix(meta): Fix every message being an error 2023-01-21 20:48:58 +01:00
7fb0cf139b fix(ui): Fix crash when tapping notification while app is dead
Fixes #217.
2023-01-21 20:41:51 +01:00
6e8d54c91b Merge branch 'Poussinou-master' 2023-01-21 19:46:33 +01:00
a6191fd8af fix(docs): Revert badge formatting 2023-01-21 19:46:09 +01:00
bfeea6ffa5 fix(ui): Stop sending chat states for not focused chats
Fixes #221.
2023-01-21 19:45:27 +01:00
48451385e9 feat(service): Treat acknowledged as displayed and received 2023-01-21 19:45:27 +01:00
0e894f84cc fix(i18n): Translate forgotten string 2023-01-21 19:45:27 +01:00
0ca12232a8 fix(meta): Show an error (again) if contact does not support OMEMO:2
Fixes #195.
2023-01-21 19:45:27 +01:00
c2d28efe62 fix(ui): Stop sending chat states for not focused chats
Fixes #221.
2023-01-21 16:07:32 +01:00
0496c38496 feat(service): Treat acknowledged as displayed and received 2023-01-21 15:48:39 +01:00
dd4c481c4f fix(i18n): Translate forgotten string 2023-01-21 15:48:21 +01:00
7f1b5233e8 fix(meta): Show an error (again) if contact does not support OMEMO:2
Fixes #195.
2023-01-21 15:43:36 +01:00
Poussinou
41aae3cab9 Mise à jour de 'README.md' 2023-01-21 10:50:43 +00:00
9838fbc95f fix(ui): Bind the displayed version to the pubspec
Adds a new builder to moxxyv2_builders that just extracts
the version string from the pubspec.yaml.
2023-01-20 14:29:10 +01:00
f5c59823bf fix(ui): Closing chat does not navigate back 2023-01-18 22:17:03 +01:00
315 changed files with 20670 additions and 12881 deletions

2
.gitignore vendored
View File

@@ -62,4 +62,4 @@ lib/i18n/*.dart
.android .android
# Build scripts # Build scripts
release/ release-*/

View File

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

101
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,101 @@
# Contribution Guide
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
on the Moxxy codebase.
## Non-Code Contributions
### Translations
You can contribute to Moxxy by translating parts of Moxxy into a language you can speak. To do that, head over to [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/), where you can start translating.
## Prerequisites
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
Moxxy requires Flutter 3.7.3, since we use a fork of the Flutter library, and the JDK 17. Building Moxxy
is currently only supported for Android.
### Android Studio
If you use Android Studio, make sure that you use version "Flamingo Canary 3", as that one comes bundled with
JDK 17, instead of JDK 11 ([See here](https://codeberg.org/moxxy/moxxy/issues/252)). If that is
not an option, you can manually add a JDK 17 installation in Android Studio and tell the Flutter addon
to use that installation instead.
### NixOS
If you use NixOS or Nix, you can use the dev shell provided by the Flake in the repository's root. It contains
the correct JDK and Flutter version. However, make sure that other environment variables, like
`ANDROID_HOME` and `ANDROID_AVD_HOME`, are correctly set.
## Building
Currently, Moxxy contains a git submodule. While it is not utilised at the moment, it contains
the list of suggested XMPP providers to use for auto-registration. To properly clone the
repository, use `git clone --recursive https://codeberg.org/moxxy/moxxy.git`
In order to build Moxxy, you first have to run the code generator. To do that, first install all dependencies with
`flutter pub get`. Next, run the code generator using `flutter pub run build_runner build`. This builds required
data classes and the i18n support.
Finally, you can build Moxxy using `flutter run`, if you want to test a change, or `flutter build apk --release` to build
an optimized release build. The release builds found in the repository's releases are build using `flutter build apk --release --split-per-abi`.
## Contributing
If you want to fix a small issue, you can just fork, create a new branch, and start working right away. However, if you want to work
on a bigger feature, please first create an issue (if an issue does not already exist) or join the [development chat](xmpp:moxxy@muc.moxxy.org?join) (xmpp:moxxy@muc.moxxy.org?join)
to discuss the feature first.
Before creating a pull request, please make sure you checked every item on the following checklist:
- [ ] I formatted the code with the dart formatter (`dart format`) before running the linter
- [ ] I ran the linter (`flutter analyze`) and introduced no new linter warnings
- [ ] I ran the tests (`flutter test`) and introduced no new failing tests
- [ ] I used [gitlint](https://github.com/jorisroovers/gitlint) to ensure propper formatting of my commig messages
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.
### Tips
#### `data_classes.yaml`
When you add, remove, or modify data classes in `data_classes.yaml`, you need to rebuild the classes using `flutter pub run build_runner build`. However, there appears
to be a bug in my own build runner script, which prevents the data classes from being
rebuilt if they are changed. To fix this, remove the generated data classes by running
`rm lib/shared/*.moxxy.dart`, after which build_runner will rebuild the data classes.
### Code Guidelines
#### Translations
If your code adds new strings that should be translated, only add them to the base
language, which is English. Even if you know more than English, do not add the keys
to other language files. To prevent merge conflicts between Weblate and the repository,
all other languages are managed via [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
#### Commit messages
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file
at the root of the repository. `gitlint` can be installed as a pre-commit hook using
`gitlint install-hook`. That way, `gitlint` runs on every commit and warns you if the
commit message violates any of the defined rules.
Commit messages always follow the following format:
```
<type>(<areas>): <summary>
<full message>
```
`<type>` is the type of action that was performed in the commit and is one of the following: `feat` (Addition of a feature), `fix` (Fix a bug or other issue), `chore` (Bump dependency versions, fix formatter issues), `refactor` (A bigger "moving around" or rewriting of code), `docs` (Commits that just touch the documentation, be it code or, for example, the README).
`<areas>` are the areas inside the code that are touched by the change. They are a comma-separated list of one or more of the following: `service` (Everything inside `lib/service`), `ui` (Everything inside `lib/ui`), `shared` (Everything inside `lib/shared`), `all` (A bit of everything is involved), `tests` (Everyting inside `test` or `integration_test`), `i18n` (The translation files have been modified), `docs` (Documentation of any kind), `flake` (The NixOS flake has been modified).
`<summary>` is the summary of the entire commit in a few words. Make that that the entire
first line is not longer than 72 characters. `<summary>` also must start with an uppercase
letter or a number.
The `<full message>` is optional. In case your commit requires more explanation, write it
there. Make sure that there is an empty line between the full message and the summary line.
The exception to these rules is a commit message of the format `release: Release version x.y.z`, as it touches everything and is thus implicitly using `(all)` as an area code.

View File

@@ -2,38 +2,29 @@
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/moxxyv2). 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)
Or [get the latest APK from Codeberg](https://codeberg.org/moxxy/moxxy/releases/latest).
## Screenshots ## Screenshots
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png) [<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png) [<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
## Developing and Building ## Building and Contributing
Clone using `git clone --recursive https://github.com/Polynomdivision/moxxyv2.git`. For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CONTRIBUTING.md)
In order to build Moxxy, you need to have [Flutter](https://docs.flutter.dev/get-started/install) set
up. If you are running NixOS or using Nix, you can also use the Flake at the root of the repository
by running `nix develop` to get a development shell including everything that is needed. Note
that if you decide to use the Flake, `ANDROID_HOME` and `ANDROID_AVD_HOME` must be set to the respective directories.
Before building Moxxy, you need to generate all needed data classes. To do this, run
`flutter pub get` to install all dependencies. Then run `flutter pub run build_runner build` to generate
state classes, data classes and the database schemata. After that is done, you can either
build the app with `flutter build apk --debug` to create a debug build,
`flutter build apk --release` to create a relase build or just run the app in development
mode with `flutter run`.
After implementing a change or a feature, please ensure that nothing is broken by the change
by running `flutter test` afterwards. Also make sure that the code passes the linter by
running `flutter analyze`. This project also uses [gitlint](https://github.com/jorisroovers/gitlint)
to ensure uniform formatting of commit messages.
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

@@ -6,13 +6,11 @@ linter:
use_setters_to_change_properties: false use_setters_to_change_properties: false
avoid_positional_boolean_parameters: false avoid_positional_boolean_parameters: false
avoid_bool_literals_in_conditional_expressions: false avoid_bool_literals_in_conditional_expressions: false
file_names: false
analyzer: analyzer:
exclude: exclude:
- "**/*.g.dart" - "**/*.g.dart"
- "**/*.freezed.dart" - "**/*.freezed.dart"
- "**/*.moxxy.dart" - "**/*.moxxy.dart"
- "test/"
- "integration_test/"
- "lib/service/database/migrations/*.dart"
- "lib/i18n/*.dart" - "lib/i18n/*.dart"

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

@@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext.kotlin_version = '1.8.21'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@@ -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,345 +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"
}
},
"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"
},
"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"
}
},
"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",
"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"
},
"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": "Devices",
"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."
}
},
"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.",
"autoAcceptSubscriptionRequests": "Auto-accept subscription requests",
"autoAcceptSubscriptionRequestsSubtext": "If enabled, subscription requests will be automatically accepted if the user is in the contact list.",
"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,345 +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": {
"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",
}, "verificationNotInList": "OMEMO:2 Gerät unbekannt",
"login": { "verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
"saslFailed": "Ungültige Logindaten", },
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen", "connection": {
"noConnection": "Konnte keine Verbindung zum Server aufbauen", "connectionTimeout": "Verbindung zum Server nicht möglich",
"unspecified": "Unbestimmter Fehler" "saslAccountDisabled": "Dein Konto ist deaktiviert",
}, "saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
"message": { "unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
"unspecified": "Unbekannter Fehler", },
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen", "login": {
"contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht", "saslFailed": "Ungültige Logindaten",
"fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen", "startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
"serviceUnavailable": "Die Nachricht konnte nicht gesendet werden", "noConnection": "Konnte keine Verbindung zum Server aufbauen",
"remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden", "unspecified": "Unbestimmter Fehler"
"remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist", },
"failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden", "message": {
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden", "unspecified": "Unbekannter Fehler",
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden", "fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen",
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen" "contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht",
}, "fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen",
"conversation": { "serviceUnavailable": "Die Nachricht konnte nicht gesendet werden",
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme", "remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden",
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen", "remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist",
"openFileGenericError": "Fehler beim Öffnen der Datei" "failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden",
} "failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
},
"conversation": {
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
"openFileGenericError": "Fehler beim Öffnen der Datei",
"messageErrorDialogTitle": "Fehler"
},
"newChat": {
"remoteServerError": "Konnte den Server nicht erreichen.",
"groupchatUnsupported": "Das Beitreten eines Gruppenchats ist aktuell nicht unterstützt.",
"unknown": "Unbekannter Fehler."
}
}, },
"warnings": { "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",
"overlaySettings": "Einstellungen", "speeddialAddNoteToSelf": "Notiz an mich",
"noOpenChats": "Du hast keine offenen chats", "overlaySettings": "Einstellungen",
"startChat": "Einen chat anfangen", "noOpenChats": "Du hast keine offenen chats",
"closeChat": "Chat schließen", "startChat": "Einen chat anfangen",
"closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?", "closeChat": "Chat schließen",
"markAsRead": "Als gelesen markieren" "closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?",
}, "markAsRead": "Als gelesen markieren"
"conversation": { },
"unencrypted": "Unverschlüsselt", "conversation": {
"encrypted": "Verschlüsselt", "unencrypted": "Unverschlüsselt",
"closeChat": "Chat schließen", "encrypted": "Verschlüsselt",
"closeChatConfirmTitle": "Chat schließen", "closeChat": "Chat schließen",
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?", "closeChatConfirmTitle": "Chat schließen",
"blockShort": "Blockieren", "closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
"blockUser": "Nutzer blockieren", "blockShort": "Blockieren",
"online": "Online", "blockUser": "Nutzer blockieren",
"retract": "Nachricht löschen", "online": "Online",
"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.", "retract": "Nachricht löschen",
"forward": "Weiterleiten", "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.",
"edit": "Bearbeiten", "forward": "Weiterleiten",
"quote": "Zitieren", "edit": "Bearbeiten",
"copy": "Inhalt kopieren", "quote": "Zitieren",
"addReaction": "Reaktion hinzufügen", "copy": "Inhalt kopieren",
"showError": "Fehler anzeigen", "messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
"showWarning": "Warnung anzeigen", "addReaction": "Reaktion hinzufügen",
"addToContacts": "Zu Kontaken hinzufügen", "showError": "Fehler anzeigen",
"addToContactsTitle": "${jid} zu Kontakten hinzufügen", "showWarning": "Warnung anzeigen",
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?", "warning": "Warnung",
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.", "addToContacts": "Zu Kontaken hinzufügen",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.", "addToContactsTitle": "${jid} zu Kontakten hinzufügen",
"stickerSettings": "Stickereinstellungen", "addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt", "stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"messageHint": "Nachricht senden...", "stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"sendImages": "Bilder senden", "stickerSettings": "Stickereinstellungen",
"sendFiles": "Dateien senden", "newDeviceMessage": {
"takePhotos": "Bilder aufnehmen" "one": "Ein neues Gerät wurde hinzugefügt",
}, "other": "Mehrere neue Geräte wurden hinzugefügt"
"addcontact": { },
"title": "Neuen Kontakt hinzufügen", "replacedDeviceMessage": {
"xmppAddress": "XMPP-Adresse", "one": "Ein Gerät hat sich verändert",
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst", "other": "Mehrere Geräte haben sich verändert"
"buttonAddToContact": "Kontakt hinzufügen" },
}, "messageHint": "Nachricht senden...",
"newconversation": { "sendImages": "Bilder senden",
"title": "Neuer chat", "sendFiles": "Dateien senden",
"addContact": "Kontakt hinzufügen", "takePhotos": "Bilder aufnehmen"
"createGroupchat": "Gruppenchat erstellen" },
}, "startchat": {
"crop": { "title": "Neuer Chat",
"setProfilePicture": "Als Profilbild festlegen" "xmppAddress": "XMPP-Adresse",
}, "subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
"shareselection": { "buttonAddToContact": "Neuen Chat beginnen"
"shareWith": "Teilen mit...", },
"confirmTitle": "Dateien senden?", "newconversation": {
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?" "title": "Neuer Chat",
}, "startChat": "Neuen Chat beginnen",
"profile": { "createGroupchat": "Gruppenchat erstellen"
"general": { },
"omemo": "Sicherheit" "crop": {
}, "setProfilePicture": "Als Profilbild festlegen"
"conversation": { },
"notifications": "Benachrichtigungen", "shareselection": {
"notificationsMuted": "Stumm", "shareWith": "Teilen mit...",
"notificationsEnabled": "Eingeschaltet", "confirmTitle": "Dateien senden?",
"sharedMedia": "Medien" "confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
}, },
"owndevices": { "profile": {
"title": "Eigene Geräte", "general": {
"thisDevice": "Dieses Gerät", "omemo": "Sicherheit",
"otherDevices": "Andere Geräte", "profile": "Profil",
"deleteDeviceConfirmTitle": "Gerät löschen", "media": "Medien"
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?", },
"recreateOwnSessions": "Sessions neuerstellen", "conversation": {
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?", "notifications": "Benachrichtigungen",
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.", "notificationsMuted": "Stumm",
"recreateOwnDevice": "Gerät neuerstellen", "notificationsEnabled": "Eingeschaltet",
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?", "sharedMedia": "Medien"
"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?" },
}, "owndevices": {
"devices": { "title": "Eigene Geräte",
"title": "Geräte", "thisDevice": "Dieses Gerät",
"recreateSessions": "Sessions zurücksetzen", "otherDevices": "Andere Geräte",
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?", "deleteDeviceConfirmTitle": "Gerät löschen",
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen." "deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
} "recreateOwnSessions": "Sessions neuerstellen",
}, "recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
"blocklist": { "recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
"title": "Blockliste", "recreateOwnDevice": "Gerät neuerstellen",
"noUsersBlocked": "Du hast niemanden blockiert", "recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
"unblockAll": "Alle entblocken", "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?"
"unblockAllConfirmTitle": "Alle entblocken", },
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?", "devices": {
"unblockJidConfirmTitle": "${jid} entblocken?", "title": "Sicherheit",
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können." "recreateSessions": "Sessions zurücksetzen",
}, "recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
"cropbackground": { "recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
"blur": "Hintergrund weichzeichnen", "noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
"setAsBackground": "Als Hintergrundbild festlegen" }
}, },
"stickerPack": { "blocklist": {
"removeConfirmTitle": "Stickerpack entfernen", "title": "Blockliste",
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?", "noUsersBlocked": "Du hast niemanden blockiert",
"installConfirmTitle": "Stickerpack installieren", "unblockAll": "Alle entblocken",
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?", "unblockAllConfirmTitle": "Alle entblocken",
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.", "unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
"fetchingFailure": "Konnte das Stickerpack nicht finden" "unblockJidConfirmTitle": "${jid} entblocken?",
}, "unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
"settings": { },
"settings": { "cropbackground": {
"title": "Einstellungen", "blur": "Hintergrund weichzeichnen",
"conversationsSection": "Unterhaltungen", "setAsBackground": "Als Hintergrundbild festlegen"
"accountSection": "Account", },
"signOut": "Abmelden", "stickerPack": {
"signOutConfirmTitle": "Abmelden", "removeConfirmTitle": "Stickerpack entfernen",
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?", "removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
"miscellaneousSection": "Unterschiedlich", "installConfirmTitle": "Stickerpack installieren",
"debuggingSection": "Debugging", "installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
"general": "Generell" "restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
}, "fetchingFailure": "Konnte das Stickerpack nicht finden"
"about": { },
"title": "Über", "settings": {
"licensed": "Lizensiert unter GPL3", "settings": {
"version": "Version ${version}", "title": "Einstellungen",
"viewSourceCode": "Quellcode anschauen", "conversationsSection": "Unterhaltungen",
"nMoreToGo": "Noch ${n}...", "accountSection": "Konto",
"debugMenuShown": "Du bist jetzt ein Entwickler!", "signOut": "Abmelden",
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!" "signOutConfirmTitle": "Abmelden",
}, "signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
"appearance": { "miscellaneousSection": "Unterschiedlich",
"title": "Aussehen", "debuggingSection": "Debugging",
"languageSection": "Sprache", "general": "Generell"
"language": "Appsprache", },
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage", "about": {
"systemLanguage": "Systemsprache" "title": "Über",
}, "licensed": "Lizensiert unter GPL3",
"licenses": { "version": "Version ${version}",
"title": "Open-Source Lizenzen", "viewSourceCode": "Quellcode anschauen",
"licensedUnder": "Lizensiert unter $license" "nMoreToGo": "Noch ${n}...",
}, "debugMenuShown": "Du bist jetzt ein Entwickler!",
"conversation": { "debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
"title": "Chat", },
"appearance": "Aussehen", "appearance": {
"selectBackgroundImage": "Hintergrundbild auswählen", "title": "Aussehen",
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet", "languageSection": "Sprache",
"removeBackgroundImage": "Hintergrundbild entfernen", "language": "Appsprache",
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen", "languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?", "systemLanguage": "Systemsprache"
"newChatsSection": "Neue Chats", },
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten", "licenses": {
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell", "title": "Open-Source Lizenzen",
"behaviourSection": "Verhalten", "licensedUnder": "Lizensiert unter $license"
"contactsIntegration": "Kontaktintegration", },
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet." "conversation": {
}, "title": "Chat",
"debugging": { "appearance": "Aussehen",
"title": "Debuggingoptionen", "selectBackgroundImage": "Hintergrundbild auswählen",
"generalSection": "Generell", "selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
"generalEnableDebugging": "Debugging einschalten", "removeBackgroundImage": "Hintergrundbild entfernen",
"generalEncryptionPassword": "Verschlüsselungspasswort", "removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase", "removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
"generalLoggingIp": "Logging-IP", "newChatsSection": "Neue Chats",
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden", "newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
"generalLoggingPort": "Logging-Port", "newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden" "behaviourSection": "Verhalten",
}, "contactsIntegration": "Kontaktintegration",
"network": { "contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
"title": "Netzwerk", },
"automaticDownloadsSection": "Automatische Downloads", "debugging": {
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...", "title": "Debuggingoptionen",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße", "generalSection": "Generell",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll", "generalEnableDebugging": "Debugging einschalten",
"automaticDownloadAlways": "Immer", "generalEncryptionPassword": "Verschlüsselungspasswort",
"wifi": "Wifi", "generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
"mobileData": "Mobile Daten" "generalLoggingIp": "Logging-IP",
}, "generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
"privacy": { "generalLoggingPort": "Logging-Port",
"title": "Privatsphäre", "generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
"generalSection": "Generell", },
"showContactRequests": "Kontaktanfragen zeigen", "network": {
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben", "title": "Netzwerk",
"profilePictureVisibility": "Öffentliches Profilbild", "automaticDownloadsSection": "Automatische Downloads",
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen", "automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"autoAcceptSubscriptionRequests": "Subscriptionanfragen automatisch annehmen", "automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"autoAcceptSubscriptionRequestsSubtext": "Wenn aktiviert, dann werden Subscriptionanfragen automatisch angenommen, wenn die Person in deiner Kontaktliste ist", "automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"conversationsSection": "Unterhaltungen", "automaticDownloadAlways": "Immer",
"sendChatMarkers": "Chatmarker senden", "wifi": "WLAN",
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast", "mobileData": "Mobile Daten"
"sendChatStates": "Chatstates senden", },
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst", "privacy": {
"redirectsSection": "Weiterleitungen", "title": "Privatsphäre",
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy", "generalSection": "Generell",
"currentlySelected": "Aktuell ausgewählt: $proxy", "showContactRequests": "Kontaktanfragen zeigen",
"redirectsTitle": "${serviceName}weiterleitung", "showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren", "profilePictureVisibility": "Öffentliches Profilbild",
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.", "profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
"urlEmpty": "URL kann nicht leer sein", "conversationsSection": "Unterhaltungen",
"urlInvalid": "Ungültige URL", "sendChatMarkers": "Chatmarker senden",
"redirectDialogTitle": "${serviceName}weiterleitung", "sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
"stickersPrivacy": "Stickerliste öffentlich halten", "sendChatStates": "Chatstates senden",
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen." "sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
}, "redirectsSection": "Weiterleitungen",
"stickers": { "redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
"title": "Stickers", "currentlySelected": "Aktuell ausgewählt: $proxy",
"stickerSection": "Sticker", "redirectsTitle": "${serviceName}weiterleitung",
"displayStickers": "Sticker im Chat anzeigen", "cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
"autoDownload": "Sticker automatisch herunterladen", "cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.", "urlEmpty": "URL kann nicht leer sein",
"stickerPacksSection": "Stickerpacks", "urlInvalid": "Ungültige URL",
"importStickerPack": "Stickerpack importieren", "redirectDialogTitle": "${serviceName}weiterleitung",
"importSuccess": "Stickerpack erfolgreich importiert", "stickersPrivacy": "Stickerliste öffentlich halten",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten" "stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
} },
} "stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Sticker im Chat anzeigen",
"autoDownload": "Sticker automatisch herunterladen",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
"stickerPacksSection": "Stickerpacks",
"importStickerPack": "Stickerpack importieren",
"importSuccess": "Stickerpack erfolgreich importiert",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten",
"stickerPackSize": "(${size})"
},
"storage": {
"title": "Speicher",
"sizePlaceholder": "Berechne...",
"storageManagement": "Speicherverwaltung",
"removeOldMedia": {
"title": "Alte Medien entfernen",
"description": "Löscht alte Medien vom Gerät"
},
"removeOldMediaDialog": {
"title": "Medien löschen",
"options": {
"all": "Alle Medien",
"oneMonth": "Älter als 1 Monat",
"oneWeek": "Älter als 1 Woche"
},
"delete": "Löschen",
"confirmation": {
"body": "Bist Du dir sicher, dass du alte Medien löschen möchtest?"
}
},
"viewMediaFiles": "Medien anzeigen",
"mediaFiles": "Medien",
"types": {
"media": "Medien",
"stickers": "Sticker"
},
"manageStickers": "Stickerpacks verwalten",
"storageUsed": "Speicherplatz verbraucht: ${size}"
}
},
"sharedMedia": {
"empty": {
"chat": "Keine Medien für diesen Chat vorhanden",
"general": "Keine Medien vorhanden"
}
}
} }
} }

View File

@@ -0,0 +1,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": "Удерживайте для записи"
}
}
}

BIN
assets/images/empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

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

117
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,11 +77,27 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1669165918, "lastModified": 1689679375,
"narHash": "sha256-hIVruk2+0wmw/Kfzy11rG3q7ev3VTi/IKVODeHcVjFo=", "narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3b400a525d92e4085e46141ff48cbf89fd89739e", "rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -33,8 +109,39 @@
}, },
"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

@@ -3,33 +3,46 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 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
pinnedJDK = pkgs.jdk; cmake-3-18-1
#ndk-21-4-7075529
(ndk-21-4-7075529.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.python27 ];
}))
]);
pinnedJDK = pkgs.jdk17;
pythonEnv = pkgs.python3.withPackages (ps: with ps; [ pythonEnv = pkgs.python3.withPackages (ps: with ps; [
requests pyyaml # For the build scripts requests pyyaml # For the build scripts
@@ -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

@@ -1,67 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:test/test.dart';
class StubConnectivityService extends ConnectivityService {
StubConnectivityService() : super();
@override
ConnectivityResult get currentState => ConnectivityResult.wifi;
}
void main() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time}: ${record.message}');
});
final log = Logger('FailureReconnectionTest');
GetIt.I.registerSingleton<ConnectivityService>(StubConnectivityService());
test('Failing an awaited connection with MoxxyReconnectionPolicy', () async {
var errors = 0;
final connection = XmppConnection(
MoxxyReconnectionPolicy(maxBackoffTime: 1),
TCPSocketWrapper(false),
);
connection.registerFeatureNegotiators([
StartTlsNegotiator(),
]);
connection.registerManagers([
DiscoManager(),
RosterManager(),
PingManager(),
MessageManager(),
PresenceManager('http://moxxmpp.example'),
]);
connection.asBroadcastStream().listen((event) {
if (event is ConnectionStateChangedEvent) {
if (event.state == XmppConnectionState.error) {
errors++;
}
}
});
connection.setConnectionSettings(
ConnectionSettings(
jid: JID.fromString('testuser@no-sasl.badxmpp.eu'),
password: 'abc123',
useDirectTLS: true,
allowPlainAuth: true,
),
);
final result = await connection.connectAwaitable();
log.info('Connection failed as expected');
expect(result.success, false);
expect(errors, 1);
log.info('Waiting 20 seconds for unexpected reconnections');
await Future.delayed(const Duration(seconds: 20));
expect(errors, 1);
}, timeout: Timeout.factor(2));
}

View File

@@ -36,18 +36,6 @@ files:
roster: roster:
type: List<RosterItem>? type: List<RosterItem>?
deserialise: true deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Returned by [GetMessagesForJidCommand]
- name: MessagesResultEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
messages:
type: List<Message>
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
@@ -74,7 +62,7 @@ files:
extends: BackgroundEvent extends: BackgroundEvent
implements: implements:
- JsonImplementation - JsonImplementation
# Send by the service if a message has been received or returned by # [SendMessageCommand]. # Send by the service if a message has been received or returned by [SendMessageCommand].
- name: MessageAddedEvent - name: MessageAddedEvent
extends: BackgroundEvent extends: BackgroundEvent
implements: implements:
@@ -217,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:
@@ -265,6 +253,88 @@ files:
stickerPack: stickerPack:
type: StickerPack type: StickerPack
deserialise: true deserialise: true
# Returned by [GetPagedMessagesCommand]
- name: PagedMessagesResultEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
messages:
type: List<Message>
deserialise: true
# Returned by [GetReactionsForMessageCommand]
- name: ReactionsForMessageResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
reactions:
type: List<ReactionGroup>
deserialise: true
# Triggered when the stream negotiations have been completed
- name: StreamNegotiationsCompletedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
resumed: bool
- name: AvatarUpdatedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
jid: String
path: String
# Returned when attempting to start a chat with a groupchat
- name: JidIsGroupchatEvent
extends: BackgroundEvent
implements:
- JsonImplementation
# 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"
@@ -294,12 +364,7 @@ files:
lastMessageBody: String lastMessageBody: String
avatarUrl: String avatarUrl: String
jid: String jid: String
- name: GetMessagesForJidCommand conversationType: String
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
- name: SetOpenConversationCommand - name: SetOpenConversationCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -319,6 +384,7 @@ files:
deserialise: true deserialise: true
editSid: String? editSid: String?
editId: int? editId: int?
currentConversationJid: String?
- name: SendFilesCommand - name: SendFilesCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -362,6 +428,12 @@ files:
- JsonImplementation - JsonImplementation
attributes: attributes:
jid: String jid: String
- name: RemoveContactCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
- name: RequestDownloadCommand - name: RequestDownloadCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -467,15 +539,14 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
conversationId: int conversationJid: String
- name: MarkMessageAsReadCommand - name: MarkMessageAsReadCommand
extends: BackgroundCommand extends: BackgroundCommand
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:
@@ -516,9 +587,13 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
stickerPackId: String sticker:
stickerHashKey: String type: Sticker
deserialise: true
recipient: String recipient: String
quotes:
type: Message?
deserialise: true
- name: FetchStickerPackCommand - name: FetchStickerPackCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -539,6 +614,68 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
- name: GetPagedMessagesCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
conversationJid: String
olderThan: bool
timestamp: int?
- name: GetPagedSharedMediaCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
conversationJid: String?
olderThan: bool
timestamp: int?
- name: GetReactionsForMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
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,16 +25,16 @@ 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/sharedmedia_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';
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/events.dart'; 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';
@@ -73,7 +79,9 @@ void setupLogging() {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO; Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
// ignore: avoid_print // ignore: avoid_print
print('[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}'); print(
'[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}',
);
}); });
GetIt.I.registerSingleton<Logger>(Logger('MoxxyMain')); GetIt.I.registerSingleton<Logger>(Logger('MoxxyMain'));
} }
@@ -81,19 +89,25 @@ 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) {
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey)); GetIt.I
.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc()); GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc()); GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc()); GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
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<SharedMediaBloc>(SharedMediaBloc());
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());
@@ -116,6 +130,8 @@ void main() async {
await initializeServiceIfNeeded(); await initializeServiceIfNeeded();
imageCache.maximumSizeBytes = 500 * 1024 * 1024;
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(
providers: [ providers: [
@@ -143,11 +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<SharedMediaBloc>(
create: (_) => GetIt.I.get<SharedMediaBloc>(),
), ),
BlocProvider<CropBloc>( BlocProvider<CropBloc>(
create: (_) => GetIt.I.get<CropBloc>(), create: (_) => GetIt.I.get<CropBloc>(),
@@ -185,7 +198,7 @@ void main() async {
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {
const MyApp(this.navigationKey, { super.key }); const MyApp(this.navigationKey, {super.key});
final GlobalKey<NavigatorState> navigationKey; final GlobalKey<NavigatorState> navigationKey;
@override @override
@@ -209,7 +222,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
await GetIt.I.get<UISharingService>().initialize(); await GetIt.I.get<UISharingService>().initialize();
// Lift the UI block // Lift the UI block
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock(); await GetIt.I
.get<SynchronizedQueue<Map<String, dynamic>?>>()
.removeQueueLock();
} }
@override @override
@@ -228,17 +243,19 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
sender.sendData( sender.sendData(
SetCSIStateCommand(active: false), SetCSIStateCommand(active: false),
); );
GetIt.I.get<ConversationBloc>().add(AppStateChanged(false)); BidirectionalConversationController.currentController
break; ?.handleAppStateChange(false);
break;
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
sender.sendData( sender.sendData(
SetCSIStateCommand(active: true), SetCSIStateCommand(active: true),
); );
GetIt.I.get<ConversationBloc>().add(AppStateChanged(true)); BidirectionalConversationController.currentController
break; ?.handleAppStateChange(true);
break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
break; break;
} }
} }
@@ -254,39 +271,80 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
navigatorKey: widget.navigationKey, navigatorKey: widget.navigationKey,
onGenerateRoute: (settings) { onGenerateRoute: (settings) {
switch (settings.name) { switch (settings.name) {
case introRoute: return Intro.route; case introRoute:
case loginRoute: return Login.route; return Intro.route;
case conversationsRoute: return ConversationsPage.route; case loginRoute:
case newConversationRoute: return NewConversationPage.route; return Login.route;
case conversationRoute: return PageTransition<dynamic>( case conversationsRoute:
type: PageTransitionType.rightToLeft, return ConversationsPage.route;
settings: settings, case newConversationRoute:
child: const ConversationPage(), return NewConversationPage.route;
); case conversationRoute:
case sharedMediaRoute: return SharedMediaPage.route; final args = settings.arguments! as ConversationPageArguments;
case blocklistRoute: return BlocklistPage.route; return PageTransition<dynamic>(
case profileRoute: return ProfilePage.route; type: PageTransitionType.rightToLeft,
case settingsRoute: return SettingsPage.route; settings: settings,
case aboutRoute: return SettingsAboutPage.route; child: ConversationPage(
case licensesRoute: return SettingsLicensesPage.route; conversationJid: args.conversationJid,
case networkRoute: return NetworkPage.route; initialText: args.initialText,
case privacyRoute: return PrivacyPage.route; ),
case debuggingRoute: return DebuggingPage.route; );
case addContactRoute: return AddContactPage.route; // case sharedMediaRoute:
case cropRoute: return CropPage.route; // return SharedMediaPage.getRoute(
case sendFilesRoute: return SendFilesPage.route; // settings.arguments! as SharedMediaPageArguments,
case backgroundCroppingRoute: return CropBackgroundPage.route; // );
case shareSelectionRoute: return ShareSelectionPage.route; case blocklistRoute:
case serverInfoRoute: return ServerInfoPage.route; return BlocklistPage.route;
case conversationSettingsRoute: return ConversationSettingsPage.route; case profileRoute:
case devicesRoute: return DevicesPage.route; return ProfilePage.getRoute(
case ownDevicesRoute: return OwnDevicesPage.route; settings.arguments! as ProfileArguments,
case appearanceRoute: return AppearanceSettingsPage.route; );
case qrCodeScannerRoute: return QrCodeScanningPage.getRoute( case settingsRoute:
settings.arguments! as QrCodeScanningArguments, return SettingsPage.route;
); case aboutRoute:
case stickersRoute: return StickersSettingsPage.route; return SettingsAboutPage.route;
case stickerPackRoute: return StickerPackPage.route; case licensesRoute:
return SettingsLicensesPage.route;
case networkRoute:
return NetworkPage.route;
case privacyRoute:
return PrivacyPage.route;
case debuggingRoute:
return DebuggingPage.route;
case addContactRoute:
return StartChatPage.route;
case cropRoute:
return CropPage.route;
case sendFilesRoute:
return SendFilesPage.route;
case backgroundCroppingRoute:
return CropBackgroundPage.route;
case shareSelectionRoute:
return ShareSelectionPage.route;
case serverInfoRoute:
return ServerInfoPage.route;
case conversationSettingsRoute:
return ConversationSettingsPage.route;
case devicesRoute:
return DevicesPage.route;
case ownDevicesRoute:
return OwnDevicesPage.route;
case appearanceRoute:
return AppearanceSettingsPage.route;
case qrCodeScannerRoute:
return QrCodeScanningPage.getRoute(
settings.arguments! as QrCodeScanningArguments,
);
case stickersRoute:
return StickersSettingsPage.route;
case stickerPacksRoute:
return StickerPacksSettingsPage.route;
case stickerPackRoute:
return StickerPackPage.route;
case storageSettingsRoute:
return StorageSettingsPage.route;
case storageSharedMediaSettingsRoute:
return StorageSharedMediaPage.route;
} }
return null; return null;

View File

@@ -1,150 +1,139 @@
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';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.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.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/avatar.dart'; 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(String jid, String hash, List<int> data) async { Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawAvatar = await am.getUserAvatar(jid);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar for $jid');
return false;
}
final avatar = rawAvatar.get<UserAvatarData>();
await _updateAvatarForJid(
jid,
avatar.hash,
avatar.data,
);
return true;
}
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
///
/// Returns true, if everything went okay. Returns false if an error occurred.
Future<bool> requestAvatar(JID jid, String? oldHash) async {
if (_requestedInStream.contains(jid)) {
return true;
}
_requestedInStream.add(jid);
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawId = await am.getAvatarId(jid);
if (rawId.isType<AvatarError>()) {
_log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
return false;
}
final id = rawId.get<String>();
if (id == oldHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return true;
}
return _fetchAvatarForJid(jid, id);
}
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
if (event.metadata.isEmpty) return;
// TODO(Unknown): Maybe make a better decision?
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
}
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
Future<void> _updateAvatarForJid(
JID jid,
String hash,
List<int> data,
) async {
final cs = GetIt.I.get<ConversationService>(); final 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 conv = await cs.updateConversation( final conversation = await cs.createOrUpdateConversation(
originalConversation.id, jid.toString(),
avatarUrl: avatarPath, update: (c) async {
return cs.updateConversation(
jid.toString(),
avatarPath: avatarPath,
avatarHash: hash,
);
},
); );
if (conversation != null) {
sendEvent(ConversationUpdatedEvent(conversation: conv)); sendEvent(
ConversationUpdatedEvent(conversation: conversation),
);
}
} }
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.get<XmppConnection>() AvatarUpdatedEvent(
.getManagerById<UserAvatarManager>(userAvatarManager)!; jid: jid.toString(),
final idResult = await am.getAvatarId(jid); path: avatarPath,
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.
@@ -159,8 +148,9 @@ class AvatarService {
final imageSize = (await getImageSizeFromData(bytes))!; final imageSize = (await getImageSizeFromData(bytes))!;
// Publish data and metadata // Publish data and metadata
final am = GetIt.I.get<XmppConnection>() final am = GetIt.I
.getManagerById<UserAvatarManager>(userAvatarManager)!; .get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
_log.finest('Publishing avatar...'); _log.finest('Publishing avatar...');
final dataResult = await am.publishUserAvatar( final dataResult = await am.publishUserAvatar(
@@ -182,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,
); );
@@ -194,41 +185,52 @@ 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 am = GetIt.I.get<XmppConnection>() final xss = GetIt.I.get<XmppStateService>();
.getManagerById<UserAvatarManager>(userAvatarManager)!; final state = await xss.getXmppState();
final xmpp = GetIt.I.get<XmppService>(); final jid = JID.fromString(state.jid!);
final state = await xmpp.getXmppState();
final jid = state.jid!; if (_requestedInStream.contains(jid)) {
final idResult = await am.getAvatarId(jid);
if (idResult.isType<AvatarError>()) {
_log.info('Error while getting latest avatar id for own avatar');
return; return;
} }
final id = idResult.get<String>(); _requestedInStream.add(jid);
if (id == state.avatarHash) return; final am = GetIt.I
.get<XmppConnection>()
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself'); .getManagerById<UserAvatarManager>(userAvatarManager)!;
final avatarDataResult = await am.getUserAvatar(jid); final rawId = await am.getAvatarId(jid);
if (avatarDataResult.isType<AvatarError>()) { if (rawId.isType<AvatarError>()) {
_log.severe('Failed to fetch our avatar'); _log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
return; return;
} }
final avatarData = avatarDataResult.get<UserAvatar>(); final id = rawId.get<String>();
_log.info('Received data for our own avatar'); if (id == state.avatarHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return;
}
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 xmpp.modifyXmppState((state) => state.copyWith( await xss.modifyXmppState(
avatarUrl: avatarPath, (state) => state.copyWith(
avatarHash: avatarData.hash, avatarUrl: avatarPath,
),); avatarHash: avatarData.hash,
),
);
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash)); sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
} }

View File

@@ -2,14 +2,12 @@ import 'dart:async';
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:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.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/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
enum BlockPushType { enum BlockPushType { block, unblock }
block,
unblock
}
class BlocklistService { class BlocklistService {
BlocklistService(); BlocklistService();
@@ -18,6 +16,23 @@ class BlocklistService {
bool? _supported; bool? _supported;
final Logger _log = Logger('BlocklistService'); final Logger _log = Logger('BlocklistService');
Future<void> _removeBlocklistEntry(String jid) async {
await GetIt.I.get<DatabaseService>().database.delete(
blocklistTable,
where: 'jid = ?',
whereArgs: [jid],
);
}
Future<void> _addBlocklistEntry(String jid) async {
await GetIt.I.get<DatabaseService>().database.insert(
blocklistTable,
{
'jid': jid,
},
);
}
void onNewConnection() { void onNewConnection() {
// Invalidate the caches // Invalidate the caches
_blocklist = null; _blocklist = null;
@@ -26,13 +41,17 @@ class BlocklistService {
} }
Future<bool> _checkSupport() async { Future<bool> _checkSupport() async {
return _supported ??= await GetIt.I.get<XmppConnection>() return _supported ??= await GetIt.I
.getManagerById<BlockingManager>(blockingManager)! .get<XmppConnection>()
.isSupported(); .getManagerById<BlockingManager>(blockingManager)!
.isSupported();
} }
Future<void> _requestBlocklist() async { Future<void> _requestBlocklist() async {
assert(_blocklist != null, 'The blocklist must be loaded from the database before requesting'); assert(
_blocklist != null,
'The blocklist must be loaded from the database before requesting',
);
// Check if blocking is supported // Check if blocking is supported
if (!(await _checkSupport())) { if (!(await _checkSupport())) {
@@ -40,17 +59,17 @@ class BlocklistService {
return; return;
} }
final blocklist = await GetIt.I.get<XmppConnection>() final blocklist = await GetIt.I
.getManagerById<BlockingManager>(blockingManager)! .get<XmppConnection>()
.getBlocklist(); .getManagerById<BlockingManager>(blockingManager)!
.getBlocklist();
// Diff the received blocklist with the cache // Diff the received blocklist with the cache
final newItems = List<String>.empty(growable: true); final newItems = List<String>.empty(growable: true);
final removedItems = List<String>.empty(growable: true); final removedItems = List<String>.empty(growable: true);
final db = GetIt.I.get<DatabaseService>();
for (final item in blocklist) { for (final item in blocklist) {
if (!_blocklist!.contains(item)) { if (!_blocklist!.contains(item)) {
await db.addBlocklistEntry(item); await _addBlocklistEntry(item);
_blocklist!.add(item); _blocklist!.add(item);
newItems.add(item); newItems.add(item);
} }
@@ -59,7 +78,7 @@ class BlocklistService {
// Diff the cache with the received blocklist // Diff the cache with the received blocklist
for (final item in _blocklist!) { for (final item in _blocklist!) {
if (!blocklist.contains(item)) { if (!blocklist.contains(item)) {
await db.removeBlocklistEntry(item); await _removeBlocklistEntry(item);
_blocklist!.remove(item); _blocklist!.remove(item);
removedItems.add(item); removedItems.add(item);
} }
@@ -81,7 +100,9 @@ class BlocklistService {
/// Returns the blocklist from the database /// Returns the blocklist from the database
Future<List<String>> getBlocklist() async { Future<List<String>> getBlocklist() async {
if (_blocklist == null) { if (_blocklist == null) {
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries(); final blocklistRaw =
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
if (!_requested) { if (!_requested) {
unawaited(_requestBlocklist()); unawaited(_requestBlocklist());
@@ -112,21 +133,23 @@ class BlocklistService {
final removedBlocks = List<String>.empty(growable: true); final removedBlocks = List<String>.empty(growable: true);
for (final item in items) { for (final item in items) {
switch (type) { switch (type) {
case BlockPushType.block: { case BlockPushType.block:
if (_blocklist!.contains(item)) continue; {
_blocklist!.add(item); if (_blocklist!.contains(item)) continue;
newBlocks.add(item); _blocklist!.add(item);
newBlocks.add(item);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item); await _addBlocklistEntry(item);
} }
break; break;
case BlockPushType.unblock: { case BlockPushType.unblock:
_blocklist!.removeWhere((i) => i == item); {
removedBlocks.add(item); _blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item); await _removeBlocklistEntry(item);
} }
break; break;
} }
} }
@@ -146,11 +169,11 @@ class BlocklistService {
} }
_blocklist!.add(jid); _blocklist!.add(jid);
await GetIt.I.get<DatabaseService>() await _addBlocklistEntry(jid);
.addBlocklistEntry(jid); return GetIt.I
return GetIt.I.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)! .getManagerById<BlockingManager>(blockingManager)!
.block([jid]); .block([jid]);
} }
Future<bool> unblockJid(String jid) async { Future<bool> unblockJid(String jid) async {
@@ -161,25 +184,28 @@ class BlocklistService {
} }
_blocklist!.remove(jid); _blocklist!.remove(jid);
await GetIt.I.get<DatabaseService>() await _removeBlocklistEntry(jid);
.removeBlocklistEntry(jid); return GetIt.I
return GetIt.I.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)! .getManagerById<BlockingManager>(blockingManager)!
.unblock([jid]); .unblock([jid]);
} }
Future<bool> unblockAll() async { Future<bool> unblockAll() async {
// Check if blocking is supported // Check if blocking is supported
if (!(await _checkSupport())) { if (!(await _checkSupport())) {
_log.warning('Unblocking all JIDs requested but server does not support it.'); _log.warning(
'Unblocking all JIDs requested but server does not support it.',
);
return false; return false;
} }
_blocklist!.clear(); _blocklist!.clear();
await GetIt.I.get<DatabaseService>() await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
.removeAllBlocklistEntries();
return GetIt.I.get<XmppConnection>() return GetIt.I
.getManagerById<BlockingManager>(blockingManager)! .get<XmppConnection>()
.unblockAll(); .getManagerById<BlockingManager>(blockingManager)!
.unblockAll();
} }
} }

View File

@@ -1,22 +1,32 @@
import 'dart:io' show Platform; import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart'; class ConnectivityEvent {
import 'package:moxxyv2/service/moxxmpp/reconnect.dart'; const ConnectivityEvent(this.regained, this.lost);
final bool regained;
final bool lost;
}
class ConnectivityService { class ConnectivityService {
ConnectivityService() : _log = Logger('ConnectivityService'); /// The internal stream controller
final Logger _log; final StreamController<ConnectivityEvent> _controller =
StreamController<ConnectivityEvent>.broadcast();
/// The logger
final Logger _log = Logger('ConnectivityService');
/// Caches the current connectivity state /// Caches the current connectivity state
late ConnectivityResult _connectivity; late ConnectivityResult _connectivity;
Stream<ConnectivityEvent> get stream => _controller.stream;
@visibleForTesting @visibleForTesting
void setConnectivity(ConnectivityResult result) { void setConnectivity(ConnectivityResult result) {
_log.warning('Internal connectivity state changed by request originating from outside ConnectivityService'); _log.warning(
'Internal connectivity state changed by request originating from outside ConnectivityService',
);
_connectivity = result; _connectivity = result;
} }
@@ -24,23 +34,24 @@ class ConnectivityService {
final conn = Connectivity(); final conn = Connectivity();
_connectivity = await conn.checkConnectivity(); _connectivity = await conn.checkConnectivity();
// TODO(Unknown): At least on Android, the stream fires directly after listening although the conn.onConnectivityChanged.listen((ConnectivityResult result) {
// network does not change. So just skip it. final regained = _connectivity == ConnectivityResult.none &&
// See https://github.com/fluttercommunity/plus_plugins/issues/567 result != ConnectivityResult.none;
final skipAmount = Platform.isAndroid ? 1 : 0;
conn.onConnectivityChanged.skip(skipAmount).listen((ConnectivityResult result) {
final regained = _connectivity == ConnectivityResult.none && result != ConnectivityResult.none;
final lost = result == ConnectivityResult.none; final lost = result == ConnectivityResult.none;
_connectivity = result; _connectivity = result;
// TODO(PapaTutuWawa): Should we use Streams? _controller.add(
// Notify other services ConnectivityEvent(
(GetIt.I.get<XmppConnection>().reconnectionPolicy as MoxxyReconnectionPolicy) regained,
.onConnectivityChanged(regained, lost); lost,
),
GetIt.I.get<HttpFileTransferService>().onConnectivityChanged(regained); );
}); });
} }
ConnectivityResult get currentState => _connectivity; ConnectivityResult get currentState => _connectivity;
Future<bool> hasConnection() async {
return _connectivity != ConnectivityResult.none;
}
} }

View File

@@ -1,56 +1,75 @@
import 'dart:async'; import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.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:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/connectivity.dart'; import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:synchronized/synchronized.dart';
class ConnectivityWatcherService { class ConnectivityWatcherService {
/// Logger.
final Logger _log = Logger('ConnectivityWatcherService');
ConnectivityWatcherService() : _log = Logger('ConnectivityWatcherService'); /// Timer counting how much time has passed since we were last connected.
final Logger _log;
// Timer counting how much time has passed since we were last connected
Timer? _timer; Timer? _timer;
/// Lock for accessing _timer
final Lock _lock = Lock();
Future<void> initialize() async {
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityEvent);
}
Future<void> _onConnectivityEvent(ConnectivityEvent event) async {
if (event.lost) {
_log.finest('Network connection lost. Stopping timer');
await _stopTimer();
}
}
Future<void> _onTimerElapsed() async { Future<void> _onTimerElapsed() async {
await _stopTimer();
await GetIt.I.get<NotificationsService>().showWarningNotification( await GetIt.I.get<NotificationsService>().showWarningNotification(
'Moxxy', 'Moxxy',
t.errors.connection.connectionTimeout, t.errors.connection.connectionTimeout,
); );
_stopTimer();
} }
/// Stops the currently running timer, if there is one. /// Stops the currently running timer, if there is one.
void _stopTimer() { Future<void> _stopTimer() async {
if (_timer != null) { await _lock.synchronized(() {
_timer!.cancel(); _timer?.cancel();
_timer = null; _timer = null;
} });
} }
/// Starts the timer. If it is already running, it stops the currently running one before /// Starts the timer. If it is already running, it stops the currently running one before
/// starting the new one. /// starting the new one.
void _startTimer() { Future<void> _startTimer() async {
_stopTimer(); await _stopTimer();
_timer = Timer(const Duration(minutes: 30), _onTimerElapsed); _timer = Timer(const Duration(minutes: 30), _onTimerElapsed);
} }
/// Called when the XMPP connection state changed /// Called when the XMPP connection state changed
Future<void> onConnectionStateChanged(XmppConnectionState before, XmppConnectionState current) async { Future<void> onConnectionStateChanged(
if (before == XmppConnectionState.connected && current != XmppConnectionState.connected) { XmppConnectionState before,
XmppConnectionState current,
) async {
if (before == XmppConnectionState.connected &&
current != XmppConnectionState.connected) {
// We somehow lost connection // We somehow lost connection
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) { if (await GetIt.I.get<ConnectivityService>().hasConnection()) {
_log.finest('Lost connection to server. Starting warning timer...'); _log.finest('Lost connection to server. Starting warning timer...');
_startTimer(); await _startTimer();
} else { } else {
_log.finest('Lost connection to server but no network connectivity available. Stopping warning timer...'); _log.finest(
_stopTimer(); 'Lost connection to server but no network connectivity available. Stopping warning timer...',
);
await _stopTimer();
} }
} else if (current == XmppConnectionState.connected) { } else if (current == XmppConnectionState.connected) {
_stopTimer(); await _stopTimer();
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import 'package:flutter_contacts/flutter_contacts.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: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/database.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
@@ -28,28 +29,37 @@ class ContactsService {
// are not returned. // are not returned.
FlutterContacts.config.includeNonVisibleOnAndroid = true; FlutterContacts.config.includeNonVisibleOnAndroid = true;
} }
/// Logger.
final Logger _log = Logger('ContactsService'); final Logger _log = Logger('ContactsService');
/// JID -> Id /// JID -> Id.
Map<String, String>? _contactIds; Map<String, String>? _contactIds;
/// Contact ID -> Display name from the contact or null if we cached that there is /// Contact ID -> Display name from the contact or null if we cached that there is
/// none /// none
final Map<String, String?> _contactDisplayNames = {}; final Map<String, String?> _contactDisplayNames = {};
Future<void> init() 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 {
@@ -67,8 +77,8 @@ class ContactsService {
final jabberContacts = List<ContactWrapper>.empty(growable: true); final jabberContacts = List<ContactWrapper>.empty(growable: true);
for (final c in contacts) { for (final c in contacts) {
final index = c.socialMedias final index =
.indexWhere((s) => s.label == SocialMediaLabel.jabber); c.socialMedias.indexWhere((s) => s.label == SocialMediaLabel.jabber);
if (index == -1) continue; if (index == -1) continue;
jabberContacts.add( jabberContacts.add(
@@ -97,13 +107,17 @@ class ContactsService {
/// Returns true if we can proceed with accessing the contact list. False, if not. /// Returns true if we can proceed with accessing the contact list. False, if not.
Future<bool> _canUseContactIntegration() async { Future<bool> _canUseContactIntegration() async {
if (!(await isContactIntegrationEnabled())) { if (!(await isContactIntegrationEnabled())) {
_log.finest('_canUseContactIntegration: Returning false since enableContactIntegration is false'); _log.finest(
'_canUseContactIntegration: Returning false since enableContactIntegration is false',
);
return false; return false;
} }
final permission = await Permission.contacts.status; final permission = await Permission.contacts.status;
if (permission == PermissionStatus.denied) { if (permission == PermissionStatus.denied) {
_log.finest("_canUseContactIntegration: Returning false since we don't have the contacts permission"); _log.finest(
"_canUseContactIntegration: Returning false since we don't have the contacts permission",
);
return false; return false;
} }
@@ -115,7 +129,14 @@ class ContactsService {
Future<Map<String, String>> _getContactIds() async { Future<Map<String, String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!; if (_contactIds != null) return _contactIds!;
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds(); _contactIds = Map<String, String>.fromEntries(
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
(item) => MapEntry(
item['jid']! as String,
item['id']! as String,
),
),
);
return _contactIds!; return _contactIds!;
} }
@@ -125,8 +146,7 @@ class ContactsService {
/// [id] is the id of the contact. A null value indicates that there is no /// [id] is the id of the contact. A null value indicates that there is no
/// contact and null will be returned immediately. /// contact and null will be returned immediately.
Future<String?> getContactDisplayName(String? id) async { Future<String?> getContactDisplayName(String? id) async {
if (id == null || if (id == null || !(await _canUseContactIntegration())) return null;
!(await _canUseContactIntegration())) return null;
if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id]; if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id];
final result = await FlutterContacts.getContact( final result = await FlutterContacts.getContact(
@@ -155,21 +175,18 @@ class ContactsService {
if (id == null) return null; if (id == null) return null;
final avatarPath = await getContactProfilePicturePath(id); final avatarPath = await getContactProfilePicturePath(id);
return File(avatarPath).existsSync() ? return File(avatarPath).existsSync() ? avatarPath : null;
avatarPath :
null;
} }
Future<void> scanContacts() async { Future<void> scanContacts() async {
final db = GetIt.I.get<DatabaseService>();
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 contacts = await _fetchContactsWithJabber(); final contacts = await _fetchContactsWithJabber();
// JID -> Id // JID -> Id
final knownContactIds = await _getContactIds(); final knownContactIds = await _getContactIds();
// Id -> JID // Id -> JID
final knownContactIdsReverse = knownContactIds final knownContactIdsReverse =
.map((key, value) => MapEntry(value, key)); knownContactIds.map((key, value) => MapEntry(value, key));
final modifiedRosterItems = List<RosterItem>.empty(growable: true); final modifiedRosterItems = List<RosterItem>.empty(growable: true);
final addedRosterItems = List<RosterItem>.empty(growable: true); final addedRosterItems = List<RosterItem>.empty(growable: true);
final removedRosterItems = List<String>.empty(growable: true); final removedRosterItems = List<String>.empty(growable: true);
@@ -179,7 +196,12 @@ class ContactsService {
if (index != -1) continue; if (index != -1) continue;
final jid = knownContactIdsReverse[id]!; final jid = knownContactIdsReverse[id]!;
await db.removeContactId(id); await GetIt.I.get<DatabaseService>().database.delete(
contactsTable,
where: 'id = ?',
whereArgs: [id],
);
_contactIds!.remove(knownContactIdsReverse[id]); _contactIds!.remove(knownContactIdsReverse[id]);
// Remove the avatar file, if it existed // Remove the avatar file, if it existed
@@ -190,17 +212,21 @@ class ContactsService {
} }
// Remove the contact attributes from the conversation, if it existed // Remove the contact attributes from the conversation, if it existed
final c = await cs.getConversationByJid(jid); final conversation = await cs.createOrUpdateConversation(
if (c != null) { jid,
final newConv = await cs.updateConversation( update: (c) async {
c.id, return cs.updateConversation(
contactId: null, jid,
contactAvatarPath: null, contactId: null,
contactDisplayName: null, contactAvatarPath: null,
); contactDisplayName: null,
);
},
);
if (conversation != null) {
sendEvent( sendEvent(
ConversationUpdatedEvent( ConversationUpdatedEvent(
conversation: newConv, conversation: conversation,
), ),
); );
} }
@@ -227,7 +253,13 @@ class ContactsService {
for (final contact in contacts) { for (final contact in contacts) {
// Add the ID to the cache and the database if it does not already exist // Add the ID to the cache and the database if it does not already exist
if (!knownContactIds.containsKey(contact.jid)) { if (!knownContactIds.containsKey(contact.jid)) {
await db.addContactId(contact.id, contact.jid); await GetIt.I.get<DatabaseService>().database.insert(
contactsTable,
<String, String>{
'id': contact.id,
'jid': contact.jid,
},
);
_contactIds![contact.jid] = contact.id; _contactIds![contact.jid] = contact.id;
} }
@@ -243,17 +275,22 @@ class ContactsService {
} }
// Update a possibly existing conversation // Update a possibly existing conversation
final c = await cs.getConversationByJid(contact.jid); final conversation = await cs.createOrUpdateConversation(
if (c != null) { contact.jid,
final newConv = await cs.updateConversation( update: (c) async {
c.id, return cs.updateConversation(
contactId: contact.id, contact.jid,
contactAvatarPath: contactAvatarPath, contactId: contact.id,
contactDisplayName: contact.displayName, contactAvatarPath:
); contact.thumbnail != null ? contactAvatarPath : null,
contactDisplayName: contact.displayName,
);
},
);
if (conversation != null) {
sendEvent( sendEvent(
ConversationUpdatedEvent( ConversationUpdatedEvent(
conversation: newConv, conversation: conversation,
), ),
); );
} }

View File

@@ -1,67 +1,142 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.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/service/database/helpers.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/cache.dart'; import 'package:moxxyv2/service/roster.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';
import 'package:synchronized/synchronized.dart';
typedef CreateConversationCallback = Future<Conversation> Function();
typedef UpdateConversationCallback = Future<Conversation> Function(
Conversation,
);
typedef PreRunConversationCallback = Future<void> Function(Conversation?);
class ConversationService { class ConversationService {
ConversationService() /// The list of known conversations.
: _conversationCache = LRUCache(100), Map<String, Conversation>? _conversationCache;
_loadedConversations = false;
final LRUCache<int, Conversation> _conversationCache; /// The lock for accessing _conversationCache
bool _loadedConversations; final Lock _lock = Lock();
/// When called with a JID [jid], then first, if non-null, [preRun] is
/// executed.
/// Next, if a conversation with JID [jid] exists, [update] is called with
/// the conversation as its argument. If not, then [create] is executed.
/// Returns either the result of [create], [update] or null.
Future<Conversation?> createOrUpdateConversation(
String jid, {
CreateConversationCallback? create,
UpdateConversationCallback? update,
PreRunConversationCallback? preRun,
}) async {
return _lock.synchronized(() async {
final conversation = await _getConversationByJid(jid);
// Pre run
if (preRun != null) {
await preRun(conversation);
}
if (conversation != null) {
// Conversation exists
if (update != null) {
return update(conversation);
}
} else {
// Conversation does not exist
if (create != null) {
return create();
}
}
return null;
});
}
/// Loads all conversations from the database and adds them to the state and cache.
Future<List<Conversation>> loadConversations() async {
final db = GetIt.I.get<DatabaseService>().database;
final conversationsRaw = await db.query(
conversationsTable,
orderBy: 'lastChangeTimestamp DESC',
);
final tmp = List<Conversation>.empty(growable: true);
for (final c in conversationsRaw) {
final jid = c['jid']! as String;
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
Message? lastMessage;
if (c['lastMessageId'] != null) {
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
c['lastMessageId']! as int,
jid,
queryReactionPreview: false,
);
}
tmp.add(
Conversation.fromDatabaseJson(
c,
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
),
);
}
return tmp;
}
/// Wrapper around DatabaseService's loadConversations that adds the loaded /// Wrapper around DatabaseService's loadConversations that adds the loaded
/// to the cache. /// to the cache.
Future<void> _loadConversations() async { Future<void> _loadConversationsIfNeeded() async {
final conversations = await GetIt.I.get<DatabaseService>().loadConversations(); if (_conversationCache != null) return;
for (final c in conversations) {
_conversationCache.cache(c.id, c);
}
}
/// Returns the conversation with jid [jid] or null if not found. final conversations = await loadConversations();
Future<Conversation?> getConversationByJid(String jid) async { _conversationCache = Map<String, Conversation>.fromEntries(
if (!_loadedConversations) { conversations.map((c) => MapEntry(c.jid, c)),
await _loadConversations();
_loadedConversations = true;
}
return firstWhereOrNull(
// TODO(Unknown): Maybe have it accept an iterable
_conversationCache.getValues(),
(Conversation c) => c.jid == jid,
); );
} }
/// Returns the conversation by its database id or null if it does not exist. /// Returns the conversation with jid [jid] or null if not found.
Future<Conversation?> _getConversationById(int id) async { Future<Conversation?> _getConversationByJid(String jid) async {
if (!_loadedConversations) { await _loadConversationsIfNeeded();
await _loadConversations(); return _conversationCache![jid];
_loadedConversations = true; }
}
return _conversationCache.getValue(id); /// Wrapper around [ConversationService._getConversationByJid] that aquires
/// the lock for the cache.
Future<Conversation?> getConversationByJid(String jid) async {
return _lock.synchronized(() async => _getConversationByJid(jid));
} }
/// For modifying the cache without writing it to disk. Useful, for example, when /// For modifying the cache without writing it to disk. Useful, for example, when
/// changing the chat state. /// changing the chat state.
void setConversation(Conversation conversation) { void setConversation(Conversation conversation) {
_conversationCache.cache(conversation.id, conversation); _conversationCache![conversation.jid] = conversation;
} }
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache. /// Updates the conversation with JID [jid] inside the database.
Future<Conversation> updateConversation(int id, { ///
/// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> updateConversation(
String jid, {
int? lastChangeTimestamp, int? lastChangeTimestamp,
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,
@@ -69,36 +144,80 @@ class ConversationService {
Object? contactAvatarPath = notSpecified, Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified, Object? contactDisplayName = notSpecified,
}) async { }) async {
final conversation = (await _getConversationById(id))!; final conversation = (await _getConversationByJid(jid))!;
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
id, final c = <String, dynamic>{};
lastMessage: lastMessage,
lastChangeTimestamp: lastChangeTimestamp, if (lastMessage != null) {
open: open, c['lastMessageId'] = lastMessage.id;
unreadCounter: unreadCounter, }
avatarUrl: avatarUrl, if (lastChangeTimestamp != null) {
chatState: conversation.chatState, c['lastChangeTimestamp'] = lastChangeTimestamp;
muted: muted, }
encrypted: encrypted, if (open != null) {
contactId: contactId, c['open'] = boolToInt(open);
contactAvatarPath: contactAvatarPath, }
contactDisplayName: contactDisplayName, if (unreadCounter != null) {
c['unreadCounter'] = unreadCounter;
}
if (avatarPath != null) {
c['avatarPath'] = avatarPath;
}
if (avatarHash != notSpecified) {
c['avatarHash'] = avatarHash as String?;
}
if (muted != null) {
c['muted'] = boolToInt(muted);
}
if (encrypted != null) {
c['encrypted'] = boolToInt(encrypted);
}
if (contactId != notSpecified) {
c['contactId'] = contactId as String?;
}
if (contactAvatarPath != notSpecified) {
c['contactAvatarPath'] = contactAvatarPath as String?;
}
if (contactDisplayName != notSpecified) {
c['contactDisplayName'] = contactDisplayName as String?;
}
final result =
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
conversationsTable,
c,
where: 'jid = ?',
whereArgs: [jid],
);
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
var newConversation = Conversation.fromDatabaseJson(
result,
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
); );
// Copy over the old lastMessage if a new one was not set // Copy over the old lastMessage if a new one was not set
if (conversation.lastMessage != null && lastMessage == null) { if (conversation.lastMessage != null && lastMessage == null) {
newConversation = newConversation.copyWith(lastMessage: conversation.lastMessage); newConversation =
newConversation.copyWith(lastMessage: conversation.lastMessage);
} }
_conversationCache.cache(id, newConversation); _conversationCache![jid] = newConversation;
return newConversation; return newConversation;
} }
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache. /// Creates a [Conversation] inside the database given the data. This is so that the
/// [Conversation] object can carry its database id.
///
/// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> addConversationFromData( Future<Conversation> addConversationFromData(
String title, String title,
Message? lastMessage, Message? lastMessage,
String avatarUrl, ConversationType type,
String avatarPath,
String jid, String jid,
int unreadCounter, int unreadCounter,
int lastChangeTimestamp, int lastChangeTimestamp,
@@ -109,22 +228,35 @@ class ConversationService {
String? contactAvatarPath, String? contactAvatarPath,
String? contactDisplayName, String? contactDisplayName,
) async { ) async {
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData( final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final newConversation = Conversation(
title, title,
lastMessage, lastMessage,
avatarUrl, avatarPath,
null,
jid, jid,
unreadCounter, unreadCounter,
type,
lastChangeTimestamp, lastChangeTimestamp,
open, open,
rosterItem?.showAddToRosterButton ?? true,
muted, muted,
encrypted, encrypted,
contactId, ChatState.gone,
contactAvatarPath, contactId: contactId,
contactDisplayName, contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
); );
await GetIt.I.get<DatabaseService>().database.insert(
conversationsTable,
newConversation.toDatabaseJson(),
);
if (_conversationCache != null) {
_conversationCache![newConversation.jid] = newConversation;
}
_conversationCache.cache(newConversation.id, newConversation);
return newConversation; return newConversation;
} }

View File

@@ -20,24 +20,30 @@ List<int> _randomBuffer(int length) {
CipherAlgorithm _sfsToCipher(SFSEncryptionType type) { CipherAlgorithm _sfsToCipher(SFSEncryptionType type) {
switch (type) { switch (type) {
case SFSEncryptionType.aes128GcmNoPadding: return CipherAlgorithm.aes128GcmNoPadding; case SFSEncryptionType.aes128GcmNoPadding:
case SFSEncryptionType.aes256GcmNoPadding: return CipherAlgorithm.aes256GcmNoPadding; return CipherAlgorithm.aes128GcmNoPadding;
case SFSEncryptionType.aes256CbcPkcs7: return CipherAlgorithm.aes256CbcPkcs7; case SFSEncryptionType.aes256GcmNoPadding:
return CipherAlgorithm.aes256GcmNoPadding;
case SFSEncryptionType.aes256CbcPkcs7:
return CipherAlgorithm.aes256CbcPkcs7;
} }
} }
class CryptographyService { class CryptographyService {
CryptographyService() : _log = Logger('CryptographyService'); CryptographyService() : _log = Logger('CryptographyService');
final Logger _log; final Logger _log;
/// Encrypt the file at path [source] and write the encrypted data to [dest]. For the /// Encrypt the file at path [source] and write the encrypted data to [dest]. For the
/// encryption, use the algorithm indicated by [encryption]. /// encryption, use the algorithm indicated by [encryption].
Future<EncryptionResult> encryptFile(String source, String dest, SFSEncryptionType encryption) async { Future<EncryptionResult> encryptFile(
String source,
String dest,
SFSEncryptionType encryption,
) async {
_log.finest('Beginning encryption routine for $source'); _log.finest('Beginning encryption routine for $source');
final key = encryption == SFSEncryptionType.aes128GcmNoPadding ? final key = encryption == SFSEncryptionType.aes128GcmNoPadding
_randomBuffer(16) : ? _randomBuffer(16)
_randomBuffer(32); : _randomBuffer(32);
final iv = _randomBuffer(12); final iv = _randomBuffer(12);
final result = (await MoxplatformPlugin.crypto.encryptFile( final result = (await MoxplatformPlugin.crypto.encryptFile(
source, source,
@@ -52,11 +58,11 @@ class CryptographyService {
return EncryptionResult( return EncryptionResult(
key, key,
iv, iv,
<String, String>{ <HashFunction, String>{
hashSha256: base64Encode(result.plaintextHash), HashFunction.sha256: base64Encode(result.plaintextHash),
}, },
<String, String>{ <HashFunction, String>{
hashSha256: base64Encode(result.ciphertextHash), HashFunction.sha256: base64Encode(result.ciphertextHash),
}, },
); );
} }
@@ -70,8 +76,8 @@ class CryptographyService {
SFSEncryptionType encryption, SFSEncryptionType encryption,
List<int> key, List<int> key,
List<int> iv, List<int> iv,
Map<String, String> plaintextHashes, Map<HashFunction, String> plaintextHashes,
Map<String, String> ciphertextHashes, Map<HashFunction, String> ciphertextHashes,
) async { ) async {
_log.finest('Beginning decryption for $source'); _log.finest('Beginning decryption for $source');
final result = await MoxplatformPlugin.crypto.encryptFile( final result = await MoxplatformPlugin.crypto.encryptFile(
@@ -88,7 +94,7 @@ class CryptographyService {
var passedPlaintextIntegrityCheck = true; var passedPlaintextIntegrityCheck = true;
var passedCiphertextIntegrityCheck = true; var passedCiphertextIntegrityCheck = true;
for (final entry in plaintextHashes.entries) { for (final entry in plaintextHashes.entries) {
if (entry.key == hashSha256) { if (entry.key == HashFunction.sha256) {
if (base64Encode(result!.plaintextHash) != entry.value) { if (base64Encode(result!.plaintextHash) != entry.value) {
passedPlaintextIntegrityCheck = false; passedPlaintextIntegrityCheck = false;
} else { } else {
@@ -98,8 +104,8 @@ class CryptographyService {
break; break;
} }
} }
for (final entry in ciphertextHashes.entries) { for (final entry in ciphertextHashes.entries) {
if (entry.key == hashSha256) { if (entry.key == HashFunction.sha256) {
if (base64Encode(result!.ciphertextHash) != entry.value) { if (base64Encode(result!.ciphertextHash) != entry.value) {
passedCiphertextIntegrityCheck = false; passedCiphertextIntegrityCheck = false;
} else { } else {

View File

@@ -1,150 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/types.dart';
Future<List<int>> hashFileImpl(HashRequest request) async {
final data = await File(request.path).readAsBytes();
return CryptographicHashManager.hashFromData(data, request.hash);
}
Future<EncryptionResult> encryptFileImpl(EncryptionRequest request) async {
Cipher algorithm;
switch (request.encryption) {
case SFSEncryptionType.aes128GcmNoPadding:
algorithm = AesGcm.with128bits();
break;
case SFSEncryptionType.aes256GcmNoPadding:
algorithm = AesGcm.with256bits();
break;
case SFSEncryptionType.aes256CbcPkcs7:
// TODO(Unknown): Implement
throw Exception();
// ignore: dead_code
break;
}
// Generate a key and an IV for the file
final key = await algorithm.newSecretKey();
final iv = algorithm.newNonce();
final plaintext = await File(request.source).readAsBytes();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: iv,
);
final ciphertext = [
...secretBox.cipherText,
...secretBox.mac.bytes,
];
// Write the file
await File(request.dest).writeAsBytes(ciphertext);
return EncryptionResult(
await key.extractBytes(),
iv,
{
hashSha256: base64Encode(
await CryptographicHashManager.hashFromData(plaintext, HashFunction.sha256),
),
},
{
hashSha256: base64Encode(
await CryptographicHashManager.hashFromData(ciphertext, HashFunction.sha256),
),
},
);
}
// TODO(PapaTutuWawa): Somehow fail when the ciphertext hash is not matching the provided data
Future<DecryptionResult> decryptFileImpl(DecryptionRequest request) async {
Cipher algorithm;
switch (request.encryption) {
case SFSEncryptionType.aes128GcmNoPadding:
algorithm = AesGcm.with128bits();
break;
case SFSEncryptionType.aes256GcmNoPadding:
algorithm = AesGcm.with256bits();
break;
case SFSEncryptionType.aes256CbcPkcs7:
// TODO(Unknown): Implement
throw Exception();
// ignore: dead_code
break;
}
final ciphertextRaw = await File(request.source).readAsBytes();
final mac = List<int>.empty(growable: true);
final ciphertext = List<int>.empty(growable: true);
// TODO(PapaTutuWawa): Somehow handle aes256CbcPkcs7
if (request.encryption == SFSEncryptionType.aes128GcmNoPadding ||
request.encryption == SFSEncryptionType.aes256GcmNoPadding) {
mac.addAll(ciphertextRaw.sublist(ciphertextRaw.length - 16));
ciphertext.addAll(ciphertextRaw.sublist(0, ciphertextRaw.length - 16));
}
var passedCiphertextIntegrityCheck = true;
var passedPlaintextIntegrityCheck = true;
// Try to find one hash we can verify
for (final entry in request.ciphertextHashes.entries) {
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
final hash = await CryptographicHashManager.hashFromData(
ciphertext,
hashFunctionFromName(entry.key),
);
if (base64Encode(hash) == entry.value) {
passedCiphertextIntegrityCheck = true;
} else {
passedCiphertextIntegrityCheck = false;
}
break;
}
}
final secretBox = SecretBox(
ciphertext,
nonce: request.iv,
mac: Mac(mac),
);
try {
final data = await algorithm.decrypt(
secretBox,
secretKey: SecretKey(request.key),
);
for (final entry in request.plaintextHashes.entries) {
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
final hash = await CryptographicHashManager.hashFromData(
data,
hashFunctionFromName(entry.key),
);
if (base64Encode(hash) == entry.value) {
passedPlaintextIntegrityCheck = true;
} else {
passedPlaintextIntegrityCheck = false;
}
break;
}
}
await File(request.dest).writeAsBytes(data);
} catch (_) {
return DecryptionResult(
false,
passedPlaintextIntegrityCheck,
passedCiphertextIntegrityCheck,
);
}
return DecryptionResult(
true,
passedPlaintextIntegrityCheck,
passedCiphertextIntegrityCheck,
);
}

View File

@@ -3,18 +3,21 @@ import 'package:moxxmpp/moxxmpp.dart';
@immutable @immutable
class EncryptionResult { class EncryptionResult {
const EncryptionResult(
const EncryptionResult(this.key, this.iv, this.plaintextHashes, this.ciphertextHashes); this.key,
this.iv,
this.plaintextHashes,
this.ciphertextHashes,
);
final List<int> key; final List<int> key;
final List<int> iv; final List<int> iv;
final Map<String, String> plaintextHashes; final Map<HashFunction, String> plaintextHashes;
final Map<String, String> ciphertextHashes; final Map<HashFunction, String> ciphertextHashes;
} }
@immutable @immutable
class EncryptionRequest { class EncryptionRequest {
const EncryptionRequest(this.source, this.dest, this.encryption); const EncryptionRequest(this.source, this.dest, this.encryption);
final String source; final String source;
final String dest; final String dest;
@@ -23,7 +26,6 @@ class EncryptionRequest {
@immutable @immutable
class DecryptionResult { class DecryptionResult {
const DecryptionResult( const DecryptionResult(
this.decryptionOkay, this.decryptionOkay,
this.plaintextOkay, this.plaintextOkay,
@@ -36,7 +38,6 @@ class DecryptionResult {
@immutable @immutable
class DecryptionRequest { class DecryptionRequest {
const DecryptionRequest( const DecryptionRequest(
this.source, this.source,
this.dest, this.dest,
@@ -51,14 +52,6 @@ class DecryptionRequest {
final SFSEncryptionType encryption; final SFSEncryptionType encryption;
final List<int> key; final List<int> key;
final List<int> iv; final List<int> iv;
final Map<String, String> plaintextHashes; final Map<HashFunction, String> plaintextHashes;
final Map<String, String> ciphertextHashes; final Map<HashFunction, String> ciphertextHashes;
}
@immutable
class HashRequest {
const HashRequest(this.path, this.hash);
final String path;
final HashFunction hash;
} }

View File

@@ -3,18 +3,19 @@ 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';
const stickerPacksTable = 'StickerPacks'; const stickerPacksTable = 'StickerPacks';
const blocklistTable = 'Blocklist'; const blocklistTable = 'Blocklist';
const subscriptionsTable = 'SubscriptionRequests';
const fileMetadataTable = 'FileMetadata';
const fileMetadataHashesTable = 'FileMetadataHashes';
const reactionsTable = 'Reactions';
const omemoDevicesTable = 'OmemoDevices';
const omemoDeviceListTable = 'OmemoDeviceList';
const omemoRatchetsTable = 'OmemoRatchets';
const omemoTrustTable = 'OmemoTrustTable';
const typeString = 0; const typeString = 0;
const typeInt = 1; const typeInt = 1;

View File

@@ -27,65 +27,110 @@ Future<void> createDatabase(Database db, int version) async {
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
sid TEXT NOT NULL, sid TEXT NOT NULL,
conversationJid TEXT NOT NULL, conversationJid TEXT NOT NULL,
isMedia INTEGER NOT NULL,
isFileUploadNotification INTEGER NOT NULL, isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL, encrypted INTEGER NOT NULL,
errorType INTEGER, errorType INTEGER,
warningType INTEGER, warningType INTEGER,
mediaUrl TEXT,
mediaType TEXT,
thumbnailData TEXT,
mediaWidth INTEGER,
mediaHeight INTEGER,
srcUrl TEXT,
key TEXT,
iv TEXT,
encryptionScheme TEXT,
received INTEGER, received INTEGER,
displayed INTEGER, displayed INTEGER,
acked INTEGER, acked INTEGER,
originId TEXT, originId TEXT,
quote_id INTEGER, quote_id INTEGER,
filename TEXT, file_metadata_id TEXT,
plaintextHashes TEXT,
ciphertextHashes TEXT,
isDownloading INTEGER NOT NULL, isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL, isUploading INTEGER NOT NULL,
mediaSize INTEGER,
isRetracted INTEGER, isRetracted INTEGER,
isEdited INTEGER NOT NULL, isEdited INTEGER NOT NULL,
reactions TEXT NOT NULL,
containsNoStore INTEGER NOT NULL, containsNoStore INTEGER NOT NULL,
stickerPackId TEXT, stickerPackId TEXT,
stickerHashKey TEXT,
pseudoMessageType INTEGER, pseudoMessageType INTEGER,
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)
)''', )''',
); );
await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
);
// Reactions
await db.execute(
'''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
);
// File metadata
await db.execute(
'''
CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY,
path TEXT,
sourceUrls TEXT,
mimeType TEXT,
thumbnailType TEXT,
thumbnailData TEXT,
width INTEGER,
height INTEGER,
plaintextHashes TEXT,
encryptionKey TEXT,
encryptionIv TEXT,
encryptionScheme TEXT,
cipherTextHashes TEXT,
filename TEXT NOT NULL,
size INTEGER
)''',
);
await db.execute(
'''
CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL,
value TEXT NOT NULL,
id TEXT NOT NULL,
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
);
// Conversations // Conversations
await db.execute( await db.execute(
''' '''
CREATE TABLE $conversationsTable ( CREATE TABLE $conversationsTable (
id INTEGER PRIMARY KEY AUTOINCREMENT, jid TEXT NOT NULL PRIMARY KEY,
jid TEXT NOT NULL, title TEXT NOT NULL,
title TEXT NOT NULL, avatarPath TEXT NOT NULL,
avatarUrl 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
)''', )''',
); );
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
);
// Contacts // Contacts
await db.execute( await db.execute(
@@ -93,21 +138,6 @@ Future<void> createDatabase(Database db, int version) async {
CREATE TABLE $contactsTable ( CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
jid TEXT NOT NULL jid TEXT NOT NULL
)'''
);
// Shared media
await db.execute(
'''
CREATE TABLE $mediaTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
mime TEXT,
timestamp INTEGER NOT NULL,
conversation_id INTEGER NOT NULL,
message_id INTEGER,
FOREIGN KEY (conversation_id) REFERENCES $conversationsTable (id),
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
)''', )''',
); );
@@ -118,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,
@@ -135,19 +165,14 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute( await db.execute(
''' '''
CREATE TABLE $stickersTable ( CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
mediaType TEXT NOT NULL, desc TEXT NOT NULL,
desc TEXT NOT NULL, suggests TEXT NOT NULL,
size INTEGER NOT NULL, file_metadata_id TEXT NOT NULL,
width INTEGER, stickerPackId TEXT NOT NULL,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
suggests TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id) CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''', )''',
); );
await db.execute( await db.execute(
@@ -158,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
)''', )''',
); );
@@ -174,72 +200,58 @@ Future<void> createDatabase(Database db, int version) async {
// 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)
)''', )''',
); );
@@ -316,14 +328,6 @@ Future<void> createDatabase(Database db, int version) async {
'true', 'true',
).toDatabaseJson(), ).toDatabaseJson(),
); );
await db.insert(
preferenceTable,
Preference(
'autoAcceptSubscriptionRequests',
typeBool,
'false',
).toDatabaseJson(),
);
await db.insert( await db.insert(
preferenceTable, preferenceTable,
Preference( Preference(

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,44 @@
import 'package:logging/logging.dart';
/// A function to be called when a migration should be performed.
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
/// This class represents a single database migration.
class DatabaseMigration<T> {
const DatabaseMigration(this.version, this.migration);
/// The version this migration upgrades the database to.
final int version;
/// The migration callback. Called the the database version is less than [version].
final DatabaseMigrationCallback<T> migration;
}
/// Given the database [db] with the current version [version], goes through the list of
/// migrations [migrations] and applies all migrations with a version greater than
/// [version]. [migrations] is sorted before usage.
///
/// NOTE: This entire setup is written as a generic to make testing easier. We cannot easily
/// mock, or better "instantiate", a Database object. Thus, to avoid having nullable
/// database argument, just pass in whatever (the tests use an integer).
Future<void> runMigrations<T>(
Logger log,
T db,
List<DatabaseMigration<T>> migrations,
int version,
) async {
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
..sort(
(a, b) => a.version.compareTo(b.version),
);
var currentVersion = version;
for (final migration in sortedMigrations) {
if (version < migration.version) {
log.info(
'Running database migration $currentVersion -> ${migration.version}',
);
await migration.migration(db);
currentVersion = migration.version;
}
}
}

View File

@@ -1,5 +1,4 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV22ToV23(Database db) async { Future<void> upgradeFromV22ToV23(Database db) async {

View File

@@ -4,13 +4,11 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV13ToV14(Database db) async { Future<void> upgradeFromV13ToV14(Database db) async {
// Create the new table // Create the new table
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
)''' )''');
);
// Migrate the conversations // Migrate the conversations
await db.execute( await db.execute(
@@ -32,9 +30,13 @@ Future<void> upgradeFromV13ToV14(Database db) async {
ON DELETE SET NULL ON DELETE SET NULL
)''', )''',
); );
await db.execute('INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable'); await db.execute(
'INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable',
);
await db.execute('DROP TABLE $conversationsTable;'); await db.execute('DROP TABLE $conversationsTable;');
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;'); await db.execute(
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
);
// Migrate the roster items // Migrate the roster items
await db.execute( await db.execute(
@@ -52,7 +54,9 @@ Future<void> upgradeFromV13ToV14(Database db) async {
ON DELETE SET NULL ON DELETE SET NULL
)''', )''',
); );
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable'); await db.execute(
'INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable',
);
await db.execute('DROP TABLE $rosterTable;'); await db.execute('DROP TABLE $rosterTable;');
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;'); await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;');

View File

@@ -1,12 +1,11 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV6ToV7(Database db) async { Future<void> upgradeFromV6ToV7(Database db) async {
await db.execute( await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;' 'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;',
); );
await db.execute( await db.execute(
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';" "ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';",
); );
} }

View File

@@ -3,15 +3,15 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV7ToV8(Database db) async { Future<void> upgradeFromV7ToV8(Database db) async {
await db.execute( await db.execute(
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;' 'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;',
); );
await db.execute( await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;" 'ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;',
); );
await db.execute( await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;" 'ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;',
); );
await db.execute( await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;" 'ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;',
); );
} }

View File

@@ -8,7 +8,7 @@ Future<void> upgradeFromV8ToV9(Database db) async {
// Step 2 // Step 2
// Step 4 // Step 4
await db.execute( await db.execute(
''' '''
CREATE TABLE ${conversationsTable}_new ( CREATE TABLE ${conversationsTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL, jid TEXT NOT NULL,
@@ -25,13 +25,17 @@ Future<void> upgradeFromV8ToV9(Database db) async {
); );
// Step 5 // Step 5
await db.execute('INSERT INTO ${conversationsTable}_new SELECT * from $conversationsTable'); await db.execute(
'INSERT INTO ${conversationsTable}_new SELECT * from $conversationsTable',
);
// Step 6 // Step 6
await db.execute('DROP TABLE $conversationsTable;'); await db.execute('DROP TABLE $conversationsTable;');
// Step 7 // Step 7
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;'); await db.execute(
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
);
// Step 10 // Step 10
//await db.execute('PRAGMA foreign_key_check;'); //await db.execute('PRAGMA foreign_key_check;');

View File

@@ -1,6 +1,5 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV9ToV10(Database db) async { Future<void> upgradeFromV9ToV10(Database db) async {

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

@@ -1,5 +1,4 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV23ToV24(Database db) async { Future<void> upgradeFromV23ToV24(Database db) async {

View File

@@ -3,6 +3,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV10ToV11(Database db) async { Future<void> upgradeFromV10ToV11(Database db) async {
await db.execute( await db.execute(
"ALTER TABLE $messagesTable ADD COLUMN reactions TEXT NOT NULL DEFAULT '[]';" "ALTER TABLE $messagesTable ADD COLUMN reactions TEXT NOT NULL DEFAULT '[]';",
); );
} }

View File

@@ -4,6 +4,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV11ToV12(Database db) async { Future<void> upgradeFromV11ToV12(Database db) async {
await db.execute( await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN containsNoStore INTEGER NOT NULL DEFAULT ${boolToInt(false)};' 'ALTER TABLE $messagesTable ADD COLUMN containsNoStore INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
); );
} }

View File

@@ -1,6 +1,5 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV3ToV4(Database db) async { Future<void> upgradeFromV3ToV4(Database db) async {

View File

@@ -1,6 +1,5 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV4ToV5(Database db) async { Future<void> upgradeFromV4ToV5(Database db) async {

View File

@@ -1,5 +1,4 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV5ToV6(Database db) async { Future<void> upgradeFromV5ToV6(Database db) async {

View File

@@ -11,12 +11,8 @@ Future<void> upgradeFromV17ToV18(Database db) async {
); );
// Drop stickers // Drop stickers
await db.execute( await db.execute('DROP TABLE $stickerPacksTable;');
'DROP TABLE $stickerPacksTable;' await db.execute('DROP TABLE $stickersTable;');
);
await db.execute(
'DROP TABLE $stickersTable;'
);
await db.execute( await db.execute(
''' '''

View File

@@ -1,5 +1,4 @@
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV21ToV22(Database db) async { Future<void> upgradeFromV21ToV22(Database db) async {

View File

@@ -0,0 +1,36 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV29ToV30(Database db) async {
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN sharedMediaAmount INTEGER NOT NULL DEFAULT 0;',
);
// Get all conversations
final conversations = await db.query(
conversationsTable,
);
for (final conversation in conversations) {
// Count the amount of shared media
final jid = conversation['jid']! as String;
final result = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM $mediaTable WHERE conversation_jid = ?',
[jid],
),
) ??
0;
final c = Map<String, Object?>.from(conversation)..remove('id');
await db.update(
conversationsTable,
{
...c,
'sharedMediaAmount': result,
},
where: 'jid = ?',
whereArgs: [jid],
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV27ToV28(Database db) async {
// Collect conversations so that we have a mapping id -> jid
final idMap = <int, String>{};
final conversations = await db.query(conversationsTable);
for (final c in conversations) {
idMap[c['id']! as int] = c['jid']! as String;
}
// Migrate the conversations
await db.execute(
'''
CREATE TABLE ${conversationsTable}_new (
jid TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
await db.execute(
'INSERT INTO ${conversationsTable}_new SELECT jid, title, avatarUrl, lastChangeTimestamp, unreadCounter, open, muted, encrypted, lastMessageId, contactid, contactAvatarPath, contactDisplayName from $conversationsTable',
);
await db.execute('DROP TABLE $conversationsTable;');
await db.execute(
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
);
// Add the jid column to shared media
await db.execute(
"ALTER TABLE $mediaTable ADD COLUMN conversation_jid TEXT NOT NULL DEFAULT '';",
);
// Update all shared media items
for (final entry in idMap.entries) {
await db.update(
mediaTable,
{
'conversation_jid': entry.value,
},
where: 'conversation_id = ?',
whereArgs: [entry.key],
);
}
// Migrate shared media
await db.execute(
'''
CREATE TABLE ${mediaTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
mime TEXT,
timestamp INTEGER NOT NULL,
conversation_jid TEXT NOT NULL,
message_id INTEGER,
FOREIGN KEY (conversation_jid) REFERENCES $conversationsTable (jid),
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
)''',
);
await db.execute(
'INSERT INTO ${mediaTable}_new SELECT id, path, mime, timestamp, message_id, conversation_jid from $mediaTable',
);
await db.execute('DROP TABLE $mediaTable;');
await db.execute('ALTER TABLE ${mediaTable}_new RENAME TO $mediaTable;');
}

View File

@@ -0,0 +1,8 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV30ToV31(Database db) async {
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN type TEXT NOT NULL DEFAULT "chat";',
);
}

View File

@@ -0,0 +1,9 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV28ToV29(Database db) async {
await db.delete(
preferenceTable,
where: 'key = "autoAcceptSubscriptionRequests"',
);
}

View File

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

View File

@@ -0,0 +1,226 @@
import 'dart:convert';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV31ToV32(Database db) async {
// Create the tracking table
await db.execute('''
CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY,
path TEXT,
sourceUrls TEXT,
mimeType TEXT,
thumbnailType TEXT,
thumbnailData TEXT,
width INTEGER,
height INTEGER,
plaintextHashes TEXT,
encryptionKey TEXT,
encryptionIv TEXT,
encryptionScheme TEXT,
cipherTextHashes TEXT,
filename TEXT NOT NULL,
size INTEGER
)''');
await db.execute('''
CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL,
value TEXT NOT NULL,
id TEXT NOT NULL,
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE
)''');
// Add the file_metadata_id column
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN file_metadata_id TEXT DEFAULT NULL;',
);
// Migrate the media messages' attributes to new table
final messages = await db.query(
messagesTable,
where: 'isMedia = ${boolToInt(true)}',
);
for (final message in messages) {
// Do we know of a hash?
String id;
if (message['plaintextHashes'] != null) {
// Plaintext hashes available (SFS)
final plaintextHashes = deserializeHashMap(
message['plaintextHashes']! as String,
);
final result = await db.query(
fileMetadataHashesTable,
where: 'algorithm = ? AND value = ?',
whereArgs: [
plaintextHashes.entries.first.key,
plaintextHashes.entries.first.value,
],
limit: 1,
);
if (result.isEmpty) {
final metadata = FileMetadata(
getStrongestHashFromMap(plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
message['mediaUrl'] as String?,
message['srcUrl'] != null ? [message['srcUrl']! as String] : null,
message['mediaType'] as String?,
message['mediaSize'] as int?,
message['thumbnailData'] != null ? 'blurhash' : null,
message['thumbnailData'] as String?,
message['mediaWidth'] as int?,
message['mediaHeight'] as int?,
plaintextHashes,
message['key'] as String?,
message['iv'] as String?,
message['encryptionScheme'] as String?,
message['plaintextHashes'] == null
? null
: deserializeHashMap(message['ciphertextHashes']! as String),
message['filename']! as String,
);
// Create the metadata
await db.insert(
fileMetadataTable,
metadata.toDatabaseJson(),
);
id = metadata.id;
} else {
id = result[0]['id']! as String;
}
} else {
// No plaintext hashes are available (OOB data)
int? size;
int? height;
int? width;
Map<HashFunction, String>? hashes;
String? filePath;
String? urlSource;
String? mediaType;
String? filename;
if (message['filename'] == null) {
// We are dealing with a sticker
assert(
message['stickerPackId'] != null,
'The message must contain a sticker',
);
assert(
message['stickerHashKey'] != null,
'The message must contain a sticker',
);
final sticker = (await db.query(
stickersTable,
where: 'stickerPackId = ? AND hashKey = ?',
whereArgs: [message['stickerPackId'], message['stickerHashKey']],
limit: 1,
))
.first;
size = sticker['size']! as int;
width = sticker['width'] as int?;
height = sticker['height'] as int?;
hashes = deserializeHashMap(sticker['hashes']! as String);
filePath = sticker['path']! as String;
urlSource =
((jsonDecode(sticker['urlSources']! as String) as List<dynamic>)
.cast<String>())
.first;
mediaType = sticker['mediaType']! as String;
filename = path.basename(sticker['path']! as String);
} else {
size = message['mediaSize'] as int?;
width = message['mediaWidth'] as int?;
height = message['mediaHeight'] as int?;
filePath = message['mediaUrl'] as String?;
urlSource = message['srcUrl'] as String?;
mediaType = message['mediaType'] as String?;
filename = message['filename'] as String?;
}
final metadata = FileMetadata(
DateTime.now().millisecondsSinceEpoch.toString(),
filePath,
urlSource != null ? [urlSource] : null,
mediaType,
size,
message['thumbnailData'] != null ? 'blurhash' : null,
message['thumbnailData'] as String?,
width,
height,
hashes,
message['key'] as String?,
message['iv'] as String?,
message['encryptionScheme'] as String?,
null,
filename!,
);
// Create the metadata
await db.insert(
fileMetadataTable,
metadata.toDatabaseJson(),
);
id = metadata.id;
}
// Update the message
await db.update(
messagesTable,
{
'file_metadata_id': id,
},
where: 'id = ?',
whereArgs: [message['id']],
);
}
// Remove columns and add foreign key
await db.execute(
'''
CREATE TABLE ${messagesTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
body TEXT,
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id INTEGER,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
reactions TEXT NOT NULL,
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
stickerHashKey TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''',
);
await db.execute(
'INSERT INTO ${messagesTable}_new SELECT id, sender, body, timestamp, sid, conversationJid, isFileUploadNotification, encrypted, errorType, warningType, received, displayed, acked, originId, quote_id, file_metadata_id, isDownloading, isUploading, isRetracted, isEdited, reactions, containsNoStore, stickerPackId, stickerHashKey, pseudoMessageType, pseudoMessageData FROM $messagesTable',
);
await db.execute('DROP TABLE $messagesTable');
await db.execute(
'ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable;',
);
}

View File

@@ -0,0 +1,24 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV36ToV37(Database db) async {
// Queries against messages by id (and sid/originId happen regularly)
await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
);
// Conversations are often queried by their jid
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
);
// Reactions must be quickly queried
await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
);
// File metadata should also be quickly queriable by its id
await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
);
}

View File

@@ -0,0 +1,60 @@
import 'dart:convert';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV34ToV35(Database db) async {
// Create the table
await db.execute('''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
// Figure out our JID
final rawJid = await db.query(
xmppStateTable,
where: "key = 'jid'",
limit: 1,
);
String? jid;
if (rawJid.isNotEmpty) {
jid = rawJid.first['value']! as String;
}
// Migrate messages
final messages = await db.query(
messagesTable,
where: "reactions IS NOT '[]'",
);
for (final message in messages) {
final reactions =
(jsonDecode(message['reactions']! as String) as List<dynamic>)
.cast<Map<String, Object?>>();
for (final reaction in reactions) {
final senders = [
...reaction['senders']! as List<String>,
if (intToBool(reaction['reactedBySelf']! as int) && jid != null) jid,
];
for (final sender in senders) {
await db.insert(
reactionsTable,
{
'senderJid': sender,
'emoji': reaction['emoji']! as String,
'message_id': message['id']! as int,
},
);
}
}
}
// Remove the column
await db.execute('ALTER TABLE $messagesTable DROP COLUMN reactions');
}

View File

@@ -0,0 +1,15 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV35ToV36(Database db) async {
await db.execute('DROP TABLE $reactionsTable');
await db.execute('''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
}

View File

@@ -0,0 +1,14 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV33ToV34(Database db) async {
// Remove the shared media counter...
await db.execute(
'ALTER TABLE $conversationsTable DROP COLUMN sharedMediaAmount',
);
// ... and the entire table.
await db.execute(
'DROP TABLE $mediaTable',
);
}

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV32ToV33(Database db) async {
final stickers = await db.query(stickersTable);
await db.execute(
'''
CREATE TABLE ${stickersTable}_new (
id TEXT PRIMARY KEY,
desc TEXT NOT NULL,
suggests TEXT NOT NULL,
file_metadata_id TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE,
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''',
);
// Mapping stickerHashKey -> fileMetadataId
final stickerHashMap = <String, String>{};
for (final sticker in stickers) {
final hashes =
(jsonDecode(sticker['hashes']! as String) as Map<String, dynamic>)
.cast<String, String>();
final buffer = StringBuffer();
for (var i = 0; i < hashes.length; i++) {
buffer.write('(algorithm = ? AND value = ?) AND');
}
final query = buffer.toString();
final rawFm = await db.query(
fileMetadataHashesTable,
where: query.substring(0, query.length - 1 - 3),
whereArgs: hashes.entries
.map<List<String>>((entry) => [entry.key, entry.value])
.flattened
.toList(),
limit: 1,
);
String fileMetadataId;
if (rawFm.isEmpty) {
// Create the metadata
fileMetadataId = DateTime.now().toString();
await db.insert(
fileMetadataTable,
{
'id': fileMetadataId,
'path': sticker['path']! as String,
'size': sticker['size']! as int,
'width': sticker['width'] as int?,
'height': sticker['height'] as int?,
'plaintextHashes': sticker['hashes']! as String,
'mimeType': sticker['mediaType']! as String,
'sourceUrls': sticker['urlSources'],
'filename': path.basename(sticker['path']! as String),
},
);
// Create hash pointers
for (final hashEntry in hashes.entries) {
await db.insert(
fileMetadataHashesTable,
{
'algorithm': hashEntry.key,
'value': hashEntry.value,
'id': fileMetadataId,
},
);
}
} else {
fileMetadataId = rawFm.first['id']! as String;
}
final hashKey = sticker['hashKey']! as String;
stickerHashMap[hashKey] = fileMetadataId;
await db.insert(
'${stickersTable}_new',
{
'id': hashKey,
'desc': sticker['desc']! as String,
'suggests': sticker['suggests']! as String,
'file_metadata_id': fileMetadataId,
'stickerPackId': sticker['stickerPackId']! as String,
},
);
}
// Rename the table
await db.execute('DROP TABLE $stickersTable');
await db.execute('ALTER TABLE ${stickersTable}_new RENAME TO $stickersTable');
// Migrate messages
for (final stickerEntry in stickerHashMap.entries) {
await db.update(
messagesTable,
{
'file_metadata_id': stickerEntry.value,
},
where: 'stickerHashKey = ?',
whereArgs: [stickerEntry.key],
);
}
// Remove the hash key from messages
await db.execute('ALTER TABLE $messagesTable DROP COLUMN stickerHashKey');
}

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++;
}
}

File diff suppressed because it is too large Load Diff

346
lib/service/files.dart Normal file
View File

@@ -0,0 +1,346 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common/sql.dart';
/// A class for returning whether a file metadata element was just created or retrieved.
class FileMetadataWrapper {
FileMetadataWrapper(
this.fileMetadata,
this.retrieved,
);
/// The file metadata.
FileMetadata fileMetadata;
/// Indicates whether the file metadata already exists (true) or
/// if it has been created (false).
bool retrieved;
}
/// Returns the strongest hash from [map], if [map] is not null. If no known hash is found
/// or [map] is null, returns null.
String? getStrongestHashFromMap(Map<HashFunction, String>? map) {
if (map == null) {
return null;
}
return map[HashFunction.blake2b512] ??
map[HashFunction.blake2b256] ??
map[HashFunction.sha3_512] ??
map[HashFunction.sha3_256] ??
map[HashFunction.sha512] ??
map[HashFunction.sha256];
}
/// Calculates the path for a given file with filename [filename] and the optional
/// plaintext hashes [hashes]. If the base directory for the file does not exist, then it
/// will be created.
Future<String> computeCachedPathForFile(
String filename,
Map<HashFunction, String>? hashes,
) async {
final basePath = path.join(
(await getApplicationDocumentsDirectory()).path,
'media',
);
final baseDir = Directory(basePath);
if (!baseDir.existsSync()) {
await baseDir.create(recursive: true);
}
// Keep the extension of the file. Otherwise Android will be really confused
// as to what it should open the file with.
final ext = path.extension(filename);
final hash = getStrongestHashFromMap(hashes)?.replaceAll('/', '_');
return path.join(
basePath,
hash != null
// NOTE: [ext] already includes a leading "."
? '$hash$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
);
}
class FilesService {
// Logging.
final Logger _log = Logger('FilesService');
Future<void> createMetadataHashEntries(
Map<HashFunction, String> plaintextHashes,
String metadataId,
) async {
final db = GetIt.I.get<DatabaseService>().database;
for (final hash in plaintextHashes.entries) {
await db.insert(
fileMetadataHashesTable,
{
'algorithm': hash.key.toName(),
'value': hash.value,
'id': metadataId,
},
// TODO(Unknown): I would like to get rid of this. In events.dart, when processing
// a request to manually download a file, we should check if we already
// have hash pointers for a file metadata item.
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
}
Future<FileMetadata?> getFileMetadataFromFile(FileMetadata metadata) async {
final hash = metadata.plaintextHashes?[HashFunction.sha256] ??
await GetIt.I
.get<CryptographyService>()
.hashFile(metadata.path!, HashFunction.sha256);
final fm = await getFileMetadataFromHash({
HashFunction.sha256: hash,
});
if (fm != null) {
return fm;
}
final result = await addFileMetadataFromData(
metadata.copyWith(
plaintextHashes: {
...metadata.plaintextHashes ?? {},
HashFunction.sha256: hash,
},
),
);
await createMetadataHashEntries(result.plaintextHashes!, result.id);
return result;
}
Future<FileMetadata?> getFileMetadataFromHash(
Map<HashFunction, String>? plaintextHashes,
) async {
if (plaintextHashes?.isEmpty ?? true) {
return null;
}
final db = GetIt.I.get<DatabaseService>().database;
final values = List<String>.empty(growable: true);
final query = plaintextHashes!.entries.map((entry) {
values
..add(entry.key.toName())
..add(entry.value);
return '(algorithm = ? AND value = ?)';
}).join(' OR ');
final hashes = await db.query(
fileMetadataHashesTable,
where: query,
whereArgs: values,
limit: 1,
);
if (hashes.isEmpty) {
return null;
}
final result = await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [hashes[0]['id']! as String],
limit: 1,
);
if (result.isEmpty) {
return null;
}
return FileMetadata.fromDatabaseJson(result[0]);
}
/// Create a FileMetadata entry if we do not know the plaintext hashes described in
/// [location].
/// If we know of at least one hash, return that FileMetadata element.
///
/// If [createHashPointers] is true and we have to create a new FileMetadata element,
/// then also create the hash pointers, if plaintext hashes are specified. If no
/// plaintext hashes are specified or [createHashPointers] is false, no pointers will be
/// created.
Future<FileMetadataWrapper> createFileMetadataIfRequired(
MediaFileLocation location,
String? mimeType,
int? size,
Size? dimensions,
String? thubnailType,
String? thumbnailData, {
bool createHashPointers = true,
String? path,
}) async {
if (location.plaintextHashes?.isNotEmpty ?? false) {
final result = await getFileMetadataFromHash(location.plaintextHashes);
if (result != null) {
_log.finest('Not creating new metadata as we found the hash');
return FileMetadataWrapper(
result,
true,
);
}
}
final db = GetIt.I.get<DatabaseService>().database;
final fm = FileMetadata(
getStrongestHashFromMap(location.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
path,
location.urls,
mimeType,
size,
thubnailType,
thumbnailData,
dimensions?.width.toInt(),
dimensions?.height.toInt(),
location.plaintextHashes,
location.key != null ? base64Encode(location.key!) : null,
location.iv != null ? base64Encode(location.iv!) : null,
location.encryptionScheme,
location.ciphertextHashes,
location.filename,
);
await db.insert(fileMetadataTable, fm.toDatabaseJson());
if ((location.plaintextHashes?.isNotEmpty ?? false) && createHashPointers) {
await createMetadataHashEntries(
location.plaintextHashes!,
fm.id,
);
}
return FileMetadataWrapper(
fm,
false,
);
}
Future<void> removeFileMetadata(String id) async {
await GetIt.I.get<DatabaseService>().database.delete(
fileMetadataTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<FileMetadata> updateFileMetadata(
String id, {
Object? path = notSpecified,
int? size,
String? encryptionScheme,
String? encryptionKey,
String? encryptionIv,
List<String>? sourceUrls,
int? width,
int? height,
String? mimeType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final m = <String, dynamic>{};
if (path != notSpecified) {
m['path'] = path as String?;
}
if (encryptionScheme != null) {
m['encryptionScheme'] = encryptionScheme;
}
if (size != null) {
m['size'] = size;
}
if (encryptionKey != null) {
m['encryptionKey'] = encryptionKey;
}
if (encryptionIv != null) {
m['encryptionIv'] = encryptionIv;
}
if (sourceUrls != null) {
m['sourceUrl'] = jsonEncode(sourceUrls);
}
if (width != null) {
m['width'] = width;
}
if (height != null) {
m['height'] = height;
}
if (mimeType != null) {
m['mimeType'] = mimeType;
}
if (plaintextHashes != null) {
m['plaintextHashes'] = jsonEncode(plaintextHashes);
}
if (ciphertextHashes != null) {
m['cipherTextHashes'] = jsonEncode(ciphertextHashes);
}
final result = await db.updateAndReturn(
fileMetadataTable,
m,
where: 'id = ?',
whereArgs: [id],
);
return FileMetadata.fromDatabaseJson(result);
}
/// Removes the file metadata described by [metadata] if it is referenced by exactly 0
/// messages and no stickers use this file. If the file is referenced by > 1 messages
/// or a sticker, does nothing.
Future<void> removeFileIfNotReferenced(FileMetadata metadata) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesCount = await db.count(
messagesTable,
'file_metadata_id = ?',
[metadata.id],
);
final stickersCount = await db.count(
stickersTable,
'file_metadata_id = ?',
[metadata.id],
);
if (messagesCount == 0 && stickersCount == 0) {
_log.finest(
'Removing file metadata as no stickers and no messages reference it',
);
await removeFileMetadata(metadata.id);
// Only remove the file if we have a path
if (metadata.path != null) {
try {
await File(metadata.path!).delete();
} catch (ex) {
_log.warning('Failed to remove file ${metadata.path!}: $ex');
}
} else {
_log.info('Not removing file as there is no path associated with it');
}
} else {
_log.info(
'Not removing file as $messagesCount messages and $stickersCount stickers reference this file',
);
}
}
Future<FileMetadata> addFileMetadataFromData(
FileMetadata metadata,
) async {
final result =
await GetIt.I.get<DatabaseService>().database.insertAndReturn(
fileMetadataTable,
metadata.toDatabaseJson(),
);
return FileMetadata.fromDatabaseJson(result);
}
}

View File

@@ -3,6 +3,8 @@ import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:native_imaging/native_imaging.dart' as native; import 'package:native_imaging/native_imaging.dart' as native;
Future<String?> _generateBlurhashThumbnailImpl(String path) async { Future<String?> _generateBlurhashThumbnailImpl(String path) async {
@@ -65,36 +67,89 @@ Future<String?> generateBlurhashThumbnail(String path) async {
String xmppErrorToTranslatableString(XmppError error) { String xmppErrorToTranslatableString(XmppError error) {
if (error is StartTLSFailedError) { if (error is StartTLSFailedError) {
return t.errors.login.startTlsFailed; return t.errors.login.startTlsFailed;
} else if (error is SaslFailedError) { } else if (error is SaslError) {
return t.errors.login.saslFailed; return t.errors.login.saslFailed;
} else if (error is NoConnectionError) { } else if (error is NoConnectionPossibleError) {
return t.errors.login.noConnection; return t.errors.login.noConnection;
} }
return t.errors.login.unspecified; return t.errors.login.unspecified;
} }
String getStickerHashKeyType(Map<String, String> hashes) { HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
if (hashes.containsKey('blake2b-512')) { if (hashes.containsKey(HashFunction.blake2b512)) {
return 'blake2b-512'; return HashFunction.blake2b512;
} else if (hashes.containsKey('blake2b-512')) { } else if (hashes.containsKey(HashFunction.blake2b256)) {
return 'blake2b-256'; return HashFunction.blake2b256;
} else if (hashes.containsKey('sha3-512')) { } else if (hashes.containsKey(HashFunction.sha3_512)) {
return 'sha3-512'; return HashFunction.sha3_512;
} else if (hashes.containsKey('sha3-256')) { } else if (hashes.containsKey(HashFunction.sha3_256)) {
return 'sha3-256'; return HashFunction.sha3_256;
} else if (hashes.containsKey('sha3-256')) { } else if (hashes.containsKey(HashFunction.sha512)) {
return 'sha-512'; return HashFunction.sha512;
} else if (hashes.containsKey('sha-256')) { } else if (hashes.containsKey(HashFunction.sha256)) {
return 'sha-256'; return HashFunction.sha256;
} }
assert(false, 'No valid hash found'); assert(false, 'No valid hash found');
return ''; return HashFunction.sha256;
} }
String getStickerHashKey(Map<String, String> hashes) { // TODO(PapaTutuWawa): Replace with getStrongestHash
String getStickerHashKey(Map<HashFunction, String> hashes) {
final key = getStickerHashKeyType(hashes); final key = getStickerHashKeyType(hashes);
return '$key:${hashes[key]}'; return '$key:${hashes[key]}';
} }
/// Return a human readable string describing an unrecoverable error event [event].
String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
final error = event.error;
if (error is SaslAccountDisabledError) {
return t.errors.connection.saslAccountDisabled;
} else if (error is SaslCredentialsExpiredError ||
error is SaslNotAuthorizedError) {
return t.errors.connection.saslInvalidCredentials;
}
return t.errors.connection.unrecoverable;
}
/// Creates the fallback body for quoted messages.
/// If the quoted message contains text, it simply quotes the text.
/// If it contains a media file, the messageEmoji (usually an emoji
/// representing the mime type) is shown together with the file size
/// (from experience this information is sufficient, as most clients show
/// the file size, and including time information might be confusing and a
/// potential privacy issue).
/// This information is complemented either the srcUrl or if unavailable
/// by the body of the quoted message. For non-media messages, we always use
/// the body as fallback.
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored
String quoteMessageSize;
if (quotedMessage.fileMetadata!.size != null &&
quotedMessage.fileMetadata!.size! > 0) {
quoteMessageSize =
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
} else {
quoteMessageSize = '';
}
// Create media url string, or use body if no srcUrl is stored
String quotedMediaUrl;
if (quotedMessage.fileMetadata!.sourceUrls != null &&
quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.fileMetadata!.sourceUrls!.first}';
} else if (quotedMessage.body.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.body}';
} else {
quotedMediaUrl = '';
}
// Concatenate emoji, size string, and media url and return
return '${quotedMessage.messageEmoji} $quoteMessageSize$quotedMediaUrl';
} else {
return quotedMessage.body;
}
}

View File

@@ -16,7 +16,11 @@ class HttpPeekResult {
/// called whenever new data has been downloaded. /// called whenever new data has been downloaded.
/// ///
/// Returns the status code if the server responded. If an error occurs, returns null. /// Returns the status code if the server responded. If an error occurs, returns null.
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async { Future<int?> downloadFile(
Uri uri,
String destination,
ProgressCallback onProgress,
) async {
// TODO(Unknown): How do we close fileSink? Do we have to? // TODO(Unknown): How do we close fileSink? Do we have to?
IOSink? fileSink; IOSink? fileSink;
final client = HttpClient(); final client = HttpClient();
@@ -36,19 +40,21 @@ Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgre
var bytes = 0; var bytes = 0;
final downloadCompleter = Completer<void>(); final downloadCompleter = Completer<void>();
unawaited( unawaited(
resp.transform( resp
StreamTransformer<List<int>, List<int>>.fromHandlers( .transform(
handleData: (data, sink) { StreamTransformer<List<int>, List<int>>.fromHandlers(
bytes += data.length; handleData: (data, sink) {
onProgress(length, bytes); bytes += data.length;
onProgress(length, bytes);
sink.add(data); sink.add(data);
}, },
handleDone: (sink) { handleDone: (sink) {
downloadCompleter.complete(); downloadCompleter.complete();
}, },
), ),
).pipe(fileSink), )
.pipe(fileSink),
); );
// Wait for the download to complete // Wait for the download to complete
@@ -69,7 +75,12 @@ Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgre
/// been downloaded. /// been downloaded.
/// ///
/// Returns the status code if the server responded. If an error occurs, returns null. /// Returns the status code if the server responded. If an error occurs, returns null.
Future<int?> uploadFile(Uri destination, Map<String, String> headers, String filePath, ProgressCallback onProgress) async { Future<int?> uploadFile(
Uri destination,
Map<String, String> headers,
String filePath,
ProgressCallback onProgress,
) async {
final client = HttpClient(); final client = HttpClient();
try { try {
final req = await client.putUrl(destination); final req = await client.putUrl(destination);
@@ -84,18 +95,18 @@ Future<int?> uploadFile(Uri destination, Map<String, String> headers, String fil
var bytes = 0; var bytes = 0;
final stream = file.openRead().transform( final stream = file.openRead().transform(
StreamTransformer<List<int>, List<int>>.fromHandlers( StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (data, sink) { handleData: (data, sink) {
bytes += data.length; bytes += data.length;
onProgress(length, bytes); onProgress(length, bytes);
sink.add(data); sink.add(data);
}, },
handleDone: (sink) { handleDone: (sink) {
sink.close(); sink.close();
}, },
), ),
); );
await req.addStream(stream); await req.addStream(stream);
final resp = await req.close(); final resp = await req.close();
@@ -125,9 +136,7 @@ Future<HttpPeekResult?> peekUrl(Uri uri) async {
client.close(force: true); client.close(force: true);
final contentType = resp.headers['Content-Type']; final contentType = resp.headers['Content-Type'];
return HttpPeekResult( return HttpPeekResult(
contentType != null && contentType.isNotEmpty ? contentType != null && contentType.isNotEmpty ? contentType.first : null,
contentType.first :
null,
resp.contentLength, resp.contentLength,
); );
} catch (ex) { } catch (ex) {

View File

@@ -1,40 +1,4 @@
import 'dart:io';
import 'package:external_path/external_path.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart'; import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:path/path.dart' as path;
/// Calculates the path for a given file to be saved to and, if neccessary, create it.
Future<String> getDownloadPath(String filename, String conversationJid, String? mime) async {
String type;
var prependMoxxy = true;
if (mime != null && ['image/', 'video/'].any((e) => mime.startsWith(e))) {
type = ExternalPath.DIRECTORY_PICTURES;
} else {
type = ExternalPath.DIRECTORY_DOWNLOADS;
prependMoxxy = false;
}
final externalDir = await ExternalPath.getExternalStoragePublicDirectory(type);
final fileDirectory = prependMoxxy ? path.join(externalDir, 'Moxxy', conversationJid) : externalDir;
final dir = Directory(fileDirectory);
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
var i = 0;
while (true) {
final filenameSuffix = i == 0 ? '' : '($i)';
final suffixedFilename = filenameWithSuffix(filename, filenameSuffix);
final filePath = path.join(fileDirectory, suffixedFilename);
if (!File(filePath).existsSync()) {
return filePath;
}
i++;
}
}
/// Returns true if the request was successful based on [statusCode]. /// Returns true if the request was successful based on [statusCode].
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status /// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
@@ -42,8 +6,8 @@ bool isRequestOkay(int? statusCode) {
return statusCode != null && statusCode >= 200 && statusCode <= 399; return statusCode != null && statusCode >= 200 && statusCode <= 399;
} }
class FileMetadata { class FileUploadMetadata {
const FileMetadata({ this.mime, this.size }); const FileUploadMetadata({this.mime, this.size});
final String? mime; final String? mime;
final int? size; final int? size;
} }
@@ -51,10 +15,10 @@ class FileMetadata {
/// Returns the size of the file at [url] in octets. If an error occurs or the server /// Returns the size of the file at [url] in octets. If an error occurs or the server
/// does not specify the Content-Length header, null is returned. /// does not specify the Content-Length header, null is returned.
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
Future<FileMetadata> peekFile(String url) async { Future<FileUploadMetadata> peekFile(String url) async {
final result = await peekUrl(Uri.parse(url)); final result = await peekUrl(Uri.parse(url));
return FileMetadata( return FileUploadMetadata(
mime: result?.contentType, mime: result?.contentType,
size: result?.contentLength, size: result?.contentLength,
); );

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
@@ -13,10 +12,11 @@ import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart'; import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/cryptography/types.dart'; import 'package:moxxyv2/service/cryptography/types.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client; import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart'; import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
@@ -32,37 +32,39 @@ import 'package:uuid/uuid.dart';
/// This service is responsible for managing the up- and download of files using Http. /// This service is responsible for managing the up- and download of files using Http.
class HttpFileTransferService { class HttpFileTransferService {
HttpFileTransferService() HttpFileTransferService() {
: _uploadQueue = Queue<FileUploadJob>(), GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityChanged);
_downloadQueue = Queue<FileDownloadJob>(), }
_uploadLock = Lock(),
_downloadLock = Lock(),
_log = Logger('HttpFileTransferService');
final Logger _log; final Logger _log = Logger('HttpFileTransferService');
/// Queues for tracking up- and download tasks /// Queues for tracking up- and download tasks
final Queue<FileDownloadJob> _downloadQueue; final Queue<FileDownloadJob> _downloadQueue = Queue<FileDownloadJob>();
final Queue<FileUploadJob> _uploadQueue; final Queue<FileUploadJob> _uploadQueue = Queue<FileUploadJob>();
/// The currently running job and their lock /// The currently running job and their lock
FileUploadJob? _currentUploadJob; FileUploadJob? _currentUploadJob;
FileDownloadJob? _currentDownloadJob; FileDownloadJob? _currentDownloadJob;
/// Locks for upload and download state /// Locks for upload and download state
final Lock _uploadLock; final Lock _uploadLock = Lock();
final Lock _downloadLock; final Lock _downloadLock = Lock();
/// Called by the ConnectivityService if the connection got lost but then was regained. /// Called by the ConnectivityService if the connection got lost but then was regained.
Future<void> onConnectivityChanged(bool regained) async { Future<void> _onConnectivityChanged(ConnectivityEvent event) async {
if (!regained) return; if (!event.regained) return;
await _uploadLock.synchronized(() async { await _uploadLock.synchronized(() async {
if (_currentUploadJob != null) { if (_currentUploadJob != null) {
_log.finest('Connectivity regained and there is still an upload job. Restarting it.'); _log.finest(
'Connectivity regained and there is still an upload job. Restarting it.',
);
unawaited(_performFileUpload(_currentUploadJob!)); unawaited(_performFileUpload(_currentUploadJob!));
} else { } else {
if (_uploadQueue.isNotEmpty) { if (_uploadQueue.isNotEmpty) {
_log.finest('Connectivity regained and the upload queue is not empty. Starting a new upload job.'); _log.finest(
'Connectivity regained and the upload queue is not empty. Starting a new upload job.',
);
_currentUploadJob = _uploadQueue.removeFirst(); _currentUploadJob = _uploadQueue.removeFirst();
unawaited(_performFileUpload(_currentUploadJob!)); unawaited(_performFileUpload(_currentUploadJob!));
} }
@@ -71,11 +73,15 @@ class HttpFileTransferService {
await _downloadLock.synchronized(() async { await _downloadLock.synchronized(() async {
if (_currentDownloadJob != null) { if (_currentDownloadJob != null) {
_log.finest('Connectivity regained and there is still a download job. Restarting it.'); _log.finest(
'Connectivity regained and there is still a download job. Restarting it.',
);
unawaited(_performFileDownload(_currentDownloadJob!)); unawaited(_performFileDownload(_currentDownloadJob!));
} else { } else {
if (_downloadQueue.isNotEmpty) { if (_downloadQueue.isNotEmpty) {
_log.finest('Connectivity regained and the download queue is not empty. Starting a new download job.'); _log.finest(
'Connectivity regained and the download queue is not empty. Starting a new download job.',
);
_currentDownloadJob = _downloadQueue.removeFirst(); _currentDownloadJob = _downloadQueue.removeFirst();
unawaited(_performFileDownload(_currentDownloadJob!)); unawaited(_performFileDownload(_currentDownloadJob!));
} }
@@ -115,29 +121,28 @@ class HttpFileTransferService {
}); });
} }
Future<void> _copyFile(FileUploadJob job) async { Future<void> _copyFile(
for (final recipient in job.recipients) { FileUploadJob job,
final newPath = await getDownloadPath( String to,
pathlib.basename(job.path), ) async {
recipient, if (!File(to).existsSync()) {
job.mime, await File(job.path).copy(to);
);
await File(job.path).copy(newPath);
// Let the media scanner index the file // Let the media scanner index the file
MoxplatformPlugin.media.scanFile(newPath); MoxplatformPlugin.media.scanFile(to);
} else {
// Update the message _log.finest(
await GetIt.I.get<MessageService>().updateMessage( 'Skipping file copy on upload as file is already at media location',
job.messageMap[recipient]!.id,
mediaUrl: newPath,
); );
} }
} }
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>();
// Notify UI of upload failure // Notify UI of upload failure
for (final recipient in job.recipients) { for (final recipient in job.recipients) {
@@ -147,6 +152,19 @@ class HttpFileTransferService {
isUploading: false, isUploading: false,
); );
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation list
final conversation = await cs.getConversationByJid(recipient);
if (conversation?.lastMessage?.id == msg.id) {
final newConversation = conversation!.copyWith(
lastMessage: msg,
);
// Update the cache
cs.setConversation(newConversation);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
} }
await _pickNextUploadTask(); await _pickNextUploadTask();
@@ -169,13 +187,13 @@ class HttpFileTransferService {
try { try {
encryption = await GetIt.I.get<CryptographyService>().encryptFile( encryption = await GetIt.I.get<CryptographyService>().encryptFile(
job.path, job.path,
path, path,
SFSEncryptionType.aes256GcmNoPadding, SFSEncryptionType.aes256GcmNoPadding,
); );
} 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;
} }
} }
@@ -185,7 +203,8 @@ class HttpFileTransferService {
// Request the upload slot // Request the upload slot
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!; final httpManager =
conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
final slotResult = await httpManager.requestUploadSlot( final slotResult = await httpManager.requestUploadSlot(
pathlib.basename(path), pathlib.basename(path),
stat.size, stat.size,
@@ -193,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>();
@@ -219,26 +238,98 @@ class HttpFileTransferService {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
if (!isRequestOkay(uploadStatusCode)) { if (!isRequestOkay(uploadStatusCode)) {
_log.severe('Upload failed'); _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');
// Get hashes
StatelessFileSharingSource source;
final plaintextHashes = <HashFunction, String>{};
Map<HashFunction, String>? ciphertextHashes;
if (encryption != null) {
source = StatelessFileSharingEncryptedSource(
SFSEncryptionType.aes256GcmNoPadding,
encryption.key,
encryption.iv,
encryption.ciphertextHashes,
StatelessFileSharingUrlSource(slot.getUrl),
);
plaintextHashes.addAll(encryption.plaintextHashes);
ciphertextHashes = encryption.ciphertextHashes;
} else {
source = StatelessFileSharingUrlSource(slot.getUrl);
try {
plaintextHashes[HashFunction.sha256] = await GetIt.I
.get<CryptographyService>()
.hashFile(job.path, HashFunction.sha256);
} catch (ex) {
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
}
}
// Update the metadata
final filename = pathlib.basename(job.path);
final filePath = await computeCachedPathForFile(
filename,
plaintextHashes,
);
final metadataWrapper =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
MediaFileLocation(
[slot.getUrl],
filename,
encryption != null
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
: null,
encryption?.key,
encryption?.iv,
plaintextHashes,
ciphertextHashes,
stat.size,
),
job.mime,
stat.size,
null,
// TODO(Unknown): job.thumbnails.first
null,
null,
path: filePath,
);
var metadata = metadataWrapper.fileMetadata;
// Remove the tempoary metadata if we already know the file
if (metadataWrapper.retrieved) {
// Only skip the copy if the existing file metadata has a path associated with it
if (metadataWrapper.fileMetadata.path != null) {
_log.fine(
'Uploaded file $filename is already tracked. Skipping copy.',
);
} else {
_log.fine(
'Uploaded file $filename is already tracked but has no path. Copying...',
);
await _copyFile(job, filePath);
metadata = await GetIt.I.get<FilesService>().updateFileMetadata(
metadata.id,
path: filePath,
);
}
} else {
_log.fine('Uploaded file $filename not tracked. Copying...');
await _copyFile(job, metadataWrapper.fileMetadata.path!);
}
const uuid = Uuid(); const uuid = Uuid();
for (final recipient in job.recipients) { for (final recipient in job.recipients) {
// 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,
mediaSize: stat.size, errorType: null,
errorType: noError,
encryptionScheme: encryption != null ?
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
null,
key: encryption != null ? base64Encode(encryption.key) : null,
iv: encryption != null ? base64Encode(encryption.iv) : null,
isUploading: false, isUploading: false,
srcUrl: slot.getUrl, fileMetadata: metadata,
); );
// TODO(Unknown): Maybe batch those two together? // TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid; final oldSid = msg.sid;
@@ -249,57 +340,39 @@ class HttpFileTransferService {
); );
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
StatelessFileSharingSource source;
final plaintextHashes = <String, String>{};
if (encryption != null) {
source = StatelessFileSharingEncryptedSource(
SFSEncryptionType.aes256GcmNoPadding,
encryption.key,
encryption.iv,
encryption.ciphertextHashes,
StatelessFileSharingUrlSource(slot.getUrl),
);
plaintextHashes.addAll(encryption.plaintextHashes);
} else {
source = StatelessFileSharingUrlSource(slot.getUrl);
try {
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
.hashFile(job.path, HashFunction.sha256);
} catch (ex) {
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
}
}
// 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, name: filename,
name: pathlib.basename(job.path), thumbnails: job.thumbnails,
thumbnails: job.thumbnails, hashes: plaintextHashes,
hashes: plaintextHashes, ),
), [source],
<StatelessFileSharingSource>[source], includeOOBFallback: true,
), ),
shouldEncrypt: job.encryptMap[recipient]!, FileUploadNotificationReplacementData(oldSid),
funReplacement: oldSid, MessageIdData(msg.sid),
), ]),
);
_log.finest(
'Sent message with file upload for ${job.path} to $recipient',
); );
_log.finest('Sent message with file upload for ${job.path} to $recipient'); }
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true; // Remove the old metadata only here because we would otherwise violate a foreign key
if (isMultiMedia) { // constraint.
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...'); if (metadataWrapper.retrieved) {
unawaited(_copyFile(job)); await GetIt.I.get<FilesService>().removeFileMetadata(
} job.metadataId,
);
} }
} }
@@ -308,7 +381,8 @@ class HttpFileTransferService {
Future<void> _pickNextUploadTask() async { Future<void> _pickNextUploadTask() async {
// Free the upload resources for the next one // Free the upload resources for the next one
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return; if (GetIt.I.get<ConnectivityService>().currentState ==
ConnectivityResult.none) return;
await _uploadLock.synchronized(() async { await _uploadLock.synchronized(() async {
if (_uploadQueue.isNotEmpty) { if (_uploadQueue.isNotEmpty) {
_currentUploadJob = _uploadQueue.removeFirst(); _currentUploadJob = _uploadQueue.removeFirst();
@@ -319,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
@@ -336,7 +413,10 @@ class HttpFileTransferService {
/// Actually attempt to download the file described by the job [job]. /// Actually attempt to download the file described by the job [job].
Future<void> _performFileDownload(FileDownloadJob job) async { Future<void> _performFileDownload(FileDownloadJob job) async {
final filename = job.location.filename; final filename = job.location.filename;
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess); final downloadedPath = await computeCachedPathForFile(
job.location.filename,
job.location.plaintextHashes,
);
var downloadPath = downloadedPath; var downloadPath = downloadedPath;
if (job.location.key != null && job.location.iv != null) { if (job.location.key != null && job.location.iv != null) {
@@ -345,13 +425,18 @@ class HttpFileTransferService {
downloadPath = pathlib.join(tempDir.path, filename); downloadPath = pathlib.join(tempDir.path, filename);
} }
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)'); // TODO(Unknown): Maybe try other URLs?
final downloadUrl = job.location.urls.first;
_log.finest(
'Downloading $downloadUrl as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)',
);
int? downloadStatusCode; int? downloadStatusCode;
var integrityCheckPassed = true;
try { try {
_log.finest('Beginning download...'); _log.finest('Beginning download...');
downloadStatusCode = await client.downloadFile( downloadStatusCode = await client.downloadFile(
Uri.parse(job.location.url), Uri.parse(downloadUrl),
downloadPath, downloadPath,
(total, current) { (total, current) {
final progress = current.toDouble() / total.toDouble(); final progress = current.toDouble() / total.toDouble();
@@ -369,15 +454,16 @@ class HttpFileTransferService {
} }
if (!isRequestOkay(downloadStatusCode)) { if (!isRequestOkay(downloadStatusCode)) {
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode'); _log.warning(
await _fileDownloadFailed(job, fileDownloadFailedError); 'HTTP GET of $downloadUrl returned $downloadStatusCode',
);
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
return; return;
} }
var integrityCheckPassed = true; final decryptionKeysAvailable =
final conv = (await GetIt.I.get<ConversationService>() job.location.key != null && job.location.iv != null;
.getConversationByJid(job.conversationJid))!; final crypto = GetIt.I.get<CryptographyService>();
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
if (decryptionKeysAvailable) { if (decryptionKeysAvailable) {
// The file was downloaded and is now being decrypted // The file was downloaded and is now being decrypted
sendEvent( sendEvent(
@@ -387,10 +473,10 @@ class HttpFileTransferService {
); );
try { try {
final result = await GetIt.I.get<CryptographyService>().decryptFile( final result = await crypto.decryptFile(
downloadPath, downloadPath,
downloadedPath, downloadedPath,
encryptionTypeFromNamespace(job.location.encryptionScheme!), SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
job.location.key!, job.location.key!,
job.location.iv!, job.location.iv!,
job.location.plaintextHashes ?? {}, job.location.plaintextHashes ?? {},
@@ -399,18 +485,44 @@ 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;
} }
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay; integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
} catch (ex) { } catch (ex) {
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex'); _log.warning(
await _fileDownloadFailed(job, messageFailedToDecryptFile); 'Decryption of $downloadPath ($downloadedPath) failed: $ex',
);
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
return; return;
} }
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true)); unawaited(
Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
);
} else if (job.location.plaintextHashes?.isNotEmpty ?? false) {
// Verify only the plaintext hash
// TODO(Unknown): Allow verification of other hash functions
if (job.location.plaintextHashes![HashFunction.sha256] != null) {
final hash = await crypto.hashFile(
downloadPath,
HashFunction.sha256,
);
integrityCheckPassed =
hash == job.location.plaintextHashes![HashFunction.sha256];
} else if (job.location.plaintextHashes![HashFunction.sha512] != null) {
final hash = await crypto.hashFile(
downloadPath,
HashFunction.sha512,
);
integrityCheckPassed =
hash == job.location.plaintextHashes![HashFunction.sha512];
} else {
_log.warning(
'Could not verify file integrity as no accelerated hash function is available (${job.location.plaintextHashes!.keys})',
);
}
} }
// Check the MIME type // Check the MIME type
@@ -454,50 +566,57 @@ class HttpFileTransferService {
} }
} }
final msg = await GetIt.I.get<MessageService>().updateMessage( final fs = GetIt.I.get<FilesService>();
job.mId, final metadata = await fs.updateFileMetadata(
mediaUrl: downloadedPath, job.metadataId,
mediaType: mime, path: downloadedPath,
mediaWidth: mediaWidth, size: File(downloadedPath).lengthSync(),
mediaHeight: mediaHeight, width: mediaWidth,
mediaSize: File(downloadedPath).lengthSync(), height: mediaHeight,
isFileUploadNotification: false, mimeType: mime,
warningType: integrityCheckPassed ?
null :
warningFileIntegrityCheckFailed,
errorType: conv.encrypted && !decryptionKeysAvailable ?
messageChatEncryptedButFileNot :
null,
isDownloading: false,
); );
// Only add the hash pointers if the file hashes match what was sent
if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
integrityCheckPassed &&
job.createMetadataHashes) {
await fs.createMetadataHashEntries(
job.location.plaintextHashes!,
job.metadataId,
);
}
final cs = GetIt.I.get<ConversationService>();
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
final msg = await GetIt.I.get<MessageService>().updateMessage(
job.mId,
fileMetadata: metadata,
isFileUploadNotification: false,
warningType:
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
errorType: conversation.encrypted && !decryptionKeysAvailable
? MessageErrorType.chatEncryptedButPlaintextFile
: null,
isDownloading: false,
);
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData( final updatedConversation = conversation.copyWith(
downloadedPath, lastMessage: conversation.lastMessage?.id == job.mId
msg.timestamp, ? msg
conv.id, : conversation.lastMessage,
job.mId,
mime: mime,
); );
final newConv = conv.copyWith( cs.setConversation(updatedConversation);
lastMessage: conv.lastMessage?.id == job.mId ?
msg :
conv.lastMessage,
sharedMedia: [
sharedMedium,
...conv.sharedMedia,
],
);
GetIt.I.get<ConversationService>().setConversation(newConv);
// Show a notification // Show a notification
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) { if (notification.shouldShowNotification(msg.conversationJid) &&
job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath'); _log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(newConv, msg, ''); await notification.showNotification(updatedConversation, msg, '');
} }
sendEvent(ConversationUpdatedEvent(conversation: newConv)); sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
// Free the download resources for the next one // Free the download resources for the next one
await _pickNextDownloadTask(); await _pickNextDownloadTask();
@@ -509,7 +628,8 @@ class HttpFileTransferService {
_currentDownloadJob = _downloadQueue.removeFirst(); _currentDownloadJob = _downloadQueue.removeFirst();
// Only download if we have a connection // Only download if we have a connection
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) { if (GetIt.I.get<ConnectivityService>().currentState !=
ConnectivityResult.none) {
unawaited(_performFileDownload(_currentDownloadJob!)); unawaited(_performFileDownload(_currentDownloadJob!));
} }
} else { } else {

View File

@@ -6,7 +6,15 @@ import 'package:moxxyv2/shared/models/message.dart';
/// A job describing the download of a file. /// A job describing the download of a file.
@immutable @immutable
class FileUploadJob { class FileUploadJob {
const FileUploadJob(this.recipients, this.path, this.mime, this.encryptMap, this.messageMap, this.thumbnails); const FileUploadJob(
this.recipients,
this.path,
this.mime,
this.encryptMap,
this.messageMap,
this.metadataId,
this.thumbnails,
);
final List<String> recipients; final List<String> recipients;
final String path; final String path;
final String? mime; final String? mime;
@@ -14,21 +22,30 @@ class FileUploadJob {
final Map<String, bool> encryptMap; final Map<String, bool> encryptMap;
// Recipient -> Message // Recipient -> Message
final Map<String, Message> messageMap; final Map<String, Message> messageMap;
final String metadataId;
final List<Thumbnail> thumbnails; final List<Thumbnail> thumbnails;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is FileUploadJob && return other is FileUploadJob &&
recipients == other.recipients && recipients == other.recipients &&
path == other.path && path == other.path &&
messageMap == other.messageMap && messageMap == other.messageMap &&
mime == other.mime && mime == other.mime &&
thumbnails == other.thumbnails && thumbnails == other.thumbnails &&
encryptMap == other.encryptMap; encryptMap == other.encryptMap &&
metadataId == other.metadataId;
} }
@override @override
int get hashCode => path.hashCode ^ recipients.hashCode ^ messageMap.hashCode ^ mime.hashCode ^ thumbnails.hashCode ^ encryptMap.hashCode; int get hashCode =>
path.hashCode ^
recipients.hashCode ^
messageMap.hashCode ^
mime.hashCode ^
thumbnails.hashCode ^
encryptMap.hashCode ^
metadataId.hashCode;
} }
/// A job describing the upload of a file. /// A job describing the upload of a file.
@@ -37,26 +54,52 @@ class FileDownloadJob {
const FileDownloadJob( const FileDownloadJob(
this.location, this.location,
this.mId, this.mId,
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;
/// 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
bool operator ==(Object other) { bool operator ==(Object other) {
return other is FileDownloadJob && return other is FileDownloadJob &&
location == other.location && location == other.location &&
mId == other.mId && mId == other.mId &&
conversationJid == other.conversationJid && metadataId == other.metadataId &&
mimeGuess == other.mimeGuess && conversationJid == other.conversationJid &&
shouldShowNotification == other.shouldShowNotification; mimeGuess == other.mimeGuess &&
shouldShowNotification == other.shouldShowNotification;
} }
@override @override
int get hashCode => location.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode; int get hashCode =>
location.hashCode ^
mId.hashCode ^
metadataId.hashCode ^
conversationJid.hashCode ^
mimeGuess.hashCode ^
shouldShowNotification.hashCode;
} }

View File

@@ -1,25 +1,27 @@
import 'dart:convert'; import 'dart:convert';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
@immutable @immutable
class MediaFileLocation { class MediaFileLocation {
const MediaFileLocation( const MediaFileLocation(
this.url, this.urls,
this.filename, this.filename,
this.encryptionScheme, this.encryptionScheme,
this.key, this.key,
this.iv, this.iv,
this.plaintextHashes, this.plaintextHashes,
this.ciphertextHashes, this.ciphertextHashes,
this.size,
); );
final String url; final List<String> urls;
final String filename; final String filename;
final String? encryptionScheme; final String? encryptionScheme;
final List<int>? key; final List<int>? key;
final List<int>? iv; final List<int>? iv;
final Map<String, String>? plaintextHashes; final Map<HashFunction, String>? plaintextHashes;
final Map<String, String>? ciphertextHashes; final Map<HashFunction, String>? ciphertextHashes;
final int? size;
String? get keyBase64 { String? get keyBase64 {
if (key != null) return base64Encode(key!); if (key != null) return base64Encode(key!);
@@ -34,16 +36,24 @@ class MediaFileLocation {
} }
@override @override
int get hashCode => url.hashCode ^ filename.hashCode ^ encryptionScheme.hashCode ^ key.hashCode ^ iv.hashCode ^ plaintextHashes.hashCode ^ ciphertextHashes.hashCode; int get hashCode =>
urls.hashCode ^
filename.hashCode ^
encryptionScheme.hashCode ^
key.hashCode ^
iv.hashCode ^
plaintextHashes.hashCode ^
ciphertextHashes.hashCode ^
size.hashCode;
@override @override
bool operator==(Object other) { bool operator ==(Object other) {
// TODO(PapaTutuWawa): Compare the Maps // TODO(PapaTutuWawa): Compare the Maps
return other is MediaFileLocation && return other is MediaFileLocation &&
url == other.url && filename == other.filename &&
filename == other.filename && encryptionScheme == other.encryptionScheme &&
encryptionScheme == other.encryptionScheme && key == other.key &&
key == other.key && iv == other.iv &&
iv == other.iv; size == other.size;
} }
} }

View File

@@ -1,36 +1,333 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:io';
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/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.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/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
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/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/cache.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/models/media.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:synchronized/synchronized.dart';
class MessageService { class MessageService {
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService'); /// Logger
final HashMap<String, List<Message>> _messageCache; final Logger _log = Logger('MessageService');
final Logger _log;
/// Returns the messages for [jid], either from cache or from the database. final LRUCache<String, List<Message>> _messageCache =
Future<List<Message>> getMessagesForJid(String jid) async { LRUCache(conversationMessagePageCacheSize);
if (!_messageCache.containsKey(jid)) { final Lock _cacheLock = Lock();
_messageCache[jid] = await GetIt.I.get<DatabaseService>().loadMessagesForJid(jid);
Future<Message?> getMessageById(
int id,
String conversationJid, {
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesRaw = await db.query(
messagesTable,
where: 'id = ? AND conversationJid = ?',
whereArgs: [id, conversationJid],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
// Load the file metadata, if available
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
limit: 1,
))
.first;
fm = FileMetadata.fromDatabaseJson(rawFm);
} }
final messages = _messageCache[jid]; return Message.fromDatabaseJson(
if (messages == null) { msg,
_log.warning('No messages found for $jid. Returning [].'); null,
return []; fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
: [],
);
}
Future<Message?> getMessageByXmppId(
String id,
String conversationJid, {
bool includeOriginId = true,
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
final messagesRaw = await db.query(
messagesTable,
where: 'conversationJid = ? AND $idQuery',
whereArgs: [
conversationJid,
if (includeOriginId) id,
id,
],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
limit: 1,
))
.first;
fm = FileMetadata.fromDatabaseJson(rawFm);
} }
return messages; return Message.fromDatabaseJson(
msg,
null,
fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
: [],
);
}
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
/// specified, or the oldest messages are returned if null. If [olderThan] is false, then message must be newer
/// than [oldestTimestamp], or the newest messages are returned if null.
Future<List<Message>> getPaginatedMessagesForJid(
String jid,
bool olderThan,
int? oldestTimestamp,
) async {
if (olderThan && oldestTimestamp == null) {
final result = await _cacheLock.synchronized<List<Message>?>(() {
return _messageCache.getValue(jid);
});
if (result != null) return result;
}
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = oldestTimestamp != null
? 'conversationJid = ? AND timestamp $comparator ?'
: 'conversationJid = ?';
final rawMessages = await db.rawQuery(
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
'''
SELECT
msg.*,
quote.id AS quote_id,
quote.sender AS quote_sender,
quote.body AS quote_body,
quote.timestamp AS quote_timestamp,
quote.sid AS quote_sid,
quote.conversationJid AS quote_conversationJid,
quote.isFileUploadNotification AS quote_isFileUploadNotification,
quote.encrypted AS quote_encrypted,
quote.errorType AS quote_errorType,
quote.warningType AS quote_warningType,
quote.received AS quote_received,
quote.displayed AS quote_displayed,
quote.acked AS quote_acked,
quote.originId AS quote_originId,
quote.quote_id AS quote_quote_id,
quote.file_metadata_id AS quote_file_metadata_id,
quote.isDownloading AS quote_isDownloading,
quote.isUploading AS quote_isUploading,
quote.isRetracted AS quote_isRetracted,
quote.isEdited AS quote_isEdited,
quote.containsNoStore AS quote_containsNoStore,
quote.stickerPackId AS quote_stickerPackId,
quote.pseudoMessageType AS quote_pseudoMessageType,
quote.pseudoMessageData AS quote_pseudoMessageData,
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 (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $messagePaginationSize) AS msg
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id
LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id;
''',
[
jid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
final page = List<Message>.empty(growable: true);
for (final m in rawMessages) {
if (m.isEmpty) {
continue;
}
Message? quotes;
if (m['quote_id'] != null) {
final rawQuote = getPrefixedSubMap(m, 'quote_');
FileMetadata? quoteFm;
if (rawQuote['file_metadata_id'] != null) {
final rawQuoteFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [rawQuote['file_metadata_id']],
limit: 1,
))
.first;
quoteFm = FileMetadata.fromDatabaseJson(rawQuoteFm);
}
quotes = Message.fromDatabaseJson(rawQuote, null, quoteFm, []);
}
FileMetadata? fm;
if (m['file_metadata_id'] != null) {
fm = FileMetadata.fromDatabaseJson(
getPrefixedSubMap(m, 'fm_'),
);
}
page.add(
Message.fromDatabaseJson(
m,
quotes,
fm,
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
),
);
}
if (olderThan && oldestTimestamp == null) {
await _cacheLock.synchronized(() {
_messageCache.cache(
jid,
page,
);
});
}
return page;
}
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
/// metadata attached. This method bypasses the cache and does not load the message's
/// quoted message, if it exists. If [jid] is set to null, then the media messages for
/// all conversations are queried.
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
String? jid,
bool olderThan,
int? oldestTimestamp,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final queryPrefix = jid != null ? 'conversationJid = ? AND' : '';
final query = oldestTimestamp != null
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
: 'file_metadata_id IS NOT NULL';
final rawMessages = await db.rawQuery(
'''
SELECT
msg.*,
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
(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);
''',
[
if (jid != null) jid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
final page = List<Message>.empty(growable: true);
for (final m in rawMessages) {
if (m.isEmpty) {
continue;
}
page.add(
Message.fromDatabaseJson(
m,
null,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(m, 'fm_'),
),
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
),
);
}
return page;
} }
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache. /// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
@@ -39,186 +336,222 @@ class MessageService {
int timestamp, int timestamp,
String sender, String sender,
String conversationJid, String conversationJid,
bool isMedia,
String sid, String sid,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
bool containsNoStore, bool containsNoStore, {
{ String? originId,
String? srcUrl, String? quoteId,
String? key, FileMetadata? fileMetadata,
String? iv, MessageErrorType? errorType,
String? encryptionScheme, int? warningType,
String? mediaUrl, bool isDownloading = false,
String? mediaType, bool isUploading = false,
String? thumbnailData, String? stickerPackId,
int? mediaWidth, PseudoMessageType? pseudoMessageType,
int? mediaHeight, Map<String, dynamic>? pseudoMessageData,
String? originId, bool received = false,
String? quoteId, bool displayed = false,
String? filename, }) async {
int? errorType, final db = GetIt.I.get<DatabaseService>().database;
int? warningType, var m = Message(
Map<String, String>? plaintextHashes, sender,
Map<String, String>? ciphertextHashes,
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
}
) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
body, body,
timestamp, timestamp,
sender,
conversationJid,
isMedia,
sid, sid,
-1,
conversationJid,
isFileUploadNotification, isFileUploadNotification,
encrypted, encrypted,
containsNoStore, containsNoStore,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaUrl: mediaUrl,
mediaType: mediaType,
thumbnailData: thumbnailData,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
originId: originId,
quoteId: quoteId,
filename: filename,
errorType: errorType, errorType: errorType,
warningType: warningType, warningType: warningType,
plaintextHashes: plaintextHashes, fileMetadata: fileMetadata,
ciphertextHashes: ciphertextHashes, received: received,
displayed: displayed,
acked: false,
originId: originId,
isUploading: isUploading, isUploading: isUploading,
isDownloading: isDownloading, isDownloading: isDownloading,
mediaSize: mediaSize,
stickerPackId: stickerPackId, stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType, pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData, pseudoMessageData: pseudoMessageData,
); );
// Only update the cache if the conversation already has been loaded. This prevents if (quoteId != null) {
// us from accidentally not loading the conversation afterwards. final quotes = await getMessageByXmppId(quoteId, conversationJid);
if (_messageCache.containsKey(conversationJid)) { if (quotes == null) {
_messageCache[conversationJid] = _messageCache[conversationJid]!..add(msg); _log.warning('Failed to add quote for message with id $quoteId');
} else {
m = m.copyWith(quotes: quotes);
}
} }
return msg; m = m.copyWith(
id: await db.insert(messagesTable, m.toDatabaseJson()),
);
await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(conversationJid);
if (cachedList != null) {
_messageCache.replaceValue(
conversationJid,
clampedListPrepend(
cachedList,
m,
messagePaginationSize,
),
);
}
});
return m;
} }
Future<Message?> getMessageByStanzaId(String conversationJid, String stanzaId) async { Future<Message?> getMessageByStanzaId(
if (!_messageCache.containsKey(conversationJid)) { String conversationJid,
await getMessagesForJid(conversationJid); String stanzaId,
} ) async {
return getMessageByXmppId(
return firstWhereOrNull( stanzaId,
_messageCache[conversationJid]!, conversationJid,
(message) => message.sid == stanzaId, includeOriginId: false,
); );
} }
Future<Message?> getMessageByStanzaOrOriginId(String conversationJid, String id) async { Future<Message?> getMessageByStanzaOrOriginId(
if (!_messageCache.containsKey(conversationJid)) { String conversationJid,
await getMessagesForJid(conversationJid); String id,
} ) async {
return getMessageByXmppId(
return firstWhereOrNull( id,
_messageCache[conversationJid]!, conversationJid,
(message) => message.sid == id || message.originId == id,
);
}
Future<Message?> getMessageById(String conversationJid, int id) async {
if (!_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid);
}
return firstWhereOrNull(
_messageCache[conversationJid]!,
(message) => message.id == id,
); );
} }
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache /// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage(int id, { Future<Message> updateMessage(
int id, {
Object? body = notSpecified, Object? body = notSpecified,
Object? mediaUrl = notSpecified,
Object? mediaType = notSpecified,
bool? isMedia,
bool? received, bool? received,
bool? displayed, bool? displayed,
bool? acked, bool? acked,
Object? fileMetadata = notSpecified,
Object? errorType = notSpecified, Object? errorType = notSpecified,
Object? warningType = notSpecified, Object? warningType = notSpecified,
bool? isFileUploadNotification, bool? isFileUploadNotification,
Object? srcUrl = notSpecified,
Object? key = notSpecified,
Object? iv = notSpecified,
Object? encryptionScheme = notSpecified,
Object? mediaWidth = notSpecified,
Object? mediaHeight = notSpecified,
Object? mediaSize = notSpecified,
bool? isUploading, bool? isUploading,
bool? isDownloading, bool? isDownloading,
Object? originId = notSpecified, Object? originId = notSpecified,
Object? sid = notSpecified, Object? sid = notSpecified,
Object? thumbnailData = notSpecified,
bool? isRetracted, bool? isRetracted,
bool? isEdited, bool? isEdited,
Object? reactions = notSpecified,
}) async { }) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage( final db = GetIt.I.get<DatabaseService>().database;
id, final m = <String, dynamic>{};
body: body,
mediaUrl: mediaUrl,
mediaType: mediaType,
received: received,
displayed: displayed,
acked: acked,
errorType: errorType,
warningType: warningType,
isFileUploadNotification: isFileUploadNotification,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: mediaSize,
isUploading: isUploading,
isDownloading: isDownloading,
originId: originId,
sid: sid,
isRetracted: isRetracted,
isMedia: isMedia,
thumbnailData: thumbnailData,
isEdited: isEdited,
reactions: reactions,
);
if (_messageCache.containsKey(newMessage.conversationJid)) { if (body != notSpecified) {
_messageCache[newMessage.conversationJid] = _messageCache[newMessage.conversationJid]!.map((m) { m['body'] = body as String?;
if (m.id == newMessage.id) return newMessage; }
if (received != null) {
return m; m['received'] = boolToInt(received);
}).toList(); }
if (displayed != null) {
m['displayed'] = boolToInt(displayed);
}
if (acked != null) {
m['acked'] = boolToInt(acked);
}
if (errorType != notSpecified) {
m['errorType'] = (errorType as MessageErrorType?)?.value;
}
if (warningType != notSpecified) {
m['warningType'] = warningType as int?;
}
if (isFileUploadNotification != null) {
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
}
if (isDownloading != null) {
m['isDownloading'] = boolToInt(isDownloading);
}
if (isUploading != null) {
m['isUploading'] = boolToInt(isUploading);
}
if (sid != notSpecified) {
m['sid'] = sid as String?;
}
if (originId != notSpecified) {
m['originId'] = originId as String?;
}
if (isRetracted != null) {
m['isRetracted'] = boolToInt(isRetracted);
}
if (fileMetadata != notSpecified) {
m['file_metadata_id'] = (fileMetadata as FileMetadata?)?.id;
}
if (isEdited != null) {
m['isEdited'] = boolToInt(isEdited);
} }
return newMessage; final updatedMessage = await db.updateAndReturn(
messagesTable,
m,
where: 'id = ?',
whereArgs: [id],
);
Message? quotes;
if (updatedMessage['quote_id'] != null) {
quotes = await getMessageById(
updatedMessage['quote_id']! as int,
updatedMessage['conversationJid']! as String,
queryReactionPreview: false,
);
}
FileMetadata? metadata;
if (fileMetadata != notSpecified) {
metadata = fileMetadata as FileMetadata?;
} else if (updatedMessage['file_metadata_id'] != null) {
final metadataRaw = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [updatedMessage['file_metadata_id']],
limit: 1,
))
.first;
metadata = FileMetadata.fromDatabaseJson(metadataRaw);
}
final msg = Message.fromDatabaseJson(
updatedMessage,
quotes,
metadata,
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
);
await _cacheLock.synchronized(() {
final page = _messageCache.getValue(msg.conversationJid);
if (page != null) {
_messageCache.replaceValue(
msg.conversationJid,
page.map((m) {
if (m.id == msg.id) {
return msg;
}
return m;
}).toList(),
);
}
});
return msg;
} }
/// Helper function that manages everything related to retracting a message. It /// Helper function that manages everything related to retracting a message. It
/// - Replaces all metadata of the message with null values and marks it as retracted /// - Replaces all metadata of the message with null values and marks it as retracted
/// - Modified the conversation, if the retracted message was the newest message /// - Modified the conversation, if the retracted message was the newest message
/// - Remove the SharedMedium from the database, if one referenced the retracted message
/// - Update the UI /// - Update the UI
/// ///
/// [conversationJid] is the bare JID of the conversation this message belongs to. /// [conversationJid] is the bare JID of the conversation this message belongs to.
@@ -227,44 +560,42 @@ class MessageService {
/// [selfRetract] indicates whether the message retraction came from the UI. If true, /// [selfRetract] indicates whether the message retraction came from the UI. If true,
/// then the sender check (see security considerations of XEP-0424) is skipped as /// then the sender check (see security considerations of XEP-0424) is skipped as
/// the UI already verifies it. /// the UI already verifies it.
Future<void> retractMessage(String conversationJid, String originId, String bareSender, bool selfRetract) async { Future<void> retractMessage(
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId( String conversationJid,
String originId,
String bareSender,
bool selfRetract,
) async {
final msg = await getMessageByXmppId(
originId, originId,
conversationJid, conversationJid,
); );
if (msg == null) { if (msg == null) {
_log.finest('Got message retraction for origin Id $originId, but did not find the message'); _log.finest(
'Got message retraction for origin Id $originId, but did not find the message',
);
return; return;
} }
// Check if the retraction was sent by the original sender // Check if the retraction was sent by the original sender
if (!selfRetract) { if (!selfRetract) {
if (JID.fromString(msg.sender).toBare().toString() != bareSender) { if (JID.fromString(msg.sender).toBare().toString() != bareSender) {
_log.warning('Received invalid message retraction from $bareSender but its original sender is ${msg.sender}'); _log.warning(
'Received invalid message retraction from $bareSender but its original sender is ${msg.sender}',
);
return; return;
} }
} }
final isMedia = msg.isMedia; final isMedia = msg.isMedia;
final mediaUrl = msg.mediaUrl;
final retractedMessage = await updateMessage( final retractedMessage = await updateMessage(
msg.id, msg.id,
isMedia: false,
mediaUrl: null,
mediaType: null,
warningType: null, warningType: null,
errorType: null, errorType: null,
srcUrl: null,
key: null,
iv: null,
encryptionScheme: null,
mediaWidth: null,
mediaHeight: null,
mediaSize: null,
isRetracted: true, isRetracted: true,
thumbnailData: null,
body: '', body: '',
fileMetadata: null,
); );
sendEvent(MessageUpdatedEvent(message: retractedMessage)); sendEvent(MessageUpdatedEvent(message: retractedMessage));
@@ -272,37 +603,85 @@ class MessageService {
final conversation = await cs.getConversationByJid(conversationJid); final conversation = await cs.getConversationByJid(conversationJid);
if (conversation != null) { if (conversation != null) {
if (conversation.lastMessage?.id == msg.id) { if (conversation.lastMessage?.id == msg.id) {
var newConversation = conversation.copyWith( final newConversation = conversation.copyWith(
lastMessage: retractedMessage, lastMessage: retractedMessage,
); );
if (isMedia) {
await GetIt.I.get<DatabaseService>().removeSharedMediumByMessageId(msg.id);
newConversation = newConversation.copyWith(
sharedMedia: newConversation.sharedMedia.where((SharedMedium medium) {
return medium.messageId != msg.id;
}).toList(),
);
// Delete the file if we downloaded it
if (mediaUrl != null) {
final file = File(mediaUrl);
if (file.existsSync()) {
unawaited(file.delete());
}
}
}
cs.setConversation(newConversation); cs.setConversation(newConversation);
sendEvent( sendEvent(
ConversationUpdatedEvent( ConversationUpdatedEvent(
conversation: newConversation, conversation: newConversation,
), ),
); );
if (isMedia) {
// Remove the file
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
msg.fileMetadata!,
);
}
} }
} else { } else {
_log.warning('Failed to find conversation with conversationJid $conversationJid'); _log.warning(
'Failed to find conversation with conversationJid $conversationJid',
);
} }
} }
Future<void> replaceMessageInCache(Message message) async {
await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(message.conversationJid);
if (cachedList != null) {
_messageCache.replaceValue(
message.conversationJid,
cachedList.map((m) {
if (m.id == message.id) {
return message;
}
return m;
}).toList(),
);
}
});
}
/// 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

@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:synchronized/synchronized.dart';
class MoxxyConnectivityManager extends ConnectivityManager {
MoxxyConnectivityManager() : super() {
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityChanged);
}
final Logger _log = Logger('MoxxyConnectivityManager');
Completer<void>? _completer;
final Lock _completerLock = Lock();
Future<void> initialize() async {
await _completerLock.synchronized(() async {
final result = await GetIt.I.get<ConnectivityService>().hasConnection();
if (!result) {
_log.finest(
'No network connection at initialization: Creating completer',
);
_completer = Completer<void>();
}
});
}
Future<void> _onConnectivityChanged(ConnectivityEvent event) async {
if (event.regained) {
await _completerLock.synchronized(() {
_log.finest(
'Network regained. _completer != null: ${_completer != null}',
);
_completer?.complete();
_completer = null;
});
} else if (event.lost) {
await _completerLock.synchronized(() {
_log.finest('Network connection lost. Creating completer');
_completer ??= Completer<void>();
});
}
}
@override
Future<bool> hasConnection() async {
return GetIt.I.get<ConnectivityService>().hasConnection();
}
@override
Future<void> waitForConnection() async {
final c = await _completerLock.synchronized(() => _completer);
if (c != null) {
_log.finest('waitForConnection: Completer non-null. Waiting.');
await c.future;
}
}
}

View File

@@ -1,6 +0,0 @@
import 'package:moxxmpp/moxxmpp.dart';
class MoxxyDiscoManager extends DiscoManager {
@override
List<Identity> getIdentities() => const [ Identity(category: 'client', type: 'phone', name: 'Moxxy') ];
}

View File

@@ -1,41 +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) {
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,125 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:synchronized/synchronized.dart';
/// This class implements a reconnection policy that is connectivity aware with a random
/// backoff. This means that we perform the random backoff only as long as we are
/// connected. Otherwise, we idle until we have a connection again.
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
: _isTesting = isTesting,
_timerLock = Lock(),
_log = Logger('MoxxyReconnectionPolicy'),
super();
final Logger _log;
/// The backoff timer
@visibleForTesting
Timer? timer;
final Lock _timerLock;
/// Just for testing purposes
final bool _isTesting;
/// Maximum backoff time
final int? maxBackoffTime;
/// To be called when the conectivity changes
Future<void> onConnectivityChanged(bool regained, bool lost) async {
// Do nothing if we should not reconnect
if (!shouldReconnect && regained) {
_log.finest('Connectivity changed but not attempting reconnection as shouldReconnect is false');
return;
}
if (lost) {
// We just lost network connectivity
_log.finest('Lost network connectivity. Queueing failure...');
// Cancel the timer if it was running
await _stopTimer();
await setIsReconnecting(false);
await triggerConnectionLost!();
} else if (regained && shouldReconnect) {
// We should reconnect
_log.finest('Network regained. Attempting reconnection...');
await _attemptReconnection(true);
}
}
@override
Future<void> reset() async {
await _stopTimer();
await setIsReconnecting(false);
}
Future<void> _stopTimer() async {
await _timerLock.synchronized(() {
if (timer != null) {
timer!.cancel();
timer = null;
_log.finest('Destroying timer');
}
});
}
@visibleForTesting
Future<void> onTimerElapsed() async {
await _stopTimer();
_log.finest('Performing reconnect');
await performReconnect!();
}
Future<void> _attemptReconnection(bool immediately) async {
if (await testAndSetIsReconnecting()) {
// Attempt reconnecting
int seconds;
if (_isTesting) {
seconds = 9999;
} else {
final r = Random().nextInt(15);
if (maxBackoffTime != null) {
seconds = min(maxBackoffTime!, r);
} else {
seconds = r;
}
}
await _stopTimer();
if (immediately) {
_log.finest('Immediately attempting reconnection...');
await onTimerElapsed();
} else {
_log.finest('Started backoff timer with ${seconds}s backoff');
await _timerLock.synchronized(() {
timer = Timer(Duration(seconds: seconds), onTimerElapsed);
});
}
} else {
_log.severe('_attemptReconnection called while reconnect is running!');
}
}
@override
Future<void> onFailure() async {
final state = GetIt.I.get<ConnectivityService>().currentState;
if (state != ConnectivityResult.none) {
await _attemptReconnection(false);
} else {
_log.fine('Failure occurred while no network connection is available. Waiting for connection...');
}
}
@override
Future<void> onSuccess() async {
await reset();
}
}

View File

@@ -1,61 +1,97 @@
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.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 {
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
return RosterCacheLoadResult( return RosterCacheLoadResult(
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion, (await GetIt.I.get<XmppStateService>().getXmppState()).lastRosterVersion,
(await rs.getRoster()).map((item) => XmppRosterItem( (await rs.getRoster())
jid: item.jid, .map(
name: item.title, (item) => XmppRosterItem(
subscription: item.subscription, jid: item.jid,
ask: item.ask.isEmpty ? null : item.ask, name: item.title,
groups: item.groups, subscription: item.subscription,
),).toList(), ask: item.ask.isEmpty ? null : item.ask,
groups: item.groups,
),
)
.toList(),
); );
} }
@override @override
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async { Future<void> commitRoster(
String? version,
List<String> removed,
List<XmppRosterItem> modified,
List<XmppRosterItem> added,
) async {
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
final xs = GetIt.I.get<XmppService>(); final xss = GetIt.I.get<XmppStateService>();
await xs.modifyXmppState((state) => state.copyWith( await xss.modifyXmppState(
lastRosterVersion: version, (state) => state.copyWith(
),); lastRosterVersion: version,
),
);
// 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
final rosterAdded = List<RosterItem>.empty(growable: true); final rosterAdded = List<RosterItem>.empty(growable: true);
for (final item in added) { for (final item in added) {
rosterAdded.add( final exists = await rs.getRosterItemByJid(item.jid) != null;
await rs.addRosterItemFromData( // Skip adding items twice
'', if (exists) continue;
'',
item.jid,
item.name ?? item.jid.split('@').first,
item.subscription,
item.ask ?? '',
false,
null,
null,
null,
groups: item.groups,
),
);
// TODO(PapaTutuWawa): Fetch the avatar final newRosterItem = await rs.addRosterItemFromData(
'',
'',
item.jid,
item.name ?? item.jid.split('@').first,
item.subscription,
item.ask ?? '',
false,
null,
null,
null,
groups: item.groups,
);
rosterAdded.add(newRosterItem);
// Update the cached conversation item
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
} }
// Update modified items // Update modified items
@@ -67,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

@@ -2,18 +2,20 @@ import 'package:moxdns/moxdns.dart';
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart'; import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
class MoxxyTCPSocketWrapper extends TCPSocketWrapper { class MoxxyTCPSocketWrapper extends TCPSocketWrapper {
MoxxyTCPSocketWrapper() : super(false); MoxxyTCPSocketWrapper() : super();
@override @override
Future<List<MoxSrvRecord>> srvQuery(String domain, bool dnssec) async { Future<List<MoxSrvRecord>> srvQuery(String domain, bool dnssec) async {
final records = await MoxdnsPlugin.srvQuery(domain, dnssec); final records = await MoxdnsPlugin.srvQuery(domain, dnssec);
return records return records
.map((record) => MoxSrvRecord( .map(
record.priority, (record) => MoxSrvRecord(
record.weight, record.priority,
record.target, record.weight,
record.port, record.target,
),) record.port,
.toList(); ),
)
.toList();
} }
} }

View File

@@ -1,31 +1,36 @@
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/xmpp.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
class MoxxyStreamManagementManager extends StreamManagementManager { class MoxxyStreamManagementManager extends StreamManagementManager {
@override @override
bool shouldTriggerAckedEvent(Stanza stanza) { bool shouldTriggerAckedEvent(Stanza stanza) {
return stanza.tag == 'message' && return stanza.tag == 'message' &&
stanza.id != null && ( stanza.id != null &&
stanza.firstTag('body') != null || (stanza.firstTag('body') != null ||
stanza.firstTag('x', xmlns: oobDataXmlns) != null || stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null || stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null || stanza.firstTag(
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null 'file-upload',
); xmlns: fileUploadNotificationXmlns,
) !=
null ||
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null);
} }
@override @override
Future<void> commitState() async { Future<void> commitState() async {
await GetIt.I.get<XmppService>().modifyXmppState((s) => s.copyWith( await GetIt.I.get<XmppStateService>().modifyXmppState(
smState: state, (s) => s.copyWith(
),); smState: state,
),
);
} }
@override @override
Future<void> loadState() async { Future<void> loadState() async {
final state = await GetIt.I.get<XmppService>().getXmppState(); final state = await GetIt.I.get<XmppStateService>().getXmppState();
if (state.smState != null) { if (state.smState != null) {
await setState(state.smState!); await setState(state.smState!);
} }

View File

@@ -1,4 +1,6 @@
class _NotSpecifiedValue { const _NotSpecifiedValue(); } class _NotSpecifiedValue {
const _NotSpecifiedValue();
}
/// A value used for indicating that a value is not specified. /// A value used for indicating that a value is not specified.
const notSpecified = _NotSpecifiedValue(); const notSpecified = _NotSpecifiedValue();

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,25 +35,24 @@ 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('Received unknown notification action key ${action.buttonKeyPressed}'); logger.warning(
'Received unknown notification action key ${action.buttonKeyPressed}',
);
} }
} }
Future<void> init() async { Future<void> initialize() async {
final an = AwesomeNotifications(); final an = AwesomeNotifications();
await an.initialize( await an.initialize(
'resource://drawable/ic_service_icon', 'resource://drawable/ic_service_icon',
@@ -62,12 +60,14 @@ class NotificationsService {
NotificationChannel( NotificationChannel(
channelKey: _messageChannelKey, channelKey: _messageChannelKey,
channelName: t.notifications.channels.messagesChannelName, channelName: t.notifications.channels.messagesChannelName,
channelDescription: t.notifications.channels.messagesChannelDescription, channelDescription:
t.notifications.channels.messagesChannelDescription,
), ),
NotificationChannel( NotificationChannel(
channelKey: _warningChannelKey, channelKey: _warningChannelKey,
channelName: t.notifications.channels.warningChannelName, channelName: t.notifications.channels.warningChannelName,
channelDescription: t.notifications.channels.warningChannelDescription, channelDescription:
t.notifications.channels.warningChannelDescription,
), ),
], ],
debug: kDebugMode, debug: kDebugMode,
@@ -85,25 +85,29 @@ class NotificationsService {
/// Show a notification for a message [m] grouped by its conversationJid /// Show a notification for a message [m] grouped by its conversationJid
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true, /// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
/// then Android's BigPicture will be used. /// then Android's BigPicture will be used.
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async { Future<void> showNotification(
modelc.Conversation c,
modelm.Message m,
String title, {
String? body,
}) async {
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293 // See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
String body; String body;
if (m.stickerPackId != null) { if (m.stickerPackId != null) {
body = t.messages.sticker; body = t.messages.sticker;
} else if (m.isMedia) { } else if (m.isMedia) {
body = mimeTypeToEmoji(m.mediaType); body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
} else { } else {
body = m.body; body = m.body;
} }
final css = GetIt.I.get<ContactsService>(); final css = GetIt.I.get<ContactsService>();
final contactIntegrationEnabled = await css.isContactIntegrationEnabled(); final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
final title = contactIntegrationEnabled ? final title =
c.contactDisplayName ?? c.title : contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
c.title; final avatarPath = contactIntegrationEnabled
final avatarPath = contactIntegrationEnabled ? ? c.contactAvatarPath ?? c.avatarPath
c.contactAvatarPath ?? c.avatarUrl : : c.avatarPath;
c.avatarUrl;
await AwesomeNotifications().createNotification( await AwesomeNotifications().createNotification(
content: NotificationContent( content: NotificationContent(
@@ -113,19 +117,18 @@ class NotificationsService {
summary: title, summary: title,
title: title, title: title,
body: body, body: body,
largeIcon: avatarPath.isNotEmpty ? largeIcon: avatarPath.isNotEmpty ? 'file://$avatarPath' : null,
'file://$avatarPath' : notificationLayout: m.isThumbnailable
null, ? NotificationLayout.BigPicture
notificationLayout: m.isThumbnailable ? : NotificationLayout.Messaging,
NotificationLayout.BigPicture :
NotificationLayout.Messaging,
category: NotificationCategory.Message, category: NotificationCategory.Message,
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null, bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
payload: <String, String>{ payload: <String, String>{
'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

@@ -2,202 +2,42 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
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/database.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.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: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();
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
final Map<String, Map<int, String>> _fingerprintCache = {};
late OmemoManager omemoManager; /// Queue for code that is waiting on the service initialization.
final Queue<Completer<void>> _waitingForInitialization =
Queue<Completer<void>>();
Future<void> initializeIfNeeded(String jid) async { /// The manager to use for OMEMO.
final done = await _lock.synchronized(() => _initialized); late OmemoManager _omemoManager;
if (done) return;
final db = GetIt.I.get<DatabaseService>(); /// Access the underlying [OmemoManager].
final device = await db.loadOmemoDevice(jid); Future<OmemoManager> getOmemoManager() async {
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{}; await ensureInitialized();
final deviceList = <String, List<int>>{}; return _omemoManager;
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 GetIt.I.get<DatabaseService>().loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet;
}
deviceList.addAll(await db.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 GetIt.I.get<DatabaseService>().saveRatchet(
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
);
if (event.added) {
// Cache the fingerprint
final fingerprint = await event.ratchet.getOmemoFingerprint();
await GetIt.I.get<DatabaseService>().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<XmppService>().getXmppState();
if (jid == xmppState.jid) return;
final ms = GetIt.I.get<MessageService>();
final message = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
'',
jid,
false,
'',
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 GetIt.I.get<DatabaseService>().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
@@ -218,217 +58,223 @@ class OmemoService {
} }
} }
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async { /// Creates or loads the [OmemoManager] for the JID [jid].
await GetIt.I.get<DatabaseService>().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 GetIt.I.get<DatabaseService>().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 = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!; final omemo =
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.getConnectionSettings().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.toString(), bareJid,
node: moxxmpp.omemoBundlesXmlns, node: moxxmpp.omemoBundlesXmlns,
); );
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
.get<List<moxxmpp.DiscoItem>>() .get<List<moxxmpp.DiscoItem>>()
.where((item) => item.name != null) .where((item) => item.name != null)
.map((item) => int.parse(item.name!)); .map((item) => int.parse(item.name!));
if (!bundleIds.contains(device.id)) { if (!bundleIds.contains(device.id)) {
final result = await omemo.publishBundle(await device.toBundle()); final result = await omemo.publishBundle(await device.toBundle());
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>(); if (result.isType<moxxmpp.OmemoError>()) {
return result.get<moxxmpp.OmemoError>();
}
return null; return null;
} }
final idsRaw = await omemo.getDeviceList(bareJid); final idsRaw = await omemo.getDeviceList(bareJid);
final ids = idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>(); final ids =
idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
if (!ids.contains(device.id)) { if (!ids.contains(device.id)) {
final result = await omemo.publishBundle(await device.toBundle()); final result = await omemo.publishBundle(await device.toBundle());
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>(); if (result.isType<moxxmpp.OmemoError>()) {
return result.get<moxxmpp.OmemoError>();
}
return null; return null;
} }
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 GetIt.I.get<DatabaseService>().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 GetIt.I.get<DatabaseService>()
.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 GetIt.I.get<DatabaseService>().saveTrustCache(
json['trust']! as Map<String, int>,
);
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
json['enable']! as Map<String, bool>,
);
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
json['devices']! as Map<String, List<int>>,
);
}
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
final db = GetIt.I.get<DatabaseService>();
return MoxxyBTBVTrustManager(
await db.loadTrustCache(),
await db.loadTrustEnablementList(),
await db.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);
},
);
return fingerprints.map((fp) {
return model.OmemoDevice(
fp.fingerprint,
trust[fp.deviceId]?.trusted ?? false,
trust[fp.deviceId]?.state == BTBVTrustState.verified,
trust[fp.deviceId]?.enabled ?? false,
fp.deviceId,
);
}).toList();
}
Future<void> setDeviceEnablement(String jid, int device, bool state) async {
await ensureInitialized();
await _omemoManager.withTrustManager(jid, (tm) async {
await (tm as BlindTrustBeforeVerificationTrustManager)
.setEnabled(jid, device, state);
});
}
Future<void> setDeviceVerified(String jid, int device) async {
await ensureInitialized();
await _omemoManager.withTrustManager(jid, (tm) async {
await (tm as BlindTrustBeforeVerificationTrustManager)
.setDeviceTrust(jid, device, BTBVTrustState.verified);
});
}
Future<void> removeAllRatchets(String jid) async {
await ensureInitialized();
await _omemoManager.removeAllRatchets(jid);
}
Future<OmemoDevice> getDevice() async {
await ensureInitialized();
return _omemoManager.getDevice();
}
Future<model.OmemoDevice> regenerateDevice() async {
await ensureInitialized();
final oldDeviceId = (await getDevice()).id;
// Generate the new device
final newDevice = await _omemoManager.regenerateDevice();
// Remove the old device
unawaited(
GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
.deleteDevice(oldDeviceId),
);
return model.OmemoDevice(
await newDevice.getFingerprint(),
true,
true,
true,
newDevice.id,
); );
} }
/// Tells omemo_dart, that certain caches are to be seen as invalidated. /// Adds a pseudo-message of type [type] to the chat with [conversationJid].
void onNewConnection() { /// Also sends an event to the UI.
if (_initialized) { Future<void> addPseudoMessage(
omemoManager.onNewConnection(); 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

@@ -1,12 +1,37 @@
import 'package:get_it/get_it.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/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
class PreferencesService { class PreferencesService {
PreferencesState? _preferences; PreferencesState? _preferences;
Future<void> _loadPreferences() async { Future<void> _loadPreferences() async {
_preferences = await GetIt.I.get<DatabaseService>().getPreferences(); final db = GetIt.I.get<DatabaseService>().database;
final preferencesRaw = (await db.query(preferenceTable)).map((preference) {
switch (preference['type']! as int) {
case typeInt:
return {
...preference,
'value': stringToInt(preference['value']! as String),
};
case typeBool:
return {
...preference,
'value': stringToBool(preference['value']! as String),
};
case typeString:
default:
return preference;
}
}).toList();
final json = <String, dynamic>{};
for (final preference in preferencesRaw) {
json[preference['key']! as String] = preference['value'];
}
_preferences = PreferencesState.fromJson(json);
} }
Future<PreferencesState> getPreferences() async { Future<PreferencesState> getPreferences() async {
@@ -15,10 +40,44 @@ class PreferencesService {
return _preferences!; return _preferences!;
} }
Future<void> modifyPreferences(PreferencesState Function(PreferencesState) func) async { Future<void> modifyPreferences(
PreferencesState Function(PreferencesState) func,
) async {
if (_preferences == null) await _loadPreferences(); if (_preferences == null) await _loadPreferences();
_preferences = func(_preferences!); _preferences = func(_preferences!);
await GetIt.I.get<DatabaseService>().savePreferences(_preferences!);
final stateJson = _preferences!.toJson();
final preferences = stateJson.keys.map((key) {
int type;
String value;
if (stateJson[key] is int) {
type = typeInt;
value = intToString(stateJson[key]! as int);
} else if (stateJson[key] is bool) {
type = typeBool;
value = boolToString(stateJson[key]! as bool);
} else {
type = typeString;
value = stateJson[key]! as String;
}
return {
'key': key,
'type': type,
'value': value,
};
});
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final preference in preferences) {
batch.update(
preferenceTable,
preference,
where: 'key = ?',
whereArgs: [preference['key']],
);
}
await batch.commit();
} }
} }

203
lib/service/reactions.dart Normal file
View File

@@ -0,0 +1,203 @@
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/message.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
class ReactionWrapper {
const ReactionWrapper(this.emojis, this.modified);
final List<String> emojis;
final bool modified;
}
class ReactionsService {
final Logger _log = Logger('ReactionsService');
/// Query the database for 6 distinct emoji reactions associated with the message id
/// [id].
Future<List<String>> getPreviewReactionsForMessage(int id) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
columns: ['emoji'],
distinct: true,
limit: 6,
);
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<List<Reaction>> getReactionsForMessage(int id) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
);
return reactions.map(Reaction.fromJson).toList();
}
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ? AND senderJid = ?',
whereArgs: [id, jid],
);
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<int> _countReactions(int messageId, String emoji) async {
return GetIt.I.get<DatabaseService>().database.count(
reactionsTable,
'message_id = ? AND emoji = ?',
[messageId, emoji],
);
}
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
/// new message reaction preview.
Future<Message?> addNewReaction(
int messageId,
String conversationJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
return null;
}
if (!msg.reactionsPreview.contains(emoji) &&
msg.reactionsPreview.length < 6) {
final newPreview = [
...msg.reactionsPreview,
emoji,
];
try {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
await GetIt.I.get<DatabaseService>().database.insert(
reactionsTable,
Reaction(
messageId,
jid,
emoji,
).toJson(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
final newMsg = msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
);
return newMsg;
} catch (ex) {
// The reaction already exists
return msg;
}
}
return msg;
}
Future<Message?> removeReaction(
int messageId,
String conversationJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
return null;
}
await GetIt.I.get<DatabaseService>().database.delete(
reactionsTable,
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
whereArgs: [
messageId,
emoji,
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
],
);
final count = await _countReactions(messageId, emoji);
if (count > 0) {
return msg;
}
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
final newMsg = msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
);
return newMsg;
}
Future<void> processNewReactions(
Message msg,
String senderJid,
List<String> emojis,
) async {
// Get all reactions know for this message
final allReactions = await getReactionsForMessage(msg.id);
final userEmojis =
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
final addedReactions = emojis.where((e) => !userEmojis.contains(e));
// Remove and add the new reactions
final db = GetIt.I.get<DatabaseService>().database;
for (final emoji in removedReactions) {
final rows = await db.delete(
reactionsTable,
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
whereArgs: [msg.id, senderJid, emoji],
);
assert(rows == 1, 'Only one row should be removed');
}
for (final emoji in addedReactions) {
await db.insert(
reactionsTable,
Reaction(
msg.id,
senderJid,
emoji,
).toJson(),
);
}
final newMessage = msg.copyWith(
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
);
await GetIt.I.get<MessageService>().replaceMessageInCache(
newMessage,
);
sendEvent(MessageUpdatedEvent(message: newMessage));
}
}

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