236 Commits

Author SHA1 Message Date
241a8b4d53 release(meta): Release 0.4.1 2023-01-18 21:39:35 +01:00
25d193e930 feat(meta): Add a build script 2023-01-18 21:19:20 +01:00
e6924cc02d fix(ui): Rearange the settings page a bit 2023-01-18 20:19:34 +01:00
60985c6b37 feat(ui): Hide testing commands outside of debug mode 2023-01-18 20:14:26 +01:00
a015399b57 fix(ui): Allow users to unlock the developer options
Fixes #211.
2023-01-18 20:10:01 +01:00
4b6c7998f3 fix(meta): Sharing now also works when the app is closed
Fixes #218.
2023-01-18 15:05:56 +01:00
26312e313f feat(meta): Bump moxxmpp 2023-01-15 00:55:47 +01:00
b63b5d7fd2 fix(service): Fix stanza correlation when from is missing
Fixed by bumping moxxmpp.
2023-01-14 16:30:22 +01:00
ca2943a94d feat(ui): Hide the speed dial when recording an audio message 2023-01-14 12:59:41 +01:00
32a4cd9361 feat(meta): Bump moxxmpp and moxxmpp_socket_tcp 2023-01-14 12:57:35 +01:00
2320e4ed17 fix(service): Remove weird newline 2023-01-14 12:44:46 +01:00
dee479a918 fix(ui): Move the DragTargets more to the left 2023-01-13 23:05:15 +01:00
6895ef1e32 feat(ui): Move the send button back to a speed dial
This makes the voice message UX more like what Signal and co. do.
Also makes the message TextField less crowded. Kind of fixes #207.
2023-01-13 23:03:02 +01:00
5c51eefa3e fix(i18n): Add missing string 2023-01-13 18:58:12 +01:00
0d7ae321a7 feat(ui): Improve the look of the message input field 2023-01-13 18:55:16 +01:00
b4063a64e0 fix(service): Await future 2023-01-13 18:20:04 +01:00
65154f2f5c feat(ui): Rework the file widget 2023-01-13 18:18:22 +01:00
19a22bd0d1 fix(ui): Fix text overflow for the file widget 2023-01-13 17:59:00 +01:00
a7da7baf5a feat(meta): Bump moxxmpp 2023-01-13 17:48:50 +01:00
a344a94112 fix(xmpp): Fix quotes being cut off
Fixes #203.
2023-01-13 13:41:37 +01:00
f44861fead feat(ui): The quote bubble base only depends on the surrounding bubble
Also adds an indicator as to who send a message. Fixes #213.
2023-01-13 00:49:56 +01:00
1c4a30ebb4 fix(ui): Show a text when no sticker packs are installed 2023-01-12 23:53:27 +01:00
70e2ca3d3e fix(ui): Fix some non-occurences of pickerHeight 2023-01-12 23:46:37 +01:00
0d4aee1625 feat(ui): Merge the emoji and the sticker picker
Fixes #209.
2023-01-12 23:44:31 +01:00
ad6aa33b7c fix(ui): Date bubbles' text is always black 2023-01-12 21:18:59 +01:00
284b5fa4df feat(ui): Make the bottom backdrop transparent 2023-01-12 21:16:14 +01:00
b9aac0c3d7 fix(service): Fix file uploads and downloads 2023-01-12 21:09:19 +01:00
6ce90e08ef fix(ui): NPE when a media message has not been downloaded 2023-01-11 17:49:04 +01:00
5ac80d8d60 fix(ui): Fix smaller code issues 2023-01-11 17:48:57 +01:00
56e1fa52d8 feat(ui): Make quotes look nicer 2023-01-11 17:44:51 +01:00
3ae1b7d168 fix(ui): Improve the contrast of the fallback avatar letters
Fixes #206.
2023-01-11 17:17:25 +01:00
d8f654c81c feat(ui): Remove the shadow of the TextField
Fixes #208.
2023-01-11 16:56:49 +01:00
cbcbd4d6dc fix(ui): Remove the use of a Stack inside the quote base
This makes the code feel nicer and also fixes #204 since Flutter
can now use the IconButton's dimensions for layouting and size
computations.
2023-01-10 18:15:58 +01:00
be899b5611 feat(ui): Small color improvements 2023-01-10 17:47:16 +01:00
361bbe8d85 fix(meta): Bump moxxmpp to fix SM 2023-01-09 13:49:43 +01:00
1e017af277 fix(service): Fix only the first roster item being added to the database 2023-01-07 22:23:50 +01:00
c4c22a36bb fix(service): Fix OMEMO device generation 2023-01-07 20:53:29 +01:00
84924b480b feat(service): Call omemo_dart's onNewConnection 2023-01-05 15:22:30 +01:00
358074f4ee fix(service): Generating OMEMO keys failed 2023-01-05 12:39:41 +01:00
084314fbcf fix(ui): Fix version number 2023-01-05 12:36:58 +01:00
c42f301ae0 fix(ui): Fix using the wrong color in text quotes
Fixes #196.
2023-01-05 12:36:03 +01:00
c8cd37e451 release: Tag version 0.4.0 2023-01-02 21:13:08 +01:00
9f8f3a5407 fix(meta): Fix fresh/migrated version hickups 2023-01-02 21:11:49 +01:00
6f1493808f feat(ui): Move the bubbles into their own directory 2023-01-02 19:01:55 +01:00
c9d32694db fix(i18n): Translate forgotten strings 2023-01-02 18:59:28 +01:00
8632a2fc81 fix(ui): Finally fix message bubbles? 2023-01-02 18:59:08 +01:00
46a09d5b62 feat(service): Manage sticker pack privacy
Fixes #192.
2023-01-02 18:04:27 +01:00
b7e5bbc7d2 fix(service): Fix avatars sometimes being not available 2023-01-02 17:38:22 +01:00
ed264f0c16 fix(service): Fix 'ghost' devices appearing 2023-01-02 17:19:23 +01:00
f1820575ad feat(ui): Show the ink splash on new device messages 2023-01-02 17:12:50 +01:00
d2e42d0a3c feat(meta): Show a message if a contact adds a new device 2023-01-02 15:19:08 +01:00
842cf5aaaa fix(service): Maybe fix avatar fetching crashes 2023-01-02 14:02:13 +01:00
c8f727e982 feat(meta): Update moxxmpp and omemo_dart 2023-01-02 14:00:21 +01:00
fd3c9190de Merge pull request 'Migrate to OmemoManager API' (#194) from feat/omemo-improvement into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/194
2023-01-01 19:09:17 +00:00
69439d2b13 feat(meta): Remove commented-out omemo_dart override 2023-01-01 19:36:58 +01:00
6d41fee73f feat(meta): Lockfile update 2023-01-01 18:24:13 +01:00
0de99adeed fix(service): Adjust to new omemo_dart API 2023-01-01 18:22:41 +01:00
f71fd7c82c feat(meta): Update omemo_dart and moxxmpp 2023-01-01 18:22:25 +01:00
0a6b0b8fa5 feat(service): Migrate to new omemo_dart design 2023-01-01 15:02:35 +01:00
5e0ce8f098 fix(ui): Only show stickers that are images 2022-12-27 12:51:23 +01:00
9fc5989bd4 feat(ui): Add an assertion for adding a contact 2022-12-25 13:20:13 +01:00
cbe81861a5 fix(service): Fix avatars being empty when OMEMO is enabled 2022-12-25 13:09:24 +01:00
76a03cc2fa feat(service): Rework the blocklist service
Maybe fixes #14.
2022-12-25 01:25:12 +01:00
3774760548 fix(service): Fix missing type 2022-12-24 22:33:54 +01:00
4b1942b949 fix(ui): Move the date bubbles out of the chat bubble 2022-12-23 14:45:32 +01:00
Millesimus
2f03c02b58 fix(service): Remove unnecessary dio error handling. 2022-12-22 20:28:26 +01:00
Millesimus
639143934f Chore(service): A little tidying. 2022-12-22 20:28:26 +01:00
Millesimus
81bbbcd8e4 Fix(service): Re-enable progress indication using a completer. 2022-12-22 20:28:26 +01:00
Millesimus
bedd46756d Fix(service): flatten memory usage of downloads (sacrificing download progress indication). 2022-12-22 20:28:26 +01:00
Millesimus
bb6b342d82 Fix(service): flatten memory usage of uploads. 2022-12-22 20:28:17 +01:00
b6eb12cf30 feat(ui): Replace settings_ui with custom UI elements 2022-12-22 13:39:07 +01:00
80f8129011 feat(ui): Fix dialog corner radius and make barrier dismissible 2022-12-22 12:20:57 +01:00
86daad2455 feat(ui): Replace the modal with a dialog 2022-12-22 00:49:01 +01:00
e71cbd5ba9 fix(ui): Translate the "Shared media" in the profile
Also left-align the title.
2022-12-22 00:22:06 +01:00
c0fb9beef7 feat(ui): Replace the squircles with a simple list 2022-12-22 00:16:14 +01:00
db4b69a24a feat(ui): Also make the profile picture summon the profile page 2022-12-21 23:41:18 +01:00
7746784949 fix(ui): Fix InkWell overflowing for shared media 2022-12-21 23:13:28 +01:00
024bd48aba fix(ui): Fix InkWell padding for files 2022-12-21 22:45:18 +01:00
cb13c9faa4 feat(ui): Show a toast when a file cannot be opened 2022-12-21 22:39:50 +01:00
009ec759a3 feat(ui): Make quotes look better 2022-12-21 21:00:08 +01:00
6ba16ad020 feat(ui): Make the file widget look better 2022-12-21 20:54:15 +01:00
43b0b34cdd fix(service): Fix crash when conversation partner has no OMEMO:2 devices 2022-12-21 20:29:54 +01:00
94e6eb2d10 fix(ui): Scroll to bottom does not respect emoji/sticker picker 2022-12-21 20:27:48 +01:00
578eea5d9f fix(ui): Fix missing space 2022-12-21 20:15:18 +01:00
724450e049 fix(ui): Use auto_size_text to prevent fingerprints from overflowing 2022-12-21 20:07:34 +01:00
1759baebad feat(ui): Display something when no sticker packs are installed 2022-12-21 18:58:52 +01:00
896ef50b9a fix(ui): Show toasts on verification errors 2022-12-20 16:03:36 +01:00
c4d52b6687 docs(meta): Add Ko-Fi link 2022-12-20 00:05:44 +01:00
5c611a59aa docs(meta): Add funding 2022-12-19 23:04:22 +01:00
7068b989ef Merge pull request 'Implement Stickers' (#184) from feat/stickers into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/184
2022-12-19 14:38:43 +00:00
820fda78e7 fix(ui): Fix sticker overviews having a black background 2022-12-19 15:35:22 +01:00
d758423ec6 fix(ui): Close the pickers when starting an audio message 2022-12-19 15:29:50 +01:00
5472f097a4 fix(meta): Depend on a git revision of moxxmpp 2022-12-19 14:15:13 +01:00
e373f5cffe feat(service): Implement automatic sticker pack downloading 2022-12-19 14:09:30 +01:00
f04729261b feat(ui): Add toasts 2022-12-19 13:33:05 +01:00
b6c8778aec fix(ui): Remove the restriction on clickable sticker messages 2022-12-19 12:48:15 +01:00
8dfe8d55a0 docs(meta): Add sticker pack format docs 2022-12-19 12:46:49 +01:00
36b7d5ce42 feat(ui): Show a spinner while a sticker pack import is running 2022-12-19 12:38:50 +01:00
8d780c3252 fix(ui): Fix alignment of description 2022-12-19 12:36:36 +01:00
a841d5de2d fix(ui): Some translations were off 2022-12-19 12:36:21 +01:00
fdd8d306f7 fix(ui): Close the emoji picker/sticker picker if the keyboard is visible 2022-12-19 12:14:25 +01:00
9510a0fced feat(ui): Make managing sticker packs nicer 2022-12-19 12:02:26 +01:00
c3ec9dfb11 fix(ui): Do not trigger the sticker pack page for sent messages 2022-12-18 20:28:29 +01:00
82c136b684 feat(ui): Update the conversation when stickers get added or removed 2022-12-18 20:19:49 +01:00
ea4bb752b9 fix(service): Guard against importing a sticker pack twice 2022-12-18 18:56:39 +01:00
bac673df99 fix(ui): Give the StickerPicker a background 2022-12-18 18:56:23 +01:00
df2c2f5e4b feat(ui): Honour restricted sticker packs 2022-12-18 18:25:44 +01:00
8c3863f970 feat(ui): Handle sticker XMPP URIs 2022-12-18 18:14:29 +01:00
bc49e31164 fix(service): Add missing attributes to stickers and sticker packs 2022-12-18 17:39:25 +01:00
ce4c54b0d5 fix(service): Freshly downloaded sticker packs are shown as still remote 2022-12-18 17:23:06 +01:00
7b09cdeefd feat(ui): Fix sticker messages not being rebuilt after downloading sticker pack 2022-12-18 17:20:50 +01:00
39dc96ab7a feat(ui): Add a shimmer for loading stickers 2022-12-18 17:10:10 +01:00
2d13ff328e feat(service): Publish a sticker pack after downloading it 2022-12-18 15:15:14 +01:00
53dd598547 feat(meta): Implement installing a remote sticker pack 2022-12-18 15:05:53 +01:00
40b4a540a8 feat(ui): Implement showing a remote sticker pack 2022-12-18 14:20:18 +01:00
33ae53c199 feat(meta): Mark XEP-0449 as complete 2022-12-18 00:57:34 +01:00
97e9b0636b fix(service): Fix OOB fallback messing the body up 2022-12-18 00:56:46 +01:00
b0b21e9d53 feat(ui): Prepare for remote sticker packs 2022-12-17 21:54:12 +01:00
53d5402502 feat(ui): Add a wrapper for local and remote images 2022-12-17 21:43:02 +01:00
a190a9564e fix(service): Silence some warning 2022-12-17 21:36:22 +01:00
7846520788 feat(service): Show 'Sticker' in the notification 2022-12-17 21:34:35 +01:00
3444683983 fix(service): Fix sent stickers not being visible in the preview 2022-12-17 21:29:06 +01:00
00118ddafe fix(meta): Switch to a hashKey 2022-12-17 21:16:38 +01:00
525ba293e3 feat(service): Remove sticker pack files on removal 2022-12-17 19:40:12 +01:00
071f6c08fd feat(ui): Add forgotten i18n string 2022-12-17 19:33:40 +01:00
da70236a45 feat(ui): Translate missing strings 2022-12-17 19:31:39 +01:00
cfdda2d293 feat(meta): Cleanup + simple sticker pack management 2022-12-17 19:24:20 +01:00
aba265d787 feat(service): Do not import sticker packs that are restricted 2022-12-17 17:42:54 +01:00
bbcb37bc4e feat(meta): Update DOAP 2022-12-17 17:34:58 +01:00
eff7d7493d feat(service): Calculate the sticker pack's id on import 2022-12-17 17:34:09 +01:00
730916758e feat(ui): Implement a simple sticker overview page 2022-12-17 16:21:14 +01:00
9acfe2751e feat(ui): Show the sticker in the conversation preview 2022-12-17 14:04:12 +01:00
386569d7cf feat(ui): Correctly handle quoting stickers 2022-12-17 14:00:41 +01:00
39a7e1eb19 feat(ui): Fix linter issues + i18n 2022-12-17 13:53:22 +01:00
f492845235 feat(ui): Somewhat handle not locally available stickers 2022-12-17 13:45:08 +01:00
ab42fc8b57 feat(ui): Add a sticker settings page 2022-12-17 13:36:44 +01:00
a5a9fce330 fix(ui): Fix crash 2022-12-17 13:21:21 +01:00
a70286dda4 feat(service): Allow receiving stickers 2022-12-17 12:40:50 +01:00
2b3e587be4 feat(meta): Display and sending of stickers 2022-12-17 12:22:10 +01:00
ebfac9730b fix(meta): Fix linter issues 2022-12-16 23:09:27 +01:00
fbd3c6ca92 refactor(ui): Refactor the StickerPicker 2022-12-16 23:02:40 +01:00
1cd3dabcea fix(ui): Make the send button's bottom padding adhere to the pickers 2022-12-16 22:54:31 +01:00
eba17880d0 feat(meta): Adapt the sticker picker 2022-12-16 22:49:59 +01:00
c168f910a9 feat(service): Allow importing sticker packs 2022-12-16 22:33:06 +01:00
98dd704fda feat(meta): Begin work in stickers 2022-12-16 20:58:02 +01:00
4ecebe8982 fix(service): Do not reopen conversations on subscription requests
Maybe fixes #165.
2022-12-12 12:56:30 +01:00
8f1d17636e Merge pull request 'Implement reading contact data from the phonebook' (#182) from feat/contact-integration into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/182
2022-12-12 11:54:16 +00:00
fb1c202586 refactor(service): contact.dart -> contacts.dart 2022-12-12 12:50:47 +01:00
d7a4ce022e feat(service): Reset a roster item to pseudoRosterItem on removal 2022-12-12 12:40:09 +01:00
64c3796429 feat(ui): Prevent removing a pseudo contact 2022-12-12 12:37:21 +01:00
80a517beaa fix(ui): Display pseudo roster items in the share selection 2022-12-12 12:35:16 +01:00
cec31550f8 fix(service): Handle inRoster for pseudo roster items 2022-12-12 12:23:12 +01:00
bee760adf5 feat(ui): Handle removing pseudo contacts 2022-12-12 12:18:49 +01:00
155d5747f8 feat(service): Implement pseudo roster items 2022-12-12 12:02:13 +01:00
fd531a360e feat(service): Better handle contact removal 2022-12-12 00:20:22 +01:00
c3884a460d fix(service): Remove debug command 2022-12-11 23:06:10 +01:00
5f5c30673d fix(ui): Make the UI elements react to changes of the contact integration 2022-12-11 22:22:57 +01:00
f423cd5611 feat(service): The correct avatar now appears in the notification 2022-12-11 22:01:00 +01:00
7e059e13ef fix(ui): Prevent the avatar image from flickering 2022-12-11 21:53:38 +01:00
d965fbd57e feat(service): Make the service more togglable 2022-12-10 23:08:24 +01:00
55854ec586 feat(ui): Handle contact info in the profile page 2022-12-10 22:46:42 +01:00
8886c8e695 fix(ui): Fix contact info not being retrieved 2022-12-10 22:13:22 +01:00
d58f5f9a01 feat(service): Make the contact integration configurable 2022-12-10 21:30:47 +01:00
e060b0f549 feat(service): First attempt at handling phone contacts 2022-12-10 19:34:11 +01:00
73913c4ae6 fix(ui): Delete icon was visible on both ends after dismiss 2022-12-10 12:12:03 +01:00
21878ae135 fix(service): Fix defaultMuteState not being honoured 2022-12-10 12:02:09 +01:00
a08a110ef6 feat(ui): Allow verifying our own devices 2022-12-10 11:52:15 +01:00
f723c43603 feat(service): Cache fingerprints for all JIDs 2022-12-09 21:17:39 +01:00
d88876c928 feat(service): Cache our own device fingerprints 2022-12-09 20:51:56 +01:00
f15a3e6bf4 fix(service): Crash when accesing our own devices 2022-12-09 18:43:51 +01:00
4852237bf8 feat(service): Implement verifying OMEMO devices 2022-12-09 18:34:24 +01:00
9a0bc87636 Merge pull request 'Message reactions' (#178) from feat/reactions into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/178
2022-12-09 14:50:55 +00:00
d73d27dccc fix(service): Fix senders being added multiple times to a reaction 2022-12-09 15:47:15 +01:00
6fa5e73226 feat(service): Handle messages with <no-store/> Message Processing Hints 2022-12-09 12:57:26 +01:00
1ff9ea256b fix(ui): Switch from Row to Wrap 2022-12-06 22:40:59 +01:00
7fca7e0246 fix(meta): Add a moxxmpp git override 2022-12-06 18:49:56 +01:00
846270b714 feat(ui): Translate the reaction button 2022-12-06 18:46:40 +01:00
50e7c5683f feat(service): Handle reactions from our own carbons 2022-12-06 15:45:31 +01:00
6883a9570f feat(ui): Swap around the emoji and the number of reactions 2022-12-06 14:10:44 +01:00
8f34bc001d docs(meta): Update DOAP 2022-12-06 14:09:54 +01:00
2f95e5452b fix(service): We don't need chat states for reactions 2022-12-06 13:57:02 +01:00
59a6307a21 feat(service): Send reactions 2022-12-06 13:52:42 +01:00
c8d52e6c41 feat(service): Implement receiving reactions 2022-12-06 13:23:17 +01:00
044766bf8a feat(ui): Build the UI for reactions 2022-12-06 12:19:21 +01:00
1f7c851228 feat(ui): Implement the basic UI for displaying reactions 2022-12-06 00:18:06 +01:00
ca90c658ff feat(ui): Make QR Codes more consistent 2022-12-05 20:19:03 +01:00
19de68e4f0 feat(ui): Make the QR scanner page more versatile 2022-12-04 18:45:38 +01:00
dc17d7d304 feat(ui): Implement scanning a contact's QR code 2022-12-04 18:31:24 +01:00
2372dbf6b3 fix(ui): Reset AddConversationPage's state when popping the route 2022-12-04 13:07:24 +01:00
3382e35447 fix(ui): Prevent clipping of the ink drop of buttons 2022-12-04 12:56:46 +01:00
a9cc4f55b8 fix(ui): Improve the look of the overview menu 2022-12-04 12:45:49 +01:00
63fbf7ebe4 fix(service): Auto-downloads are not marked as downloading (Fixes #167) 2022-12-04 12:04:44 +01:00
d1d6b67fd6 feat(ui): Replace 'custom' icon button with a FAB 2022-12-03 20:38:05 +01:00
87160e8648 fix(ui): Re-add JIDs to the newconversation page (Fixes #176) 2022-12-03 18:35:32 +01:00
e5553699c5 feat(ui): Show an indicator for the swipe gesture 2022-12-03 18:26:06 +01:00
adcfdc1a73 feat(ui,service): Implement sending audio messages 2022-12-03 17:55:23 +01:00
70464a2b71 feat(ui): Make cancelling a recording also possible via dragging 2022-12-03 13:24:38 +01:00
0852a75d9f fix(ui): The timer was opaque during hit testing when not recording 2022-12-03 13:11:48 +01:00
9affa0e89a feat(ui): Show a toast when the record button is tapped 2022-12-03 13:01:54 +01:00
cc13078ec5 feat(ui): Improve the touch behaviour of the overview menu 2022-12-03 12:48:34 +01:00
35285343b1 feat(ui): Add a timer while recording audio 2022-12-03 12:40:45 +01:00
cb6bce0c56 feat(ui): Implement the UI for audio recording 2022-12-02 23:05:06 +01:00
5c1eda72c3 fix(ui): Reimplement file sending 2022-12-02 20:30:03 +01:00
4542accc33 feat(ui): Remove the speeddial 2022-12-02 20:27:08 +01:00
8f5470076b feat(ui): Implement UI for audio messages during up/download 2022-12-02 16:55:02 +01:00
3427c3c761 feat(ui): Move the circle to the bottom bar 2022-11-30 20:50:51 +01:00
0cc8d0947b feat(ui): Don't generate video thumbnails for problematic formats 2022-11-30 19:19:54 +01:00
c3795450a9 feat(ui): Begin working on the recording UI 2022-11-30 19:08:42 +01:00
868d924836 fix(ui): Translate quoted message strings 2022-11-30 12:50:47 +01:00
d8f634d67c feat(ui): Hide the microphone and sticker buttons when quoting 2022-11-30 12:47:09 +01:00
09b97ab4c5 feat(ui): Make SharedFileWidget adhere to the other widget's style 2022-11-30 12:45:16 +01:00
8b4d7dd569 feat(ui): Make audio messages quotable 2022-11-30 12:38:16 +01:00
a63205d5e1 feat(ui): Implement shared media view for audio 2022-11-29 23:44:43 +01:00
e7a4e93366 fix(ui): Remove unused value 2022-11-29 23:38:45 +01:00
2c23f40415 feat(ui): Implement an audio widget 2022-11-29 23:36:09 +01:00
daf4ee79f2 fix(ui): Fix indentation 2022-11-27 13:01:00 +01:00
38a73d2890 feat(ui): Replace video spinners with a shimmer
Fixes #172.
2022-11-27 13:00:01 +01:00
b004d8364c docs(ui): Rename string 2022-11-26 22:42:13 +01:00
2498e23bd5 feat(ui): Generate video thumbnails 2022-11-26 22:40:42 +01:00
3a80d50cf5 docs(ui): Remove fixed TODO 2022-11-26 21:19:29 +01:00
8c12eb47ce feat(ui): Minor touch-ups for the 'about' page 2022-11-26 19:24:12 +01:00
cfec6afc7d feat(ui): Allow tapping the profile picture in a conversation 2022-11-26 19:04:13 +01:00
a3bdabca3c feat(ui): Allow tapping on the profile picture 2022-11-26 19:00:24 +01:00
f094a326ac feat(ui): Switch cropping libraries
This makes the avatar cropper much more consistent
with the background cropper. Fixes #168.
2022-11-26 18:40:46 +01:00
d24cab9c1a fix(i18n): Add missing translations 2022-11-26 18:09:42 +01:00
c7d1ecce35 feat(ui): Show whether the last received message was edited 2022-11-26 15:57:06 +01:00
306fd99b84 feat(service): Handle received corrections (Fixes #81) 2022-11-26 15:50:24 +01:00
ab63bc44a6 feat(service,ui): Allow correcting the last message 2022-11-26 15:17:33 +01:00
ef15f15458 feat(ui): Build the backbone for LMC support 2022-11-26 14:29:46 +01:00
db86136aa8 fix(service): Enable carbons (Fixes #171) 2022-11-26 13:56:24 +01:00
c344aed471 feat(ui): Encode backgrounds as JPEG (Fixes #94) 2022-11-26 13:47:09 +01:00
79b0a4ba7a feat(meta): Remove image_size_getter 2022-11-26 13:40:57 +01:00
4ce5e29a81 fix(ui): Use the CancelButton for the avatar cropper 2022-11-26 13:24:35 +01:00
524dec0991 fix(ui): Hide the media preview if the contact is typing 2022-11-26 12:12:36 +01:00
05074ed4f0 feat(ui): Replace the custom code with InteractiveViewer 2022-11-26 00:50:29 +01:00
4e4ed58605 feat(ui): Use the InteractiveViewer 2022-11-26 00:40:43 +01:00
183 changed files with 10706 additions and 2923 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: papatutuwawa

3
.gitignore vendored
View File

@@ -60,3 +60,6 @@ lib/i18n/*.dart
# Android artifacts # Android artifacts
.android .android
# Build scripts
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))*\): .*$ regex=^((feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\)|release): .*$
[body-trailing-whitespace] [body-trailing-whitespace]

View File

@@ -46,3 +46,9 @@ See `./LICENSE`.
## Special Thanks ## Special Thanks
- New logo designed by [Synoh](https://twitter.com/synoh_manda) - New logo designed by [Synoh](https://twitter.com/synoh_manda)
## Support
If you like what I do and you want to support me, feel free to donate to me on Ko-Fi.
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/assets/repo/kofi.png" height="36" style="height: 36px; border: 0px;"></img>](https://ko-fi.com/papatutuwawa)

View File

@@ -48,6 +48,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View File

@@ -27,13 +27,40 @@
"warningChannelDescription": "Warnings related to Moxxy" "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": { "messages": {
"image": "Image", "image": "Image",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
"file": "File", "file": "File",
"sticker": "Sticker",
"retracted": "The message has been retracted", "retracted": "The message has been retracted",
"retractedFallback": "A previous message has been retracted but your client does not support it" "retractedFallback": "A previous message has been retracted but your client does not support it",
"you": "You"
}, },
"errors": { "errors": {
"omemo": { "omemo": {
@@ -41,7 +68,13 @@
"notEncryptedForDevice": "This message was not encrypted for this device", "notEncryptedForDevice": "This message was not encrypted for this device",
"invalidHmac": "Could not decrypt message", "invalidHmac": "Could not decrypt message",
"noDecryptionKey": "No decryption key available", "noDecryptionKey": "No decryption key available",
"messageInvalidAfixElement": "Invalid encrypted message" "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": { "connection": {
"connectionTimeout": "Could not connect to server" "connectionTimeout": "Could not connect to server"
@@ -64,11 +97,19 @@
"failedToEncryptFile": "The file could not be encrypted", "failedToEncryptFile": "The file could not be encrypted",
"failedToDecryptFile": "The file could not be decrypted", "failedToDecryptFile": "The file could not be decrypted",
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted" "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": { "warnings": {
"message": { "message": {
"integrityCheckFailed": "Could not verify file integrity" "integrityCheckFailed": "Could not verify file integrity"
},
"conversation": {
"holdForLonger": "Hold button longer to record a voice message"
} }
}, },
"pages": { "pages": {
@@ -100,6 +141,7 @@
"closeChat": "Close chat", "closeChat": "Close chat",
"closeChatConfirmTitle": "Close chat", "closeChatConfirmTitle": "Close chat",
"closeChatConfirmSubtext": "Are you sure you want to close this chat?", "closeChatConfirmSubtext": "Are you sure you want to close this chat?",
"blockShort": "Block",
"blockUser": "Block user", "blockUser": "Block user",
"online": "Online", "online": "Online",
"retract": "Retract message", "retract": "Retract message",
@@ -107,7 +149,21 @@
"forward": "Forward", "forward": "Forward",
"edit": "Edit", "edit": "Edit",
"quote": "Quote", "quote": "Quote",
"copy": "Copy content" "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": { "addcontact": {
"title": "Add new contact", "title": "Add new contact",
@@ -129,15 +185,14 @@
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?" "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": { "profile": {
"self": { "general": {
"devices": "Devices" "omemo": "Security"
}, },
"conversation": { "conversation": {
"muteChatTooltip": "Mute chat", "notifications": "Notifications",
"unmuteChatTooltip": "Unmute chat", "notificationsMuted": "Muted",
"muteChat": "Mute", "notificationsEnabled": "Enabled",
"unmuteChat": "Unmute", "sharedMedia": "Media"
"devices": "Devices"
}, },
"owndevices": { "owndevices": {
"title": "Own Devices", "title": "Own Devices",
@@ -168,6 +223,18 @@
"unblockJidConfirmTitle": "Unblock ${jid}?", "unblockJidConfirmTitle": "Unblock ${jid}?",
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again." "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": {
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@@ -177,12 +244,17 @@
"signOutConfirmTitle": "Sign Out", "signOutConfirmTitle": "Sign Out",
"signOutConfirmBody": "You are about to sign out. Proceed?", "signOutConfirmBody": "You are about to sign out. Proceed?",
"miscellaneousSection": "Miscellaneous", "miscellaneousSection": "Miscellaneous",
"debuggingSection": "Debugging" "debuggingSection": "Debugging",
"general": "General"
}, },
"about": { "about": {
"title": "About", "title": "About",
"licensed": "Licensed under GPL3", "licensed": "Licensed under GPL3",
"viewSourceCode": "View source code" "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": { "appearance": {
"title": "Appearance", "title": "Appearance",
@@ -205,7 +277,10 @@
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?", "removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
"newChatsSection": "New Conversations", "newChatsSection": "New Conversations",
"newChatsMuteByDefault": "Mute new chats by default", "newChatsMuteByDefault": "Mute new chats by default",
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental" "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": { "debugging": {
"title": "Debugging options", "title": "Debugging options",
@@ -224,6 +299,7 @@
"automaticDownloadsText": "Moxxy will automatically download files on...", "automaticDownloadsText": "Moxxy will automatically download files on...",
"automaticDownloadsMaximumSize": "Maximum Download Size", "automaticDownloadsMaximumSize": "Maximum Download Size",
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded", "automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
"automaticDownloadAlways": "Always",
"wifi": "Wifi", "wifi": "Wifi",
"mobileData": "Mobile data" "mobileData": "Mobile data"
}, },
@@ -249,7 +325,20 @@
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.", "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", "urlEmpty": "URL cannot be empty",
"urlInvalid": "Invalid URL", "urlInvalid": "Invalid URL",
"redirectDialogTitle": "$serviceName Redirect" "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

@@ -27,13 +27,40 @@
"warningChannelDescription": "Warnungen im Bezug auf Moxxy" "warningChannelDescription": "Warnungen im Bezug auf Moxxy"
} }
}, },
"dateTime": {
"justNow": "Gerade",
"nMinutesAgo": "vor ${min}min",
"mondayAbbrev": "Mon",
"tuesdayAbbrev": "Die",
"wednessdayAbbrev": "Mit",
"thursdayAbbrev": "Don",
"fridayAbbrev": "Fre",
"saturdayAbbrev": "Sam",
"sundayAbbrev": "Son",
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember",
"today": "Heute",
"yesterday": "Gestern"
},
"messages": { "messages": {
"image": "Bild", "image": "Bild",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
"file": "Datei", "file": "Datei",
"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"
}, },
"errors": { "errors": {
"omemo": { "omemo": {
@@ -41,7 +68,13 @@
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt", "notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden", "invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden", "noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht" "messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
"verificationWrongJid": "Falsche XMPP-Addresse",
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
}, },
"connection": { "connection": {
"connectionTimeout": "Verbindung zum Server nicht möglich" "connectionTimeout": "Verbindung zum Server nicht möglich"
@@ -64,11 +97,19 @@
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden", "failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden", "failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen" "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"
} }
}, },
"warnings": { "warnings": {
"message": { "message": {
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen" "integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
},
"conversation": {
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
} }
}, },
"pages": { "pages": {
@@ -100,6 +141,7 @@
"closeChat": "Chat schließen", "closeChat": "Chat schließen",
"closeChatConfirmTitle": "Chat schließen", "closeChatConfirmTitle": "Chat schließen",
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?", "closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
"blockShort": "Blockieren",
"blockUser": "Nutzer blockieren", "blockUser": "Nutzer blockieren",
"online": "Online", "online": "Online",
"retract": "Nachricht löschen", "retract": "Nachricht löschen",
@@ -107,7 +149,21 @@
"forward": "Weiterleiten", "forward": "Weiterleiten",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"quote": "Zitieren", "quote": "Zitieren",
"copy": "Inhalt kopieren" "copy": "Inhalt kopieren",
"addReaction": "Reaktion hinzufügen",
"showError": "Fehler anzeigen",
"showWarning": "Warnung anzeigen",
"addToContacts": "Zu Kontaken hinzufügen",
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"stickerSettings": "Stickereinstellungen",
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
"messageHint": "Nachricht senden...",
"sendImages": "Bilder senden",
"sendFiles": "Dateien senden",
"takePhotos": "Bilder aufnehmen"
}, },
"addcontact": { "addcontact": {
"title": "Neuen Kontakt hinzufügen", "title": "Neuen Kontakt hinzufügen",
@@ -129,15 +185,14 @@
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?" "confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
}, },
"profile": { "profile": {
"self": { "general": {
"devices": "Geräte" "omemo": "Sicherheit"
}, },
"conversation": { "conversation": {
"muteChatTooltip": "Chat stummschalten", "notifications": "Benachrichtigungen",
"unmuteChatTooltip": "Chat lautstellen", "notificationsMuted": "Stumm",
"muteChat": "Stummschalten", "notificationsEnabled": "Eingeschaltet",
"unmuteChat": "Lautstellen", "sharedMedia": "Medien"
"devices": "Geräte"
}, },
"owndevices": { "owndevices": {
"title": "Eigene Geräte", "title": "Eigene Geräte",
@@ -153,10 +208,10 @@
"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?" "recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
}, },
"devices": { "devices": {
"title": "Devices", "title": "Geräte",
"recreateSessions": "Rebuild sessions", "recreateSessions": "Sessions zurücksetzen",
"recreateSessionsConfirmTitle": "Rebuild sessions?", "recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors." "recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen."
} }
}, },
"blocklist": { "blocklist": {
@@ -168,6 +223,18 @@
"unblockJidConfirmTitle": "${jid} entblocken?", "unblockJidConfirmTitle": "${jid} entblocken?",
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können." "unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
}, },
"cropbackground": {
"blur": "Hintergrund weichzeichnen",
"setAsBackground": "Als Hintergrundbild festlegen"
},
"stickerPack": {
"removeConfirmTitle": "Stickerpack entfernen",
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
"installConfirmTitle": "Stickerpack installieren",
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
"fetchingFailure": "Konnte das Stickerpack nicht finden"
},
"settings": { "settings": {
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
@@ -177,12 +244,17 @@
"signOutConfirmTitle": "Abmelden", "signOutConfirmTitle": "Abmelden",
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?", "signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
"miscellaneousSection": "Unterschiedlich", "miscellaneousSection": "Unterschiedlich",
"debuggingSection": "Debugging" "debuggingSection": "Debugging",
"general": "Generell"
}, },
"about": { "about": {
"title": "Über", "title": "Über",
"licensed": "Lizensiert unter GPL3", "licensed": "Lizensiert unter GPL3",
"viewSourceCode": "Quellcode anschauen" "version": "Version ${version}",
"viewSourceCode": "Quellcode anschauen",
"nMoreToGo": "Noch ${n}...",
"debugMenuShown": "Du bist jetzt ein Entwickler!",
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
}, },
"appearance": { "appearance": {
"title": "Aussehen", "title": "Aussehen",
@@ -205,7 +277,10 @@
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?", "removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
"newChatsSection": "Neue Chats", "newChatsSection": "Neue Chats",
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten", "newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell" "newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
"behaviourSection": "Verhalten",
"contactsIntegration": "Kontaktintegration",
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
}, },
"debugging": { "debugging": {
"title": "Debuggingoptionen", "title": "Debuggingoptionen",
@@ -224,6 +299,7 @@
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...", "automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße", "automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll", "automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"automaticDownloadAlways": "Immer",
"wifi": "Wifi", "wifi": "Wifi",
"mobileData": "Mobile Daten" "mobileData": "Mobile Daten"
}, },
@@ -249,7 +325,20 @@
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.", "cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
"urlEmpty": "URL kann nicht leer sein", "urlEmpty": "URL kann nicht leer sein",
"urlInvalid": "Ungültige URL", "urlInvalid": "Ungültige URL",
"redirectDialogTitle": "${serviceName}weiterleitung" "redirectDialogTitle": "${serviceName}weiterleitung",
"stickersPrivacy": "Stickerliste öffentlich halten",
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Sticker im Chat anzeigen",
"autoDownload": "Sticker automatisch herunterladen",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
"stickerPacksSection": "Stickerpacks",
"importStickerPack": "Stickerpack importieren",
"importSuccess": "Stickerpack erfolgreich importiert",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
} }
} }
} }

BIN
assets/repo/kofi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

43
docs/stickerpacks.md Normal file
View File

@@ -0,0 +1,43 @@
# Sticker Packs
Moxxy supports sending and receiving sticker packs using XEP-0449 version 0.1.1. Sticker
packs can also be imported using a Moxxy specific format.
## File Format
A Moxxy sticker pack is a flat tar archive that contains the following files:
- `urn.xmpp.stickers.0.xml`
- The sticker files
### `urn.xmpp.stickers.0.xml`
This file is the sticker pack's metadata file. It describes the sticker pack the same
way as the examples in XEP-0449 do. There are, however, some differences:
- Each `<file />` element must contain a `<name />` element that matches with a file in the tar archive
- Each sticker MUST contain at least one HTTP(s) source
- The `<hash />` of the `<pack />` element is ignored as Moxxy computes it itself, so it can be omitted
An example for the metadata file is the following:
```xml
<pack xmlns='urn:xmpp:stickers:0'>
<name>Example</name>
<summary>Example sticker pack.</summary>
<item>
<file xmlns='urn:xmpp:file:metadata:0'>
<media-type>image/png</media-type>
<desc>:some-sticker:</desc>
<name>suprise.png</name>
<size>531910</size>
<dimensions>1030x1030</dimensions>
<hash xmlns='urn:xmpp:hashes:2' algo='sha-256'>1Ha4okUGNRAA04KibwWUmklqqBqdhg7+20dfsr/wLik=</hash>
</file>
<sources xmlns='urn:xmpp:sfs:0'>
<url-data xmlns='http://jabber.org/protocol/url-data' target='...' />
</sources>
</item>
<!-- ... -->
</pack>
```

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

@@ -10,12 +10,14 @@ Currently supported features include:
<li>Typing indicators and message markers</li> <li>Typing indicators and message markers</li>
<li>Chat backgrounds</li> <li>Chat backgrounds</li>
<li>Runs in the background without Push Notifications</li> <li>Runs in the background without Push Notifications</li>
<li>OMEMO (Currently not compatible with most apps)</li>
<li>Stickers</li>
</ul> </ul>
For the best experience, I recommend a server that: For the best experience, I recommend a server that:
<ul> <ul>
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li> <li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li> <li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
<li>Supports HTTP File Upload</li> <li>Supports HTTP File Upload</li>
<li>Supports Stream Management</li> <li>Supports Stream Management</li>
<li>Supports Client State Indication</li> <li>Supports Client State Indication</li>

View File

@@ -36,6 +36,9 @@ files:
roster: roster:
type: List<RosterItem>? type: List<RosterItem>?
deserialise: true deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Returned by [GetMessagesForJidCommand] # Returned by [GetMessagesForJidCommand]
- name: MessagesResultEvent - name: MessagesResultEvent
extends: BackgroundEvent extends: BackgroundEvent
@@ -103,6 +106,13 @@ files:
extends: BackgroundEvent extends: BackgroundEvent
implements: implements:
- JsonImplementation - JsonImplementation
# Triggered in response to a [GetBlocklistCommand]
- name: GetBlocklistResultEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
entries: List<String>
# Triggered by DownloadService or UploadService. # Triggered by DownloadService or UploadService.
- name: ProgressEvent - name: ProgressEvent
extends: BackgroundEvent extends: BackgroundEvent
@@ -163,6 +173,7 @@ files:
supportsCsi: bool supportsCsi: bool
supportsUserBlocking: bool supportsUserBlocking: bool
supportsHttpFileUpload: bool supportsHttpFileUpload: bool
supportsCarbons: bool
# Returned by [SignOutCommand] # Returned by [SignOutCommand]
- name: SignedOutEvent - name: SignedOutEvent
extends: BackgroundEvent extends: BackgroundEvent
@@ -207,6 +218,53 @@ files:
conversationJid: String conversationJid: String
title: String title: String
avatarUrl: String avatarUrl: String
- name: StickerPackImportSuccessEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: StickerPackImportFailureEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: FetchStickerPackSuccessResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: FetchStickerPackFailureResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: StickerPackInstallSuccessEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: StickerPackInstallFailureEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: StickerPackAddedEvent
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"
@@ -259,6 +317,8 @@ files:
quotedMessage: quotedMessage:
type: Message? type: Message?
deserialise: true deserialise: true
editSid: String?
editId: int?
- name: SendFilesCommand - name: SendFilesCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -416,6 +476,69 @@ files:
conversationJid: String conversationJid: String
sid: String sid: String
newUnreadCounter: int newUnreadCounter: int
- name: AddReactionToMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
conversationJid: String
emoji: String
- name: RemoveReactionFromMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
conversationJid: String
emoji: String
- name: MarkOmemoDeviceAsVerifiedCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
deviceId: int
jid: String
- name: ImportStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
path: String
- name: RemoveStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
- name: SendStickerCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
stickerHashKey: String
recipient: String
- name: FetchStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
jid: String
- name: InstallStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: GetBlocklistCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
generate_builder: true generate_builder: true
# get${builder_Name}FromJson # get${builder_Name}FromJson
builder_name: "Command" builder_name: "Command"

View File

@@ -27,6 +27,8 @@ 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/sharedmedia_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/events.dart'; import 'package:moxxyv2/ui/events.dart';
/* /*
@@ -55,14 +57,17 @@ 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/stickers.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/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.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/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';
import 'package:share_handler/share_handler.dart';
void setupLogging() { void setupLogging() {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO; Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
@@ -76,6 +81,7 @@ 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<UISharingService>(UISharingService());
} }
void setupBlocs(GlobalKey<NavigatorState> navKey) { void setupBlocs(GlobalKey<NavigatorState> navKey) {
@@ -83,7 +89,8 @@ void setupBlocs(GlobalKey<NavigatorState> 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<ProfileBloc>(ProfileBloc()); GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc()); GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc()); GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc()); GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
@@ -94,11 +101,10 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc()); GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc()); GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc()); GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
} }
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
// Padding(padding: ..., child: Column(children: [ ... ]))
// TODO(Unknown): Theme the switches
void main() async { void main() async {
setupLogging(); setupLogging();
await setupUIServices(); await setupUIServices();
@@ -164,6 +170,12 @@ void main() async {
BlocProvider<OwnDevicesBloc>( BlocProvider<OwnDevicesBloc>(
create: (_) => GetIt.I.get<OwnDevicesBloc>(), create: (_) => GetIt.I.get<OwnDevicesBloc>(),
), ),
BlocProvider<StickersBloc>(
create: (_) => GetIt.I.get<StickersBloc>(),
),
BlocProvider<StickerPackBloc>(
create: (_) => GetIt.I.get<StickerPackBloc>(),
),
], ],
child: TranslationProvider( child: TranslationProvider(
child: MyApp(navKey), child: MyApp(navKey),
@@ -173,7 +185,6 @@ 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;
@@ -187,46 +198,18 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initState();
}
/// Async "version" of initState()
Future<void> _initState() async {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_setupSharingHandler(); // Set up receiving share intents
await GetIt.I.get<UISharingService>().initialize();
// Lift the UI block // Lift the UI block
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock(); await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
}
Future<void> _handleSharedMedia(SharedMedia media) async {
final attachments = media.attachments ?? [];
GetIt.I.get<ShareSelectionBloc>().add(
ShareSelectionRequestedEvent(
attachments.map((a) => a!.path).toList(),
media.content,
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
),
);
}
Future<void> _setupSharingHandler() async {
final handler = ShareHandlerPlatform.instance;
final media = await handler.getInitialSharedMedia();
// Shared while the app was closed
if (media != null) {
if (GetIt.I.get<UIDataService>().isLoggedIn) {
await _handleSharedMedia(media);
}
await handler.resetInitialSharedMedia();
}
// Shared while the app is stil running
handler.sharedMediaStream.listen((SharedMedia media) async {
if (GetIt.I.get<UIDataService>().isLoggedIn) {
await _handleSharedMedia(media);
}
await handler.resetInitialSharedMedia();
});
} }
@override @override
@@ -299,6 +282,11 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case devicesRoute: return DevicesPage.route; case devicesRoute: return DevicesPage.route;
case ownDevicesRoute: return OwnDevicesPage.route; case ownDevicesRoute: return OwnDevicesPage.route;
case appearanceRoute: return AppearanceSettingsPage.route; case appearanceRoute: return AppearanceSettingsPage.route;
case qrCodeScannerRoute: return QrCodeScanningPage.getRoute(
settings.arguments! as QrCodeScanningArguments,
);
case stickersRoute: return StickersSettingsPage.route;
case stickerPackRoute: return StickerPackPage.route;
} }
return null; return null;

View File

@@ -3,9 +3,7 @@ import 'dart:io';
import 'package:cryptography/cryptography.dart'; 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:hex/hex.dart';
import 'package:image_size_getter/image_size_getter.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/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
@@ -14,6 +12,7 @@ import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart'; import 'package:moxxyv2/service/xmpp.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';
/// Removes line breaks and spaces from [original]. This might happen when we request the /// Removes line breaks and spaces from [original]. This might happen when we request the
/// avatar data. Returns the cleaned version. /// avatar data. Returns the cleaned version.
@@ -26,56 +25,48 @@ String _cleanBase64String(String original) {
return ret; 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');
AvatarService() : _log = Logger('AvatarService');
final Logger _log;
UserAvatarManager _getUserAvatarManager() => GetIt.I.get<XmppConnection>().getManagerById<UserAvatarManager>(userAvatarManager)!; Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
await updateAvatarForJid(
DiscoManager _getDiscoManager() => GetIt.I.get<XmppConnection>().getManagerById<DiscoManager>(discoManager)!; event.jid,
event.hash,
base64Decode(_cleanBase64String(event.base64)),
);
}
Future<void> updateAvatarForJid(String jid, String hash, String base64) async { Future<void> updateAvatarForJid(String 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);
var saved = false; final originalRoster = await rs.getRosterItemByJid(jid);
// Clean the raw data. Since this may arrive by chunks, those chunks may contain if (originalConversation == null && originalRoster == null) return;
// weird data pieces.
final base64Data = base64Decode(_cleanBase64String(base64)); final avatarPath = await saveAvatarInCache(
data,
hash,
jid,
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
);
if (originalConversation != null) { if (originalConversation != null) {
final avatarPath = await saveAvatarInCache(
base64Data,
hash,
jid,
originalConversation.avatarUrl,
);
saved = true;
final conv = await cs.updateConversation( final conv = await cs.updateConversation(
originalConversation.id, originalConversation.id,
avatarUrl: avatarPath, avatarUrl: avatarPath,
); );
sendEvent(ConversationUpdatedEvent(conversation: conv)); sendEvent(ConversationUpdatedEvent(conversation: conv));
} else {
_log.warning('Failed to get conversation');
} }
final originalRoster = await rs.getRosterItemByJid(jid);
if (originalRoster != null) { if (originalRoster != null) {
var avatarPath = '';
if (saved) {
avatarPath = await getAvatarPath(jid, hash);
} else {
avatarPath = await saveAvatarInCache(
base64Data,
hash,
jid,
originalRoster.avatarUrl,
);
}
final roster = await rs.updateRosterItem( final roster = await rs.updateRosterItem(
originalRoster.id, originalRoster.id,
avatarUrl: avatarPath, avatarUrl: avatarPath,
@@ -85,66 +76,73 @@ class AvatarService {
sendEvent(RosterDiffEvent(modified: [roster])); sendEvent(RosterDiffEvent(modified: [roster]));
} }
} }
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
final am = GetIt.I.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final idResult = await am.getAvatarId(jid);
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 { Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
final response = await _getDiscoManager().discoItemsQuery(jid); _AvatarData? data;
final items = response.isType<DiscoError>() ? data ??= await _handleUserAvatar(jid, oldHash);
<DiscoItem>[] : data ??= await _handleVcardAvatar(jid, oldHash);
response.get<List<DiscoItem>>();
final itemNodes = items.map((i) => i.node);
_log.finest('Disco items for $jid:'); if (data != null) {
for (final item in itemNodes) { await updateAvatarForJid(jid, data.id, data.data);
_log.finest('- $item');
} }
var base64 = '';
var hash = '';
if (listContains<DiscoItem>(items, (item) => item.node == userAvatarDataXmlns)) {
final avatar = _getUserAvatarManager();
final pubsubHash = await avatar.getAvatarId(jid);
// Don't request if we already have the newest avatar
if (pubsubHash == oldHash) return;
// Query via PubSub
final data = await avatar.getUserAvatar(jid);
if (data == null) return;
base64 = data.base64;
hash = data.hash;
} else {
// Query the vCard
final vm = GetIt.I.get<XmppConnection>().getManagerById<VCardManager>(vcardManager)!;
final vcard = await vm.requestVCard(jid);
if (vcard != null) {
final binval = vcard.photo?.binval;
if (binval != null) {
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
// weird data pieces.
base64 = _cleanBase64String(binval);
final rawHash = await Sha1().hash(base64Decode(base64));
hash = HEX.encode(rawHash.bytes);
vm.setLastHash(jid, hash);
} else {
return;
}
} else {
return;
}
}
await updateAvatarForJid(jid, hash, base64);
} }
Future<bool> subscribeJid(String jid) async { Future<bool> subscribeJid(String jid) async {
return _getUserAvatarManager().subscribe(jid); return (await GetIt.I.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.subscribe(jid)).isType<bool>();
} }
Future<bool> unsubscribeJid(String jid) async { Future<bool> unsubscribeJid(String jid) async {
return _getUserAvatarManager().unsubscribe(jid); 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
@@ -158,59 +156,80 @@ class AvatarService {
final public = prefs.isAvatarPublic; final public = prefs.isAvatarPublic;
// Read the image metadata // Read the image metadata
final imageSize = ImageSizeGetter.getSize(MemoryInput(bytes)); final imageSize = (await getImageSizeFromData(bytes))!;
// Publish data and metadata // Publish data and metadata
final manager = _getUserAvatarManager(); final am = GetIt.I.get<XmppConnection>()
await manager.publishUserAvatar( .getManagerById<UserAvatarManager>(userAvatarManager)!;
_log.finest('Publishing avatar...');
final dataResult = await am.publishUserAvatar(
base64, base64,
hash, hash,
public, public,
); );
await manager.publishUserAvatarMetadata( if (dataResult.isType<AvatarError>()) {
_log.finest('Avatar data publishing failed');
return false;
}
// TODO(Unknown): Make sure that the image is not too large.
final metadataResult = await am.publishUserAvatarMetadata(
UserAvatarMetadata( UserAvatarMetadata(
hash, hash,
bytes.length, bytes.length,
imageSize.width, imageSize.width.toInt(),
imageSize.height, imageSize.height.toInt(),
// TODO(PapaTutuWawa): Maybe do a check here // TODO(PapaTutuWawa): Maybe do a check here
'image/png', 'image/png',
), ),
public, public,
); );
if (metadataResult.isType<AvatarError>()) {
_log.finest('Avatar metadata publishing failed');
return false;
}
_log.finest('Avatar publishing done');
return true; return true;
} }
Future<void> requestOwnAvatar() async { Future<void> requestOwnAvatar() async {
final avatar = _getUserAvatarManager(); final am = GetIt.I.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final xmpp = GetIt.I.get<XmppService>(); final xmpp = GetIt.I.get<XmppService>();
final state = await xmpp.getXmppState(); final state = await xmpp.getXmppState();
final jid = state.jid!; final jid = state.jid!;
final id = await avatar.getAvatarId(jid); final idResult = await am.getAvatarId(jid);
if (idResult.isType<AvatarError>()) {
_log.info('Error while getting latest avatar id for own avatar');
return;
}
final id = idResult.get<String>();
if (id == state.avatarHash) return; if (id == state.avatarHash) return;
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself'); _log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
final data = await avatar.getUserAvatar(jid); final avatarDataResult = await am.getUserAvatar(jid);
if (data == null) { if (avatarDataResult.isType<AvatarError>()) {
_log.severe('Failed to fetch our avatar'); _log.severe('Failed to fetch our avatar');
return; return;
} }
final avatarData = avatarDataResult.get<UserAvatar>();
_log.info('Received data for our own avatar'); _log.info('Received data for our own avatar');
final avatarPath = await saveAvatarInCache( final avatarPath = await saveAvatarInCache(
base64Decode(_cleanBase64String(data.base64)), base64Decode(_cleanBase64String(avatarData.base64)),
data.hash, avatarData.hash,
jid, jid,
state.avatarUrl, state.avatarUrl,
); );
await xmpp.modifyXmppState((state) => state.copyWith( await xmpp.modifyXmppState((state) => state.copyWith(
avatarUrl: avatarPath, avatarUrl: avatarPath,
avatarHash: data.hash, avatarHash: avatarData.hash,
),); ),);
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: data.hash)); sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
} }
} }

View File

@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.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';
@@ -9,35 +12,93 @@ enum BlockPushType {
} }
class BlocklistService { class BlocklistService {
BlocklistService();
BlocklistService() : List<String>? _blocklist;
_blocklistCache = List.empty(growable: true), bool _requested = false;
_requestedBlocklist = false; bool? _supported;
final List<String> _blocklistCache; final Logger _log = Logger('BlocklistService');
bool _requestedBlocklist;
Future<List<String>> _requestBlocklist() async { void onNewConnection() {
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!; // Invalidate the caches
_blocklistCache _blocklist = null;
..clear() _requested = false;
..addAll(await manager.getBlocklist()); _supported = null;
_requestedBlocklist = true; }
return _blocklistCache;
Future<bool> _checkSupport() async {
return _supported ??= await GetIt.I.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
.isSupported();
}
Future<void> _requestBlocklist() async {
assert(_blocklist != null, 'The blocklist must be loaded from the database before requesting');
// Check if blocking is supported
if (!(await _checkSupport())) {
_log.warning('Blocklist requested but server does not support it.');
return;
}
final blocklist = await GetIt.I.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
.getBlocklist();
// Diff the received blocklist with the cache
final newItems = List<String>.empty(growable: true);
final removedItems = List<String>.empty(growable: true);
final db = GetIt.I.get<DatabaseService>();
for (final item in blocklist) {
if (!_blocklist!.contains(item)) {
await db.addBlocklistEntry(item);
_blocklist!.add(item);
newItems.add(item);
}
}
// Diff the cache with the received blocklist
for (final item in _blocklist!) {
if (!blocklist.contains(item)) {
await db.removeBlocklistEntry(item);
_blocklist!.remove(item);
removedItems.add(item);
}
}
_requested = true;
// Trigger an UI event if we have anything to tell the UI
if (newItems.isNotEmpty || removedItems.isNotEmpty) {
sendEvent(
BlocklistPushEvent(
added: newItems,
removed: removedItems,
),
);
}
} }
/// Returns the blocklist from the database /// Returns the blocklist from the database
Future<List<String>> getBlocklist() async { Future<List<String>> getBlocklist() async {
if (!_requestedBlocklist) { if (_blocklist == null) {
_blocklistCache _blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
..clear()
..addAll(await _requestBlocklist()); if (!_requested) {
unawaited(_requestBlocklist());
}
return _blocklist!;
} }
return _blocklistCache; if (!_requested) {
unawaited(_requestBlocklist());
}
return _blocklist!;
} }
void onUnblockAllPush() { void onUnblockAllPush() {
_blocklistCache.clear(); _blocklist = List<String>.empty(growable: true);
sendEvent( sendEvent(
BlocklistUnblockAllEvent(), BlocklistUnblockAllEvent(),
); );
@@ -45,21 +106,25 @@ class BlocklistService {
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async { Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
// We will fetch it later when getBlocklist is called // We will fetch it later when getBlocklist is called
if (!_requestedBlocklist) return; if (!_requested) return;
final newBlocks = List<String>.empty(growable: true); final newBlocks = List<String>.empty(growable: true);
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 (_blocklistCache.contains(item)) continue; if (_blocklist!.contains(item)) continue;
_blocklistCache.add(item); _blocklist!.add(item);
newBlocks.add(item); newBlocks.add(item);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
} }
break; break;
case BlockPushType.unblock: { case BlockPushType.unblock: {
_blocklistCache.removeWhere((i) => i == item); _blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item); removedBlocks.add(item);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
} }
break; break;
} }
@@ -74,17 +139,47 @@ class BlocklistService {
} }
Future<bool> blockJid(String jid) async { Future<bool> blockJid(String jid) async {
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!; // Check if blocking is supported
return manager.block([ jid ]); if (!(await _checkSupport())) {
_log.warning('Blocking $jid requested but server does not support it.');
return false;
}
_blocklist!.add(jid);
await GetIt.I.get<DatabaseService>()
.addBlocklistEntry(jid);
return GetIt.I.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
.block([jid]);
} }
Future<bool> unblockJid(String jid) async { Future<bool> unblockJid(String jid) async {
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!; // Check if blocking is supported
return manager.unblock([ jid ]); if (!(await _checkSupport())) {
_log.warning('Unblocking $jid requested but server does not support it.');
return false;
}
_blocklist!.remove(jid);
await GetIt.I.get<DatabaseService>()
.removeBlocklistEntry(jid);
return GetIt.I.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
.unblock([jid]);
} }
Future<bool> unblockAll() async { Future<bool> unblockAll() async {
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!; // Check if blocking is supported
return manager.unblockAll(); if (!(await _checkSupport())) {
_log.warning('Unblocking all JIDs requested but server does not support it.');
return false;
}
_blocklist!.clear();
await GetIt.I.get<DatabaseService>()
.removeAllBlocklistEntries();
return GetIt.I.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
.unblockAll();
} }
} }

300
lib/service/contacts.dart Normal file
View File

@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:permission_handler/permission_handler.dart';
class ContactWrapper {
const ContactWrapper(this.id, this.jid, this.displayName, this.thumbnail);
final String id;
final String jid;
final String displayName;
final Uint8List? thumbnail;
}
class ContactsService {
ContactsService() {
// NOTE: Apparently, this means that if false, contacts that are in 0 groups
// are not returned.
FlutterContacts.config.includeNonVisibleOnAndroid = true;
}
final Logger _log = Logger('ContactsService');
/// JID -> Id
Map<String, String>? _contactIds;
/// Contact ID -> Display name from the contact or null if we cached that there is
/// none
final Map<String, String?> _contactDisplayNames = {};
Future<void> init() async {
if (await _canUseContactIntegration()) {
enableDatabaseListener();
}
}
/// Enable listening to contact database events
void enableDatabaseListener() {
FlutterContacts.addListener(_onContactsDatabaseUpdate);
}
/// Disable listening to contact database events
void disableDatabaseListener() {
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
}
Future<void> _onContactsDatabaseUpdate() async {
_log.finest('Got contacts database update');
await scanContacts();
}
/// Queries the contact list for contacts that include a XMPP URI.
Future<List<ContactWrapper>> _fetchContactsWithJabber() async {
final contacts = await FlutterContacts.getContacts(
withProperties: true,
withThumbnail: true,
);
_log.finest('Got ${contacts.length} contacts');
final jabberContacts = List<ContactWrapper>.empty(growable: true);
for (final c in contacts) {
final index = c.socialMedias
.indexWhere((s) => s.label == SocialMediaLabel.jabber);
if (index == -1) continue;
jabberContacts.add(
ContactWrapper(
c.id,
c.socialMedias[index].userName,
c.displayName,
c.thumbnail,
),
);
}
_log.finest('${jabberContacts.length} contacts have an XMPP address');
return jabberContacts;
}
/// Checks whether the contact integration is enabled by the user in the preferences.
/// Returns true if that is the case. If not, returns false.
Future<bool> isContactIntegrationEnabled() async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
return prefs.enableContactIntegration;
}
/// Checks if we a) have the permission to access the contact list and b) if the
/// user wants to use this integration.
/// Returns true if we can proceed with accessing the contact list. False, if not.
Future<bool> _canUseContactIntegration() async {
if (!(await isContactIntegrationEnabled())) {
_log.finest('_canUseContactIntegration: Returning false since enableContactIntegration is false');
return false;
}
final permission = await Permission.contacts.status;
if (permission == PermissionStatus.denied) {
_log.finest("_canUseContactIntegration: Returning false since we don't have the contacts permission");
return false;
}
return true;
}
/// Queries the database for the mapping of JID -> Contact ID. The result is
/// cached after the first call.
Future<Map<String, String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!;
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds();
return _contactIds!;
}
/// Queries the contact list, if enabled and allowed, and returns the contact's
/// display name.
///
/// [id] is the id of the contact. A null value indicates that there is no
/// contact and null will be returned immediately.
Future<String?> getContactDisplayName(String? id) async {
if (id == null ||
!(await _canUseContactIntegration())) return null;
if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id];
final result = await FlutterContacts.getContact(
id,
withThumbnail: false,
);
_contactDisplayNames[id] = result?.displayName;
return result?.displayName;
}
/// Returns the contact Id for the JID [jid]. If either the contact integration is
/// disabled, not possible (due to missing permissions) or there is no contact with
/// [jid] as their Jabber attribute, returns null.
Future<String?> getContactIdForJid(String jid) async {
if (!(await _canUseContactIntegration())) return null;
return (await _getContactIds())[jid];
}
/// Returns the path to the avatar file for the contact with JID [jid] as their
/// Jabber attribute. If either the contact integration is disabled, not possible
/// (due to missing permissions) or there is no contact with [jid] as their Jabber
/// attribute, returns null.
Future<String?> getProfilePicturePathForJid(String jid) async {
final id = await getContactIdForJid(jid);
if (id == null) return null;
final avatarPath = await getContactProfilePicturePath(id);
return File(avatarPath).existsSync() ?
avatarPath :
null;
}
Future<void> scanContacts() async {
final db = GetIt.I.get<DatabaseService>();
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final contacts = await _fetchContactsWithJabber();
// JID -> Id
final knownContactIds = await _getContactIds();
// Id -> JID
final knownContactIdsReverse = knownContactIds
.map((key, value) => MapEntry(value, key));
final modifiedRosterItems = List<RosterItem>.empty(growable: true);
final addedRosterItems = List<RosterItem>.empty(growable: true);
final removedRosterItems = List<String>.empty(growable: true);
for (final id in List<String>.from(knownContactIds.values)) {
final index = contacts.indexWhere((c) => c.id == id);
if (index != -1) continue;
final jid = knownContactIdsReverse[id]!;
await db.removeContactId(id);
_contactIds!.remove(knownContactIdsReverse[id]);
// Remove the avatar file, if it existed
final avatarPath = await getContactProfilePicturePath(id);
final avatarFile = File(avatarPath);
if (avatarFile.existsSync()) {
unawaited(avatarFile.delete());
}
// Remove the contact attributes from the conversation, if it existed
final c = await cs.getConversationByJid(jid);
if (c != null) {
final newConv = await cs.updateConversation(
c.id,
contactId: null,
contactAvatarPath: null,
contactDisplayName: null,
);
sendEvent(
ConversationUpdatedEvent(
conversation: newConv,
),
);
}
// Remove the contact attributes from the roster item, if it existed
final r = await rs.getRosterItemByJid(jid);
if (r != null) {
if (r.pseudoRosterItem) {
_log.finest('Removing pseudo roster item $jid');
await rs.removeRosterItem(r.id);
removedRosterItems.add(jid);
} else {
final newRosterItem = await rs.updateRosterItem(
r.id,
contactId: null,
contactAvatarPath: null,
contactDisplayName: null,
);
modifiedRosterItems.add(newRosterItem);
}
}
}
for (final contact in contacts) {
// Add the ID to the cache and the database if it does not already exist
if (!knownContactIds.containsKey(contact.jid)) {
await db.addContactId(contact.id, contact.jid);
_contactIds![contact.jid] = contact.id;
}
// Store the avatar image
// NOTE: We do not check if the file already exists since this function may also
// be triggered by the contact database listener. That listener fires when
// a change happened, without telling us exactly what happened. So, we
// just overwrite it.
final contactAvatarPath = await getContactProfilePicturePath(contact.id);
if (contact.thumbnail != null) {
final file = File(contactAvatarPath);
await file.writeAsBytes(contact.thumbnail!);
}
// Update a possibly existing conversation
final c = await cs.getConversationByJid(contact.jid);
if (c != null) {
final newConv = await cs.updateConversation(
c.id,
contactId: contact.id,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contact.displayName,
);
sendEvent(
ConversationUpdatedEvent(
conversation: newConv,
),
);
}
// Update a possibly existing roster item
final r = await rs.getRosterItemByJid(contact.jid);
if (r != null) {
final newRosterItem = await rs.updateRosterItem(
r.id,
contactId: contact.id,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contact.displayName,
);
modifiedRosterItems.add(newRosterItem);
} else {
final newRosterItem = await rs.addRosterItemFromData(
'',
'',
contact.jid,
contact.jid.split('@').first,
'none',
'none',
true,
contact.id,
contactAvatarPath,
contact.displayName,
);
addedRosterItems.add(newRosterItem);
}
}
if (addedRosterItems.isNotEmpty ||
modifiedRosterItems.isNotEmpty ||
removedRosterItems.isNotEmpty) {
sendEvent(
RosterDiffEvent(
added: addedRosterItems,
modified: modifiedRosterItems,
removed: removedRosterItems,
),
);
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart'; import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.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/shared/cache.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
@@ -64,6 +65,9 @@ class ConversationService {
ChatState? chatState, ChatState? chatState,
bool? muted, bool? muted,
bool? encrypted, bool? encrypted,
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
}) async { }) async {
final conversation = (await _getConversationById(id))!; final conversation = (await _getConversationById(id))!;
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation( var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
@@ -76,6 +80,9 @@ class ConversationService {
chatState: conversation.chatState, chatState: conversation.chatState,
muted: muted, muted: muted,
encrypted: encrypted, encrypted: encrypted,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
); );
// Copy over the old lastMessage if a new one was not set // Copy over the old lastMessage if a new one was not set
@@ -98,6 +105,9 @@ class ConversationService {
bool open, bool open,
bool muted, bool muted,
bool encrypted, bool encrypted,
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
) async { ) async {
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData( final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
title, title,
@@ -109,6 +119,9 @@ class ConversationService {
open, open,
muted, muted,
encrypted, encrypted,
contactId,
contactAvatarPath,
contactDisplayName,
); );
_conversationCache.cache(newConversation.id, newConversation); _conversationCache.cache(newConversation.id, newConversation);

View File

@@ -9,7 +9,12 @@ const omemoRatchetsTable = 'OmemoSessions';
const omemoTrustCacheTable = 'OmemoTrustCacheList'; const omemoTrustCacheTable = 'OmemoTrustCacheList';
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList'; const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
const omemoTrustEnableListTable = 'OmemoTrustEnableList'; const omemoTrustEnableListTable = 'OmemoTrustEnableList';
const omemoFingerprintCache = 'OmemoFingerprintCache';
const xmppStateTable = 'XmppState'; const xmppStateTable = 'XmppState';
const contactsTable = 'Contacts';
const stickersTable = 'Stickers';
const stickerPacksTable = 'StickerPacks';
const blocklistTable = 'Blocklist';
const typeString = 0; const typeString = 0;
const typeInt = 1; const typeInt = 1;

View File

@@ -1,4 +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/shared/models/preference.dart'; import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:sqflite_sqlcipher/sqflite.dart';
@@ -52,6 +53,13 @@ Future<void> createDatabase(Database db, int version) async {
isUploading INTEGER NOT NULL, isUploading INTEGER NOT NULL,
mediaSize INTEGER, mediaSize INTEGER,
isRetracted INTEGER, 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_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
)''', )''',
); );
@@ -69,11 +77,25 @@ Future<void> createDatabase(Database db, int version) async {
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 NOT NULL, lastMessageId INTEGER,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id) 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
)''', )''',
); );
// Contacts
await db.execute(
'''
CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY,
jid TEXT NOT NULL
)'''
);
// Shared media // Shared media
await db.execute( await db.execute(
''' '''
@@ -99,10 +121,56 @@ Future<void> createDatabase(Database db, int version) async {
avatarUrl TEXT NOT NULL, avatarUrl 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,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
pseudoRosterItem INTEGER NOT NULL,
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''', )''',
); );
// Stickers
await db.execute(
'''
CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
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)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
restricted INTEGER NOT NULL
)''',
);
// Blocklist
await db.execute(
'''
CREATE TABLE $blocklistTable (
jid TEXT PRIMARY KEY
);
''',
);
// OMEMO // OMEMO
await db.execute( await db.execute(
''' '''
@@ -157,7 +225,7 @@ Future<void> createDatabase(Database db, int version) async {
PRIMARY KEY (jid, id) PRIMARY KEY (jid, id)
)''', )''',
); );
await db.execute( await db.execute(
''' '''
CREATE TABLE $omemoDeviceListTable ( CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL, jid TEXT NOT NULL,
@@ -165,6 +233,15 @@ Future<void> createDatabase(Database db, int version) async {
PRIMARY KEY (jid, id) PRIMARY KEY (jid, id)
)''', )''',
); );
await db.execute(
'''
CREATE TABLE $omemoFingerprintCache (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
fingerprint TEXT NOT NULL,
PRIMARY KEY (jid, id)
)''',
);
// Settings // Settings
await db.execute( await db.execute(
@@ -335,4 +412,28 @@ Future<void> createDatabase(Database db, int version) async {
'default', 'default',
).toDatabaseJson(), ).toDatabaseJson(),
); );
await db.insert(
preferenceTable,
Preference(
'enableContactIntegration',
typeBool,
'false',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'isStickersNodePublic',
typeBool,
'true',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'showDebugMenu',
typeBool,
boolToString(false),
).toDatabaseJson(),
);
} }

View File

@@ -8,23 +8,45 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart'; import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/creation.dart'; import 'package:moxxyv2/service/database/creation.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/database/migrations/0000_blocklist.dart';
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration.dart';
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_avatar.dart';
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_pseudo.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart'; import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart'; import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart'; import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
import 'package:moxxyv2/service/database/migrations/0000_language.dart'; import 'package:moxxyv2/service/database/migrations/0000_language.dart';
import 'package:moxxyv2/service/database/migrations/0000_lmc.dart';
import 'package:moxxyv2/service/database/migrations/0000_omemo_fingerprint_cache.dart';
import 'package:moxxyv2/service/database/migrations/0000_pseudo_messages.dart';
import 'package:moxxyv2/service/database/migrations/0000_reactions.dart';
import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart'; import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart'; import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart'; import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key2.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes2.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes3.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_privacy.dart';
import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart'; import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart';
import 'package:moxxyv2/service/database/migrations/0001_debug_menu.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/omemo/types.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/state.dart'; import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart'; import 'package:random_string/random_string.dart';
@@ -61,7 +83,7 @@ class DatabaseService {
_db = await openDatabase( _db = await openDatabase(
dbPath, dbPath,
password: key, password: key,
version: 9, version: 26,
onCreate: createDatabase, onCreate: createDatabase,
onConfigure: (db) async { onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign // In order to do schema changes during database upgrades, we disable foreign
@@ -106,6 +128,74 @@ class DatabaseService {
_log.finest('Running migration for database version 9'); _log.finest('Running migration for database version 9');
await upgradeFromV8ToV9(db); await upgradeFromV8ToV9(db);
} }
if (oldVersion < 10) {
_log.finest('Running migration for database version 10');
await upgradeFromV9ToV10(db);
}
if (oldVersion < 11) {
_log.finest('Running migration for database version 11');
await upgradeFromV10ToV11(db);
}
if (oldVersion < 12) {
_log.finest('Running migration for database version 12');
await upgradeFromV11ToV12(db);
}
if (oldVersion < 13) {
_log.finest('Running migration for database version 13');
await upgradeFromV12ToV13(db);
}
if (oldVersion < 14) {
_log.finest('Running migration for database version 14');
await upgradeFromV13ToV14(db);
}
if (oldVersion < 15) {
_log.finest('Running migration for database version 15');
await upgradeFromV14ToV15(db);
}
if (oldVersion < 16) {
_log.finest('Running migration for database version 16');
await upgradeFromV15ToV16(db);
}
if (oldVersion < 17) {
_log.finest('Running migration for database version 17');
await upgradeFromV16ToV17(db);
}
if (oldVersion < 18) {
_log.finest('Running migration for database version 18');
await upgradeFromV17ToV18(db);
}
if (oldVersion < 19) {
_log.finest('Running migration for database version 19');
await upgradeFromV18ToV19(db);
}
if (oldVersion < 20) {
_log.finest('Running migration for database version 20');
await upgradeFromV19ToV20(db);
}
if (oldVersion < 21) {
_log.finest('Running migration for database version 21');
await upgradeFromV20ToV21(db);
}
if (oldVersion < 22) {
_log.finest('Running migration for database version 22');
await upgradeFromV21ToV22(db);
}
if (oldVersion < 23) {
_log.finest('Running migration for database version 23');
await upgradeFromV22ToV23(db);
}
if (oldVersion < 24) {
_log.finest('Running migration for database version 24');
await upgradeFromV23ToV24(db);
}
if (oldVersion < 25) {
_log.finest('Running migration for database version 25');
await upgradeFromV24ToV25(db);
}
if (oldVersion < 26) {
_log.finest('Running migration for database version 26');
await upgradeFromV25ToV26(db);
}
}, },
); );
@@ -139,7 +229,7 @@ class DatabaseService {
tmp.add( tmp.add(
Conversation.fromDatabaseJson( Conversation.fromDatabaseJson(
c, c,
rosterItem != null, rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none', rosterItem?.subscription ?? 'none',
sharedMediaRaw, sharedMediaRaw,
lastMessage, lastMessage,
@@ -187,6 +277,9 @@ class DatabaseService {
ChatState? chatState, ChatState? chatState,
bool? muted, bool? muted,
bool? encrypted, bool? encrypted,
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
}) async { }) async {
final cd = (await _db.query( final cd = (await _db.query(
'Conversations', 'Conversations',
@@ -223,6 +316,15 @@ class DatabaseService {
if (encrypted != null) { if (encrypted != null) {
c['encrypted'] = boolToInt(encrypted); 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?;
}
await _db.update( await _db.update(
'Conversations', 'Conversations',
@@ -253,6 +355,9 @@ class DatabaseService {
bool open, bool open,
bool muted, bool muted,
bool encrypted, bool encrypted,
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
) async { ) async {
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid); final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final conversation = Conversation( final conversation = Conversation(
@@ -265,11 +370,14 @@ class DatabaseService {
<SharedMedium>[], <SharedMedium>[],
-1, -1,
open, open,
rosterItem != null, rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none', rosterItem?.subscription ?? 'none',
muted, muted,
encrypted, encrypted,
ChatState.gone, ChatState.gone,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
); );
return conversation.copyWith( return conversation.copyWith(
@@ -312,6 +420,7 @@ class DatabaseService {
String sid, String sid,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
bool containsNoStore,
{ {
String? srcUrl, String? srcUrl,
String? key, String? key,
@@ -332,6 +441,10 @@ class DatabaseService {
bool isDownloading = false, bool isDownloading = false,
bool isUploading = false, bool isUploading = false,
int? mediaSize, int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
} }
) async { ) async {
var m = Message( var m = Message(
@@ -344,6 +457,7 @@ class DatabaseService {
isMedia, isMedia,
isFileUploadNotification, isFileUploadNotification,
encrypted, encrypted,
containsNoStore,
errorType: errorType, errorType: errorType,
warningType: warningType, warningType: warningType,
mediaUrl: mediaUrl, mediaUrl: mediaUrl,
@@ -365,6 +479,10 @@ class DatabaseService {
isUploading: isUploading, isUploading: isUploading,
isDownloading: isDownloading, isDownloading: isDownloading,
mediaSize: mediaSize, mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
); );
if (quoteId != null) { if (quoteId != null) {
@@ -451,6 +569,8 @@ class DatabaseService {
Object? sid = notSpecified, Object? sid = notSpecified,
bool? isRetracted, bool? isRetracted,
Object? thumbnailData = notSpecified, Object? thumbnailData = notSpecified,
bool? isEdited,
Object? reactions = notSpecified,
}) async { }) async {
final md = (await _db.query( final md = (await _db.query(
'Messages', 'Messages',
@@ -460,6 +580,9 @@ class DatabaseService {
)).first; )).first;
final m = Map<String, dynamic>.from(md); final m = Map<String, dynamic>.from(md);
if (body != notSpecified) {
m['body'] = body as String?;
}
if (mediaUrl != notSpecified) { if (mediaUrl != notSpecified) {
m['mediaUrl'] = mediaUrl as String?; m['mediaUrl'] = mediaUrl as String?;
} }
@@ -526,6 +649,17 @@ class DatabaseService {
if (thumbnailData != notSpecified) { if (thumbnailData != notSpecified) {
m['thumbnailData'] = thumbnailData as String?; m['thumbnailData'] = thumbnailData as String?;
} }
if (isEdited != null) {
m['isEdited'] = boolToInt(isEdited);
}
if (reactions != notSpecified) {
assert(reactions != null, 'Cannot set reactions to null');
m['reactions'] = jsonEncode(
(reactions! as List<Reaction>)
.map((r) => r.toJson())
.toList(),
);
}
await _db.update( await _db.update(
'Messages', 'Messages',
@@ -566,6 +700,10 @@ class DatabaseService {
String title, String title,
String subscription, String subscription,
String ask, String ask,
bool pseudoRosterItem,
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
{ {
List<String> groups = const [], List<String> groups = const [],
} }
@@ -579,7 +717,11 @@ class DatabaseService {
title, title,
subscription, subscription,
ask, ask,
pseudoRosterItem,
<String>[], <String>[],
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
); );
return i.copyWith( return i.copyWith(
@@ -595,11 +737,15 @@ class DatabaseService {
String? title, String? title,
String? subscription, String? subscription,
String? ask, String? ask,
Object pseudoRosterItem = notSpecified,
List<String>? groups, List<String>? groups,
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
} }
) async { ) async {
final id_ = (await _db.query( final id_ = (await _db.query(
'RosterItems', rosterTable,
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
limit: 1, limit: 1,
@@ -626,9 +772,21 @@ class DatabaseService {
if (ask != null) { if (ask != null) {
i['ask'] = ask; i['ask'] = ask;
} }
if (contactId != notSpecified) {
i['contactId'] = contactId as String?;
}
if (contactAvatarPath != notSpecified) {
i['contactAvatarPath'] = contactAvatarPath as String?;
}
if (contactDisplayName != notSpecified) {
i['contactDisplayName'] = contactDisplayName as String?;
}
if (pseudoRosterItem != notSpecified) {
i['pseudoRosterItem'] = boolToInt(pseudoRosterItem as bool);
}
await _db.update( await _db.update(
'RosterItems', rosterTable,
i, i,
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
@@ -881,7 +1039,7 @@ class DatabaseService {
await batch.commit(); await batch.commit();
} }
Future<void> saveOmemoDevice(Device device) async { Future<void> saveOmemoDevice(OmemoDevice device) async {
await _db.insert( await _db.insert(
omemoDeviceTable, omemoDeviceTable,
{ {
@@ -893,7 +1051,7 @@ class DatabaseService {
); );
} }
Future<Device?> loadOmemoDevice(String jid) async { Future<OmemoDevice?> loadOmemoDevice(String jid) async {
final data = await _db.query( final data = await _db.query(
omemoDeviceTable, omemoDeviceTable,
where: 'jid = ?', where: 'jid = ?',
@@ -916,7 +1074,7 @@ class DatabaseService {
}); });
} }
deviceJson['opks'] = opks; deviceJson['opks'] = opks;
return Device.fromJson(deviceJson); return OmemoDevice.fromJson(deviceJson);
} }
Future<Map<String, List<int>>> loadOmemoDeviceList() async { Future<Map<String, List<int>>> loadOmemoDeviceList() async {
@@ -968,4 +1126,190 @@ class DatabaseService {
await batch.commit(); await batch.commit();
} }
Future<void> addFingerprintsToCache(List<OmemoCacheTriple> items) async {
final batch = _db.batch();
for (final item in items) {
batch.insert(
omemoFingerprintCache,
<String, dynamic>{
'jid': item.jid,
'id': item.deviceId,
'fingerprint': item.fingerprint,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<List<OmemoCacheTriple>> getFingerprintsFromCache(String jid) async {
final rawItems = await _db.query(
omemoFingerprintCache,
where: 'jid = ?',
whereArgs: [jid],
);
return rawItems
.map((item) {
return OmemoCacheTriple(
jid,
item['id']! as int,
item['fingerprint']! as String,
);
})
.toList();
}
Future<Map<String, String>> getContactIds() async {
return Map<String, String>.fromEntries(
(await _db.query(contactsTable))
.map((item) => MapEntry(
item['jid']! as String,
item['id']! as String,
),
),
);
}
Future<void> addContactId(String id, String jid) async {
await _db.insert(
contactsTable,
<String, String>{
'id': id,
'jid': jid,
},
);
}
Future<void> removeContactId(String id) async {
await _db.delete(
contactsTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> addStickerPackFromData(sticker_pack.StickerPack pack) async {
await _db.insert(
stickerPacksTable,
pack.toDatabaseJson(),
);
}
Future<sticker.Sticker> addStickerFromData(
String mediaType,
String desc,
int size,
int? width,
int? height,
Map<String, String> hashes,
List<String> urlSources,
String path,
String stickerPackId,
Map<String, String> suggests,
) async {
final s = sticker.Sticker(
getStickerHashKey(hashes),
mediaType,
desc,
size,
width,
height,
hashes,
urlSources,
path,
stickerPackId,
suggests,
);
await _db.insert(stickersTable, s.toDatabaseJson());
return s;
}
Future<List<sticker_pack.StickerPack>> loadStickerPacks() async {
final rawPacks = await _db.query(stickerPacksTable);
final stickerPacks = List<sticker_pack.StickerPack>.empty(growable: true);
for (final pack in rawPacks) {
final rawStickers = await _db.query(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [pack['id']! as String],
);
stickerPacks.add(
sticker_pack.StickerPack.fromDatabaseJson(
pack,
rawStickers
.map(sticker.Sticker.fromDatabaseJson)
.toList(),
),
);
}
return stickerPacks;
}
Future<void> removeStickerPackById(String id) async {
await _db.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<sticker_pack.StickerPack?> getStickerPackById(String id) async {
final rawPack = await _db.query(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rawPack.isEmpty) return null;
final rawStickers = await _db.query(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
return sticker_pack.StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers
.map(sticker.Sticker.fromDatabaseJson)
.toList(),
);
}
Future<void> addBlocklistEntry(String jid) async {
await _db.insert(
blocklistTable,
{
'jid': jid,
},
);
}
Future<void> removeBlocklistEntry(String jid) async {
await _db.delete(
blocklistTable,
where: 'jid = ?',
whereArgs: [jid],
);
}
Future<void> removeAllBlocklistEntries() async {
await _db.delete(
blocklistTable,
);
}
Future<List<String>> getBlocklistEntries() async {
final result = await _db.query(blocklistTable);
return result
.map((m) => m['jid']! as String)
.toList();
}
} }

View File

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

View File

@@ -0,0 +1,68 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV13ToV14(Database db) async {
// Create the new table
await db.execute(
'''
CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY,
jid TEXT NOT NULL
)'''
);
// Migrate the conversations
await db.execute(
'''
CREATE TABLE ${conversationsTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
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,
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 *, NULL from $conversationsTable');
await db.execute('DROP TABLE $conversationsTable;');
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;');
// Migrate the roster items
await db.execute(
'''
CREATE TABLE ${rosterTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL,
contactId TEXT,
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable');
await db.execute('DROP TABLE $rosterTable;');
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;');
// Introduce the new preference key
await db.insert(
preferenceTable,
Preference(
'enableContactIntegration',
typeBool,
'false',
).toDatabaseJson(),
);
}

View File

@@ -0,0 +1,17 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV14ToV15(Database db) async {
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
);
await db.execute(
'ALTER TABLE $rosterTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
);
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
);
await db.execute(
'ALTER TABLE $rosterTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
);
}

View File

@@ -0,0 +1,9 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV15ToV16(Database db) async {
await db.execute(
'ALTER TABLE $rosterTable ADD COLUMN pseudoRosterItem INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
}

View File

@@ -9,5 +9,4 @@ Future<void> upgradeFromV6ToV7(Database db) async {
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

@@ -0,0 +1,11 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV9ToV10(Database db) async {
// Mark all messages as not edited
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN isEdited INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
}

View File

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

View File

@@ -0,0 +1,12 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV23ToV24(Database db) async {
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageType INTEGER;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageData TEXT;',
);
}

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV16ToV17(Database db) async {
await db.execute(
'''
CREATE TABLE $stickersTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL
)''',
);
// Add the sticker attributes to Messages
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerPackId TEXT;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerId INTEGER;',
);
// Add the new preferences
await db.insert(
preferenceTable,
Preference(
'enableStickers',
typeBool,
'true',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'autoDownloadStickersFromContacts',
typeBool,
'true',
).toDatabaseJson(),
);
}

View File

@@ -0,0 +1,49 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV17ToV18(Database db) async {
// Update messages
await db.execute(
'ALTER TABLE $messagesTable DROP COLUMN stickerId;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerHashKey TEXT;',
);
// Drop stickers
await db.execute(
'DROP TABLE $stickerPacksTable;'
);
await db.execute(
'DROP TABLE $stickersTable;'
);
await db.execute(
'''
CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
stickerHashKey TEXT NOT NULL
)''',
);
}

View File

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

View File

@@ -0,0 +1,12 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV19ToV20(Database db) async {
await db.execute(
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted DEFAULT ${boolToInt(false)};',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests DEFAULT "";',
);
}

View File

@@ -0,0 +1,19 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV20ToV21(Database db) async {
await db.execute(
'ALTER TABLE $stickerPacksTable DROP COLUMN restricted;',
);
await db.execute(
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
);
await db.execute(
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "";',
);
}

View File

@@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV21ToV22(Database db) async {
await db.execute(
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "{}";',
);
}

View File

@@ -0,0 +1,14 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV24ToV25(Database db) async {
await db.insert(
preferenceTable,
Preference(
'isStickersNodePublic',
typeBool,
'true',
).toDatabaseJson(),
);
}

View File

@@ -0,0 +1,15 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV25ToV26(Database db) async {
await db.insert(
preferenceTable,
Preference(
'showDebugMenu',
typeBool,
boolToString(false),
).toDatabaseJson(),
);
}

View File

@@ -7,6 +7,7 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart'; import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart'; import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
@@ -23,12 +24,16 @@ 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/state.dart'; import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/xmpp.dart'; import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart'; import 'package:moxxyv2/shared/eventhandler.dart';
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/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
import 'package:moxxyv2/shared/synchronized_queue.dart'; import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -66,6 +71,15 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction), EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction),
EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead), EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead),
EventTypeMatcher<MarkMessageAsReadCommand>(performMarkMessageAsRead), EventTypeMatcher<MarkMessageAsReadCommand>(performMarkMessageAsRead),
EventTypeMatcher<AddReactionToMessageCommand>(performAddMessageReaction),
EventTypeMatcher<RemoveReactionFromMessageCommand>(performRemoveMessageReaction),
EventTypeMatcher<MarkOmemoDeviceAsVerifiedCommand>(performMarkDeviceVerified),
EventTypeMatcher<ImportStickerPackCommand>(performImportStickerPack),
EventTypeMatcher<SendStickerCommand>(performSendSticker),
EventTypeMatcher<RemoveStickerPackCommand>(performRemoveStickerPack),
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
]); ]);
GetIt.I.registerSingleton<EventHandler>(handler); GetIt.I.registerSingleton<EventHandler>(handler);
@@ -126,7 +140,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
permissions.add(Permission.storage.value); permissions.add(Permission.storage.value);
await xmpp.modifyXmppState((state) => state.copyWith( await xmpp.modifyXmppState((state) => state.copyWith(
askedStoragePermission: true, askedStoragePermission: true,
),); ),);
} }
@@ -140,6 +154,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
preferences: preferences, preferences: preferences,
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(), conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(), roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
); );
} }
@@ -187,6 +202,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
final updatedConversation = await cs.updateConversation( final updatedConversation = await cs.updateConversation(
conversation.id, conversation.id,
open: true, open: true,
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
); );
sendEvent( sendEvent(
@@ -204,17 +220,22 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
); );
return; return;
} else { } else {
final css = GetIt.I.get<ContactsService>();
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
final contactId = await css.getContactIdForJid(command.jid);
final conversation = await cs.addConversationFromData( final conversation = await cs.addConversationFromData(
command.title, command.title,
null, null,
command.avatarUrl, command.avatarUrl,
command.jid, command.jid,
0, 0,
-1, DateTime.now().millisecondsSinceEpoch,
true, true,
// TODO(PapaTutuWawa): Take as an argument preferences.defaultMuteState,
false, preferences.enableOmemoByDefault,
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault, contactId,
await css.getProfilePicturePathForJid(command.jid),
await css.getContactDisplayName(contactId),
); );
sendEvent( sendEvent(
@@ -247,7 +268,23 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
} }
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async { Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppService>().sendMessage( final xs = GetIt.I.get<XmppService>();
if (command.editSid != null && command.editId != null) {
assert(command.recipients.length == 1, 'Edits must not be sent to multiple recipients');
await xs.sendMessageCorrection(
command.editId!,
command.body,
command.editSid!,
command.recipients.first,
command.chatState.isNotEmpty
? chatStateFromString(command.chatState)
: null,
);
return;
}
await xs.sendMessage(
body: command.body, body: command.body,
recipients: command.recipients, recipients: command.recipients,
chatState: command.chatState.isNotEmpty chatState: command.chatState.isNotEmpty
@@ -287,7 +324,9 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
} }
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async { Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
await GetIt.I.get<PreferencesService>().modifyPreferences((_) => command.preferences); final ps = GetIt.I.get<PreferencesService>();
final oldPrefs = await ps.getPreferences();
await ps.modifyPreferences((_) => command.preferences);
// Set the logging mode // Set the logging mode
if (!kDebugMode) { if (!kDebugMode) {
@@ -295,6 +334,52 @@ Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extr
Logger.root.level = enableDebug ? Level.ALL : Level.INFO; Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
} }
// Scan all contacts if the setting is enabled or disable the database callback
// if it is disabled.
final css = GetIt.I.get<ContactsService>();
if (command.preferences.enableContactIntegration) {
if (!oldPrefs.enableContactIntegration) {
css.enableDatabaseListener();
}
unawaited(css.scanContacts());
} else {
if (oldPrefs.enableContactIntegration) {
css.disableDatabaseListener();
}
}
// TODO(Unknown): Maybe handle this in StickersService
// If sticker visibility was changed, apply the settings to the PubSub node
final pm = GetIt.I.get<XmppConnection>()
.getManagerById<PubSubManager>(pubsubManager)!;
final ownJid = (await GetIt.I.get<XmppService>().getXmppState()).jid!;
if (command.preferences.isStickersNodePublic && !oldPrefs.isStickersNodePublic) {
// Set to open
unawaited(
pm.configure(
ownJid,
stickersXmlns,
const PubSubPublishOptions(
accessModel: 'open',
maxItems: 'max',
),
),
);
} else if (!command.preferences.isStickersNodePublic && oldPrefs.isStickersNodePublic) {
// Set to presence
unawaited(
pm.configure(
ownJid,
stickersXmlns,
const PubSubPublishOptions(
accessModel: 'presence',
maxItems: 'max',
),
),
);
}
// Set the locale // Set the locale
final locale = command.preferences.languageLocaleCode == 'default' ? final locale = command.preferences.languageLocaleCode == 'default' ?
GetIt.I.get<LanguageService>().defaultLocale : GetIt.I.get<LanguageService>().defaultLocale :
@@ -321,24 +406,30 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
final c = await cs.updateConversation( final c = await cs.updateConversation(
conversation.id, conversation.id,
open: true, open: true,
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
); );
sendEvent( sendEvent(
AddContactResultEvent(conversation: c, added: false), AddContactResultEvent(conversation: c, added: false),
id: id, id: id,
); );
} else { } else {
final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid);
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final c = await cs.addConversationFromData( final c = await cs.addConversationFromData(
jid.split('@')[0], jid.split('@')[0],
null, null,
'', '',
jid, jid,
0, 0,
-1, DateTime.now().millisecondsSinceEpoch,
true, true,
// TODO(PapaTutuWawa): Take as an argument prefs.defaultMuteState,
false, prefs.enableOmemoByDefault,
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault, contactId,
await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId),
); );
sendEvent( sendEvent(
AddContactResultEvent(conversation: c, added: true), AddContactResultEvent(conversation: c, added: true),
@@ -459,12 +550,14 @@ Future<void> performGetFeatures(GetFeaturesCommand command, { dynamic extra }) a
final csi = conn.getNegotiatorById<CSINegotiator>(csiNegotiator)!; final csi = conn.getNegotiatorById<CSINegotiator>(csiNegotiator)!;
final httpFileUpload = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!; final httpFileUpload = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
final userBlocking = conn.getManagerById<BlockingManager>(blockingManager)!; final userBlocking = conn.getManagerById<BlockingManager>(blockingManager)!;
final carbons = conn.getManagerById<CarbonsManager>(carbonsManager)!;
sendEvent( sendEvent(
GetFeaturesEvent( GetFeaturesEvent(
supportsStreamManagement: sm.isSupported, supportsStreamManagement: sm.isSupported,
supportsCsi: csi.isSupported, supportsCsi: csi.isSupported,
supportsHttpFileUpload: await httpFileUpload.isSupported(), supportsHttpFileUpload: await httpFileUpload.isSupported(),
supportsUserBlocking: await userBlocking.isSupported(), supportsUserBlocking: await userBlocking.isSupported(),
supportsCarbons: await carbons.isSupported(),
), ),
id: id, id: id,
); );
@@ -527,9 +620,8 @@ Future<void> performRecreateSessions(RecreateSessionsCommand command, { dynamic
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid); await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
await conn.getManagerById<OmemoManager>(omemoManager)!.sendEmptyMessage( await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
JID.fromString(command.jid), command.jid,
findNewSessions: true,
); );
} }
@@ -561,7 +653,7 @@ Future<void> performGetOwnOmemoFingerprints(GetOwnOmemoFingerprintsCommand comma
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async { Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppConnection>() await GetIt.I.get<XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)! .getManagerById<BaseOmemoManager>(omemoManager)!
.deleteDevice(command.deviceId); .deleteDevice(command.deviceId);
} }
@@ -636,3 +728,208 @@ Future<void> performMarkMessageAsRead(MarkMessageAsReadCommand command, { dynami
command.sid, command.sid,
); );
} }
Future<void> performAddMessageReaction(AddReactionToMessageCommand command, { dynamic extra }) async {
final ms = GetIt.I.get<MessageService>();
final conn = GetIt.I.get<XmppConnection>();
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
assert(msg != null, 'The message must be found');
// Update the state
final reactions = List<Reaction>.from(msg!.reactions);
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
if (i == -1) {
reactions.add(Reaction([], command.emoji, true));
} else {
reactions[i] = reactions[i].copyWith(reactedBySelf: true);
}
await ms.updateMessage(msg.id, reactions: reactions);
// Collect all our reactions
final ownReactions = reactions
.where((r) => r.reactedBySelf)
.map((r) => r.emoji)
.toList();
// Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
msg.originId ?? msg.sid,
ownReactions,
),
requestChatMarkers: false,
messageProcessingHints: !msg.containsNoStore ?
[MessageProcessingHint.store] :
null,
),
);
}
Future<void> performRemoveMessageReaction(RemoveReactionFromMessageCommand command, { dynamic extra }) async {
final ms = GetIt.I.get<MessageService>();
final conn = GetIt.I.get<XmppConnection>();
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
assert(msg != null, 'The message must be found');
// Update the state
final reactions = List<Reaction>.from(msg!.reactions);
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
assert(i >= -1, 'The reaction must be found');
if (reactions[i].senders.isEmpty) {
reactions.removeAt(i);
} else {
reactions[i] = reactions[i].copyWith(reactedBySelf: false);
}
await ms.updateMessage(msg.id, reactions: reactions);
// Collect all our reactions
final ownReactions = reactions
.where((r) => r.reactedBySelf)
.map((r) => r.emoji)
.toList();
// Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
msg.originId ?? msg.sid,
ownReactions,
),
requestChatMarkers: false,
messageProcessingHints: !msg.containsNoStore ?
[MessageProcessingHint.store] :
null,
),
);
}
Future<void> performMarkDeviceVerified(MarkOmemoDeviceAsVerifiedCommand command, { dynamic extra }) async {
await GetIt.I.get<OmemoService>().verifyDevice(
command.deviceId,
command.jid,
);
}
Future<void> performImportStickerPack(ImportStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<StickersService>().importFromFile(command.path);
if (result != null) {
sendEvent(
StickerPackImportSuccessEvent(
stickerPack: result,
),
id: id,
);
} else {
sendEvent(
StickerPackImportFailureEvent(),
id: id,
);
}
}
Future<void> performSendSticker(SendStickerCommand command, { dynamic extra }) async {
final xs = GetIt.I.get<XmppService>();
final ss = GetIt.I.get<StickersService>();
final sticker = await ss.getStickerByHashKey(
command.stickerPackId,
command.stickerHashKey,
);
assert(sticker != null, 'Sticker not found');
await xs.sendMessage(
body: sticker!.desc,
recipients: [command.recipient],
sticker: sticker,
);
}
Future<void> performRemoveStickerPack(RemoveStickerPackCommand command, { dynamic extra }) async {
await GetIt.I.get<StickersService>().removeStickerPack(
command.stickerPackId,
);
}
Future<void> performFetchStickerPack(FetchStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<XmppConnection>()
.getManagerById<StickersManager>(stickersManager)!
.fetchStickerPack(JID.fromString(command.jid), command.stickerPackId);
if (result.isType<PubSubError>()) {
sendEvent(
FetchStickerPackFailureResult(),
id: id,
);
} else {
final stickerPack = result.get<StickerPack>();
sendEvent(
FetchStickerPackSuccessResult(
stickerPack: sticker_pack.StickerPack(
command.stickerPackId,
stickerPack.name,
stickerPack.summary,
stickerPack.stickers
.map((s) => sticker.Sticker(
'',
s.metadata.mediaType!,
s.metadata.desc!,
s.metadata.size!,
s.metadata.width,
s.metadata.height,
s.metadata.hashes,
s.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
command.stickerPackId,
s.suggests,
),).toList(),
stickerPack.hashAlgorithm.toName(),
stickerPack.hashValue,
stickerPack.restricted,
false,
),
),
id: id,
);
}
}
Future<void> performStickerPackInstall(InstallStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final ss = GetIt.I.get<StickersService>();
final pack = await ss.installFromPubSub(command.stickerPack);
if (pack != null) {
sendEvent(
StickerPackInstallSuccessEvent(
stickerPack: pack,
),
id: id,
);
} else {
sendEvent(
StickerPackInstallFailureEvent(),
id: id,
);
}
}
Future<void> performGetBlocklist(GetBlocklistCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<BlocklistService>().getBlocklist();
sendEvent(
GetBlocklistResultEvent(
entries: result,
),
id: id,
);
}

View File

@@ -73,3 +73,28 @@ String xmppErrorToTranslatableString(XmppError error) {
return t.errors.login.unspecified; return t.errors.login.unspecified;
} }
String getStickerHashKeyType(Map<String, String> hashes) {
if (hashes.containsKey('blake2b-512')) {
return 'blake2b-512';
} else if (hashes.containsKey('blake2b-512')) {
return 'blake2b-256';
} else if (hashes.containsKey('sha3-512')) {
return 'sha3-512';
} else if (hashes.containsKey('sha3-256')) {
return 'sha3-256';
} else if (hashes.containsKey('sha3-256')) {
return 'sha-512';
} else if (hashes.containsKey('sha-256')) {
return 'sha-256';
}
assert(false, 'No valid hash found');
return '';
}
String getStickerHashKey(Map<String, String> hashes) {
final key = getStickerHashKeyType(hashes);
return '$key:${hashes[key]}';
}

View File

@@ -0,0 +1,137 @@
import 'dart:async';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
typedef ProgressCallback = void Function(int total, int current);
@immutable
class HttpPeekResult {
const HttpPeekResult(this.contentType, this.contentLength);
final String? contentType;
final int? contentLength;
}
/// Download the file found at [uri] into the file [destination]. [onProgress] is
/// called whenever new data has been downloaded.
///
/// Returns the status code if the server responded. If an error occurs, returns null.
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async {
// TODO(Unknown): How do we close fileSink? Do we have to?
IOSink? fileSink;
final client = HttpClient();
try {
final req = await client.getUrl(uri);
final resp = await req.close();
if (!isRequestOkay(resp.statusCode)) {
client.close(force: true);
return resp.statusCode;
}
// The size of the remote file
final length = resp.contentLength;
fileSink = File(destination).openWrite(mode: FileMode.append);
var bytes = 0;
final downloadCompleter = Completer<void>();
unawaited(
resp.transform(
StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (data, sink) {
bytes += data.length;
onProgress(length, bytes);
sink.add(data);
},
handleDone: (sink) {
downloadCompleter.complete();
},
),
).pipe(fileSink),
);
// Wait for the download to complete
await downloadCompleter.future;
client.close(force: true);
//await fileSink.close();
return resp.statusCode;
} catch (ex) {
client.close(force: true);
//await fileSink?.close();
return null;
}
}
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
/// that are added to the PUT request. [onProgress] is called whenever new data has
/// been downloaded.
///
/// 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 {
final client = HttpClient();
try {
final req = await client.putUrl(destination);
final file = File(filePath);
final length = await file.length();
req.contentLength = length;
// Set all known headers
headers.forEach((headerName, headerValue) {
req.headers.set(headerName, headerValue);
});
var bytes = 0;
final stream = file.openRead().transform(
StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (data, sink) {
bytes += data.length;
onProgress(length, bytes);
sink.add(data);
},
handleDone: (sink) {
sink.close();
},
),
);
await req.addStream(stream);
final resp = await req.close();
return resp.statusCode;
} catch (ex) {
client.close(force: true);
return null;
}
}
/// Sends a HEAD request to [uri].
///
/// Returns the content type and content length if the server responded. If an error
/// occurs, returns null.
Future<HttpPeekResult?> peekUrl(Uri uri) async {
final client = HttpClient();
try {
final req = await client.headUrl(uri);
final resp = await req.close();
if (!isRequestOkay(resp.statusCode)) {
client.close(force: true);
return null;
}
client.close(force: true);
final contentType = resp.headers['Content-Type'];
return HttpPeekResult(
contentType != null && contentType.isNotEmpty ?
contentType.first :
null,
resp.contentLength,
);
} catch (ex) {
client.close(force: true);
return null;
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:external_path/external_path.dart'; import 'package:external_path/external_path.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
} }
class FileMetadata { class FileMetadata {
const FileMetadata({ this.mime, this.size }); const FileMetadata({ this.mime, this.size });
final String? mime; final String? mime;
final int? size; final int? size;
@@ -53,15 +52,10 @@ class FileMetadata {
/// 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<FileMetadata> peekFile(String url) async {
final response = await Dio().headUri<dynamic>(Uri.parse(url)); final result = await peekUrl(Uri.parse(url));
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
final contentLengthHeaders = response.headers['Content-Length'];
final contentTypeHeaders = response.headers['Content-Type'];
return FileMetadata( return FileMetadata(
mime: contentTypeHeaders?.first, mime: result?.contentType,
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null, size: result?.contentLength,
); );
} }

View File

@@ -4,10 +4,7 @@ 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';
import 'package:dio/dio.dart' as dio;
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:image_size_getter/file_input.dart';
import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
@@ -17,6 +14,7 @@ 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/database/database.dart';
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/message.dart'; import 'package:moxxyv2/service/message.dart';
@@ -24,6 +22,7 @@ import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib; import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -103,19 +102,17 @@ class HttpFileTransferService {
/// Queue the download job [job] to be performed. /// Queue the download job [job] to be performed.
Future<void> downloadFile(FileDownloadJob job) async { Future<void> downloadFile(FileDownloadJob job) async {
var canDownload = false;
await _uploadLock.synchronized(() async { await _uploadLock.synchronized(() async {
if (_currentDownloadJob != null) { if (_currentDownloadJob != null) {
_log.finest('Queuing up download task.');
_downloadQueue.add(job); _downloadQueue.add(job);
} else { } else {
_log.finest('Executing download task.');
_currentDownloadJob = job; _currentDownloadJob = job;
canDownload = true;
unawaited(_performFileDownload(job));
} }
}); });
if (canDownload) {
unawaited(_performFileDownload(job));
}
} }
Future<void> _copyFile(FileUploadJob job) async { Future<void> _copyFile(FileUploadJob job) async {
@@ -184,7 +181,6 @@ class HttpFileTransferService {
} }
final file = File(path); final file = File(path);
final data = await file.readAsBytes();
final stat = file.statSync(); final stat = file.statSync();
// Request the upload slot // Request the upload slot
@@ -201,120 +197,110 @@ class HttpFileTransferService {
return; return;
} }
final slot = slotResult.get<HttpFileUploadSlot>(); final slot = slotResult.get<HttpFileUploadSlot>();
try {
final response = await dio.Dio().putUri<dynamic>(
Uri.parse(slot.putUrl),
options: dio.Options(
headers: slot.headers,
contentType: 'application/octet-stream',
requestEncoder: (_, __) => data,
),
data: data,
onSendProgress: (count, total) {
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
// is open.
if (job.recipients.length == 1) {
final progress = count.toDouble() / total.toDouble();
sendEvent(
ProgressEvent(
id: job.messageMap.values.first.id,
progress: progress == 1 ? 0.99 : progress,
),
);
}
},
);
final ms = GetIt.I.get<MessageService>(); final uploadStatusCode = await client.uploadFile(
if (response.statusCode != 201) { Uri.parse(slot.putUrl),
// TODO(PapaTutuWawa): Trigger event slot.headers,
_log.severe('Upload failed'); path,
await _fileUploadFailed(job, fileUploadFailedError); (total, current) {
return; // TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
} else { // is open.
_log.fine('Upload was successful'); if (job.recipients.length == 1) {
final progress = current.toDouble() / total.toDouble();
const uuid = Uuid(); sendEvent(
for (final recipient in job.recipients) { ProgressEvent(
// Notify UI of upload completion id: job.messageMap.values.first.id,
var msg = await ms.updateMessage( progress: progress == 1 ? 0.99 : progress,
job.messageMap[recipient]!.id,
mediaSize: stat.size,
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,
srcUrl: slot.getUrl,
);
// TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid;
msg = await ms.updateMessage(
msg.id,
sid: uuid.v4(),
originId: uuid.v4(),
);
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
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: slot.getUrl,
requestDeliveryReceipt: true,
id: msg.sid,
originId: msg.originId,
sfs: StatelessFileSharingData(
FileMetadataData(
mediaType: job.mime,
size: stat.size,
name: pathlib.basename(job.path),
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
<StatelessFileSharingSource>[source],
),
shouldEncrypt: job.encryptMap[recipient]!,
funReplacement: oldSid,
), ),
); );
_log.finest('Sent message with file upload for ${job.path} to $recipient');
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
if (isMultiMedia) {
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
unawaited(_copyFile(job));
}
} }
} },
} on dio.DioError { );
_log.finest('Upload failed due to connection error');
final ms = GetIt.I.get<MessageService>();
if (!isRequestOkay(uploadStatusCode)) {
_log.severe('Upload failed');
await _fileUploadFailed(job, fileUploadFailedError); await _fileUploadFailed(job, fileUploadFailedError);
return; return;
} else {
_log.fine('Upload was successful');
const uuid = Uuid();
for (final recipient in job.recipients) {
// Notify UI of upload completion
var msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
mediaSize: stat.size,
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,
srcUrl: slot.getUrl,
);
// TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid;
msg = await ms.updateMessage(
msg.id,
sid: uuid.v4(),
originId: uuid.v4(),
);
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
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: slot.getUrl,
requestDeliveryReceipt: true,
id: msg.sid,
originId: msg.originId,
sfs: StatelessFileSharingData(
FileMetadataData(
mediaType: job.mime,
size: stat.size,
name: pathlib.basename(job.path),
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
<StatelessFileSharingSource>[source],
),
shouldEncrypt: job.encryptMap[recipient]!,
funReplacement: oldSid,
),
);
_log.finest('Sent message with file upload for ${job.path} to $recipient');
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
if (isMultiMedia) {
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
unawaited(_copyFile(job));
}
}
} }
await _pickNextUploadTask(); await _pickNextUploadTask();
@@ -350,7 +336,6 @@ 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;
_log.finest('Downloading ${job.location.url} as $filename');
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess); final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
var downloadPath = downloadedPath; var downloadPath = downloadedPath;
@@ -360,13 +345,16 @@ class HttpFileTransferService {
downloadPath = pathlib.join(tempDir.path, filename); downloadPath = pathlib.join(tempDir.path, filename);
} }
dio.Response<dynamic>? response; _log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
int? downloadStatusCode;
try { try {
response = await dio.Dio().downloadUri( _log.finest('Beginning download...');
downloadStatusCode = await client.downloadFile(
Uri.parse(job.location.url), Uri.parse(job.location.url),
downloadPath, downloadPath,
onReceiveProgress: (count, total) { (total, current) {
final progress = count.toDouble() / total.toDouble(); final progress = current.toDouble() / total.toDouble();
sendEvent( sendEvent(
ProgressEvent( ProgressEvent(
id: job.mId, id: job.mId,
@@ -375,144 +363,155 @@ class HttpFileTransferService {
); );
}, },
); );
} on dio.DioError catch(err) { _log.finest('Download done...');
// TODO(PapaTutuWawa): React if we received an error that is not related to the } catch (err) {
// connection.
_log.finest('Failed to download: $err'); _log.finest('Failed to download: $err');
}
if (!isRequestOkay(downloadStatusCode)) {
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
await _fileDownloadFailed(job, fileDownloadFailedError); await _fileDownloadFailed(job, fileDownloadFailedError);
return; return;
} }
if (!isRequestOkay(response.statusCode)) { var integrityCheckPassed = true;
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}'); final conv = (await GetIt.I.get<ConversationService>()
await _fileDownloadFailed(job, fileDownloadFailedError); .getConversationByJid(job.conversationJid))!;
return; final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
} else { if (decryptionKeysAvailable) {
var integrityCheckPassed = true; // The file was downloaded and is now being decrypted
final conv = (await GetIt.I.get<ConversationService>() sendEvent(
.getConversationByJid(job.conversationJid))!; ProgressEvent(
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null; id: job.mId,
if (decryptionKeysAvailable) { ),
// The file was downloaded and is now being decrypted );
sendEvent(
ProgressEvent( try {
id: job.mId, final result = await GetIt.I.get<CryptographyService>().decryptFile(
), downloadPath,
downloadedPath,
encryptionTypeFromNamespace(job.location.encryptionScheme!),
job.location.key!,
job.location.iv!,
job.location.plaintextHashes ?? {},
job.location.ciphertextHashes ?? {},
); );
try { if (!result.decryptionOkay) {
final result = await GetIt.I.get<CryptographyService>().decryptFile( _log.warning('Failed to decrypt $downloadPath');
downloadPath,
downloadedPath,
encryptionTypeFromNamespace(job.location.encryptionScheme!),
job.location.key!,
job.location.iv!,
job.location.plaintextHashes ?? {},
job.location.ciphertextHashes ?? {},
);
if (!result.decryptionOkay) {
_log.warning('Failed to decrypt $downloadPath');
await _fileDownloadFailed(job, messageFailedToDecryptFile);
return;
}
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
} catch (ex) {
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
await _fileDownloadFailed(job, messageFailedToDecryptFile); await _fileDownloadFailed(job, messageFailedToDecryptFile);
return; return;
} }
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true)); integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
} catch (ex) {
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
await _fileDownloadFailed(job, messageFailedToDecryptFile);
return;
} }
// Check the MIME type unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
final notification = GetIt.I.get<NotificationsService>();
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
int? mediaWidth;
int? mediaHeight;
if (mime != null) {
if (mime.startsWith('image/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
// Find out the dimensions
// TODO(Unknown): Restrict to the library's supported file types
Size? size;
try {
size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
} catch (ex) {
_log.warning('Failed to get image size for $downloadedPath: $ex');
}
mediaWidth = size?.width;
mediaHeight = size?.height;
} else if (mime.startsWith('video/')) {
// TODO(Unknown): Also figure out the thumbnail size here
MoxplatformPlugin.media.scanFile(downloadedPath);
} else if (mime.startsWith('audio/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
}
}
final msg = await GetIt.I.get<MessageService>().updateMessage(
job.mId,
mediaUrl: downloadedPath,
mediaType: mime,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: File(downloadedPath).lengthSync(),
isFileUploadNotification: false,
warningType: integrityCheckPassed ?
null :
warningFileIntegrityCheckFailed,
errorType: conv.encrypted && !decryptionKeysAvailable ?
messageChatEncryptedButFileNot :
null,
isDownloading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
downloadedPath,
msg.timestamp,
conv.id,
job.mId,
mime: mime,
);
final newConv = conv.copyWith(
lastMessage: conv.lastMessage?.id == job.mId ?
msg :
conv.lastMessage,
sharedMedia: [
sharedMedium,
...conv.sharedMedia,
],
);
GetIt.I.get<ConversationService>().setConversation(newConv);
// Show a notification
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(newConv, msg, '');
}
sendEvent(ConversationUpdatedEvent(conversation: newConv));
} }
// Check the MIME type
final notification = GetIt.I.get<NotificationsService>();
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
int? mediaWidth;
int? mediaHeight;
if (mime != null) {
if (mime.startsWith('image/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
// Find out the dimensions
final imageSize = await getImageSizeFromPath(downloadedPath);
if (imageSize == null) {
_log.warning('Failed to get image size for $downloadedPath');
}
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();
} else if (mime.startsWith('video/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
/*
// Generate thumbnail
final thumbnailPath = await getVideoThumbnailPath(
downloadedPath,
job.conversationJid,
);
// Find out the dimensions
final imageSize = await getImageSizeFromPath(thumbnailPath);
if (imageSize == null) {
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
}
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();*/
} else if (mime.startsWith('audio/')) {
MoxplatformPlugin.media.scanFile(downloadedPath);
}
}
final msg = await GetIt.I.get<MessageService>().updateMessage(
job.mId,
mediaUrl: downloadedPath,
mediaType: mime,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: File(downloadedPath).lengthSync(),
isFileUploadNotification: false,
warningType: integrityCheckPassed ?
null :
warningFileIntegrityCheckFailed,
errorType: conv.encrypted && !decryptionKeysAvailable ?
messageChatEncryptedButFileNot :
null,
isDownloading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
downloadedPath,
msg.timestamp,
conv.id,
job.mId,
mime: mime,
);
final newConv = conv.copyWith(
lastMessage: conv.lastMessage?.id == job.mId ?
msg :
conv.lastMessage,
sharedMedia: [
sharedMedium,
...conv.sharedMedia,
],
);
GetIt.I.get<ConversationService>().setConversation(newConv);
// Show a notification
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(newConv, msg, '');
}
sendEvent(ConversationUpdatedEvent(conversation: newConv));
// Free the download resources for the next one // Free the download resources for the next one
await _pickNextDownloadTask(); await _pickNextDownloadTask();
} }
Future<void> _pickNextDownloadTask() async { Future<void> _pickNextDownloadTask() async {
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
await _downloadLock.synchronized(() async { await _downloadLock.synchronized(() async {
if (_downloadQueue.isNotEmpty) { if (_downloadQueue.isNotEmpty) {
_currentDownloadJob = _downloadQueue.removeFirst(); _currentDownloadJob = _downloadQueue.removeFirst();
unawaited(_performFileDownload(_currentDownloadJob!));
// Only download if we have a connection
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
unawaited(_performFileDownload(_currentDownloadJob!));
}
} else { } else {
_currentDownloadJob = null; _currentDownloadJob = null;
} }

View File

@@ -43,6 +43,7 @@ class MessageService {
String sid, String sid,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
bool containsNoStore,
{ {
String? srcUrl, String? srcUrl,
String? key, String? key,
@@ -63,6 +64,10 @@ class MessageService {
bool isDownloading = false, bool isDownloading = false,
bool isUploading = false, bool isUploading = false,
int? mediaSize, int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
} }
) async { ) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData( final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
@@ -74,6 +79,7 @@ class MessageService {
sid, sid,
isFileUploadNotification, isFileUploadNotification,
encrypted, encrypted,
containsNoStore,
srcUrl: srcUrl, srcUrl: srcUrl,
key: key, key: key,
iv: iv, iv: iv,
@@ -93,6 +99,10 @@ class MessageService {
isUploading: isUploading, isUploading: isUploading,
isDownloading: isDownloading, isDownloading: isDownloading,
mediaSize: mediaSize, mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
); );
// Only update the cache if the conversation already has been loaded. This prevents // Only update the cache if the conversation already has been loaded. This prevents
@@ -115,6 +125,17 @@ class MessageService {
); );
} }
Future<Message?> getMessageByStanzaOrOriginId(String conversationJid, String id) async {
if (!_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid);
}
return firstWhereOrNull(
_messageCache[conversationJid]!,
(message) => message.sid == id || message.originId == id,
);
}
Future<Message?> getMessageById(String conversationJid, int id) async { Future<Message?> getMessageById(String conversationJid, int id) async {
if (!_messageCache.containsKey(conversationJid)) { if (!_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid); await getMessagesForJid(conversationJid);
@@ -151,6 +172,8 @@ class MessageService {
Object? sid = notSpecified, Object? sid = notSpecified,
Object? thumbnailData = notSpecified, Object? thumbnailData = notSpecified,
bool? isRetracted, bool? isRetracted,
bool? isEdited,
Object? reactions = notSpecified,
}) async { }) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage( final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
id, id,
@@ -177,6 +200,8 @@ class MessageService {
isRetracted: isRetracted, isRetracted: isRetracted,
isMedia: isMedia, isMedia: isMedia,
thumbnailData: thumbnailData, thumbnailData: thumbnailData,
isEdited: isEdited,
reactions: reactions,
); );
if (_messageCache.containsKey(newMessage.conversationJid)) { if (_messageCache.containsKey(newMessage.conversationJid)) {

View File

@@ -4,15 +4,14 @@ import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
class MoxxyOmemoManager extends OmemoManager { class MoxxyOmemoManager extends BaseOmemoManager {
MoxxyOmemoManager() : super(); MoxxyOmemoManager() : super();
@override @override
Future<OmemoSessionManager> getSessionManager() async { Future<OmemoManager> getOmemoManager() async {
final os = GetIt.I.get<OmemoService>(); final os = GetIt.I.get<OmemoService>();
await os.ensureInitialized(); await os.ensureInitialized();
return os.omemoState; return os.omemoManager;
} }
@override @override

View File

@@ -12,7 +12,6 @@ import 'package:synchronized/synchronized.dart';
/// backoff. This means that we perform the random backoff only as long as we are /// 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. /// connected. Otherwise, we idle until we have a connection again.
class MoxxyReconnectionPolicy extends ReconnectionPolicy { class MoxxyReconnectionPolicy extends ReconnectionPolicy {
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime }) MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
: _isTesting = isTesting, : _isTesting = isTesting,
_timerLock = Lock(), _timerLock = Lock(),
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
// Cancel the timer if it was running // Cancel the timer if it was running
await _stopTimer(); await _stopTimer();
await setIsReconnecting(false); await setIsReconnecting(false);
triggerConnectionLost!(); await triggerConnectionLost!();
} else if (regained && shouldReconnect) { } else if (regained && shouldReconnect) {
// We should reconnect // We should reconnect
_log.finest('Network regained. Attempting reconnection...'); _log.finest('Network regained. Attempting reconnection...');

View File

@@ -1,21 +1,91 @@
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/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart'; import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
class MoxxyRosterManager extends RosterManager { class MoxxyRosterStateManager extends BaseRosterStateManager {
@override @override
Future<void> commitLastRosterVersion(String version) async { Future<RosterCacheLoadResult> loadRosterCache() async {
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith( final rs = GetIt.I.get<RosterService>();
lastRosterVersion: version, return RosterCacheLoadResult(
),); (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
(await rs.getRoster()).map((item) => XmppRosterItem(
jid: item.jid,
name: item.title,
subscription: item.subscription,
ask: item.ask.isEmpty ? null : item.ask,
groups: item.groups,
),).toList(),
);
} }
@override @override
Future<void> loadLastRosterVersion() async { Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion; final rs = GetIt.I.get<RosterService>();
if (ver != null) { final xs = GetIt.I.get<XmppService>();
setRosterVersion(ver); await xs.modifyXmppState((state) => state.copyWith(
lastRosterVersion: version,
),);
// Remove stale items
for (final jid in removed) {
await rs.removeRosterItemByJid(jid);
} }
// Create new roster items
final rosterAdded = List<RosterItem>.empty(growable: true);
for (final item in added) {
rosterAdded.add(
await rs.addRosterItemFromData(
'',
'',
item.jid,
item.name ?? item.jid.split('@').first,
item.subscription,
item.ask ?? '',
false,
null,
null,
null,
groups: item.groups,
),
);
// TODO(PapaTutuWawa): Fetch the avatar
}
// Update modified items
final rosterModified = List<RosterItem>.empty(growable: true);
for (final item in modified) {
final ritem = await rs.getRosterItemByJid(item.jid);
if (ritem == null) {
//_log.warning('Could not find roster item with JID $jid during update');
continue;
}
rosterModified.add(
await rs.updateRosterItem(
ritem.id,
title: item.name,
subscription: item.subscription,
ask: item.ask,
groups: item.groups,
),
);
}
// Tell the UI
// TODO(Unknown): This may not be the cleanest place to put it
sendEvent(
RosterDiffEvent(
added: rosterAdded,
modified: rosterModified,
removed: removed,
),
);
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/events.dart'; import 'package:moxxyv2/service/events.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';
@@ -85,21 +86,36 @@ class NotificationsService {
/// 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 {
// TODO(Unknown): Keep track of notifications to create a summary notification
// 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
final body = m.isMedia ? String body;
mimeTypeToEmoji(m.mediaType) : if (m.stickerPackId != null) {
m.body; body = t.messages.sticker;
} else if (m.isMedia) {
body = mimeTypeToEmoji(m.mediaType);
} else {
body = m.body;
}
final css = GetIt.I.get<ContactsService>();
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
final title = contactIntegrationEnabled ?
c.contactDisplayName ?? c.title :
c.title;
final avatarPath = contactIntegrationEnabled ?
c.contactAvatarPath ?? c.avatarUrl :
c.avatarUrl;
await AwesomeNotifications().createNotification( await AwesomeNotifications().createNotification(
content: NotificationContent( content: NotificationContent(
id: m.id, id: m.id,
groupKey: c.jid, groupKey: c.jid,
channelKey: _messageChannelKey, channelKey: _messageChannelKey,
summary: c.title, summary: title,
title: c.title, title: title,
body: body, body: body,
largeIcon: c.avatarUrl.isNotEmpty ? 'file://${c.avatarUrl}' : null, largeIcon: avatarPath.isNotEmpty ?
'file://$avatarPath' :
null,
notificationLayout: m.isThumbnailable ? notificationLayout: m.isThumbnailable ?
NotificationLayout.BigPicture : NotificationLayout.BigPicture :
NotificationLayout.Messaging, NotificationLayout.Messaging,
@@ -108,8 +124,8 @@ class NotificationsService {
payload: <String, String>{ payload: <String, String>{
'conversationJid': c.jid, 'conversationJid': c.jid,
'sid': m.sid, 'sid': m.sid,
'title': c.title, 'title': title,
'avatarUrl': c.avatarUrl, 'avatarUrl': avatarPath,
}, },
), ),
actionButtons: [ actionButtons: [

View File

@@ -1,13 +1,5 @@
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
Future<OmemoSessionManager> generateNewIdentityImpl(String jid) async { Future<OmemoDevice> generateNewIdentityImpl(String jid) async {
return OmemoSessionManager.generateNewIdentity( return OmemoDevice.generateNewDevice(jid);
jid,
MoxxyBTBVTrustManager(
<RatchetMapKey, BTBVTrustState>{},
<RatchetMapKey, bool>{},
<String, List<int>>{},
),
);
} }

View File

@@ -4,16 +4,21 @@ 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:hex/hex.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.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/shared/models/omemo_device.dart'; import 'package:moxxyv2/service/omemo/types.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
class OmemoDoubleRatchetWrapper { class OmemoDoubleRatchetWrapper {
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid); OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
final OmemoDoubleRatchet ratchet; final OmemoDoubleRatchet ratchet;
final int id; final int id;
@@ -21,14 +26,14 @@ class OmemoDoubleRatchetWrapper {
} }
class OmemoService { class OmemoService {
final Logger _log = Logger('OmemoService'); final Logger _log = Logger('OmemoService');
bool _initialized = false; bool _initialized = false;
final Lock _lock = Lock(); final Lock _lock = Lock();
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>(); final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
final Map<String, Map<int, String>> _fingerprintCache = {};
late OmemoSessionManager omemoState; late OmemoManager omemoManager;
Future<void> initializeIfNeeded(String jid) async { Future<void> initializeIfNeeded(String jid) async {
final done = await _lock.synchronized(() => _initialized); final done = await _lock.synchronized(() => _initialized);
@@ -36,44 +41,73 @@ class OmemoService {
final db = GetIt.I.get<DatabaseService>(); final db = GetIt.I.get<DatabaseService>();
final device = await db.loadOmemoDevice(jid); final device = await db.loadOmemoDevice(jid);
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
final deviceList = <String, List<int>>{};
if (device == null) { if (device == null) {
_log.info('No OMEMO marker found. Generating OMEMO identity...'); _log.info('No OMEMO marker found. Generating OMEMO identity...');
// Generate the identity in the background
omemoState = await compute(generateNewIdentityImpl, jid);
await commitDevice(await omemoState.getDevice());
await commitDeviceMap(<String, List<int>>{});
await commitTrustManager(await omemoState.trustManager.toJson());
} else { } else {
_log.info('OMEMO marker found. Restoring OMEMO state...'); _log.info('OMEMO marker found. Restoring OMEMO state...');
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) { for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id); final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet; ratchetMap[key] = ratchet.ratchet;
} }
final db = GetIt.I.get<DatabaseService>(); deviceList.addAll(await db.loadOmemoDeviceList());
omemoState = OmemoSessionManager(
device,
await db.loadOmemoDeviceList(),
ratchetMap,
await loadTrustManager(),
);
} }
omemoState.eventStream.listen((event) async { 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) { if (event is RatchetModifiedEvent) {
await GetIt.I.get<DatabaseService>().saveRatchet( await GetIt.I.get<DatabaseService>().saveRatchet(
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid), OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
); );
} else if (event is DeviceMapModifiedEvent) {
await commitDeviceMap(event.map); 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) { } else if (event is DeviceModifiedEvent) {
await commitDevice(event.device); await commitDevice(event.device);
// Publish it // Publish it
await GetIt.I.get<XmppConnection>() await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)! .getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
.publishBundle(await event.device.toBundle()); .publishBundle(await event.device.toBundle());
} }
}); });
@@ -88,32 +122,63 @@ class OmemoService {
}); });
} }
Future<OmemoDevice> regenerateDevice(String jid) async { /// 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 // Prevent access to the session manager as it is (mostly) guarded ensureInitialized
await _lock.synchronized(() { await _lock.synchronized(() {
_initialized = false; _initialized = false;
}); });
_log.info('No OMEMO marker found. Generating OMEMO identity...'); _log.info('No OMEMO marker found. Generating OMEMO identity...');
final oldId = await omemoState.getDeviceId(); final oldId = await omemoManager.getDeviceId();
// Clear the database // Clear the database
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables(); await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
// Regenerate the identity in the background // Regenerate the identity in the background
omemoState = await compute(generateNewIdentityImpl, jid); final device = await compute(generateNewIdentityImpl, jid);
await omemoManager.replaceDevice(device);
await commitDevice(await omemoState.getDevice()); await commitDevice(device);
await commitDeviceMap(<String, List<int>>{}); await commitDeviceMap(<String, List<int>>{});
await commitTrustManager(await omemoState.trustManager.toJson()); await commitTrustManager(await omemoManager.trustManager.toJson());
// Remove the old device // Remove the old device
final omemo = GetIt.I.get<XmppConnection>() final omemo = GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)!; .getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
await omemo.deleteDevice(oldId); await omemo.deleteDevice(oldId);
// Publish the new one // Publish the new one
await omemo.publishBundle(await omemoState.getDeviceBundle()); await omemo.publishBundle(await omemoManager.getDeviceBundle());
// Allow access again // Allow access again
await _lock.synchronized(() { await _lock.synchronized(() {
@@ -126,7 +191,7 @@ class OmemoService {
}); });
// Return the OmemoDevice // Return the OmemoDevice
return OmemoDevice( return model.OmemoDevice(
await getDeviceFingerprint(), await getDeviceFingerprint(),
true, true,
true, true,
@@ -157,7 +222,7 @@ class OmemoService {
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap); await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
} }
Future<void> commitDevice(Device device) async { Future<void> commitDevice(OmemoDevice device) async {
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device); await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
} }
@@ -168,55 +233,108 @@ class OmemoService {
await ensureInitialized(); await ensureInitialized();
_log.finest('publishDeviceIfNeeded: Done'); _log.finest('publishDeviceIfNeeded: Done');
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<moxxmpp.XmppConnection>();
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!; final omemo = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
final dm = conn.getManagerById<DiscoManager>(discoManager)!; final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
final bareJid = conn.getConnectionSettings().jid.toBare(); final bareJid = conn.getConnectionSettings().jid.toBare();
final device = await omemoState.getDevice(); final device = await omemoManager.getDevice();
final bundlesRaw = await dm.discoItemsQuery( final bundlesRaw = await dm.discoItemsQuery(
bareJid.toString(), bareJid.toString(),
node: omemoBundlesXmlns, node: moxxmpp.omemoBundlesXmlns,
); );
if (bundlesRaw.isType<DiscoError>()) { if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
await omemo.publishBundle(await device.toBundle()); await omemo.publishBundle(await device.toBundle());
return bundlesRaw.get<DiscoError>(); return bundlesRaw.get<moxxmpp.DiscoError>();
} }
final bundleIds = bundlesRaw final bundleIds = bundlesRaw
.get<List<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<OmemoError>()) return result.get<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<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<OmemoError>()) return result.get<OmemoError>(); if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
return null; return null;
} }
return null; return null;
} }
Future<List<OmemoDevice>> getOmemoKeysForJid(String jid) async { Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
final bareJid = jid.toBare().toString();
final allDevicesRaw = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
.retrieveDeviceBundles(jid);
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
final map = <int, String>{};
final items = List<OmemoCacheTriple>.empty(growable: true);
for (final device in allDevices) {
final curveIk = await device.ik.toCurve25519();
final fingerprint = HEX.encode(await curveIk.getBytes());
map[device.id] = fingerprint;
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
}
// Cache them in memory
_fingerprintCache[bareJid] = map;
// Cache them in the database
await 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();
final fingerprints = await omemoState.getHexFingerprintsForJid(jid);
final keys = List<OmemoDevice>.empty(growable: true); // Get finger prints if we have to
for (final fp in fingerprints) { 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( keys.add(
OmemoDevice( model.OmemoDevice(
fp.fingerprint, _fingerprintCache[jid]![deviceId]!,
await omemoState.trustManager.isTrusted(jid, fp.deviceId), await tm.isTrusted(jid, deviceId),
// TODO(Unknown): Allow verifying OMEMO keys trustMap[deviceId] == BTBVTrustState.verified,
false, await tm.isEnabled(jid, deviceId),
await omemoState.trustManager.isEnabled(jid, fp.deviceId), deviceId,
fp.deviceId,
), ),
); );
} }
@@ -225,7 +343,6 @@ class OmemoService {
} }
Future<void> commitTrustManager(Map<String, dynamic> json) async { Future<void> commitTrustManager(Map<String, dynamic> json) async {
await GetIt.I.get<DatabaseService>().saveTrustCache( await GetIt.I.get<DatabaseService>().saveTrustCache(
json['trust']! as Map<String, int>, json['trust']! as Map<String, int>,
); );
@@ -248,58 +365,70 @@ class OmemoService {
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async { Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
await ensureInitialized(); await ensureInitialized();
await omemoState.trustManager.setEnabled(jid, deviceId, enabled); await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
} }
Future<void> removeAllSessions(String jid) async { Future<void> removeAllSessions(String jid) async {
await ensureInitialized(); await ensureInitialized();
await omemoState.removeAllRatchets(jid); await omemoManager.removeAllRatchets(jid);
} }
Future<int> getDeviceId() async { Future<int> getDeviceId() async {
await ensureInitialized(); await ensureInitialized();
return omemoState.getDeviceId(); return omemoManager.getDeviceId();
} }
Future<String> getDeviceFingerprint() async { Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
}
/// Returns a list of OmemoDevices for devices we have sessions with and other devices /// Returns a list of OmemoDevices for devices we have sessions with and other devices
/// published on [ownJid]'s devices PubSub node. /// published on [ownJid]'s devices PubSub node.
/// Note that the list is made so that the current device is excluded. /// Note that the list is made so that the current device is excluded.
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async { Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
final conn = GetIt.I.get<XmppConnection>();
final ownId = await getDeviceId(); final ownId = await getDeviceId();
final keys = List<OmemoDevice>.from( final keys = List<model.OmemoDevice>.from(
await getOmemoKeysForJid(ownJid.toString()), await getOmemoKeysForJid(ownJid.toString()),
); );
final bareJid = ownJid.toBare().toString();
// TODO(PapaTutuWawa): This should be cached in the database and only requested if // Get fingerprints if we have to
// it's not cached. await _loadOrFetchFingerprints(ownJid);
final allDevicesRaw = await conn.getManagerById<OmemoManager>(omemoManager)!
.retrieveDeviceBundles(ownJid);
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
for (final device in allDevices) { final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
// All devices that are publishes that is not the current device final trustMap = await tm.getDevicesTrust(bareJid);
if (device.id == ownId) continue;
final curveIk = await device.ik.toCurve25519(); for (final deviceId in _fingerprintCache[bareJid]!.keys) {
if (deviceId == ownId) continue;
keys.add( if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
OmemoDevice(
HEX.encode(await curveIk.getBytes()), final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
false, keys.add(
false, model.OmemoDevice(
false, fingerprint,
device.id, await tm.isTrusted(bareJid, deviceId),
hasSessionWith: false, trustMap[deviceId] == BTBVTrustState.verified,
), await tm.isEnabled(bareJid, deviceId),
); deviceId,
} hasSessionWith: false,
),
);
} }
return keys; return keys;
} }
Future<void> verifyDevice(int deviceId, String jid) async {
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
await tm.setDeviceTrust(
jid,
deviceId,
BTBVTrustState.verified,
);
}
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
void onNewConnection() {
if (_initialized) {
omemoManager.onNewConnection();
}
}
} }

View File

@@ -0,0 +1,6 @@
class OmemoCacheTriple {
const OmemoCacheTriple(this.jid, this.deviceId, this.fingerprint);
final String jid;
final int deviceId;
final String fingerprint;
}

View File

@@ -1,189 +1,28 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/not_specified.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';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
return (i) => i.jid == jid;
}
typedef AddRosterItemFunction = Future<RosterItem> Function(
String avatarUrl,
String avatarHash,
String jid,
String title,
String subscription,
String ask,
{
List<String> groups,
}
);
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
int id, {
String? avatarUrl,
String? avatarHash,
String? title,
String? subscription,
String? ask,
List<String>? groups,
}
);
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
/// Compare the local roster with the roster we received either by request or by push.
/// Returns a diff between the roster before and after the request or the push.
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
Future<RosterDiffEvent> processRosterDiff(
List<RosterItem> currentRoster,
List<XmppRosterItem> remoteRoster,
bool isRosterPush,
AddRosterItemFunction addRosterItemFromData,
UpdateRosterItemFunction updateRosterItem,
RemoveRosterItemFunction removeRosterItemByJid,
GetConversationFunction getConversationByJid,
SendEventFunction _sendEvent,
) async {
final removed = List<String>.empty(growable: true);
final modified = List<RosterItem>.empty(growable: true);
final added = List<RosterItem>.empty(growable: true);
for (final item in remoteRoster) {
if (isRosterPush) {
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
if (litem != null) {
if (item.subscription == 'remove') {
// We have the item locally but it has been removed
await removeRosterItemByJid(item.jid);
removed.add(item.jid);
continue;
}
// Item has been modified
final newItem = await updateRosterItem(
litem.id,
subscription: item.subscription,
title: item.name,
ask: item.ask,
groups: item.groups,
);
modified.add(newItem);
// Check if we have a conversation that we need to modify
final conv = await getConversationByJid(item.jid);
if (conv != null) {
_sendEvent(
ConversationUpdatedEvent(
conversation: conv.copyWith(subscription: item.subscription),
),
);
}
} else {
// Item does not exist locally
if (item.subscription == 'remove') {
// Item has been removed but we don't have it locally
removed.add(item.jid);
} else {
// Item has been added and we don't have it locally
final newItem = await addRosterItemFromData(
'',
'',
item.jid,
item.name ?? item.jid.split('@')[0],
item.subscription,
item.ask ?? '',
groups: item.groups,
);
added.add(newItem);
}
}
} else {
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
if (litem != null) {
// Item is modified
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
final modifiedItem = await updateRosterItem(
litem.id,
title: item.name,
subscription: item.subscription,
groups: item.groups,
);
modified.add(modifiedItem);
// Check if we have a conversation that we need to modify
final conv = await getConversationByJid(litem.jid);
if (conv != null) {
_sendEvent(
ConversationUpdatedEvent(
conversation: conv.copyWith(subscription: item.subscription),
),
);
}
}
} else {
// Item is new
added.add(await addRosterItemFromData(
'',
'',
item.jid,
item.jid.split('@')[0],
item.subscription,
item.ask ?? '',
groups: item.groups,
),);
}
}
}
if (!isRosterPush) {
for (final item in currentRoster) {
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
if (ritem == null) {
await removeRosterItemByJid(item.jid);
removed.add(item.jid);
}
// We don't handle the modification case here as that is covered by the huge
// loop above
}
}
return RosterDiffEvent(
added: added,
modified: modified,
removed: removed,
);
}
class RosterService { class RosterService {
RosterService() : _log = Logger('RosterService');
RosterService() Map<String, RosterItem>? _rosterCache;
: _rosterCache = HashMap(),
_rosterLoaded = false,
_log = Logger('RosterService');
final HashMap<String, RosterItem> _rosterCache;
bool _rosterLoaded;
final Logger _log; final Logger _log;
Future<bool> isInRoster(String jid) async { Future<void> _loadRosterIfNeeded() async {
if (!_rosterLoaded) { if (_rosterCache == null) {
await loadRosterFromDatabase(); await loadRosterFromDatabase();
} }
}
return _rosterCache.containsKey(jid); Future<bool> isInRoster(String jid) async {
await _loadRosterIfNeeded();
return _rosterCache!.containsKey(jid);
} }
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache. /// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
@@ -194,6 +33,10 @@ class RosterService {
String title, String title,
String subscription, String subscription,
String ask, String ask,
bool pseudoRosterItem,
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
{ {
List<String> groups = const [], List<String> groups = const [],
} }
@@ -205,11 +48,15 @@ class RosterService {
title, title,
subscription, subscription,
ask, ask,
pseudoRosterItem,
contactId,
contactAvatarPath,
contactDisplayName,
groups: groups, groups: groups,
); );
// Update the cache // Update the cache
_rosterCache[item.jid] = item; _rosterCache![item.jid] = item;
return item; return item;
} }
@@ -222,7 +69,11 @@ class RosterService {
String? title, String? title,
String? subscription, String? subscription,
String? ask, String? ask,
Object pseudoRosterItem = notSpecified,
List<String>? groups, List<String>? groups,
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
} }
) async { ) async {
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem( final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
@@ -232,30 +83,34 @@ class RosterService {
title: title, title: title,
subscription: subscription, subscription: subscription,
ask: ask, ask: ask,
pseudoRosterItem: pseudoRosterItem,
groups: groups, groups: groups,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
); );
// Update cache // Update cache
_rosterCache[newItem.jid] = newItem; _rosterCache![newItem.jid] = newItem;
return newItem; return newItem;
} }
/// Wrapper around [DatabaseService]'s removeRosterItem. /// Wrapper around [DatabaseService]'s removeRosterItem.
Future<void> removeRosterItem(int id) async { Future<void> removeRosterItem(int id) async {
// NOTE: This call ensures that _rosterCache != null
await GetIt.I.get<DatabaseService>().removeRosterItem(id); await GetIt.I.get<DatabaseService>().removeRosterItem(id);
assert(_rosterCache != null, '_rosterCache must be non-null');
/// Update cache /// Update cache
_rosterCache.removeWhere((_, value) => value.id == id); _rosterCache!.removeWhere((_, value) => value.id == id);
} }
/// Removes a roster item from the database based on its JID. /// Removes a roster item from the database based on its JID.
Future<void> removeRosterItemByJid(String jid) async { Future<void> removeRosterItemByJid(String jid) async {
if (!_rosterLoaded) { await _loadRosterIfNeeded();
await loadRosterFromDatabase();
}
for (final item in _rosterCache.values) { for (final item in _rosterCache!.values) {
if (item.jid == jid) { if (item.jid == jid) {
await removeRosterItem(item.id); await removeRosterItem(item.id);
return; return;
@@ -265,17 +120,14 @@ class RosterService {
/// Returns the entire roster /// Returns the entire roster
Future<List<RosterItem>> getRoster() async { Future<List<RosterItem>> getRoster() async {
if (!_rosterLoaded) { await _loadRosterIfNeeded();
await loadRosterFromDatabase(); return _rosterCache!.values.toList();
}
return _rosterCache.values.toList();
} }
/// Returns the roster item with jid [jid] if it exists. Null otherwise. /// Returns the roster item with jid [jid] if it exists. Null otherwise.
Future<RosterItem?> getRosterItemByJid(String jid) async { Future<RosterItem?> getRosterItemByJid(String jid) async {
if (await isInRoster(jid)) { if (await isInRoster(jid)) {
return _rosterCache[jid]; return _rosterCache![jid];
} }
return null; return null;
@@ -286,9 +138,9 @@ class RosterService {
Future<List<RosterItem>> loadRosterFromDatabase() async { Future<List<RosterItem>> loadRosterFromDatabase() async {
final items = await GetIt.I.get<DatabaseService>().loadRosterItems(); final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
_rosterLoaded = true; _rosterCache = <String, RosterItem>{};
for (final item in items) { for (final item in items) {
_rosterCache[item.jid] = item; _rosterCache![item.jid] = item;
} }
return items; return items;
@@ -298,6 +150,8 @@ class RosterService {
/// and, if it was successful, create the database entry. Returns the /// and, if it was successful, create the database entry. Returns the
/// [RosterItem] model object. /// [RosterItem] model object.
Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async { Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async {
final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid);
final item = await addRosterItemFromData( final item = await addRosterItemFromData(
avatarUrl, avatarUrl,
avatarHash, avatarHash,
@@ -305,6 +159,10 @@ class RosterService {
title, title,
'none', 'none',
'', '',
false,
contactId,
await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId),
); );
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title); final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
if (!result) { if (!result) {
@@ -337,59 +195,6 @@ class RosterService {
return false; return false;
} }
Future<void> requestRoster() async {
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
Result<RosterRequestResult?, RosterError> result;
if (roster.rosterVersioningAvailable()) {
_log.fine('Stream supports roster versioning');
result = await roster.requestRosterPushes();
_log.fine('Requesting roster pushes done');
} else {
_log.fine('Stream does not support roster versioning');
result = await roster.requestRoster();
}
if (result.isType<RosterError>()) {
_log.warning('Failed to request roster');
return;
}
final value = result.get<RosterRequestResult?>();
if (value != null) {
final currentRoster = await getRoster();
sendEvent(
await processRosterDiff(
currentRoster,
value.items,
false,
addRosterItemFromData,
updateRosterItem,
removeRosterItemByJid,
GetIt.I.get<ConversationService>().getConversationByJid,
sendEvent,
),
);
}
}
/// Handles a roster push.
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
final item = event.item;
final currentRoster = await getRoster();
sendEvent(
await processRosterDiff(
currentRoster,
[ item ],
true,
addRosterItemFromData,
updateRosterItem,
removeRosterItemByJid,
GetIt.I.get<ConversationService>().getConversationByJid,
sendEvent,
),
);
}
Future<void> acceptSubscriptionRequest(String jid) async { Future<void> acceptSubscriptionRequest(String jid) async {
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid); GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
} }

View File

@@ -13,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart'; import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart'; import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart'; import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contacts.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/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
@@ -30,6 +31,7 @@ import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.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/stickers.dart';
import 'package:moxxyv2/service/xmpp.dart'; import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart'; import 'package:moxxyv2/shared/eventhandler.dart';
@@ -153,10 +155,13 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<MessageService>(MessageService()); GetIt.I.registerSingleton<MessageService>(MessageService());
GetIt.I.registerSingleton<OmemoService>(OmemoService()); GetIt.I.registerSingleton<OmemoService>(OmemoService());
GetIt.I.registerSingleton<CryptographyService>(CryptographyService()); GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
GetIt.I.registerSingleton<ContactsService>(ContactsService());
GetIt.I.registerSingleton<StickersService>(StickersService());
final xmpp = XmppService(); final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp); GetIt.I.registerSingleton<XmppService>(xmpp);
await GetIt.I.get<NotificationsService>().init(); await GetIt.I.get<NotificationsService>().init();
await GetIt.I.get<ContactsService>().init();
if (!kDebugMode) { if (!kDebugMode) {
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled; final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
@@ -173,7 +178,7 @@ Future<void> entrypoint() async {
)..registerManagers([ )..registerManagers([
MoxxyStreamManagementManager(), MoxxyStreamManagementManager(),
MoxxyDiscoManager(), MoxxyDiscoManager(),
MoxxyRosterManager(), RosterManager(MoxxyRosterStateManager()),
MoxxyOmemoManager(), MoxxyOmemoManager(),
PingManager(), PingManager(),
MessageManager(), MessageManager(),
@@ -197,6 +202,9 @@ Future<void> entrypoint() async {
CryptographicHashManager(), CryptographicHashManager(),
DelayedDeliveryManager(), DelayedDeliveryManager(),
MessageRetractionManager(), MessageRetractionManager(),
LastMessageCorrectionManager(),
MessageReactionsManager(),
StickersManager(),
]) ])
..registerFeatureNegotiators([ ..registerFeatureNegotiators([
ResourceBindingNegotiator(), ResourceBindingNegotiator(),
@@ -244,6 +252,7 @@ Future<void> entrypoint() async {
sendEvent(ServiceReadyEvent()); sendEvent(ServiceReadyEvent());
} }
@pragma('vm:entry-point')
Future<void> receiveUIEvent(Map<String, dynamic>? data) async { Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data); await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
} }

344
lib/service/stickers.dart Normal file
View File

@@ -0,0 +1,344 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p;
class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService');
Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
if (pack == null) return null;
_stickerPacks[id] = pack;
return _stickerPacks[id];
}
Future<Sticker?> getStickerByHashKey(String packId, String hashKey) async {
final pack = await getStickerPackById(packId);
if (pack == null) return null;
return firstWhereOrNull<Sticker>(
pack.stickers,
(sticker) => sticker.hashKey == hashKey,
);
}
Future<List<StickerPack>> getStickerPacks() async {
if (_stickerPacks.isEmpty) {
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
for (final pack in packs) {
_stickerPacks[pack.id] = pack;
}
}
return _stickerPacks.values.toList();
}
Future<void> removeStickerPack(String id) async {
final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist');
// Delete the files
final stickerPackPath = await getStickerPackPath(
pack!.hashAlgorithm,
pack.hashValue,
);
final stickerPackDir = Directory(stickerPackPath);
if (stickerPackDir.existsSync()) {
unawaited(
stickerPackDir.delete(
recursive: true,
),
);
}
// Remove from the database
await GetIt.I.get<DatabaseService>().removeStickerPackById(id);
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub
final state = await GetIt.I.get<XmppService>().getXmppState();
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.retractStickerPack(moxxmpp.JID.fromString(state.jid!), id);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to retract sticker pack');
}
}
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final state = await GetIt.I.get<XmppService>().getXmppState();
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.publishStickerPack(
moxxmpp.JID.fromString(state.jid!),
pack,
accessModel: prefs.isStickersNodePublic ?
'open' :
null,
);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to publish sticker pack');
}
}
/// Returns the path to the sticker pack with hash algorithm [algo] and hash [hash].
/// Ensures that the directory exists before returning.
Future<String> _getStickerPackPath(String algo, String hash) async {
final stickerDirPath = await getStickerPackPath(algo, hash);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
return stickerDirPath;
}
Future<void> importFromPubSubWithEvent(moxxmpp.JID jid, String stickerPackId) async {
final stickerPack = await importFromPubSub(jid, stickerPackId);
if (stickerPack == null) return;
sendEvent(
StickerPackAddedEvent(
stickerPack: stickerPack,
),
);
}
/// Takes the jid of the host [jid] and the id [stickerPackId] of the sticker pack
/// and tries to fetch and install it, including publishing on our own PubSub node.
///
/// On success, returns the installed StickerPack. On failure, returns null.
Future<StickerPack?> importFromPubSub(moxxmpp.JID jid, String stickerPackId) async {
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.fetchStickerPack(jid.toBare(), stickerPackId);
if (result.isType<moxxmpp.PubSubError>()) {
_log.warning('Failed to fetch sticker pack $jid:$stickerPackId');
return null;
}
final stickerPackRaw = StickerPack.fromMoxxmpp(
result.get<moxxmpp.StickerPack>(),
false,
);
// Install the sticker pack
return installFromPubSub(stickerPackRaw);
}
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
assert(!remotePack.local, 'Sticker pack must be remote');
final stickerPackPath = await _getStickerPackPath(
remotePack.hashAlgorithm,
remotePack.hashValue,
);
var success = true;
final stickers = List<Sticker>.from(remotePack.stickers);
for (var i = 0; i < stickers.length; i++) {
final sticker = stickers[i];
final stickerPath = p.join(
stickerPackPath,
sticker.hashes.values.first,
);
final downloadStatusCode = await downloadFile(
Uri.parse(sticker.urlSources.first),
stickerPath,
(_, __) {},
);
if (!isRequestOkay(downloadStatusCode)) {
_log.severe('Request not okay: $downloadStatusCode');
success = false;
break;
}
stickers[i] = sticker.copyWith(
path: stickerPath,
hashKey: getStickerHashKey(sticker.hashes),
);
}
if (!success) {
_log.severe('Import failed');
return null;
}
// Add the sticker pack to the database
final db = GetIt.I.get<DatabaseService>();
await db.addStickerPackFromData(remotePack);
// Add the stickers to the database
final stickersDb = List<Sticker>.empty(growable: true);
for (final sticker in stickers) {
stickersDb.add(
await db.addStickerFromData(
sticker.mediaType,
sticker.desc,
sticker.size,
sticker.width,
sticker.height,
sticker.hashes,
sticker.urlSources,
sticker.path,
remotePack.hashValue,
sticker.suggests,
),
);
}
// Publish but don't block
unawaited(
_publishStickerPack(remotePack.toMoxxmpp()),
);
return remotePack.copyWith(
stickers: stickersDb,
local: true,
);
}
/// Imports a sticker pack from [path].
/// The format is as follows:
/// - The file MUST be an uncompressed tar archive
/// - All files must be at the top level of the archive
/// - A file 'urn.xmpp.stickers.0.xml' must exist and must contain only the <pack /> element
/// - The File Metadata Elements must also contain a <name /> element
/// - The file referenced by the <name/> element must also exist on the archive's top level
Future<StickerPack?> importFromFile(String path) async {
final archiveBytes = await File(path).readAsBytes();
final archive = TarDecoder().decodeBytes(archiveBytes);
final metadata = archive.findFile('urn.xmpp.stickers.0.xml');
if (metadata == null) {
_log.severe('Invalid sticker pack: No metadata file');
return null;
}
final content = utf8.decode(metadata.content as List<int>);
final node = moxxmpp.XMLNode.fromString(content);
final packRaw = moxxmpp.StickerPack.fromXML(
'',
node,
hashAvailable: false,
);
if (packRaw.restricted) {
_log.severe('Invalid sticker pack: Restricted');
return null;
}
for (final sticker in packRaw.stickers) {
final filename = sticker.metadata.name;
if (filename == null) {
_log.severe('Invalid sticker pack: One sticker has no <name/>');
return null;
}
final stickerFile = archive.findFile(filename);
if (stickerFile == null) {
_log.severe('Invalid sticker pack: $filename does not exist in archive');
return null;
}
}
final pack = packRaw.copyWithId(
moxxmpp.HashFunction.sha256,
await packRaw.getHash(moxxmpp.HashFunction.sha256),
);
_log.finest('New sticker pack identifier: sha256:${pack.id}');
if (await getStickerPackById(pack.id) != null) {
_log.severe('Invalid sticker pack: Already exists');
return null;
}
final stickerDirPath = await getStickerPackPath(
pack.hashAlgorithm.toName(),
pack.hashValue,
);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
final db = GetIt.I.get<DatabaseService>();
// Create the sticker pack first
final stickerPack = StickerPack(
pack.hashValue,
pack.name,
pack.summary,
[],
pack.hashAlgorithm.toName(),
pack.hashValue,
pack.restricted,
true,
);
await db.addStickerPackFromData(stickerPack);
// Add all stickers
final stickers = List<Sticker>.empty(growable: true);
for (final sticker in pack.stickers) {
final filename = sticker.metadata.name!;
final stickerFile = archive.findFile(filename)!;
final stickerPath = p.join(stickerDirPath, filename);
await File(stickerPath).writeAsBytes(
stickerFile.content as List<int>,
);
stickers.add(
await db.addStickerFromData(
sticker.metadata.mediaType!,
sticker.metadata.desc!,
sticker.metadata.size!,
null,
null,
sticker.metadata.hashes,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
.toList(),
stickerPath,
pack.hashValue,
sticker.suggests,
),
);
}
final stickerPackWithStickers = stickerPack.copyWith(
stickers: stickers,
);
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info('Sticker pack ${stickerPack.id} successfully added to the database');
// Publish but don't block
unawaited(_publishStickerPack(pack));
return stickerPackWithStickers;
}
}

View File

@@ -3,8 +3,6 @@ import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:image_size_getter/file_input.dart';
import 'package:image_size_getter/image_size_getter.dart' as image_size;
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:moxlib/moxlib.dart'; import 'package:moxlib/moxlib.dart';
@@ -15,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart'; import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart'; import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart'; import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
@@ -29,12 +28,15 @@ 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/state.dart'; import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart'; import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:path/path.dart' as pathlib; import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -53,7 +55,6 @@ class XmppService {
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived), EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived), EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker), EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated), EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked), EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
EventTypeMatcher<MessageEvent>(_onMessage), EventTypeMatcher<MessageEvent>(_onMessage),
@@ -126,6 +127,49 @@ class XmppService {
/// Returns the JID of the chat that is currently opened. Null, if none is open. /// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid; String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Sends a message correction to [recipient] regarding the message with stanza id
/// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
/// database id. [chatState] can be optionally specified to also include a chat state
/// in the message.
///
/// This function handles updating the message and optionally the corresponding
/// conversation.
Future<void> sendMessageCorrection(int id, String newBody, String oldId, String recipient, ChatState? chatState) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
// Update the database
final msg = await ms.updateMessage(
id,
isEdited: true,
body: newBody,
);
sendEvent(MessageUpdatedEvent(message: msg));
final conv = await cs.getConversationByJid(msg.conversationJid);
if (conv != null && conv.lastMessage?.id == id) {
final newConv = await cs.updateConversation(
conv.id,
lastChangeTimestamp: timestamp,
lastMessage: msg,
);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
// Send the correction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: newBody,
lastMessageCorrectionId: oldId,
chatState: chatState,
),
);
}
/// Sends a message to JIDs in [recipients] with the body of [body]. /// Sends a message to JIDs in [recipients] with the body of [body].
Future<void> sendMessage({ Future<void> sendMessage({
@@ -134,6 +178,7 @@ class XmppService {
Message? quotedMessage, Message? quotedMessage,
String? commandId, String? commandId,
ChatState? chatState, ChatState? chatState,
sticker.Sticker? sticker,
}) async { }) async {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
@@ -149,12 +194,18 @@ class XmppService {
timestamp, timestamp,
conn.getConnectionSettings().jid.toString(), conn.getConnectionSettings().jid.toString(),
recipient, recipient,
false, sticker != null,
sid, sid,
false, false,
conversation!.encrypted, conversation!.encrypted,
// TODO(Unknown): Maybe make this depend on some setting
false,
originId: originId, originId: originId,
quoteId: quotedMessage?.sid, quoteId: quotedMessage?.sid,
stickerPackId: sticker?.stickerPackId,
stickerHashKey: sticker?.hashKey,
srcUrl: sticker?.urlSources.first,
mediaType: sticker?.mediaType,
); );
final newConversation = await cs.updateConversation( final newConversation = await cs.updateConversation(
conversation.id, conversation.id,
@@ -180,6 +231,27 @@ class XmppService {
quoteId: quotedMessage?.sid, quoteId: quotedMessage?.sid,
chatState: chatState, chatState: chatState,
shouldEncrypt: newConversation.encrypted, shouldEncrypt: newConversation.encrypted,
stickerPackId: sticker?.stickerPackId,
sfs: sticker == null ?
null :
StatelessFileSharingData(
FileMetadataData(
mediaType: sticker.mediaType,
width: sticker.width,
height: sticker.height,
desc: sticker.desc,
size: sticker.size,
thumbnails: [],
hashes: sticker.hashes,
),
sticker.urlSources
// ignore: unnecessary_lambdas
.map((s) => StatelessFileSharingUrlSource(s))
.toList(),
),
setOOBFallbackBody: sticker != null ?
false :
true,
), ),
); );
@@ -351,6 +423,7 @@ class XmppService {
// Create a new message // Create a new message
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final css = GetIt.I.get<ContactsService>();
final prefs = await GetIt.I.get<PreferencesService>().getPreferences(); final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Path -> Recipient -> Message // Path -> Recipient -> Message
@@ -375,13 +448,13 @@ class XmppService {
// TODO(Unknown): Do the same for videos // TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) { if (pathMime != null && pathMime.startsWith('image/')) {
try { final imageSize = await getImageSizeFromPath(path);
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path))); if (imageSize != null) {
dimensions[path] = Size( dimensions[path] = Size(
imageSize.width.toDouble(), imageSize.width,
imageSize.height.toDouble(), imageSize.height,
); );
} catch (ex) { } else {
_log.warning('Failed to get image dimensions for $path'); _log.warning('Failed to get image dimensions for $path');
} }
} }
@@ -395,6 +468,8 @@ class XmppService {
conn.generateId(), conn.generateId(),
false, false,
encrypt[recipient]!, encrypt[recipient]!,
// TODO(Unknown): Maybe make this depend on some setting
false,
mediaUrl: path, mediaUrl: path,
mediaType: pathMime, mediaType: pathMime,
originId: conn.generateId(), originId: conn.generateId(),
@@ -445,6 +520,7 @@ class XmppService {
} else { } else {
// Create conversation // Create conversation
final rosterItem = await rs.getRosterItemByJid(recipient); final rosterItem = await rs.getRosterItemByJid(recipient);
final contactId = await css.getContactIdForJid(recipient);
var newConversation = await cs.addConversationFromData( var newConversation = await cs.addConversationFromData(
// TODO(Unknown): Should we use the JID parser? // TODO(Unknown): Should we use the JID parser?
rosterItem?.title ?? recipient.split('@').first, rosterItem?.title ?? recipient.split('@').first,
@@ -456,6 +532,9 @@ class XmppService {
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
prefs.enableOmemoByDefault, prefs.enableOmemoByDefault,
contactId,
await css.getProfilePicturePathForJid(recipient),
await css.getContactDisplayName(contactId),
); );
sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id); sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
@@ -583,12 +662,28 @@ class XmppService {
_log.finest('Connection connected. Is resumed? ${event.resumed}'); _log.finest('Connection connected. Is resumed? ${event.resumed}');
unawaited(_initializeOmemoService(settings.jid.toString())); unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) { if (!event.resumed) {
// Reset the blocking service's cache
GetIt.I.get<BlocklistService>().onNewConnection();
// Reset the OMEMO cache
GetIt.I.get<OmemoService>().onNewConnection();
// Enable carbons
final carbonsResult = await connection
.getManagerById<CarbonsManager>(carbonsManager)!
.enableCarbons();
if (!carbonsResult) {
_log.warning('Failed to enable carbons');
}
// In section 5 of XEP-0198 it says that a client should not request the roster // In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption. // in case of a stream resumption.
await GetIt.I.get<RosterService>().requestRoster(); await connection
.getManagerById<RosterManager>(rosterManager)!
.requestRoster();
// TODO(Unknown): Once groupchats come into the equation, this gets trickier // TODO(Unknown): Once groupchats come into the equation, this gets trickier
final roster = await GetIt.I.get<RosterService>().getRoster(); final roster = await GetIt.I.get<RosterService>().getRoster();
for (final item in roster) { for (final item in roster) {
@@ -632,29 +727,27 @@ class XmppService {
if (!prefs.showSubscriptionRequests) return; if (!prefs.showSubscriptionRequests) return;
final css = GetIt.I.get<ContactsService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(event.from.toBare().toString()); final conversation = await cs.getConversationByJid(event.from.toBare().toString());
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
if (conversation != null) { if (conversation != null && !conversation.open) {
final newConversation = await cs.updateConversation(
conversation.id,
open: true,
lastChangeTimestamp: timestamp,
);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
} else {
// TODO(Unknown): Make it configurable if this should happen // TODO(Unknown): Make it configurable if this should happen
final bare = event.from.toBare(); final bare = event.from.toBare().toString();
final contactId = await css.getContactIdForJid(bare);
final conv = await cs.addConversationFromData( final conv = await cs.addConversationFromData(
bare.toString().split('@')[0], bare.split('@')[0],
null, null,
'', // TODO(Unknown): avatarUrl '', // TODO(Unknown): avatarUrl
bare.toString(), bare,
0, 0,
timestamp, timestamp,
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
prefs.enableOmemoByDefault, prefs.enableOmemoByDefault,
contactId,
await css.getProfilePicturePathForJid(bare),
await css.getContactDisplayName(contactId),
); );
sendEvent(ConversationAddedEvent(conversation: conv)); sendEvent(ConversationAddedEvent(conversation: conv));
@@ -788,7 +881,8 @@ class XmppService {
// that the message body and the OOB url are the same if the OOB url is not null. // that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFile != null return embeddedFile != null
&& Uri.parse(embeddedFile.url).scheme == 'https' && Uri.parse(embeddedFile.url).scheme == 'https'
&& implies(event.oob != null, event.body == event.oob?.url); && implies(event.oob != null, event.body == event.oob?.url)
&& event.stickerPackId == null;
} }
/// Handle a message retraction given the MessageEvent [event]. /// Handle a message retraction given the MessageEvent [event].
@@ -853,6 +947,144 @@ class XmppService {
// that mean that the message could not be delivered. // that mean that the message could not be delivered.
sendEvent(MessageUpdatedEvent(message: newMsg)); sendEvent(MessageUpdatedEvent(message: newMsg));
} }
Future<void> _handleMessageCorrection(MessageEvent event, String conversationJid) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final msg = await ms.getMessageByStanzaId(conversationJid, event.messageCorrectionId!);
if (msg == null) {
_log.warning('Received message correction for message ${event.messageCorrectionId} we cannot find.');
return;
}
// Check if the Jid is allowed to do correct the message
// TODO(Unknown): Maybe use the JID parser?
final bareSender = event.fromJid.toBare().toString();
if (msg.sender.split('/').first != bareSender) {
_log.warning('Received a message correction from $bareSender for message that is not sent by $bareSender');
return;
}
// Check if the message can be corrected
if (!msg.canEdit(true)) {
_log.warning('Received a message correction for a message that cannot be edited');
return;
}
final newMsg = await ms.updateMessage(
msg.id,
body: event.body,
isEdited: true,
);
sendEvent(MessageUpdatedEvent(message: newMsg));
final conv = await cs.getConversationByJid(msg.conversationJid);
if (conv != null && conv.lastMessage?.id == msg.id) {
final newConv = conv.copyWith(
lastMessage: newMsg,
);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
}
Future<void> _handleMessageReactions(MessageEvent event, String conversationJid) async {
final ms = GetIt.I.get<MessageService>();
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
final msg = await ms.getMessageByStanzaOrOriginId(
conversationJid,
event.messageReactions!.messageId,
);
if (msg == null) {
_log.warning('Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.');
return;
}
final state = await getXmppState();
final sender = event.fromJid.toBare().toString();
final isCarbon = sender == state.jid;
final reactions = List<Reaction>.from(msg.reactions);
final emojis = event.messageReactions!.emojis;
// Find out what emojis the sender has already sent
final sentEmojis = msg.reactions
.where((r) {
return isCarbon ?
r.reactedBySelf :
r.senders.contains(sender);
})
.map((r) => r.emoji)
.toList();
// Find out what reactions were removed
final removedEmojis = sentEmojis
.where((e) => !emojis.contains(e));
for (final emoji in emojis) {
final i = reactions.indexWhere((r) => r.emoji == emoji);
if (i == -1) {
reactions.add(
Reaction(
isCarbon ?
[] :
[sender],
emoji,
isCarbon,
),
);
} else {
List<String> senders;
if (isCarbon) {
senders = reactions[i].senders;
} else {
// Ensure that we don't add a sender multiple times to the same reaction
if (reactions[i].senders.contains(sender)) {
senders = reactions[i].senders;
} else {
senders = [
...reactions[i].senders,
sender,
];
}
}
reactions[i] = reactions[i].copyWith(
senders: senders,
reactedBySelf: isCarbon ?
true :
reactions[i].reactedBySelf,
);
}
}
for (final emoji in removedEmojis) {
final i = reactions.indexWhere((r) => r.emoji == emoji);
assert(i >= -1, 'The reaction must exist');
if (isCarbon && reactions[i].senders.isEmpty ||
!isCarbon && reactions[i].senders.length == 1 && !reactions[i].reactedBySelf) {
reactions.removeAt(i);
} else {
reactions[i] = reactions[i].copyWith(
senders: isCarbon ?
reactions[i].senders :
reactions[i].senders
.where((s) => s != sender)
.toList(),
reactedBySelf: isCarbon ?
false :
reactions[i].reactedBySelf,
);
}
}
final newMessage = await ms.updateMessage(
msg.id,
reactions: reactions,
);
sendEvent(
MessageUpdatedEvent(message: newMessage),
);
}
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async { Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
// The jid this message event is meant for // The jid this message event is meant for
@@ -869,6 +1101,12 @@ class XmppService {
// Process the chat state update. Can also be attached to other messages // Process the chat state update. Can also be attached to other messages
if (event.chatState != null) await _onChatState(event.chatState!, conversationJid); if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);
// Process message corrections separately
if (event.messageCorrectionId != null) {
await _handleMessageCorrection(event, conversationJid);
return;
}
// Process File Upload Notifications replacements separately // Process File Upload Notifications replacements separately
if (event.funReplacement != null) { if (event.funReplacement != null) {
await _handleFileUploadNotificationReplacement(event, conversationJid); await _handleFileUploadNotificationReplacement(event, conversationJid);
@@ -879,6 +1117,12 @@ class XmppService {
await _handleMessageRetraction(event, conversationJid); await _handleMessageRetraction(event, conversationJid);
return; return;
} }
// Handle message reactions
if (event.messageReactions != null) {
await _handleMessageReactions(event, conversationJid);
return;
}
// Stop the processing here if the event does not describe a displayable message // Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return; if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
@@ -903,15 +1147,12 @@ class XmppService {
// Pre-process the message in case it is a reply to another message // Pre-process the message in case it is a reply to another message
String? replyId; String? replyId;
var messageBody = event.body; var messageBody = event.body;
// TODO(Unknown): Implement if (event.reply != null) {
if (event.reply != null /* && check if event.reply.to is okay */) {
replyId = event.reply!.id; replyId = event.reply!.id;
// Strip the compatibility fallback, if specified // Strip the compatibility fallback, if specified
if (event.reply!.start != null && event.reply!.end != null) { messageBody = event.reply!.removeFallback(messageBody);
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end, ''); _log.finest('Removed message reply compatibility fallback from message');
_log.finest('Removed message reply compatibility fallback from message');
}
} }
// The Url of the file embedded in the message, if there is one. // The Url of the file embedded in the message, if there is one.
@@ -930,7 +1171,34 @@ class XmppService {
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload); var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file. // A guess for the Mime type of the embedded file.
var mimeGuess = _getMimeGuess(event); var mimeGuess = _getMimeGuess(event);
// Guess a sticker hash key, if the message is a sticker
final stickerHashKey = event.stickerPackId != null ?
getStickerHashKey(event.sfs!.metadata.hashes) :
null;
// The potential sticker pack
final stickerPack = event.stickerPackId != null ?
await GetIt.I.get<StickersService>().getStickerPackById(
event.stickerPackId!,
) :
null;
// Automatically download the sticker pack, if
// - a sticker was received,
// - the sender is in the roster,
// - we don't have the sticker pack locally,
// - and it is enabled in the settings
if (event.stickerPackId != null &&
stickerPack == null &&
prefs.autoDownloadStickersFromContacts &&
isInRoster) {
unawaited(
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
event.fromJid,
event.stickerPackId!,
),
);
}
// Create the message in the database // Create the message in the database
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final dimensions = _getDimensions(event); final dimensions = _getDimensions(event);
@@ -939,10 +1207,11 @@ class XmppService {
messageTimestamp, messageTimestamp,
event.fromJid.toString(), event.fromJid.toString(),
conversationJid, conversationJid,
isFileEmbedded || event.fun != null, isFileEmbedded || event.fun != null || event.stickerPackId != null,
event.sid, event.sid,
event.fun != null, event.fun != null,
event.encrypted, event.encrypted,
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? false,
srcUrl: embeddedFile?.url, srcUrl: embeddedFile?.url,
filename: event.fun?.name ?? embeddedFile?.filename, filename: event.fun?.name ?? embeddedFile?.filename,
key: embeddedFile?.keyBase64, key: embeddedFile?.keyBase64,
@@ -955,6 +1224,9 @@ class XmppService {
quoteId: replyId, quoteId: replyId,
originId: event.stanzaId.originId, originId: event.stanzaId.originId,
errorType: errorTypeFromException(event.other['encryption_error']), errorType: errorTypeFromException(event.other['encryption_error']),
plaintextHashes: event.sfs?.metadata.hashes,
stickerPackId: event.stickerPackId,
stickerHashKey: stickerHashKey,
); );
// Attempt to auto-download the embedded file // Attempt to auto-download the embedded file
@@ -962,6 +1234,7 @@ class XmppService {
final fts = GetIt.I.get<HttpFileTransferService>(); final fts = GetIt.I.get<HttpFileTransferService>();
final metadata = await peekFile(embeddedFile!.url); final metadata = await peekFile(embeddedFile!.url);
_log.finest('Advertised file MIME: ${metadata.mime}');
if (metadata.mime != null) mimeGuess = metadata.mime; if (metadata.mime != null) mimeGuess = metadata.mime;
// Auto-download only if the file is below the set limit, if the limit is not set to // Auto-download only if the file is below the set limit, if the limit is not set to
@@ -987,6 +1260,7 @@ class XmppService {
} }
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final css = GetIt.I.get<ContactsService>();
final ns = GetIt.I.get<NotificationsService>(); final ns = GetIt.I.get<NotificationsService>();
// The body to be displayed in the conversations list // The body to be displayed in the conversations list
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody; final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
@@ -1026,6 +1300,7 @@ class XmppService {
} }
} else { } else {
// The conversation does not exist, so we must create it // The conversation does not exist, so we must create it
final contactId = await css.getContactIdForJid(conversationJid);
final newConversation = await cs.addConversationFromData( final newConversation = await cs.addConversationFromData(
rosterItem?.title ?? conversationJid.split('@')[0], rosterItem?.title ?? conversationJid.split('@')[0],
message, message,
@@ -1036,6 +1311,9 @@ class XmppService {
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
message.encrypted, message.encrypted,
contactId,
await css.getProfilePicturePathForJid(conversationJid),
await css.getContactDisplayName(contactId),
); );
// Notify the UI // Notify the UI
@@ -1052,13 +1330,15 @@ class XmppService {
} }
} }
// Notify the UI of the message // Mark the file as downlading when it includes a File Upload Notification
if (message.isDownloading != (event.fun != null)) { if (event.fun != null) {
message = await ms.updateMessage( message = await ms.updateMessage(
message.id, message.id,
isDownloading: event.fun != null, isDownloading: true,
); );
} }
// Notify the UI of the message
sendEvent(MessageAddedEvent(message: message)); sendEvent(MessageAddedEvent(message: message));
} }
@@ -1106,12 +1386,13 @@ class XmppService {
sendEvent(MessageUpdatedEvent(message: message)); sendEvent(MessageUpdatedEvent(message: message));
if (shouldDownload) { if (shouldDownload) {
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
await GetIt.I.get<HttpFileTransferService>().downloadFile( await GetIt.I.get<HttpFileTransferService>().downloadFile(
FileDownloadJob( FileDownloadJob(
embeddedFile, embeddedFile,
message.id, message.id,
conversationJid, conversationJid,
null, _getMimeGuess(event),
shouldShowNotification: false, shouldShowNotification: false,
), ),
); );
@@ -1121,17 +1402,8 @@ class XmppService {
} }
} }
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {
_log.fine("Roster push version: ${event.ver ?? "(null)"}");
await GetIt.I.get<RosterService>().handleRosterPushEvent(event);
}
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async { Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
await GetIt.I.get<AvatarService>().updateAvatarForJid( await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
event.jid,
event.hash,
event.base64,
);
} }
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async { Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {

View File

@@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'package:path/path.dart' as pathlib; import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';

View File

@@ -2,5 +2,6 @@ import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'commands.moxxy.dart'; part 'commands.moxxy.dart';

View File

@@ -5,6 +5,7 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart'; import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'events.moxxy.dart'; part 'events.moxxy.dart';

View File

@@ -1,7 +1,13 @@
import 'dart:core'; import 'dart:core';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
/// Add a leading zero, if required, to ensure that an integer is rendered /// Add a leading zero, if required, to ensure that an integer is rendered
/// as a two "digit" string. /// as a two "digit" string.
@@ -34,7 +40,7 @@ String formatConversationTimestamp(int timestamp, int now) {
return '${hourDifference}h'; return '${hourDifference}h';
} }
} else if (difference <= Duration.millisecondsPerMinute) { } else if (difference <= Duration.millisecondsPerMinute) {
return 'Just now'; return t.dateTime.justNow;
} }
return '${(difference / Duration.millisecondsPerMinute).floor()}min'; return '${(difference / Duration.millisecondsPerMinute).floor()}min';
@@ -52,9 +58,10 @@ String formatMessageTimestamp(int timestamp, int now) {
return '${dt.hour}:${padInt(dt.minute)}'; return '${dt.hour}:${padInt(dt.minute)}';
} else { } else {
if (difference < Duration.millisecondsPerMinute) { if (difference < Duration.millisecondsPerMinute) {
return 'Just now'; return t.dateTime.justNow;
} else { } else {
return '${(difference / Duration.millisecondsPerMinute).floor()}min ago'; final diff = (difference / Duration.millisecondsPerMinute).floor();
return t.dateTime.nMinutesAgo(min: diff);
} }
} }
} }
@@ -63,19 +70,19 @@ String formatMessageTimestamp(int timestamp, int now) {
String weekdayToStringAbbrev(int day) { String weekdayToStringAbbrev(int day) {
switch (day) { switch (day) {
case DateTime.monday: case DateTime.monday:
return 'Mon'; return t.dateTime.mondayAbbrev;
case DateTime.tuesday: case DateTime.tuesday:
return 'Tue'; return t.dateTime.tuesdayAbbrev;
case DateTime.wednesday: case DateTime.wednesday:
return 'Wed'; return t.dateTime.wednessdayAbbrev;
case DateTime.thursday: case DateTime.thursday:
return 'Thu'; return t.dateTime.thursdayAbbrev;
case DateTime.friday: case DateTime.friday:
return 'Fri'; return t.dateTime.fridayAbbrev;
case DateTime.saturday: case DateTime.saturday:
return 'Sat'; return t.dateTime.saturdayAbbrev;
case DateTime.sunday: case DateTime.sunday:
return 'Sun'; return t.dateTime.sundayAbbrev;
} }
// Should not happen // Should not happen
@@ -86,29 +93,29 @@ String weekdayToStringAbbrev(int day) {
String monthToString(int month) { String monthToString(int month) {
switch (month) { switch (month) {
case DateTime.january: case DateTime.january:
return 'January'; return t.dateTime.january;
case DateTime.february: case DateTime.february:
return 'February'; return t.dateTime.february;
case DateTime.march: case DateTime.march:
return 'March'; return t.dateTime.march;
case DateTime.april: case DateTime.april:
return 'April'; return t.dateTime.april;
case DateTime.may: case DateTime.may:
return 'May'; return t.dateTime.may;
case DateTime.june: case DateTime.june:
return 'June'; return t.dateTime.june;
case DateTime.july: case DateTime.july:
return 'July'; return t.dateTime.july;
case DateTime.august: case DateTime.august:
return 'August'; return t.dateTime.august;
case DateTime.september: case DateTime.september:
return 'September'; return t.dateTime.september;
case DateTime.october: case DateTime.october:
return 'October'; return t.dateTime.october;
case DateTime.november: case DateTime.november:
return 'November'; return t.dateTime.november;
case DateTime.december: case DateTime.december:
return 'December'; return t.dateTime.december;
} }
// Should not happen // Should not happen
@@ -119,9 +126,9 @@ String monthToString(int month) {
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'. /// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
String formatDateBubble(DateTime dt, DateTime now) { String formatDateBubble(DateTime dt, DateTime now) {
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) { if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
return 'Today'; return t.dateTime.today;
} else if (now.subtract(const Duration(days: 1)).day == dt.day) { } else if (now.subtract(const Duration(days: 1)).day == dt.day) {
return 'Yesterday'; return t.dateTime.yesterday;
} else if (dt.year == now.year) { } else if (dt.year == now.year) {
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}'; return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
} else { } else {
@@ -317,3 +324,91 @@ String fileSizeToString(int size) {
return '$size B'; return '$size B';
} }
} }
/// Load [path] into memory and determine its width and height. Returns null in case
/// of an error.
Future<Size?> getImageSizeFromPath(String path) async {
final bytes = await File(path).readAsBytes();
return getImageSizeFromData(bytes);
}
/// Like getImageSizeFromPath but taking the image's bytes directly.
Future<Size?> getImageSizeFromData(Uint8List bytes) async {
try {
final dartCodec = await instantiateImageCodec(bytes);
final dartFrame = await dartCodec.getNextFrame();
final size = Size(
dartFrame.image.width.toDouble(),
dartFrame.image.height.toDouble(),
);
dartFrame.image.dispose();
dartCodec.dispose();
return size;
} catch (_) {
// TODO(PapaTutuWawa): Log error
return null;
}
}
/// Generate a thumbnail file (JPEG) for the video at [path]. [conversationJid] refers
/// to the JID of the conversation the file comes from.
/// If the thumbnail already exists, then just its path is returned. If not, then
/// it gets generated first.
Future<String?> getVideoThumbnailPath(String path, String conversationJid, String mime) async {
//print('getVideoThumbnailPath: Mime type: $mime');
// Ignore mime types that may be wacky
if (mime == 'video/webm') return null;
final tempDir = await getTemporaryDirectory();
final thumbnailFilenameNoExtension = p.withoutExtension(
p.basename(path),
);
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
final thumbnailDirectory = p.join(
tempDir.path,
'thumbnails',
conversationJid,
);
final thumbnailPath = p.join(thumbnailDirectory, thumbnailFilename);
final dir = Directory(thumbnailDirectory);
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File(thumbnailPath);
if (file.existsSync()) return thumbnailPath;
final r = await VideoThumbnail.thumbnailFile(
video: path,
thumbnailPath: thumbnailDirectory,
imageFormat: ImageFormat.JPEG,
quality: 75,
);
assert(r == thumbnailPath, 'The generated video thumbnail has a different path than we expected: $r vs. $thumbnailPath');
return thumbnailPath;
}
Future<String> getContactProfilePicturePath(String id) async {
final tempDir = await getTemporaryDirectory();
final avatarDir = p.join(
tempDir.path,
'contacts',
'avatars',
);
final dir = Directory(avatarDir);
if (!dir.existsSync()) await dir.create(recursive: true);
return p.join(avatarDir, id);
}
Future<String> getStickerPackPath(String hashFunction, String hashValue) async {
final appDir = await getApplicationDocumentsDirectory();
return p.join(
appDir.path,
'stickers',
'${hashFunction}_$hashValue',
);
}

View File

@@ -1,8 +1,10 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
part 'conversation.freezed.dart'; part 'conversation.freezed.dart';
part 'conversation.g.dart'; part 'conversation.g.dart';
@@ -59,6 +61,14 @@ class Conversation with _$Conversation {
bool encrypted, bool encrypted,
// The current chat state // The current chat state
@ConversationChatStateConverter() ChatState chatState, @ConversationChatStateConverter() ChatState chatState,
{
// The id of the contact in the device's phonebook if it exists
String? contactId,
// The path to the contact avatar, if available
String? contactAvatarPath,
// The contact's display name, if it exists
String? contactDisplayName,
}
) = _Conversation; ) = _Conversation;
const Conversation._(); const Conversation._();
@@ -76,10 +86,9 @@ class Conversation with _$Conversation {
'subscription': subscription, 'subscription': subscription,
'encrypted': intToBool(json['encrypted']! as int), 'encrypted': intToBool(json['encrypted']! as int),
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone), 'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
'lastMessage': <String, dynamic>{ }).copyWith(
'message': lastMessage?.toJson(), lastMessage: lastMessage,
}, );
});
} }
Map<String, dynamic> toDatabaseJson() { Map<String, dynamic> toDatabaseJson() {
@@ -96,9 +105,34 @@ class Conversation with _$Conversation {
'open': boolToInt(open), 'open': boolToInt(open),
'muted': boolToInt(muted), 'muted': boolToInt(muted),
'encrypted': boolToInt(encrypted), 'encrypted': boolToInt(encrypted),
'lastMessage': lastMessage?.id, 'lastMessageId': lastMessage?.id,
}; };
} }
/// True, when the chat state of the conversation indicates typing. False, if not.
bool get isTyping => chatState == ChatState.composing;
/// The path to the avatar. This returns, if enabled, first the contact's avatar
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
/// XMPP avatar's path.
String? get avatarPathWithOptionalContact {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
return contactAvatarPath ?? avatarUrl;
}
return avatarUrl;
}
/// The title of the chat. This returns, if enabled, first the contact's display
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
/// title.
String get titleWithOptionalContact {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
return contactDisplayName ?? title;
}
return title;
}
} }
/// Sorts conversations in descending order by their last change timestamp. /// Sorts conversations in descending order by their last change timestamp.

View File

@@ -3,39 +3,55 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart'; part 'message.freezed.dart';
part 'message.g.dart'; part 'message.g.dart';
const pseudoMessageTypeNewDevice = 1;
Map<String, String>? _optionalJsonDecode(String? data) { Map<String, String>? _optionalJsonDecode(String? data) {
if (data == null) return null; if (data == null) return null;
return jsonDecode(data) as Map<String, String>; return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
} }
String? _optionalJsonEncode(Map<String, String>? data) { Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
if (data == null) return <String, dynamic>{};
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
}
String? _optionalJsonEncode(Map<String, dynamic>? data) {
if (data == null) return null; if (data == null) return null;
return jsonEncode(data); return jsonEncode(data);
} }
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
if (data == null) return null;
if (data.isEmpty) return null;
return jsonEncode(data);
}
@freezed @freezed
class Message with _$Message { class Message with _$Message {
// NOTE: id is the database id of the message
// NOTE: isMedia is for telling the UI that this message contains the URL for media but the path is not yet available
// NOTE: srcUrl is the Url that a file has been or can be downloaded from
factory Message( factory Message(
String sender, String sender,
String body, String body,
int timestamp, int timestamp,
String sid, String sid,
// The database-internal identifier of the message
int id, int id,
String conversationJid, String conversationJid,
// True if the message contains some embedded media
bool isMedia, bool isMedia,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
// True if the message contains a <no-store> Message Processing Hint. False if not
bool containsNoStore,
{ {
int? errorType, int? errorType,
int? warningType, int? warningType,
@@ -46,6 +62,7 @@ class Message with _$Message {
String? thumbnailData, String? thumbnailData,
int? mediaWidth, int? mediaWidth,
int? mediaHeight, int? mediaHeight,
// If non-null: Indicates where some media entry originated/originates from
String? srcUrl, String? srcUrl,
String? key, String? key,
String? iv, String? iv,
@@ -54,12 +71,18 @@ class Message with _$Message {
@Default(false) bool displayed, @Default(false) bool displayed,
@Default(false) bool acked, @Default(false) bool acked,
@Default(false) bool isRetracted, @Default(false) bool isRetracted,
@Default(false) bool isEdited,
String? originId, String? originId,
Message? quotes, Message? quotes,
String? filename, String? filename,
Map<String, String>? plaintextHashes, Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes, Map<String, String>? ciphertextHashes,
int? mediaSize, int? mediaSize,
@Default([]) List<Reaction> reactions,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
} }
) = _Message; ) = _Message;
@@ -82,13 +105,25 @@ class Message with _$Message {
'isDownloading': intToBool(json['isDownloading']! as int), 'isDownloading': intToBool(json['isDownloading']! as int),
'isUploading': intToBool(json['isUploading']! as int), 'isUploading': intToBool(json['isUploading']! as int),
'isRetracted': intToBool(json['isRetracted']! as int), 'isRetracted': intToBool(json['isRetracted']! as int),
}).copyWith(quotes: quotes); 'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactions': <Map<String, dynamic>>[],
'pseudoMessageData': _optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
}).copyWith(
quotes: quotes,
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
.cast<Map<String, dynamic>>()
.map<Reaction>(Reaction.fromJson)
.toList(),
);
} }
Map<String, dynamic> toDatabaseJson() { Map<String, dynamic> toDatabaseJson() {
final map = toJson() final map = toJson()
..remove('id') ..remove('id')
..remove('quotes'); ..remove('quotes')
..remove('reactions')
..remove('pseudoMessageData');
return { return {
...map, ...map,
@@ -105,6 +140,14 @@ class Message with _$Message {
'isDownloading': boolToInt(isDownloading), 'isDownloading': boolToInt(isDownloading),
'isUploading': boolToInt(isUploading), 'isUploading': boolToInt(isUploading),
'isRetracted': boolToInt(isRetracted), 'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited),
'containsNoStore': boolToInt(containsNoStore),
'reactions': jsonEncode(
reactions
.map((r) => r.toJson())
.toList(),
),
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
}; };
} }
@@ -120,24 +163,30 @@ class Message with _$Message {
return mimeTypeToEmoji(mediaType, addTypeName: false); return mimeTypeToEmoji(mediaType, addTypeName: false);
} }
/// True if the message is a pseudo message.
bool get isPseudoMessage => pseudoMessageType != null && pseudoMessageData != null;
/// Returns true if the message can be quoted. False if not. /// Returns true if the message can be quoted. False if not.
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading; bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
/// Returns true if the message can be retracted. False if not. /// Returns true if the message can be retracted. False if not.
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid). /// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
bool canRetract(bool sentBySelf) { bool canRetract(bool sentBySelf) {
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading; return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
} }
/// Returns true if we can send a reaction for this message.
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
/// Returns true if the message can be edited. False if not. /// Returns true if the message can be edited. False if not.
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid). /// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
bool canEdit(bool sentBySelf) { bool canEdit(bool sentBySelf) {
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading; return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
} }
/// Returns true if the message can open the selection menu by longpressing. False if /// Returns true if the message can open the selection menu by longpressing. False if
/// not. /// not.
bool get isLongpressable => !isRetracted; bool get isLongpressable => !isRetracted && !isPseudoMessage;
/// Returns true if the menu item to show the error should be shown in the /// Returns true if the menu item to show the error should be shown in the
/// longpress menu. /// longpress menu.
@@ -150,11 +199,14 @@ class Message with _$Message {
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or /// Returns true if the message contains media that can be thumbnailed, i.e. videos or
/// images. /// images.
bool get isThumbnailable => isMedia && mediaType != null && ( bool get isThumbnailable => !isPseudoMessage && isMedia && mediaType != null && (
mediaType!.startsWith('image/') || mediaType!.startsWith('image/') ||
mediaType!.startsWith('video/') mediaType!.startsWith('video/')
); );
/// Returns true if the message can be copied to the clipboard. /// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty; bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null && !isPseudoMessage;
} }

View File

@@ -16,7 +16,7 @@ class OmemoDevice with _$OmemoDevice {
@Default(true) bool hasSessionWith, @Default(true) bool hasSessionWith,
} }
) = _OmemoDevice; ) = _OmemoDevice;
/// JSON /// JSON
factory OmemoDevice.fromJson(Map<String, dynamic> json) => _$OmemoDeviceFromJson(json); factory OmemoDevice.fromJson(Map<String, dynamic> json) => _$OmemoDeviceFromJson(json);
} }

View File

@@ -28,6 +28,11 @@ class PreferencesState with _$PreferencesState {
// NOTE: A value of 'default' means that the system's configured language should // NOTE: A value of 'default' means that the system's configured language should
// be used // be used
@Default('default') String languageLocaleCode, @Default('default') String languageLocaleCode,
@Default(false) bool enableContactIntegration,
@Default(true) bool enableStickers,
@Default(true) bool autoDownloadStickersFromContacts,
@Default(true) bool isStickersNodePublic,
@Default(false) bool showDebugMenu,
}) = _PreferencesState; }) = _PreferencesState;
// JSON serialization // JSON serialization

View File

@@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'reaction.freezed.dart';
part 'reaction.g.dart';
@freezed
class Reaction with _$Reaction {
factory Reaction(
List<String> senders,
String emoji,
// NOTE: Store this with the model to prevent having to to a O(n) search across the
// list of reactions on every rebuild
bool reactedBySelf,
) = _Reaction;
const Reaction._();
/// JSON
factory Reaction.fromJson(Map<String, dynamic> json) => _$ReactionFromJson(json);
int get reactions {
if (reactedBySelf) return senders.length + 1;
return senders.length;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxyv2/service/database/helpers.dart';
part 'roster.freezed.dart'; part 'roster.freezed.dart';
part 'roster.g.dart'; part 'roster.g.dart';
@@ -13,7 +14,18 @@ class RosterItem with _$RosterItem {
String title, String title,
String subscription, String subscription,
String ask, String ask,
// Indicates whether the "roster item" really exists on the roster and is not just there
// for the contact integration
bool pseudoRosterItem,
List<String> groups, List<String> groups,
{
// The id of the contact in the device's phonebook, if it exists
String? contactId,
// The path to the profile picture of the contact, if it exists
String? contactAvatarPath,
// The contact's display name, if it exists
String? contactDisplayName,
}
) = _RosterItem; ) = _RosterItem;
const RosterItem._(); const RosterItem._();
@@ -26,13 +38,20 @@ class RosterItem with _$RosterItem {
...json, ...json,
// TODO(PapaTutuWawa): Fix // TODO(PapaTutuWawa): Fix
'groups': <String>[], 'groups': <String>[],
'pseudoRosterItem': intToBool(json['pseudoRosterItem']! as int),
}); });
} }
Map<String, dynamic> toDatabaseJson() { Map<String, dynamic> toDatabaseJson() {
return toJson() final json = toJson()
..remove('id') ..remove('id')
// TODO(PapaTutuWawa): Fix // TODO(PapaTutuWawa): Fix
..remove('groups'); ..remove('groups')
..remove('pseudoRosterItem');
return {
...json,
'pseudoRosterItem': boolToInt(pseudoRosterItem),
};
} }
} }

View File

@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/helpers.dart';
part 'sticker.freezed.dart';
part 'sticker.g.dart';
@freezed
class Sticker with _$Sticker {
factory Sticker(
String hashKey,
String mediaType,
String desc,
int size,
int? width,
int? height,
/// Hash algorithm (algo attribute) -> Base64 encoded hash
Map<String, String> hashes,
List<String> urlSources,
String path,
String stickerPackId,
Map<String, String> suggests,
) = _Sticker;
const Sticker._();
/// Moxxmpp
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) => Sticker(
getStickerHashKey(sticker.metadata.hashes),
sticker.metadata.mediaType!,
sticker.metadata.desc!,
sticker.metadata.size!,
sticker.metadata.width,
sticker.metadata.height,
sticker.metadata.hashes,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
stickerPackId,
sticker.suggests,
);
/// JSON
factory Sticker.fromJson(Map<String, dynamic> json) => _$StickerFromJson(json);
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
return Sticker.fromJson({
...json,
'hashes': (jsonDecode(json['hashes']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
'urlSources': (jsonDecode(json['urlSources']! as String) as List<dynamic>).cast<String>(),
'suggests': (jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
});
}
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('hashes')
..remove('urlSources')
..remove('suggests');
return {
...map,
'hashes': jsonEncode(hashes),
'urlSources': jsonEncode(urlSources),
'suggests': jsonEncode(suggests),
};
}
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
moxxmpp.FileMetadataData(
mediaType: mediaType,
desc: desc,
size: size,
width: width,
height: height,
thumbnails: [],
hashes: hashes,
),
urlSources
// ignore: unnecessary_lambdas
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
.toList(),
suggests,
);
/// True, if the sticker is backed by an image with MIME type image/*.
bool get isImage => mediaType.startsWith('image/');
}

View File

@@ -0,0 +1,74 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
part 'sticker_pack.freezed.dart';
part 'sticker_pack.g.dart';
@freezed
class StickerPack with _$StickerPack {
factory StickerPack(
String id,
String name,
String description,
List<Sticker> stickers,
String hashAlgorithm,
String hashValue,
bool restricted,
bool local,
) = _StickerPack;
const StickerPack._();
/// Moxxmpp
factory StickerPack.fromMoxxmpp(moxxmpp.StickerPack pack, bool local) => StickerPack(
pack.id,
pack.name,
pack.summary,
pack.stickers
.map((sticker) => Sticker.fromMoxxmpp(sticker, pack.id))
.toList(),
pack.hashAlgorithm.toName(),
pack.hashValue,
pack.restricted,
local,
);
/// JSON
factory StickerPack.fromJson(Map<String, dynamic> json) => _$StickerPackFromJson(json);
factory StickerPack.fromDatabaseJson(Map<String, dynamic> json, List<Sticker> stickers) {
final pack = StickerPack.fromJson({
...json,
'local': true,
'restricted': intToBool(json['restricted']! as int),
'stickers': <Sticker>[],
});
return pack.copyWith(stickers: stickers);
}
Map<String, dynamic> toDatabaseJson() {
final json = toJson()
..remove('local')
..remove('stickers');
return {
...json,
'restricted': boolToInt(restricted),
};
}
moxxmpp.StickerPack toMoxxmpp() => moxxmpp.StickerPack(
id,
name,
description,
moxxmpp.hashFunctionFromName(hashAlgorithm),
hashValue,
stickers
.map((sticker) => sticker.toMoxxmpp())
.toList(),
restricted,
);
}

View File

@@ -16,12 +16,10 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
AddContactBloc() : super(AddContactState()) { AddContactBloc() : super(AddContactState()) {
on<AddedContactEvent>(_onContactAdded); on<AddedContactEvent>(_onContactAdded);
on<JidChangedEvent>(_onJidChanged); on<JidChangedEvent>(_onJidChanged);
on<PageResetEvent>(_onPageReset);
} }
Future<void> _onContactAdded(AddedContactEvent event, Emitter<AddContactState> emit) async { Future<void> _onContactAdded(AddedContactEvent event, Emitter<AddContactState> emit) async {
// TODO(Unknown): Remove once we can disable the custom buttom
if (state.working) return;
final validation = validateJidString(state.jid); final validation = validateJidString(state.jid);
if (validation != null) { if (validation != null) {
emit(state.copyWith(jidError: validation)); emit(state.copyWith(jidError: validation));
@@ -30,7 +28,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
emit( emit(
state.copyWith( state.copyWith(
working: true, isWorking: true,
jidError: null, jidError: null,
), ),
); );
@@ -42,14 +40,21 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
), ),
) as AddContactResultEvent; ) as AddContactResultEvent;
await _onPageReset(PageResetEvent(), emit);
if (result.conversation != null) { if (result.conversation != null) {
if (result.added) { if (result.added) {
GetIt.I.get<ConversationsBloc>().add(ConversationsAddedEvent(result.conversation!)); GetIt.I.get<ConversationsBloc>().add(
ConversationsAddedEvent(result.conversation!),
);
} else { } else {
GetIt.I.get<ConversationsBloc>().add(ConversationsUpdatedEvent(result.conversation!)); GetIt.I.get<ConversationsBloc>().add(
ConversationsUpdatedEvent(result.conversation!),
);
} }
} }
assert(result.conversation != null, 'RequestedConversationEvent must contain a not null conversation');
GetIt.I.get<ConversationBloc>().add( GetIt.I.get<ConversationBloc>().add(
RequestedConversationEvent( RequestedConversationEvent(
result.conversation!.jid, result.conversation!.jid,
@@ -61,6 +66,20 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
} }
Future<void> _onJidChanged(JidChangedEvent event, Emitter<AddContactState> emit) async { Future<void> _onJidChanged(JidChangedEvent event, Emitter<AddContactState> emit) async {
emit(state.copyWith(jid: event.jid)); emit(
state.copyWith(
jid: event.jid,
),
);
}
Future<void> _onPageReset(PageResetEvent event, Emitter<AddContactState> emit) async {
emit(
state.copyWith(
jidError: null,
jid: '',
isWorking: false,
),
);
} }
} }

View File

@@ -7,7 +7,9 @@ class AddedContactEvent extends AddContactEvent {}
/// Triggered by the UI when the JID input field is changed /// Triggered by the UI when the JID input field is changed
class JidChangedEvent extends AddContactEvent { class JidChangedEvent extends AddContactEvent {
JidChangedEvent(this.jid); JidChangedEvent(this.jid);
final String jid; final String jid;
} }
/// Triggered when the UI wants to reset its state
class PageResetEvent extends AddContactEvent {}

View File

@@ -5,6 +5,6 @@ class AddContactState with _$AddContactState {
factory AddContactState({ factory AddContactState({
@Default('') String jid, @Default('') String jid,
@Default(null) String? jidError, @Default(null) String? jidError,
@Default(false) bool working, @Default(false) bool isWorking,
}) = _AddContactState; }) = _AddContactState;
} }

View File

@@ -1,7 +1,11 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
part 'blocklist_bloc.freezed.dart'; part 'blocklist_bloc.freezed.dart';
part 'blocklist_event.dart'; part 'blocklist_event.dart';
@@ -9,11 +13,44 @@ part 'blocklist_state.dart';
class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> { class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> {
BlocklistBloc() : super(BlocklistState()) { BlocklistBloc() : super(BlocklistState()) {
on<BlocklistRequestedEvent>(_onBlocklistRequested);
on<UnblockedJidEvent>(_onJidUnblocked); on<UnblockedJidEvent>(_onJidUnblocked);
on<UnblockedAllEvent>(_onUnblockedAll); on<UnblockedAllEvent>(_onUnblockedAll);
on<BlocklistPushedEvent>(_onBlocklistPushed); on<BlocklistPushedEvent>(_onBlocklistPushed);
} }
Future<void> _onBlocklistRequested(BlocklistRequestedEvent event, Emitter<BlocklistState> emit) async {
final mustDoWork = state.blocklist.isEmpty;
if (mustDoWork) {
emit(
state.copyWith(
isWorking: true,
),
);
}
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(blocklistRoute),
),
);
if (state.blocklist.isEmpty) {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetBlocklistCommand(),
) as GetBlocklistResultEvent;
emit(
state.copyWith(
blocklist: result.entries,
isWorking: false,
),
);
}
}
Future<void> _onJidUnblocked(UnblockedJidEvent event, Emitter<BlocklistState> emit) async { Future<void> _onJidUnblocked(UnblockedJidEvent event, Emitter<BlocklistState> emit) async {
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
UnblockJidCommand( UnblockJidCommand(

View File

@@ -2,9 +2,11 @@ part of 'blocklist_bloc.dart';
abstract class BlocklistEvent {} abstract class BlocklistEvent {}
/// Triggered when the blocklist page has been requested
class BlocklistRequestedEvent extends BlocklistEvent {}
/// Triggered when a JID is unblocked /// Triggered when a JID is unblocked
class UnblockedJidEvent extends BlocklistEvent { class UnblockedJidEvent extends BlocklistEvent {
UnblockedJidEvent(this.jid); UnblockedJidEvent(this.jid);
final String jid; final String jid;
} }
@@ -16,7 +18,6 @@ class UnblockedAllEvent extends BlocklistEvent {
/// Triggered when we receive a blocklist push /// Triggered when we receive a blocklist push
class BlocklistPushedEvent extends BlocklistEvent { class BlocklistPushedEvent extends BlocklistEvent {
BlocklistPushedEvent(this.added, this.removed); BlocklistPushedEvent(this.added, this.removed);
final List<String> added; final List<String> added;
final List<String> removed; final List<String> removed;

View File

@@ -4,5 +4,6 @@ part of 'blocklist_bloc.dart';
class BlocklistState with _$BlocklistState { class BlocklistState with _$BlocklistState {
factory BlocklistState({ factory BlocklistState({
@Default(<String>[]) List<String> blocklist, @Default(<String>[]) List<String> blocklist,
@Default(false) bool isWorking,
}) = _BlocklistState; }) = _BlocklistState;
} }

View File

@@ -1,21 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart'; import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart' as events; import 'package:moxxyv2/shared/events.dart' as events;
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:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart'; import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart'; import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
part 'conversation_bloc.freezed.dart'; part 'conversation_bloc.freezed.dart';
part 'conversation_event.dart'; part 'conversation_event.dart';
@@ -42,10 +51,23 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<BackgroundChangedEvent>(_onBackgroundChanged); on<BackgroundChangedEvent>(_onBackgroundChanged);
on<ImagePickerRequestedEvent>(_onImagePickerRequested); on<ImagePickerRequestedEvent>(_onImagePickerRequested);
on<FilePickerRequestedEvent>(_onFilePickerRequested); on<FilePickerRequestedEvent>(_onFilePickerRequested);
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled); on<PickerToggledEvent>(_onPickerToggled);
on<OwnJidReceivedEvent>(_onOwnJidReceived); on<OwnJidReceivedEvent>(_onOwnJidReceived);
on<OmemoSetEvent>(_onOmemoSet); on<OmemoSetEvent>(_onOmemoSet);
on<MessageRetractedEvent>(_onMessageRetracted); on<MessageRetractedEvent>(_onMessageRetracted);
on<MessageEditSelectedEvent>(_onMessageEditSelected);
on<MessageEditCancelledEvent>(_onMessageEditCancelled);
on<SendButtonDragStartedEvent>(_onDragStarted);
on<SendButtonDragEndedEvent>(_onDragEnded);
on<SendButtonLockedEvent>(_onSendButtonLocked);
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
on<RecordingCanceledEvent>(_onRecordingCanceled);
on<ReactionAddedEvent>(_onReactionAdded);
on<ReactionRemovedEvent>(_onReactionRemoved);
on<StickerSentEvent>(_onStickerSent);
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
_audioRecorder = Record();
} }
/// The current chat state with the conversation partner /// The current chat state with the conversation partner
ChatState _currentChatState; ChatState _currentChatState;
@@ -53,6 +75,10 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
Timer? _composeTimer; Timer? _composeTimer;
/// The last time the text has been changed /// The last time the text has been changed
int _lastChangeTimestamp; int _lastChangeTimestamp;
/// The audio recorder
late Record _audioRecorder;
DateTime? _recordingStart;
void _setLastChangeTimestamp() { void _setLastChangeTimestamp() {
_lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch; _lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch;
@@ -97,7 +123,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state: s.toString().split('.').last, state: s.toString().split('.').last,
jid: state.conversation!.jid, jid: state.conversation!.jid,
), ),
awaitable: false,); awaitable: false,
);
} }
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async { Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {
@@ -115,6 +142,15 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state.copyWith( state.copyWith(
conversation: conversation, conversation: conversation,
quotedMessage: null, quotedMessage: null,
messageEditing: false,
messageEditingOriginalBody: '',
messageText: '',
messageEditingId: null,
messageEditingSid: null,
sendButtonState: defaultSendButtonState,
isLocked: false,
isDragging: false,
isRecording: false,
), ),
); );
@@ -160,10 +196,21 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
_startComposeTimer(); _startComposeTimer();
_updateChatState(ChatState.composing); _updateChatState(ChatState.composing);
SendButtonState sendButtonState;
if (state.messageEditing) {
sendButtonState = event.value == state.messageEditingOriginalBody ?
SendButtonState.cancelCorrection :
SendButtonState.send;
} else {
sendButtonState = event.value.isEmpty ?
defaultSendButtonState :
SendButtonState.send;
}
return emit( return emit(
state.copyWith( state.copyWith(
messageText: event.value, messageText: event.value,
showSendButton: event.value.isNotEmpty, sendButtonState: sendButtonState,
), ),
); );
} }
@@ -174,22 +221,28 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
_stopComposeTimer(); _stopComposeTimer();
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
SendMessageCommand( SendMessageCommand(
recipients: [state.conversation!.jid], recipients: [state.conversation!.jid],
body: state.messageText, body: state.messageText,
quotedMessage: state.quotedMessage, quotedMessage: state.quotedMessage,
chatState: chatStateToString(ChatState.active), chatState: chatStateToString(ChatState.active),
editId: state.messageEditingId,
editSid: state.messageEditingSid,
), ),
) as events.MessageAddedEvent; awaitable: false,
);
emit( emit(
state.copyWith( state.copyWith(
messages: List<Message>.from(<Message>[ ...state.messages, result.message ]),
messageText: '', messageText: '',
quotedMessage: null, quotedMessage: null,
showSendButton: false, sendButtonState: defaultSendButtonState,
emojiPickerVisible: false, pickerVisible: false,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
), ),
); );
} }
@@ -221,7 +274,16 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
} }
Future<void> _onJidAdded(JidAddedEvent event, Emitter<ConversationState> emit) async { Future<void> _onJidAdded(JidAddedEvent event, Emitter<ConversationState> emit) async {
// TODO(Unknown): Maybe have some state here // Just update the state here. If it does not work, then the next conversation
// update will fix it.
emit(
state.copyWith(
conversation: state.conversation!.copyWith(
inRoster: true,
),
),
);
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
AddContactCommand(jid: state.conversation!.jid), AddContactCommand(jid: state.conversation!.jid),
); );
@@ -311,9 +373,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
); );
} }
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async { Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
final newState = !state.emojiPickerVisible; final newState = !state.pickerVisible;
emit(state.copyWith(emojiPickerVisible: newState)); emit(
state.copyWith(
pickerVisible: newState,
),
);
if (event.handleKeyboard) { if (event.handleKeyboard) {
if (newState) { if (newState) {
@@ -352,4 +418,252 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
awaitable: false, awaitable: false,
); );
} }
Future<void> _onMessageEditSelected(MessageEditSelectedEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
messageText: event.message.body,
quotedMessage: event.message.quotes,
messageEditing: true,
messageEditingOriginalBody: event.message.body,
messageEditingId: event.message.id,
messageEditingSid: event.message.sid,
sendButtonState: SendButtonState.cancelCorrection,
),
);
}
Future<void> _onMessageEditCancelled(MessageEditCancelledEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
messageText: '',
quotedMessage: null,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
sendButtonState: defaultSendButtonState,
),
);
}
Future<void> _onDragStarted(SendButtonDragStartedEvent event, Emitter<ConversationState> emit) async {
final status = await Permission.speech.status;
if (status.isDenied) {
await Permission.speech.request();
return;
}
emit(
state.copyWith(
isDragging: true,
isRecording: true,
pickerVisible: false,
),
);
final now = DateTime.now();
_recordingStart = now;
final tempDir = await getTemporaryDirectory();
final timestamp = '${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
await _audioRecorder.start(
path: tempFile,
);
}
Future<void> _handleRecordingEnd() async {
// Prevent messages of really short duration being sent
final now = DateTime.now();
if (now.difference(_recordingStart!).inSeconds < 1) {
await Fluttertoast.showToast(
msg: t.warnings.conversation.holdForLonger,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Warn if something unexpected happened
final recordingPath = await _audioRecorder.stop();
if (recordingPath == null) {
await Fluttertoast.showToast(
msg: t.errors.conversation.audioRecordingError,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Send the file
await MoxplatformPlugin.handler.getDataSender().sendData(
SendFilesCommand(
paths: [recordingPath],
recipients: [state.conversation!.jid],
),
awaitable: false,
);
}
Future<void> _onDragEnded(SendButtonDragEndedEvent event, Emitter<ConversationState> emit) async {
final recording = state.isRecording;
emit(
state.copyWith(
isDragging: false,
isLocked: false,
isRecording: false,
),
);
if (recording) {
await _handleRecordingEnd();
}
}
Future<void> _onSendButtonLocked(SendButtonLockedEvent event, Emitter<ConversationState> emit) async {
Vibrate.feedback(FeedbackType.light);
emit(state.copyWith(isLocked: true));
}
Future<void> _onSendButtonLockPressed(SendButtonLockPressedEvent event, Emitter<ConversationState> emit) async {
final recording = state.isRecording;
emit(
state.copyWith(
isLocked: false,
isDragging: false,
isRecording: false,
),
);
if (recording) {
await _handleRecordingEnd();
}
}
Future<void> _onRecordingCanceled(RecordingCanceledEvent event, Emitter<ConversationState> emit) async {
Vibrate.feedback(FeedbackType.heavy);
emit(
state.copyWith(
isLocked: false,
isDragging: false,
isRecording: false,
),
);
final file = await _audioRecorder.stop();
unawaited(File(file!).delete());
}
Future<void> _onReactionAdded(ReactionAddedEvent event, Emitter<ConversationState> emit) async {
// Check if such a reaction already exists
final message = state.messages[event.index];
final msgs = List<Message>.from(state.messages);
final reactionIndex = message.reactions.indexWhere(
(Reaction r) => r.emoji == event.emoji,
);
if (reactionIndex != -1) {
// Ignore the request when the reaction would be invalid
final reaction = message.reactions[reactionIndex];
if (reaction.reactedBySelf) return;
final reactions = List<Reaction>.from(message.reactions);
reactions[reactionIndex] = reaction.copyWith(
reactedBySelf: true,
);
msgs[event.index] = message.copyWith(
reactions: reactions,
);
} else {
// The reaction is new
msgs[event.index] = message.copyWith(
reactions: [
...message.reactions,
Reaction(
[],
event.emoji,
true,
),
],
);
}
emit(
state.copyWith(
messages: msgs,
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
AddReactionToMessageCommand(
messageId: message.id,
emoji: event.emoji,
conversationJid: message.conversationJid,
),
awaitable: false,
);
}
Future<void> _onReactionRemoved(ReactionRemovedEvent event, Emitter<ConversationState> emit) async {
final message = state.messages[event.index];
final msgs = List<Message>.from(state.messages);
final reactionIndex = message.reactions.indexWhere(
(Reaction r) => r.emoji == event.emoji,
);
// We assume that reactionIndex >= 0
assert(reactionIndex >= 0, 'The reaction must be found');
final reactions = List<Reaction>.from(message.reactions);
if (message.reactions[reactionIndex].senders.isEmpty) {
reactions.removeAt(reactionIndex);
} else {
reactions[reactionIndex] = reactions[reactionIndex].copyWith(
reactedBySelf: false,
);
}
msgs[event.index] = message.copyWith(reactions: reactions);
emit(
state.copyWith(
messages: msgs,
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveReactionFromMessageCommand(
messageId: message.id,
emoji: event.emoji,
conversationJid: message.conversationJid,
),
awaitable: false,
);
}
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
await MoxplatformPlugin.handler.getDataSender().sendData(
SendStickerCommand(
stickerPackId: event.stickerPackId,
stickerHashKey: event.stickerHashKey,
recipient: state.conversation!.jid,
),
awaitable: false,
);
// Close the picker
emit(
state.copyWith(
pickerVisible: false,
),
);
}
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
if (event.visible && (state.pickerVisible)) {
emit(
state.copyWith(
pickerVisible: false,
),
);
}
}
} }

View File

@@ -98,8 +98,8 @@ class ImagePickerRequestedEvent extends ConversationEvent {}
class FilePickerRequestedEvent extends ConversationEvent {} class FilePickerRequestedEvent extends ConversationEvent {}
/// Triggered when the emoji button is pressed /// Triggered when the emoji button is pressed
class EmojiPickerToggledEvent extends ConversationEvent { class PickerToggledEvent extends ConversationEvent {
EmojiPickerToggledEvent({this.handleKeyboard = true}); PickerToggledEvent({this.handleKeyboard = true});
final bool handleKeyboard; final bool handleKeyboard;
} }
@@ -120,3 +120,56 @@ class MessageRetractedEvent extends ConversationEvent {
MessageRetractedEvent(this.id); MessageRetractedEvent(this.id);
final String id; final String id;
} }
/// Triggered when a message has been selected for editing
class MessageEditSelectedEvent extends ConversationEvent {
MessageEditSelectedEvent(this.message);
final Message message;
}
/// Triggered when a message edit has been cancelled
class MessageEditCancelledEvent extends ConversationEvent {
MessageEditCancelledEvent();
}
/// Triggered when the dragging began
class SendButtonDragStartedEvent extends ConversationEvent {}
/// Triggered when the dragging ended
class SendButtonDragEndedEvent extends ConversationEvent {}
/// Triggered when the dragging ended
class SendButtonLockedEvent extends ConversationEvent {}
/// Triggered when the FAB has been locked
class SendButtonLockPressedEvent extends ConversationEvent {}
/// Triggered when the recording has been canceled
class RecordingCanceledEvent extends ConversationEvent {}
/// Triggered when a reaction has been added
class ReactionAddedEvent extends ConversationEvent {
ReactionAddedEvent(this.emoji, this.index);
final String emoji;
final int index;
}
/// Triggered when a reaction has been removed
class ReactionRemovedEvent extends ConversationEvent {
ReactionRemovedEvent(this.emoji, this.index);
final String emoji;
final int index;
}
/// Triggered when a sticker has been sent
class StickerSentEvent extends ConversationEvent {
StickerSentEvent(this.stickerPackId, this.stickerHashKey);
final String stickerPackId;
final String stickerHashKey;
}
/// Triggered when the softkeyboard's visibility changed
class SoftKeyboardVisibilityChanged extends ConversationEvent {
SoftKeyboardVisibilityChanged(this.visible);
final bool visible;
}

View File

@@ -1,16 +1,32 @@
part of 'conversation_bloc.dart'; part of 'conversation_bloc.dart';
enum SendButtonState {
multi,
send,
cancelCorrection,
}
const defaultSendButtonState = SendButtonState.multi;
@freezed @freezed
class ConversationState with _$ConversationState { class ConversationState with _$ConversationState {
factory ConversationState({ factory ConversationState({
// Our own JID // Our own JID
@Default('') String jid, @Default('') String jid,
@Default('') String messageText, @Default('') String messageText,
@Default(false) bool showSendButton, @Default(defaultSendButtonState) SendButtonState sendButtonState,
@Default(null) Message? quotedMessage, @Default(null) Message? quotedMessage,
@Default(<Message>[]) List<Message> messages, @Default(<Message>[]) List<Message> messages,
@Default(null) Conversation? conversation, @Default(null) Conversation? conversation,
@Default('') String backgroundPath, @Default('') String backgroundPath,
@Default(false) bool emojiPickerVisible, @Default(false) bool pickerVisible,
@Default(false) bool messageEditing,
@Default('') String messageEditingOriginalBody,
@Default(null) String? messageEditingSid,
@Default(null) int? messageEditingId,
// For recording
@Default(false) bool isDragging,
@Default(false) bool isLocked,
@Default(false) bool isRecording,
}) = _ConversationState; }) = _ConversationState;
} }

View File

@@ -1,37 +1,59 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:cropperx/cropperx.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
part 'crop_bloc.freezed.dart';
part 'crop_event.dart'; part 'crop_event.dart';
part 'crop_state.dart';
class CropBloc extends Bloc<CropEvent, CropState> { class CropBloc extends Bloc<CropEvent, CropState> {
CropBloc() : super(CropState()) {
CropBloc() : super(CropState(null)) {
on<ImageCroppedEvent>(_onImageCropped); on<ImageCroppedEvent>(_onImageCropped);
on<ResetImageEvent>(_onImageReset); on<ResetImageEvent>(_onImageReset);
on<SetImageEvent>(_onImageSet); on<SetImageEvent>(_onImageSet);
} }
late Completer<Uint8List?> _completer; late Completer<Uint8List?> _completer;
final GlobalKey cropKey = GlobalKey();
Future<void> _onImageCropped(ImageCroppedEvent event, Emitter<CropState> emit) async { Future<void> _onImageCropped(ImageCroppedEvent event, Emitter<CropState> emit) async {
_completer.complete(event.image); emit(
state.copyWith(
isWorking: true,
),
);
final bytes = await Cropper.crop(
cropperKey: cropKey,
);
_completer.complete(bytes);
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent()); GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
emit(CropState(null)); await _onImageReset(ResetImageEvent(), emit);
} }
Future<void> _onImageReset(ResetImageEvent event, Emitter<CropState> emit) async { Future<void> _onImageReset(ResetImageEvent event, Emitter<CropState> emit) async {
emit(CropState(null)); emit(
state.copyWith(
image: null,
isWorking: false,
),
);
} }
Future<void> _onImageSet(SetImageEvent event, Emitter<CropState> emit) async { Future<void> _onImageSet(SetImageEvent event, Emitter<CropState> emit) async {
emit(CropState(event.image)); emit(
state.copyWith(
image: event.image,
),
);
} }
Future<void> _performCropping(String path) async { Future<void> _performCropping(String path) async {

View File

@@ -2,22 +2,11 @@ part of 'crop_bloc.dart';
abstract class CropEvent {} abstract class CropEvent {}
class ImageCroppedEvent extends CropEvent { class ImageCroppedEvent extends CropEvent {}
ImageCroppedEvent(this.image);
final Uint8List image;
}
class ResetImageEvent extends CropEvent {} class ResetImageEvent extends CropEvent {}
class SetImageEvent extends CropEvent { class SetImageEvent extends CropEvent {
SetImageEvent(this.image); SetImageEvent(this.image);
final Uint8List image; final Uint8List image;
} }
class CropState {
CropState(this.image);
final Uint8List? image;
}

View File

@@ -0,0 +1,9 @@
part of 'crop_bloc.dart';
@freezed
class CropState with _$CropState {
factory CropState({
@Default(null) Uint8List? image,
@Default(false) bool isWorking,
}) = _CropState;
}

View File

@@ -5,7 +5,7 @@ import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:image/image.dart'; import 'package:image/image.dart';
import 'package:image_size_getter/image_size_getter.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart'; import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
@@ -46,7 +46,7 @@ void _cropImage(List<dynamic> data) {
stackBlurRgba(cropped.data, cropped.width, cropped.height, 20); stackBlurRgba(cropped.data, cropped.width, cropped.height, 20);
} }
File(destination).writeAsBytesSync(encodePng(cropped)); File(destination).writeAsBytesSync(encodeJpg(cropped, quality: 85));
port.send(true); port.send(true);
} }
@@ -81,13 +81,13 @@ class CropBackgroundBloc extends Bloc<CropBackgroundEvent, CropBackgroundState>
); );
final data = await File(event.path).readAsBytes(); final data = await File(event.path).readAsBytes();
final imageSize = ImageSizeGetter.getSize(MemoryInput(data)); final imageSize = (await getImageSizeFromData(data))!;
emit( emit(
state.copyWith( state.copyWith(
image: data, image: data,
imagePath: event.path, imagePath: event.path,
imageWidth: imageSize.width, imageWidth: imageSize.width.toInt(),
imageHeight: imageSize.height, imageHeight: imageSize.height.toInt(),
), ),
); );
} }

View File

@@ -7,17 +7,18 @@ import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart'; import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
part 'devices_bloc.freezed.dart'; part 'devices_bloc.freezed.dart';
part 'devices_event.dart'; part 'devices_event.dart';
part 'devices_state.dart'; part 'devices_state.dart';
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> { class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
DevicesBloc() : super(DevicesState()) { DevicesBloc() : super(DevicesState()) {
on<DevicesRequestedEvent>(_onRequested); on<DevicesRequestedEvent>(_onRequested);
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet); on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
on<SessionsRecreatedEvent>(_onSessionsRecreated); on<SessionsRecreatedEvent>(_onSessionsRecreated);
on<DeviceVerifiedEvent>(_onDeviceVerified);
} }
Future<void> _onRequested(DevicesRequestedEvent event, Emitter<DevicesState> emit) async { Future<void> _onRequested(DevicesRequestedEvent event, Emitter<DevicesState> emit) async {
@@ -66,4 +67,28 @@ class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent()); GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
} }
Future<void> _onDeviceVerified(DeviceVerifiedEvent event, Emitter<DevicesState> emit) async {
final result = isVerificationUriValid(
state.devices,
event.uri,
state.jid,
event.deviceId,
);
if (result == -1) return;
final devices = List<OmemoDevice>.from(state.devices);
devices[result] = devices[result].copyWith(
verified: true,
);
emit(state.copyWith(devices: devices));
await MoxplatformPlugin.handler.getDataSender().sendData(
MarkOmemoDeviceAsVerifiedCommand(
jid: state.jid,
deviceId: event.deviceId,
),
awaitable: false,
);
}
} }

View File

@@ -4,14 +4,12 @@ abstract class DevicesEvent {}
/// Triggered when the user requested the key page /// Triggered when the user requested the key page
class DevicesRequestedEvent extends DevicesEvent { class DevicesRequestedEvent extends DevicesEvent {
DevicesRequestedEvent(this.jid); DevicesRequestedEvent(this.jid);
final String jid; final String jid;
} }
/// Triggered by the UI when we want to enable or disable a key /// Triggered by the UI when we want to enable or disable a key
class DeviceEnabledSetEvent extends DevicesEvent { class DeviceEnabledSetEvent extends DevicesEvent {
DeviceEnabledSetEvent(this.deviceId, this.enabled); DeviceEnabledSetEvent(this.deviceId, this.enabled);
final int deviceId; final int deviceId;
final bool enabled; final bool enabled;
@@ -19,3 +17,10 @@ class DeviceEnabledSetEvent extends DevicesEvent {
/// Triggered by the UI when all OMEMO sessions should be recreated /// Triggered by the UI when all OMEMO sessions should be recreated
class SessionsRecreatedEvent extends DevicesEvent {} class SessionsRecreatedEvent extends DevicesEvent {}
/// Triggered by the UI when a device has been verified using the QR code
class DeviceVerifiedEvent extends DevicesEvent {
DeviceVerifiedEvent(this.uri, this.deviceId);
final Uri uri;
final int deviceId;
}

View File

@@ -15,20 +15,17 @@ class NavigationDestination {
abstract class NavigationEvent {} abstract class NavigationEvent {}
class PushedNamedEvent extends NavigationEvent { class PushedNamedEvent extends NavigationEvent {
PushedNamedEvent(this.destination); PushedNamedEvent(this.destination);
final NavigationDestination destination; final NavigationDestination destination;
} }
class PushedNamedAndRemoveUntilEvent extends NavigationEvent { class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate); PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
final NavigationDestination destination; final NavigationDestination destination;
final RoutePredicate predicate; final RoutePredicate predicate;
} }
class PushedNamedReplaceEvent extends NavigationEvent { class PushedNamedReplaceEvent extends NavigationEvent {
PushedNamedReplaceEvent(this.destination); PushedNamedReplaceEvent(this.destination);
final NavigationDestination destination; final NavigationDestination destination;
} }

View File

@@ -89,6 +89,10 @@ class NewConversationBloc extends Bloc<NewConversationEvent, NewConversationStat
final roster = List<RosterItem>.from(event.added); final roster = List<RosterItem>.from(event.added);
for (final item in state.roster) { for (final item in state.roster) {
// Handle removed items
if (event.removed.contains(item.jid)) continue;
// Handle modified items
final modified = firstWhereOrNull( final modified = firstWhereOrNull(
event.modified, event.modified,
(RosterItem i) => i.id == item.id, (RosterItem i) => i.id == item.id,

View File

@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart'; import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/service/data.dart'; import 'package:moxxyv2/ui/service/data.dart';
part 'own_devices_bloc.freezed.dart'; part 'own_devices_bloc.freezed.dart';
@@ -14,13 +15,13 @@ part 'own_devices_event.dart';
part 'own_devices_state.dart'; part 'own_devices_state.dart';
class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> { class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
OwnDevicesBloc() : super(OwnDevicesState()) { OwnDevicesBloc() : super(OwnDevicesState()) {
on<OwnDevicesRequestedEvent>(_onRequested); on<OwnDevicesRequestedEvent>(_onRequested);
on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet); on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet);
on<OwnSessionsRecreatedEvent>(_onSessionsRecreated); on<OwnSessionsRecreatedEvent>(_onSessionsRecreated);
on<OwnDeviceRemovedEvent>(_onDeviceRemoved); on<OwnDeviceRemovedEvent>(_onDeviceRemoved);
on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated); on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated);
on<DeviceVerifiedEvent>(_onDeviceVerified);
} }
Future<void> _onRequested(OwnDevicesRequestedEvent event, Emitter<OwnDevicesState> emit) async { Future<void> _onRequested(OwnDevicesRequestedEvent event, Emitter<OwnDevicesState> emit) async {
@@ -124,4 +125,29 @@ class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
), ),
); );
} }
Future<void> _onDeviceVerified(DeviceVerifiedEvent event, Emitter<OwnDevicesState> emit) async {
final ownJid = GetIt.I.get<UIDataService>().ownJid!;
final result = isVerificationUriValid(
state.keys,
event.uri,
ownJid,
event.deviceId,
);
if (result == -1) return;
final newDevices = List<OmemoDevice>.from(state.keys);
newDevices[result] = newDevices[result].copyWith(
verified: true,
);
emit(state.copyWith(keys: newDevices));
await MoxplatformPlugin.handler.getDataSender().sendData(
MarkOmemoDeviceAsVerifiedCommand(
jid: ownJid,
deviceId: event.deviceId,
),
awaitable: false,
);
}
} }

View File

@@ -21,7 +21,13 @@ class OwnDeviceRegeneratedEvent extends OwnDevicesEvent {}
/// Triggered by the UI when the device with id [deviceId] should be removed. /// Triggered by the UI when the device with id [deviceId] should be removed.
class OwnDeviceRemovedEvent extends OwnDevicesEvent { class OwnDeviceRemovedEvent extends OwnDevicesEvent {
OwnDeviceRemovedEvent(this.deviceId); OwnDeviceRemovedEvent(this.deviceId);
final int deviceId; final int deviceId;
} }
/// Triggered by the UI when a device has been verified using the QR code
class DeviceVerifiedEvent extends OwnDevicesEvent {
DeviceVerifiedEvent(this.uri, this.deviceId);
final Uri uri;
final int deviceId;
}

View File

@@ -6,9 +6,8 @@ abstract class PreferencesEvent {}
/// If [notify] is true, then the background service will be /// If [notify] is true, then the background service will be
/// notified of this change. /// notified of this change.
class PreferencesChangedEvent extends PreferencesEvent { class PreferencesChangedEvent extends PreferencesEvent {
PreferencesChangedEvent(this.preferences, { PreferencesChangedEvent(this.preferences, {
this.notify = true, this.notify = true,
}); });
final PreferencesState preferences; final PreferencesState preferences;
final bool notify; final bool notify;
@@ -19,7 +18,6 @@ class SignedOutEvent extends PreferencesEvent {}
/// Triggered when a background image has been set /// Triggered when a background image has been set
class BackgroundImageSetEvent extends PreferencesEvent { class BackgroundImageSetEvent extends PreferencesEvent {
BackgroundImageSetEvent(this.backgroundPath); BackgroundImageSetEvent(this.backgroundPath);
final String backgroundPath; final String backgroundPath;
} }

View File

@@ -34,6 +34,7 @@ class ServerInfoBloc extends Bloc<ServerInfoEvent, ServerInfoState> {
csiSupported: result.supportsCsi, csiSupported: result.supportsCsi,
httpFileUploadSupported: result.supportsHttpFileUpload, httpFileUploadSupported: result.supportsHttpFileUpload,
userBlockingSupported: result.supportsUserBlocking, userBlockingSupported: result.supportsUserBlocking,
carbonsSupported: result.supportsCarbons,
working: false, working: false,
), ),
); );

View File

@@ -8,5 +8,6 @@ class ServerInfoState with _$ServerInfoState {
@Default(false) bool userBlockingSupported, @Default(false) bool userBlockingSupported,
@Default(false) bool httpFileUploadSupported, @Default(false) bool httpFileUploadSupported,
@Default(false) bool csiSupported, @Default(false) bool csiSupported,
@Default(false) bool carbonsSupported,
}) = _ServerInfoState; }) = _ServerInfoState;
} }

View File

@@ -26,12 +26,26 @@ enum ShareSelectionType {
/// Create a common ground between Conversations and RosterItems /// Create a common ground between Conversations and RosterItems
class ShareListItem { class ShareListItem {
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation, this.isEncrypted); const ShareListItem(
this.avatarPath,
this.jid,
this.title,
this.isConversation,
this.isEncrypted,
this.pseudoRosterItem,
this.contactId,
this.contactAvatarPath,
this.contactDisplayName,
);
final String avatarPath; final String avatarPath;
final String jid; final String jid;
final String title; final String title;
final bool isConversation; final bool isConversation;
final bool isEncrypted; final bool isEncrypted;
final bool pseudoRosterItem;
final String? contactId;
final String? contactAvatarPath;
final String? contactDisplayName;
} }
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> { class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
@@ -67,6 +81,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
c.title, c.title,
true, true,
c.encrypted, c.encrypted,
false,
c.contactId,
c.contactAvatarPath,
c.contactDisplayName,
); );
}), }),
); );
@@ -83,6 +101,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
rosterItem.title, rosterItem.title,
false, false,
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault, GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
rosterItem.pseudoRosterItem,
rosterItem.contactId,
rosterItem.contactAvatarPath,
rosterItem.contactDisplayName,
), ),
); );
} else { } else {
@@ -92,6 +114,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
rosterItem.title, rosterItem.title,
false, false,
items[index].isEncrypted, items[index].isEncrypted,
items[index].pseudoRosterItem,
items[index].contactId,
items[index].contactAvatarPath,
items[index].contactDisplayName,
); );
} }
} }
@@ -104,7 +130,13 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
} }
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async { Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type)); emit(
state.copyWith(
paths: event.paths,
text: event.text,
type: event.type,
),
);
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedAndRemoveUntilEvent( PushedNamedAndRemoveUntilEvent(

View File

@@ -0,0 +1,184 @@
import 'package:bloc/bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/constants.dart';
part 'sticker_pack_bloc.freezed.dart';
part 'sticker_pack_event.dart';
part 'sticker_pack_state.dart';
class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
StickerPackBloc() : super(StickerPackState()) {
on<LocallyAvailableStickerPackRequested>(_onLocalStickerPackRequested);
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<RemoteStickerPackRequested>(_onRemoteStickerPackRequested);
on<StickerPackInstalledEvent>(_onStickerPackInstalled);
on<StickerPackRequested>(_onStickerPackRequested);
}
Future<void> _onLocalStickerPackRequested(LocallyAvailableStickerPackRequested event, Emitter<StickerPackState> emit) async {
emit(
state.copyWith(
isWorking: true,
isInstalling: false,
),
);
// Navigate
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(stickerPackRoute),
),
);
// Apply
final stickerPack = firstWhereOrNull(
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
(StickerPack pack) => pack.id == event.stickerPackId,
);
assert(stickerPack != null, 'The sticker pack must be found');
emit(
state.copyWith(
isWorking: false,
stickerPack: stickerPack,
),
);
}
Future<void> _onStickerPackRemoved(StickerPackRemovedEvent event, Emitter<StickerPackState> emit) async {
// Reset internal state
emit(
state.copyWith(
stickerPack: null,
isWorking: true,
),
);
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
// Remove the sticker pack
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackRemovedEvent(event.stickerPackId),
);
}
Future<void> _onRemoteStickerPackRequested(RemoteStickerPackRequested event, Emitter<StickerPackState> emit) async {
final mustDoWork = state.stickerPack == null || state.stickerPack?.id != event.stickerPackId;
if (mustDoWork) {
emit(
state.copyWith(
isWorking: true,
isInstalling: false,
),
);
}
// Navigate
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(stickerPackRoute),
),
);
if (mustDoWork) {
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
FetchStickerPackCommand(
stickerPackId: event.stickerPackId,
jid: event.jid,
),
);
if (result is FetchStickerPackSuccessResult) {
emit(
state.copyWith(
isWorking: false,
stickerPack: result.stickerPack,
),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
}
}
}
Future<void> _onStickerPackInstalled(StickerPackInstalledEvent event, Emitter<StickerPackState> emit) async {
assert(!state.stickerPack!.local, 'Sticker pack must be remote');
emit(
state.copyWith(
isInstalling: true,
),
);
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
InstallStickerPackCommand(
stickerPack: state.stickerPack!,
),
);
emit(
state.copyWith(
isInstalling: false,
),
);
if (result is StickerPackInstallSuccessEvent) {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(result.stickerPack),
);
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
await Fluttertoast.showToast(
msg: t.pages.stickerPack.fetchingFailure,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
}
}
Future<void> _onStickerPackRequested(StickerPackRequested event, Emitter<StickerPackState> emit) async {
// Find out if the sticker pack is locally available or not
final stickerPack = firstWhereOrNull(
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
(StickerPack pack) => pack.id == event.stickerPackId,
);
if (stickerPack == null) {
await _onRemoteStickerPackRequested(
RemoteStickerPackRequested(
event.stickerPackId,
event.jid,
),
emit,
);
} else {
await _onLocalStickerPackRequested(
LocallyAvailableStickerPackRequested(event.stickerPackId),
emit,
);
}
}
}

View File

@@ -0,0 +1,33 @@
part of 'sticker_pack_bloc.dart';
abstract class StickerPackEvent {}
/// Triggered by the UI when the user navigates to a locally available sticker pack
class LocallyAvailableStickerPackRequested extends StickerPackEvent {
LocallyAvailableStickerPackRequested(this.stickerPackId);
final String stickerPackId;
}
/// Triggered by the UI when the user navigates to a remote sticker pack
class RemoteStickerPackRequested extends StickerPackEvent {
RemoteStickerPackRequested(this.stickerPackId, this.jid);
final String stickerPackId;
final String jid;
}
/// Triggered by the UI when the sticker pack is removed
class StickerPackRemovedEvent extends StickerPackEvent {
StickerPackRemovedEvent(this.stickerPackId);
final String stickerPackId;
}
/// Triggered by the UI when the sticker pack currently displayed is to be installed
class StickerPackInstalledEvent extends StickerPackEvent {}
/// Triggered by the UI when a URL has been tapped that contains a sticker pack that
/// or may not be locally available.
class StickerPackRequested extends StickerPackEvent {
StickerPackRequested(this.jid, this.stickerPackId);
final String jid;
final String stickerPackId;
}

View File

@@ -0,0 +1,10 @@
part of 'sticker_pack_bloc.dart';
@freezed
class StickerPackState with _$StickerPackState {
factory StickerPackState({
StickerPack? stickerPack,
@Default(false) bool isWorking,
@Default(false) bool isInstalling,
}) = _StickerPackState;
}

View File

@@ -0,0 +1,150 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/painting.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'stickers_bloc.freezed.dart';
part 'stickers_event.dart';
part 'stickers_state.dart';
class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickersBloc() : super(StickersState()) {
on<StickersSetEvent>(_onStickersSet);
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<StickerPackImportedEvent>(_onStickerPackImported);
on<StickerPackAddedEvent>(_onStickerPackAdded);
}
Future<void> _onStickersSet(StickersSetEvent event, Emitter<StickersState> emit) async {
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
// of the sticker in the UI.
final map = <StickerKey, Sticker>{};
for (final pack in event.stickerPacks) {
for (final sticker in pack.stickers) {
if (!sticker.isImage) continue;
map[StickerKey(pack.id, sticker.hashKey)] = sticker;
}
}
emit(
state.copyWith(
stickerPacks: event.stickerPacks,
stickerMap: map,
),
);
}
Future<void> _onStickerPackRemoved(StickerPackRemovedEvent event, Emitter<StickersState> emit) async {
final stickerPack = firstWhereOrNull(
state.stickerPacks,
(StickerPack sp) => sp.id == event.stickerPackId,
)!;
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in stickerPack.stickers) {
sm.remove(StickerKey(stickerPack.id, sticker.hashKey));
// Evict stickers from the cache
unawaited(FileImage(File(sticker.path)).evict());
}
emit(
state.copyWith(
stickerPacks: List.from(
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
),
stickerMap: sm,
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveStickerPackCommand(
stickerPackId: event.stickerPackId,
),
awaitable: false,
);
}
Future<void> _onStickerPackImported(StickerPackImportedEvent event, Emitter<StickersState> emit) async {
final file = await FilePicker.platform.pickFiles();
if (file == null) return;
emit(
state.copyWith(
isImportRunning: true,
),
);
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
ImportStickerPackCommand(
path: file.files.single.path!,
),
);
if (result is StickerPackImportSuccessEvent) {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in result.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
result.stickerPack,
]),
stickerMap: sm,
isImportRunning: false,
),
);
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importSuccess,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
} else {
emit(
state.copyWith(
isImportRunning: false,
),
);
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importFailure,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
}
}
Future<void> _onStickerPackAdded(StickerPackAddedEvent event, Emitter<StickersState> emit) async {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in event.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
event.stickerPack,
]),
stickerMap: sm,
),
);
}
}

View File

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

View File

@@ -0,0 +1,25 @@
part of 'stickers_bloc.dart';
@immutable
class StickerKey {
const StickerKey(this.packId, this.stickerHashKey);
final String packId;
final String stickerHashKey;
@override
int get hashCode => packId.hashCode ^ stickerHashKey.hashCode;
@override
bool operator ==(Object other) {
return other is StickerKey && other.packId == packId && other.stickerHashKey == stickerHashKey;
}
}
@freezed
class StickersState with _$StickersState {
factory StickersState({
@Default([]) List<StickerPack> stickerPacks,
@Default({}) Map<StickerKey, Sticker> stickerMap,
@Default(false) bool isImportRunning,
}) = _StickersState;
}

View File

@@ -4,9 +4,24 @@ const Radius radiusLarge = Radius.circular(10);
const Radius radiusSmall = Radius.circular(4); const Radius radiusSmall = Radius.circular(4);
const double textfieldRadiusRegular = 15; const double textfieldRadiusRegular = 15;
const double textfieldRadiusConversation = 20; const double textfieldRadiusConversation = 25;
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, bottom: 4, left: 8, right: 8); const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10); top: 4,
bottom: 4,
left: 8,
right: 8,
);
/// The inner TextField padding for the TextField on the ConversationPage.
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.only(
top: 12,
bottom: 12,
left: 8,
right: 8,
);
/// The font size for the TextField on the ConversationPage
const double textFieldFontSizeConversation = 18;
const int primaryColorHexRGBO = 0xffcf4aff; const int primaryColorHexRGBO = 0xffcf4aff;
const int primaryColorAltHexRGB = 0xff9c18cd; const int primaryColorAltHexRGB = 0xff9c18cd;
@@ -17,12 +32,66 @@ const Color primaryColorAlt = Color(primaryColorAltHexRGB);
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB); const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
const Color textColorDisabled = Color(textColorDisabledHexRGB); const Color textColorDisabled = Color(textColorDisabledHexRGB);
/// The color of a quote bubble displayed inside the TextField
const Color bubbleQuoteInTextFieldColorLight = Color(0xffc7c7c7);
const Color bubbleQuoteInTextFieldColorDark = Color(0xff2f2f2f);
/// The color of text inside a quote bubble inside the TextField
const Color bubbleQuoteInTextFieldTextColorLight = Color(0xff373737);
const Color bubbleQuoteInTextFieldTextColorDark = Color(0xffdadada);
/// The text color of the hint text on the ConversationPage
const Color textFieldHintTextColorLight = Color(0xff4a4a4a);
const Color textFieldHintTextColorDark = Color(0xffd6d6d6);
/// The regular text color of the TextField on the ConversationPage
const Color textFieldTextColorLight = Colors.black;
const Color textFieldTextColorDark = Colors.white;
/// The color of a bubble that was sent
const Color bubbleColorSent = Color(0xff7e0bce); const Color bubbleColorSent = Color(0xff7e0bce);
const Color bubbleColorSentQuoted = bubbleColorSent;
/// The color of the quote widget for a sent quote
const Color bubbleColorSentQuoted = Color(0xff6e0ab4);
/// The color of a bubble that was received
const Color bubbleColorReceived = Color(0xff222222); const Color bubbleColorReceived = Color(0xff222222);
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
/// The color of the quote widget for a received quote
const Color bubbleColorReceivedQuoted = Color(0xff2f2f2f);
/// The color of a bubble when the message is unencrypted while the chat is encrypted
const Color bubbleColorUnencrypted = Color(0xffd40000); const Color bubbleColorUnencrypted = Color(0xffd40000);
/// The color of a bubble for a pseudo message of type new device
const Color bubbleColorNewDevice = Color(0xffeee8d5);
/// The color of text within a regular bubble
const Color bubbleTextColor = Color(0xffffffff);
/// The color of text within a quote widget
const Color bubbleTextQuoteColor = Color(0xffdadada);
/// The color of the sender name in a quote
const Color bubbleTextQuoteSenderColor = Color(0xffff90ff);
/// The color of the input text field of the conversation page
const Color conversationTextFieldColorLight = Color(0xffe6e6e6);
const Color conversationTextFieldColorDark = Color(0xff414141);
/// The width of the white left border of quote widgets
const double quoteLeftBorderWidth = 4;
/// The background color of the avatar when no actual avatar is available
const Color profileFallbackBackgroundColorLight = Color(0xffc3c3c3);
const Color profileFallbackBackgroundColorDark = Color(0xff424242);
/// The text color of the avatar fallback text
const Color profileFallbackTextColorLight = Color(0xff343434);
const Color profileFallbackTextColorDark = Colors.white;
const Color settingsSectionTitleColor = Color(0xffb72fe7);
const double paddingVeryLarge = 64; const double paddingVeryLarge = 64;
const Color tileColorDark = Color(0xff5c5c5c); const Color tileColorDark = Color(0xff5c5c5c);
@@ -35,11 +104,20 @@ const double fontsizeBody = 15;
const double fontsizeBodyOnlyEmojis = 30; const double fontsizeBodyOnlyEmojis = 30;
const double fontsizeSubbody = 10; const double fontsizeSubbody = 10;
// The translucent black we use when we need to ensure good contrast, for example when /// The color for a shared media item
// displaying the download progress indicator. final Color sharedMediaItemBackgroundColor = Colors.grey.shade500;
/// The color for a shared media summary
final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
/// The translucent black we use when we need to ensure good contrast, for example when
/// displaying the download progress indicator.
final backdropBlack = Colors.black.withAlpha(150); final backdropBlack = Colors.black.withAlpha(150);
// Navigation constants /// The height of the emoji/sticker picker
const double pickerHeight = 300;
/// Navigation constants
const String cropRoute = '/crop'; const String cropRoute = '/crop';
const String introRoute = '/intro'; const String introRoute = '/intro';
const String loginRoute = '/route'; const String loginRoute = '/route';
@@ -61,8 +139,11 @@ const String networkRoute = '$settingsRoute/network';
const String backgroundCroppingRoute = '$settingsRoute/appearance/background'; const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String conversationSettingsRoute = '$settingsRoute/conversation'; const String conversationSettingsRoute = '$settingsRoute/conversation';
const String appearanceRoute = '$settingsRoute/appearance'; const String appearanceRoute = '$settingsRoute/appearance';
const String stickersRoute = '$settingsRoute/stickers';
const String blocklistRoute = '/blocklist'; const String blocklistRoute = '/blocklist';
const String shareSelectionRoute = '/share_selection'; const String shareSelectionRoute = '/share_selection';
const String serverInfoRoute = '$profileRoute/server_info'; const String serverInfoRoute = '$profileRoute/server_info';
const String devicesRoute = '$profileRoute/devices'; const String devicesRoute = '$profileRoute/devices';
const String ownDevicesRoute = '$profileRoute/own_devices'; const String ownDevicesRoute = '$profileRoute/own_devices';
const String qrCodeScannerRoute = '/util/qr_code_scanner';
const String stickerPackRoute = '/stickers/sticker_pack';

View File

@@ -15,6 +15,7 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation; import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile; import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart' as sharedmedia; import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart' as sharedmedia;
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/prestart.dart'; import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/progress.dart'; import 'package:moxxyv2/ui/service/progress.dart';
@@ -32,6 +33,7 @@ void setupEventHandler() {
EventTypeMatcher<PreStartDoneEvent>(preStartDone), EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady), EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend), EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
]); ]);
GetIt.I.registerSingleton<EventHandler>(handler); GetIt.I.registerSingleton<EventHandler>(handler);
@@ -159,3 +161,9 @@ Future<void> onNotificationTappend(MessageNotificationTappedEvent event, { dynam
), ),
); );
} }
Future<void> onStickerPackAdded(StickerPackAddedEvent event, { dynamic extra }) async {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(event.stickerPack),
);
}

View File

@@ -1,15 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:better_open_file/better_open_file.dart';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart'; import 'package:hex/hex.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/avatar.dart'; import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart'; import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/redirects.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// Shows a dialog asking the user if they are sure that they want to proceed with an /// Shows a dialog asking the user if they are sure that they want to proceed with an
/// action. Resolves to true if the user pressed the confirm button. Returns false if /// action. Resolves to true if the user pressed the confirm button. Returns false if
@@ -179,3 +188,190 @@ String localeCodeToLanguageName(String localeCode) {
assert(false, 'Language code $localeCode has no name'); assert(false, 'Language code $localeCode has no name');
return ''; return '';
} }
/// Scans QR Codes for an URI with a scheme of xmpp:. Returns the URI when found.
/// Returns null if not.
Future<Uri?> scanXmppUriQrCode(BuildContext context) async {
final value = await Navigator.of(context).pushNamed<String>(
qrCodeScannerRoute,
arguments: QrCodeScanningArguments(
(value) {
if (value == null) return false;
final uri = Uri.tryParse(value);
if (uri == null) return false;
if (uri.scheme == 'xmpp') {
return true;
}
return false;
},
),
);
if (value != null) {
return Uri.parse(value);
}
return null;
}
/// Shows a dialog with the given data string encoded as a QR Code.
void showQrCode(BuildContext context, String data, { bool embedLogo = true }) {
showDialog<void>(
context: context,
builder: (BuildContext context) => Center(
child: ClipRRect(
borderRadius: const BorderRadius.all(radiusLarge),
child: SizedBox(
width: 220,
height: 220,
child: QrImage(
data: data,
size: 220,
backgroundColor: Colors.white,
embeddedImage: embedLogo ?
const AssetImage('assets/images/logo.png') :
null,
embeddedImageStyle: embedLogo ?
QrEmbeddedImageStyle(
size: const Size(50, 50),
) :
null,
),
),
),
),
);
}
/// Compares the scanned fingerprint (encoded by [scannedUri]) against the device list
/// [devices] for the device with id [deviceId] with the JID [deviceJid].
///
/// Returns the index of the device in [devices] on success. On failure of any kind,
/// returns -1.
int isVerificationUriValid(List<OmemoDevice> devices, Uri scannedUri, String deviceJid, int deviceId) {
if (scannedUri.queryParameters.isEmpty) {
// No query parameters
Fluttertoast.showToast(
msg: t.errors.omemo.verificationInvalidOmemoUrl,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return -1;
}
final jid = scannedUri.path;
if (deviceJid != jid) {
// The Jid is wrong
Fluttertoast.showToast(
msg: t.errors.omemo.verificationWrongJid,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return -1;
}
// TODO(PapaTutuWawa): Use an exception safe version of firstWhere
final sidParam = scannedUri.queryParameters
.keys
.firstWhere((param) => param.startsWith('omemo2-sid-'));
final id = int.parse(sidParam.replaceFirst('omemo2-sid-', ''));
final fp = scannedUri.queryParameters[sidParam];
if (id != deviceId) {
// The scanned device has the wrong Id
Fluttertoast.showToast(
msg: t.errors.omemo.verificationWrongDevice,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return -1;
}
final index = devices.indexWhere((device) => device.deviceId == deviceId);
if (index == -1) {
// The device is not in the list
Fluttertoast.showToast(
msg: t.errors.omemo.verificationNotInList,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return -1;
}
final device = devices[index];
if (device.fingerprint != fp) {
// The fingerprint is not what we expected
Fluttertoast.showToast(
msg: t.errors.omemo.verificationWrongFingerprint,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return -1;
}
return index;
}
/// Parse the URI [uriString] and trigger an appropriate UI action.
Future<void> handleUri(String uriString) async {
final uri = Uri.tryParse(uriString);
if (uri == null) return;
if (uri.scheme == 'xmpp') {
final psAction = uri.queryParameters['pubsub;action'];
if (psAction != null) {
final parts = psAction.split(';');
String? node;
String? item;
for (final p in parts) {
if (p.startsWith('node=')) {
node = p.substring(5);
} else if (p.startsWith('item=')) {
item = p.substring(5);
}
}
if (node == moxxmpp.stickersXmlns && item != null) {
// Retrieve a sticker pack
GetIt.I.get<StickerPackBloc>().add(
StickerPackRequested(
uri.path,
item,
),
);
}
}
return;
}
await launchUrl(
redirectUrl(uri),
mode: LaunchMode.externalNonBrowserApplication,
);
}
/// Open the file [path] using the system native means. Shows a toast if the
/// file cannot be opened.
Future<void> openFile(String path) async {
final result = await OpenFile.open(path);
if (result.type != ResultType.done) {
String message;
if (result.type == ResultType.noAppToOpen) {
message = t.errors.conversation.openFileNoAppError;
} else {
message = t.errors.conversation.openFileGenericError;
}
await Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.SNACKBAR,
);
}
}

View File

@@ -8,70 +8,97 @@ import 'package:moxxyv2/ui/widgets/button.dart';
import 'package:moxxyv2/ui/widgets/textfield.dart'; import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart'; import 'package:moxxyv2/ui/widgets/topbar.dart';
class AddContactPage extends StatelessWidget { class AddContactPage extends StatefulWidget {
const AddContactPage({ super.key }); const AddContactPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>( static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const AddContactPage(), builder: (_) => const AddContactPage(),
settings: const RouteSettings( settings: const RouteSettings(
name: addContactRoute, name: addContactRoute,
), ),
); );
@override
AddContactPageState createState() => AddContactPageState();
}
class AddContactPageState extends State<AddContactPage> {
final TextEditingController _controller = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<AddContactBloc, AddContactState>( return BlocBuilder<AddContactBloc, AddContactState>(
builder: (context, state) => Scaffold( builder: (context, state) => WillPopScope(
appBar: BorderlessTopbar.simple(t.pages.addcontact.title), onWillPop: () async {
body: Column( if (state.isWorking) {
children: [ return false;
Visibility( }
visible: state.working,
child: const LinearProgressIndicator(),
),
Padding( context.read<AddContactBloc>().add(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)), PageResetEvent(),
child: CustomTextField( );
labelText: t.pages.addcontact.xmppAddress, return true;
onChanged: (value) => context.read<AddContactBloc>().add( },
JidChangedEvent(value), child: Scaffold(
), appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
enabled: !state.working, body: Column(
cornerRadius: textfieldRadiusRegular, children: [
borderColor: primaryColor, Visibility(
borderWidth: 1, visible: state.isWorking,
errorText: state.jidError, child: const LinearProgressIndicator(),
suffixIcon: IconButton( ),
icon: const Icon(Icons.qr_code),
onPressed: () { Padding(
showNotImplementedDialog('QR-code scanning', context); padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
}, child: CustomTextField(
labelText: t.pages.addcontact.xmppAddress,
onChanged: (value) => context.read<AddContactBloc>().add(
JidChangedEvent(value),
),
controller: _controller,
enabled: !state.isWorking,
cornerRadius: textfieldRadiusRegular,
borderColor: primaryColor,
borderWidth: 1,
errorText: state.jidError,
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code),
onPressed: () async {
final jid = await scanXmppUriQrCode(context);
if (jid == null) return;
_controller.text = jid.path;
// ignore: use_build_context_synchronously
context.read<AddContactBloc>().add(
JidChangedEvent(jid.path),
);
},
),
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)), padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
child: Text(t.pages.addcontact.subtitle), child: Text(t.pages.addcontact.subtitle),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 32)),
child: Row(
children: [
Expanded(
child: RoundedButton(
cornerRadius: 32,
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
enabled: !state.working,
child: Text(t.pages.addcontact.buttonAddToContact),
),
)
],
), ),
)
], Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 32)),
child: Row(
children: [
Expanded(
child: RoundedButton(
cornerRadius: 32,
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
enabled: !state.isWorking,
child: Text(t.pages.addcontact.buttonAddToContact),
),
)
],
),
)
],
),
), ),
), ),
); );

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