Compare commits

...

237 Commits

Author SHA1 Message Date
0acd13f0f0 feat(ui): Use curves for bettwe animations 2022-11-21 18:37:14 +01:00
cfdb948372 feat(ui): Make the longpress vibration stronger 2022-11-21 18:24:03 +01:00
9b3130f363 feat(ui): Animate a transition between chat state and no chat state 2022-11-21 18:19:58 +01:00
d10efc274c fix(service): Every download is marked as verification failed 2022-11-21 17:52:02 +01:00
2e1b7fab53 Merge pull request 'Implement message retraction' (#161) from feat/message_retraction into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/161
2022-11-21 16:28:42 +00:00
91b92b2cc4 fix(ui): Make colors more readable 2022-11-21 17:23:46 +01:00
6e3ab111f3 feat(service): Set isMedia to false when retracting 2022-11-21 17:23:32 +01:00
7de0b1c00e fix(meta): Bump moxxmpp 2022-11-21 16:08:18 +01:00
8107743af2 fix(ui): Retract messages had no padding 2022-11-21 16:02:08 +01:00
aeff82f625 fix(service): Fix images being retracted 2022-11-21 15:48:32 +01:00
935cb1c38b feat(ui): Render all retracted messages as a text message 2022-11-21 14:05:24 +01:00
acd5b7706b fix(ui): Make showConfirmationDialog more like Flutter 2022-11-21 12:40:54 +01:00
0b111d1012 feat(ui): Indicate retracted messages in the overview 2022-11-21 12:22:44 +01:00
211d8f37d8 feat(service): Store if a last message is retracted 2022-11-21 11:59:17 +01:00
e2517a7786 feat(ui): Show a 'show warning' button 2022-11-21 11:13:57 +01:00
aaf5b4fecc refactor(ui): Move some functions into the message model 2022-11-21 10:56:47 +01:00
173e251e9f fix(ui): Images cannot be long pressed 2022-11-21 10:45:04 +01:00
3c0891e069 feat(meta): Update DOAP 2022-11-20 23:44:16 +01:00
2dc3de43d1 fix(ui): Only allow sending retractions on our own messages 2022-11-20 23:41:55 +01:00
1b332b5b3b feat(service): Tell the service to send the retraction 2022-11-20 23:40:22 +01:00
6ef34afc6d fix(i18n): Translate the new strings 2022-11-20 22:52:49 +01:00
1cd6b63f1e feat(ui): Implement a message selection menu 2022-11-20 22:33:18 +01:00
6fd17ee70e fix(ui): Prevent quoting retracted messages 2022-11-20 17:45:27 +01:00
050d151c67 fix(service): Ensure only the original sender can retract a message 2022-11-20 17:38:37 +01:00
25ec569cd8 fix(i18n): Translate the retracted message 2022-11-20 17:33:34 +01:00
2dd9847566 feat(service): Handle message retraction 2022-11-20 17:30:32 +01:00
ef108f2e4a Merge pull request 'Make Moxxy translatable' (#158) from feat/i18n into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/158
2022-11-20 11:56:06 +00:00
4894c2d627 feat(i18n): Give each translation a name 2022-11-20 12:52:57 +01:00
f0861a9a62 fix(service): Store the system's default language in the service 2022-11-20 12:07:44 +01:00
221cf89d10 feat(service): Tell the foreground service the systems default locale 2022-11-20 00:11:26 +01:00
c54ef9b84a feat(meta): Make the default language configurable 2022-11-19 23:56:38 +01:00
7aad272316 feat(service): Make login errors translatable 2022-11-19 22:54:14 +01:00
1f43353360 fix(service): Adjust to moxxmpp changes 2022-11-19 22:40:20 +01:00
5b54566c89 feat(shared): Make the message emoji messages translatable 2022-11-18 22:41:22 +01:00
a9e3d331fc feat(service): Make service strings translatable 2022-11-18 22:29:47 +01:00
192086546a feat(ui): Translate the entire UI 2022-11-17 23:25:05 +01:00
a30b8f888d feat(ui): Localise more pages 2022-11-17 21:33:00 +01:00
1e3fc9be3d feat(ui): Localise the conversations page 2022-11-17 20:23:34 +01:00
2ec08c7f68 refactor(ui): Use super parameters 2022-11-17 20:10:37 +01:00
3adf9b0d00 feat(ui): Use slang for the intro page 2022-11-17 18:13:29 +01:00
6fba0f28db test(service): Integration test MoxxyReconnectionPolicy 2022-11-16 22:58:25 +01:00
9bdc2c09e5 fix(ui): Open URL externally (fixes #155) 2022-11-16 20:27:38 +01:00
1eb95cde92 fix(ui): Make switches easier to visually decipher
Fixes #153.
2022-11-16 19:11:23 +01:00
719e793860 fix(xmpp): Bump moxxmpp to fix SCRAM-SHA-{256,512}
Turns out that the PBKDF was incorrectly configured
for hashes other than SHA-1.
2022-11-16 15:40:10 +01:00
f0e68f7b48 fix(service): Fix crash if a sign out happened 2022-11-16 14:28:56 +01:00
72501bd0b3 feat(ui,service): Only enable debug logging if required
So, either it's enabled or the app is a debug build.
2022-11-16 14:27:58 +01:00
bd6aaa07c8 feat(service): Replace the Migrator system with the database 2022-11-15 13:16:32 +01:00
77a9f81a1d fix(ui): Add a fallback error text 2022-11-14 21:10:24 +01:00
3eb88f66cf fix(service): Fix crash if login failed with no reason 2022-11-14 21:05:12 +01:00
3fb76d59b2 feat(meta): Release 0.3.0 2022-11-13 12:24:08 +01:00
72db2863d0 feat(meta): Bump moxxmpp 2022-11-12 21:52:04 +01:00
81ad0cf4db fix(meta): Remove unused dependencies 2022-11-12 21:04:27 +01:00
b6f2a89e04 fix(xmpp): Don't deadlock on TLS issues 2022-11-12 21:02:19 +01:00
e2c735b804 fix(meta): Remove ANDROID_* from the Flake 2022-11-12 20:13:36 +01:00
3f576ce3e5 docs(ui): Add TODO 2022-11-12 18:51:01 +01:00
1e51e8bb8b fix(ui): Always center the conversation title
Fixes #72.
2022-11-12 18:47:55 +01:00
ad48191b53 fix(ui): Make the UI more consistent colorwise
Fixes #34.
Fixes #144.

This commit makes more UI elements use the primary color. Also
adds an enabled state to the RoundedButton.
2022-11-12 17:54:54 +01:00
d851f302cc feat(service,ui): Successful login sends a PreStartDoneEvent
Closes #102.
2022-11-12 16:36:30 +01:00
09ba2122e7 fix(service): Fix loading the wrong quote from the database 2022-11-12 14:12:15 +01:00
9b16bf6e6f fix(service): File Upload Notification replacements should now be acked
Fixes #111.
2022-11-12 13:40:42 +01:00
ab6b5eefc0 fix(ui): Prevent quoting File Upload Notifications 2022-11-12 13:23:33 +01:00
993cd5ed1c refactor(ui): Remove ThumbnailService
Closes #31.
2022-11-12 13:02:47 +01:00
b733bb4154 fix(service): Set the caphash node 2022-11-12 12:53:10 +01:00
2edbbc3ede feat(meta): Bump moxxmpp and moxxmpp_socket_tcp 2022-11-12 12:52:20 +01:00
af3013ad67 refactor(service): Move manager overrides into moxxmpp/ 2022-11-09 16:43:44 +01:00
d02fe73952 refactor(meta): Migrate to using moxxmpp
Fixes #139.
2022-11-09 16:41:38 +01:00
32444d5a7e fix(ui): Error messages now cannot be quoted
Fixes #142.
2022-11-09 11:56:04 +01:00
b003b5e04b fix(ui): Make message draggable only in one direction
Fixes #118.
2022-11-09 11:47:55 +01:00
5d217a264a fix(shared): Fix smaller style issues 2022-11-09 11:38:08 +01:00
8d8c4d2da3 Merge pull request 'Add fallback body to quotes' (#134) from millesimus/moxxyv2:fix_quote_fallback into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/134
2022-11-09 11:34:13 +01:00
Millesimus
943a03bd2c
feat(service): Return readable file sizes in quote fallback body.
Following Conversations' file-size-to-string logic.
2022-11-07 16:29:56 +01:00
Millesimus
680303cfa2
feat(service): Save media size of downloaded and uploaded files. 2022-11-07 16:29:03 +01:00
Millesimus
e16bbf8acf
fix(xmpp+service): Enrich fallback message for quoted media…
…with srcUrl, messageEmojis and messageSizes. Fixes moxxy#128.
2022-11-07 16:29:01 +01:00
42ebbdba6d Merge pull request 'Implement OMEMO' (#126) from feat/omemo into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/126
2022-11-04 21:53:43 +01:00
a2b477a3dc fix(ui): Add missing comma 2022-11-04 21:52:06 +01:00
3c7d5ad5ad feat(ui): Show a spinner while we are regenerating the device 2022-11-04 21:52:06 +01:00
4261e26f19 feat(ui): Update our own device fingerprint after regeneration 2022-11-04 21:52:06 +01:00
a0ee4e1312 feat(xmpp): Add an event for receiving a device list update 2022-11-04 21:52:06 +01:00
93630650fc fix(xmpp): Sessions are not built for new Omemo devices 2022-11-04 21:52:06 +01:00
36b20fa2dd fix(xmpp): Wrong return for queued disco request 2022-11-04 21:52:06 +01:00
ae1c4dd3e6 fix(xmpp): Remove unneeded critical section exit 2022-11-04 21:52:06 +01:00
69438f44b3 feat(service): Notify if we could not publish the Omemo device 2022-11-04 21:52:06 +01:00
aceaa01cdb fix(xmpp): Disco#info requests stall after one fails 2022-11-04 21:52:06 +01:00
7fe220a630 feat(ui): Show which chats are encrypted 2022-11-04 21:52:06 +01:00
f07599adf2 feat(ui): Warn when sending a file to multiple chats where >= 1 is unencrypted 2022-11-04 21:52:06 +01:00
1b89b16705 fix(xmpp): Awaiting a stanza does not return the transformed received stanza 2022-11-04 21:52:06 +01:00
0fb1148508 fix(meta): Ignore .android 2022-11-04 21:52:06 +01:00
208145d288 fix(xmpp): I think ratchet acking should work better now 2022-11-04 21:52:06 +01:00
a6bd60077d fix(xmpp): Fix outgoing presence being wrongly processed 2022-11-04 21:52:06 +01:00
387c20a708 fix(xmpp): Fix encrypting direct Presence and IQs 2022-11-04 21:52:06 +01:00
1a9d34d347 fix(service): Fix dynamic access 2022-11-04 21:52:06 +01:00
ed4ee53fdb fix(tests): Fix tests 2022-11-04 21:52:06 +01:00
4848a13fa0 fix(xmpp): Remove the sendRawXml method of Managers' attributes 2022-11-04 21:52:06 +01:00
b9ebd506c8 chore(ui): Refactor 'Key' to 'Device' 2022-11-04 21:52:06 +01:00
249f49f7b3 feat(xmpp): Enable OMEMO for IQs and Presence Stanzas as well 2022-11-04 21:52:06 +01:00
db3f5eb066 feat(service): Perform (most) file hashing operations on the native side 2022-11-04 21:52:06 +01:00
c2f43e0096 chore(meta): Remove cryptography_flutter 2022-11-04 21:52:06 +01:00
d81586d026 feat(service): Massively speed up BlurHash calculation 2022-11-04 21:52:06 +01:00
18a9419cef fix(service): Make HttpFileTransferService update messages on error 2022-11-04 21:52:06 +01:00
8a69083c19 feat(service): Improve encryption and decryption speed 2022-11-04 21:52:06 +01:00
ba1b79f657 test(xmpp): Test hash parsing for SFS 2022-11-04 21:52:06 +01:00
1a2415925f feat(xmpp): Attempt PubSub subscription only once per session 2022-11-04 21:52:06 +01:00
16a597183a feat(xmpp): Log stanzas in their decrypted forms 2022-11-04 21:52:06 +01:00
2461430869 fix(xmpp): Tell the UI about the message change 2022-11-04 21:52:06 +01:00
605201dbc8 feat(xmpp): Try to keep our device list up-to-date 2022-11-04 21:52:06 +01:00
214d3250fe feat(service): Mark message if the chat is encrypted while the file isn't 2022-11-04 21:52:06 +01:00
ea9c634a25 fix(xmpp): Fix a small logic bug in _findNewSessions 2022-11-04 21:52:06 +01:00
62095cb170 feat(service): Move isUploading and isDownloading to the database 2022-11-04 21:52:06 +01:00
aba85c70c1 feat(service): Remove support for SIMS 2022-11-04 21:52:06 +01:00
98569cff69 feat(service): Pull the filename from SFS, if given 2022-11-04 21:52:06 +01:00
952dfdc521 feat(meta): Make gitlint rules nicer 2022-11-04 21:52:06 +01:00
93724802d8 feat(xmpp): Bail early on encryption if the stanza contains PubSub 2022-11-04 21:52:06 +01:00
e5f19e3b8b feat(xmpp): Make SM's setState async 2022-11-04 21:52:06 +01:00
4479506ee0 fix(xmpp): Fix the Stream Management not counting awaited stanzas 2022-11-04 21:52:06 +01:00
359d4508b1 test(xmpp): Write test to ensure early-ended stanzas are counted 2022-11-04 21:52:06 +01:00
ef9ba68790 fix(xmpp): Make SM counting run at the very beginning or end 2022-11-04 21:52:06 +01:00
53ce0d9e54 chore(xmpp): Track kex timestamp to prevent performing old kex 2022-11-04 21:52:06 +01:00
505921045e fix(xmpp): Fix receiving kex again breaking the ratchets 2022-11-04 21:52:06 +01:00
2c71e01e5a fix(xmpp): Do not try to decrypt if the stanza has no from 2022-11-04 21:52:06 +01:00
c3cf84ee7d fix(service): Clear session related tables when regenerating the device 2022-11-04 21:52:06 +01:00
5787a8943d feat(xmpp): Ignore PubSub elements 2022-11-04 21:52:06 +01:00
313e276ad6 feat(service): Implement regenerating one's device 2022-11-04 21:52:06 +01:00
310891bf16 feat(ui): Add more confirmation prompts 2022-11-04 21:52:06 +01:00
8852356966 feat(xmpp): Work around ejabberd not accepting max in publish options 2022-11-04 21:52:06 +01:00
6243766ecc fix(ui): Fix not popping the dialog 2022-11-04 21:52:06 +01:00
2d5e987fcc feat(service): Implement deleting devices
- Fix accidentally deleting the entire bundles node instead of just
  retracting the single item.
- ASK IF THE USER WANTED TO DO THIS
- Fix checking for the wrong result type
2022-11-04 21:52:06 +01:00
bf851c2bd6 fix(service): For now, revert the multiple PK situation 2022-11-04 21:52:06 +01:00
0327f254a2 feat(ui): Display warnings for messages 2022-11-04 21:52:06 +01:00
a0c7078593 feat(service): Verify given file hashes 2022-11-04 21:52:06 +01:00
0cbea9607e feat(xmpp): Communicate encryption errors due to missing devices 2022-11-04 21:52:06 +01:00
e799d516ea feat(xmpp): The send cancelled event now carries the handler data 2022-11-04 21:52:06 +01:00
c630d8f091 fix(xmpp): Only add EME for messages 2022-11-04 21:52:06 +01:00
2494fbb837 fix(ui): Fix title of the contact devices page 2022-11-04 21:52:06 +01:00
cb2560d46f fix(xmpp): Clean the publish method 2022-11-04 21:52:06 +01:00
240ed5f859 feat(ui): Add warning to enabling OMEMO by default 2022-11-04 21:52:06 +01:00
0365730e0e chore(xmpp): Move namespace into namespaces.dart 2022-11-04 21:52:06 +01:00
b9d5eab3ea chore(xmpp): Move xep_0060.dart into its folder 2022-11-04 21:52:06 +01:00
16c84d59dc fix(service): Make more message attributes primary keys 2022-11-04 21:52:06 +01:00
621c396407 feat(service): Handle file decryption errors 2022-11-04 21:52:06 +01:00
3f2cc3d97a feat(xmpp): When sending is cancelled, return an error stanza
This will help once we try to encrypt IQ stanzas.
2022-11-04 21:52:06 +01:00
ce927308c4 feat(xmpp): Allow stanza handlers to cancel sending 2022-11-04 21:52:06 +01:00
df99eb0aab chore(service): Add TODO 2022-11-04 21:52:06 +01:00
e8473d4f5b chore(service): Refactor the MediaFileLocation 2022-11-04 21:52:06 +01:00
1175d77c55 feat(service): Generate the OMEMO device in the background 2022-11-04 21:52:06 +01:00
570f4ca7d9 chore(service): Clean the cryptography service 2022-11-04 21:52:06 +01:00
e4b9c8f1bc feat(service): Generate a plaintext hash in all cases 2022-11-04 21:52:06 +01:00
ea6e7c5d8c feat(service): Hash the file before sending the metadata 2022-11-04 21:52:06 +01:00
a462945c98 feat(service): Perform encryption and decryption off-thread 2022-11-04 21:52:06 +01:00
003d4d65e5 chore(meta): Bump omemo_dart 2022-11-04 21:52:06 +01:00
38fa5ab991 fix(service): Fix the result of file encryption 2022-11-04 21:52:06 +01:00
e22e7b9c90 fix(xmpp): Fix not parsing ESFS 2022-11-04 21:52:06 +01:00
fc6a8eae9d fix(service): Fix trust device list not loaded 2022-11-04 21:52:06 +01:00
4dab811388 fix(service): Fix file not encrypted before upload 2022-11-04 21:52:06 +01:00
012dc5ec69 feat(xmpp): Collection commit
- Fix Db issue when saving the trust device list
- Prevent constantly requesting our own device bundles if it's just our
  one device.
- Encrypt uploads and decrypt downloads
2022-11-04 21:52:06 +01:00
f72f67342d feat(service): Implement a service for file cryptography 2022-11-04 21:52:06 +01:00
283ac315d8 feat(service): Also store the used encryption mechanism 2022-11-04 21:52:06 +01:00
1a5b0f372d chore(meta): Update DOAP 2022-11-04 21:52:06 +01:00
de40e859d7 feat(xmpp): Implement XEP-0448 2022-11-04 21:52:06 +01:00
6140de8eea feat(ui): Add (untested) support for recreating own sessions 2022-11-04 21:52:06 +01:00
a9fcbd7909 feat(xmpp): Add Message Processing Hints to OMEMO messages 2022-11-04 21:52:06 +01:00
a963153c2a feat(service): Use cryptography_flutter for possible speedup 2022-11-04 21:52:06 +01:00
8efb743b84 feat(ui): Implement (untested) device deletion 2022-11-04 21:52:06 +01:00
f251d6b97b feat(xmpp): Implement PubSub item delete 2022-11-04 21:52:06 +01:00
8a5a96d02c feat(ui): Implement enabling and disabling one's own sessions 2022-11-04 21:52:06 +01:00
fc0dd14b4d feat(ui): Display key controls only on keys we have a session with 2022-11-04 21:52:06 +01:00
4d67c157f0 feat(service): Implement getting one's own device fingerprints 2022-11-04 21:52:06 +01:00
a678ef70e7 fix(ui): isEmpty -> isNotEmpty 2022-11-04 21:52:06 +01:00
87d320b6da feat(ui): Implement viewing the device's fingerprint 2022-11-04 21:52:06 +01:00
6b7d3c4b7c chore(ui): Move the fingerprint widget into its own file 2022-11-04 21:52:06 +01:00
8a99f8f6b1 feat(service): Remove the need for secure storage in OmemoService 2022-11-04 21:52:06 +01:00
4aa24cc0a1 chore(service): Move shouldEncrypt check to ConversationService 2022-11-04 21:52:06 +01:00
e309f3bbd0 fix(ui): Make the fingerprint display font size better 2022-11-04 21:52:06 +01:00
a757c45b84 fix(ui): Make the message list react to changes in encryption 2022-11-04 21:52:06 +01:00
ed397e352f chore(xmpp): Add TODO 2022-11-04 21:52:06 +01:00
cf6cec4d32 fix(xmpp): Fix not encrypting messages 2022-11-04 21:52:06 +01:00
c4422355e3 feat(xmpp): Make Omemo optional 2022-11-04 21:52:06 +01:00
dd947ecd39 feat(service): Improve initializing the OmemoManager 2022-11-04 21:52:06 +01:00
968b59aaee fix(service): Ensure OmemoService is safe against late initialization 2022-11-04 21:52:06 +01:00
b9cb023306 chore(ui): Add TODO 2022-11-04 21:52:06 +01:00
031ef140f3 feat(ui): Color bubbles red if they are unencrypted when they should not 2022-11-04 21:52:06 +01:00
7376607475 feat(ui): Implement enabling and disabling Omemo (UI Only) 2022-11-04 21:52:06 +01:00
1aea6ee588 feat(ui): Make enabling Omemo by default configurable 2022-11-04 21:52:06 +01:00
12717ba25e feat(service): Store the encryption status of conversations in the DB 2022-11-04 21:52:03 +01:00
a1fa666cd3 chore(meta): Finally make gitlint rules prettier 2022-11-04 21:51:18 +01:00
30cfd67e28 xmpp: Encrypt to self 2022-11-04 21:51:18 +01:00
068d156da3 xmpp: Attempt to ignore our own device ratchet 2022-11-04 21:51:18 +01:00
ce4ed9b0a9 xmpp: Implement the race condition detection 2022-11-04 21:51:18 +01:00
b8acbe7359 ui: Fix issues with const 2022-11-04 21:51:18 +01:00
c61485638b ui: Prevent session rebuilding if there are no sessions 2022-11-04 21:51:18 +01:00
fd20d5177d xmpp: Move Disco classes into their own file 2022-11-04 21:51:18 +01:00
2a603e1e41 xmpp: Migrate disco to Resultv2 2022-11-04 21:51:18 +01:00
e4d71c5a39 xmpp: Migrate discoItemsQuery to Resultsv2 2022-11-04 21:51:18 +01:00
842a6ebe16 meta: Fix style issues 2022-11-04 21:51:18 +01:00
14ecc63944 xmpp: Add documentation to member functions of the Omemo manager 2022-11-04 21:51:18 +01:00
18fb728973 service: Send an empty OMEMO message on session recreation 2022-11-04 21:51:18 +01:00
a11b75f1cb xmpp: (Hopefully) fix session resetting not working 2022-11-04 21:51:18 +01:00
86abadd6bb xmpp: Fix not adding new bundles 2022-11-04 21:51:18 +01:00
ab47b06fd6 tmp: Migrate OMEMO to Resultsv2 api 2022-11-04 21:51:18 +01:00
640ffcb77e meta: Update omemo_dart to 0.3.0 2022-11-04 21:51:18 +01:00
26b6abe66b ui: Make bubble icons smaller 2022-11-04 21:51:18 +01:00
c00df84f2a xmpp: Unholy fix for errors on publish 2022-11-04 21:51:18 +01:00
2b7b7a10bc ui: Fix every sent message having an error 2022-11-04 21:51:18 +01:00
5b18b3d50d ui: Fix messages having no text 2022-11-04 21:51:18 +01:00
be24afc8bf xmpp: Fix bug with invalid affix elements 2022-11-04 21:51:18 +01:00
5332572b2e ui: Display decryption errors 2022-11-04 21:51:18 +01:00
6551fda493 service: Restore the trust manager 2022-11-04 21:51:18 +01:00
e3d33f201c service: Implement enabling and disabling keys 2022-11-04 21:51:18 +01:00
ea3d550f64 xmpp: Implement Explicit Message Encryption 2022-11-04 21:51:18 +01:00
21c1632233 xmpp: Don't try to decrypt unencrypted stanzas 2022-11-04 21:51:18 +01:00
1a66cadb53 style: Fix minor style issues 2022-11-04 21:51:18 +01:00
c8b1330244 xmpp: Also fail affix checks if the afix elements are missing 2022-11-04 21:51:18 +01:00
4c5204598e xmpp: Make the decision which elements to encrypt an implementation issue 2022-11-04 21:51:18 +01:00
b8aedc842e xmpp: Move conversion functions into the helpers file 2022-11-04 21:51:18 +01:00
c1579cb106 service: Commit the device map and handle device changes 2022-11-04 21:51:18 +01:00
c1ff949346 ui: Implement listing a Jid's Omemo fingerprints 2022-11-04 21:51:18 +01:00
3eea6c2ff9 xmpp: Clean the OMEMO implementation a bit 2022-11-04 21:51:18 +01:00
d1f826bdb5 xmpp: Generate affix elements 2022-11-04 21:51:18 +01:00
5586fcff7a refactor: Move the OMEMO implementation 2022-11-04 21:51:18 +01:00
f98b18affc xmpp: Move envelope creation into _encryptChildren 2022-11-04 21:51:18 +01:00
28135244c3 xmpp: I SENT TWO MESSAGES BETWEEN TWO MOXXY INSTANCES 2022-11-04 21:51:18 +01:00
47533c7512 xmpp: Communicate decryption errors 2022-11-04 21:51:18 +01:00
f79f35e2be meta: Update DOAP 2022-11-04 21:51:18 +01:00
c43c4a9b24 service: Mark only encrypted messages as encrypted (receive only) 2022-11-04 21:51:18 +01:00
81a47a12ec ui: Display encrypted messages as encrypted 2022-11-04 21:51:18 +01:00
9e3a0a0f1d xmpp: RECEIVE THE FIRST OMEMO MESSAGE! 2022-11-04 21:51:18 +01:00
2d0426c0a3 xmpp: Fix PubSub issues
- Setting max_items=max sets max_items=#items + 1 if it is not supported
- Publish options were not working
2022-11-04 21:51:18 +01:00
7f366d3f3c service: Publish OMEMO bundle after connecting 2022-11-04 21:51:18 +01:00
3dd1e0461c service: Initialize the OMEMO service 2022-11-04 21:51:18 +01:00
8036a3a5be xmpp: Implement publishing a bundle 2022-11-04 21:51:18 +01:00
29a692de5f xmpp: Implement retrieving the device bundle 2022-11-04 21:51:18 +01:00
4f515d4733 xmpp: Start working on OMEMO 2022-11-04 21:51:18 +01:00
2c28e95bd9 ui: Change the icon and wording for the keys page 2022-11-04 21:51:18 +01:00
e7d354d4c7 meta: Pull in omemo_dart 2022-11-04 21:51:18 +01:00
5b64506612 ui: Add comment about OmemoKey usage 2022-11-04 21:51:18 +01:00
f2135081ef ui: Make dialogs prettier 2022-11-04 21:51:18 +01:00
ae9b1a8215 ui: Stub the scanning button 2022-11-04 21:51:18 +01:00
0f5b3d62b1 ui: Stub out the OMEMO key page 2022-11-04 21:51:18 +01:00
235 changed files with 5548 additions and 11392 deletions

4
.gitignore vendored
View File

@ -52,7 +52,11 @@ app.*.map.json
**/*.g.dart
**/*.freezed.dart
**/*.moxxy.dart
lib/i18n/*.dart
# Direnv
.envrc
.direnv/
# Android artifacts
.android

View File

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

View File

@ -17,7 +17,8 @@ Clone using `git clone --recursive https://github.com/Polynomdivision/moxxyv2.gi
In order to build Moxxy, you need to have [Flutter](https://docs.flutter.dev/get-started/install) set
up. If you are running NixOS or using Nix, you can also use the Flake at the root of the repository
by running `nix develop` to get a development shell including everything that is needed.
by running `nix develop` to get a development shell including everything that is needed. Note
that if you decide to use the Flake, `ANDROID_HOME` and `ANDROID_AVD_HOME` must be set to the respective directories.
Before building Moxxy, you need to generate all needed data classes. To do this, run
`flutter pub get` to install all dependencies. Then run `flutter pub run build_runner build` to generate

View File

@ -13,3 +13,6 @@ analyzer:
- "**/*.freezed.dart"
- "**/*.moxxy.dart"
- "test/"
- "integration_test/"
- "lib/service/database/migrations/*.dart"
- "lib/i18n/*.dart"

Binary file not shown.

View File

@ -0,0 +1,224 @@
{
"@@name": "English",
"global": {
"title": "Moxxy",
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
"dialogAccept": "Okay",
"dialogCancel": "Cancel",
"yes": "Yes",
"no": "No"
},
"notifications": {
"permanent": {
"idle": "Idle",
"ready": "Ready to receive messages",
"connecting": "Connecting...",
"disconnect": "Disconnected",
"error": "Error"
}
},
"messages": {
"image": "Image",
"video": "Video",
"audio": "Audio",
"file": "File",
"retracted": "The message has been retracted",
"retractedFallback": "A previous message has been retracted but your client does not support it"
},
"errors": {
"omemo": {
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work."
},
"connection": {
"connectionTimeout": "Could not connect to server"
},
"login": {
"saslFailed": "Invalid login credentials",
"startTlsFailed": "Failed to establish a secure connection",
"noConnection": "Failed to establish a connection",
"unspecified": "Unspecified error"
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Could not verify file integrity"
}
},
"pages": {
"intro": {
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
"loginButton": "Login",
"registerButton": "Register"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Address",
"password": "Password",
"advancedOptions": "Advanced options",
"createAccount": "Create account on server"
},
"conversations": {
"speeddialNewChat": "New chat",
"speeddialJoinGroupchat": "Join groupchat",
"overlaySettings": "Settings",
"noOpenChats": "You have no open chats",
"startChat": "Start a chat"
},
"conversation": {
"unencrypted": "Unencrypted",
"encrypted": "Encrypted",
"closeChat": "Close chat",
"closeChatConfirmTitle": "Close chat",
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
"blockUser": "Block user",
"online": "Online",
"retract": "Retract message",
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
"forward": "Forward",
"edit": "Edit"
},
"addcontact": {
"title": "Add new contact",
"xmppAddress": "XMPP-Address",
"subtitle": "You can add a contact either by typing in their XMPP address or by scanning their QR code",
"buttonAddToContact": "Add to contacts"
},
"newconversation": {
"title": "Start new chat",
"addContact": "Add contact",
"createGroupchat": "Create groupchat"
},
"crop": {
"setProfilePicture": "Set as profile picture"
},
"shareselection": {
"shareWith": "Share with...",
"confirmTitle": "Send file",
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
},
"profile": {
"self": {
"devices": "Devices"
},
"conversation": {
"muteChatTooltip": "Mute chat",
"unmuteChatTooltip": "Unmute chat",
"muteChat": "Mute",
"unmuteChat": "Unmute",
"devices": "Devices"
},
"owndevices": {
"title": "Own Devices",
"thisDevice": "This device",
"otherDevices": "Other devices",
"deleteDeviceConfirmTitle": "Delete device",
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
"recreateOwnSessions": "Rebuild sessions",
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
"recreateOwnDevice": "Recreate device",
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
},
"devices": {
"title": "Devices",
"recreateSessions": "Rebuild sessions",
"recreateSessionsConfirmTitle": "Rebuild sessions?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
}
},
"blocklist": {
"title": "Blocklist",
"noUsersBlocked": "You have no users blocked",
"unblockAll": "Unblock all",
"unblockAllConfirmTitle": "Are you sure?",
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
"unblockJidConfirmTitle": "Unblock ${jid}?",
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
},
"settings": {
"settings": {
"title": "Settings",
"conversationsSection": "Conversations",
"accountSection": "Account",
"signOut": "Sign out",
"signOutConfirmTitle": "Sign Out",
"signOutConfirmBody": "You are about to sign out. Proceed?",
"miscellaneousSection": "Miscellaneous",
"debuggingSection": "Debugging"
},
"about": {
"title": "About",
"licensed": "Licensed under GPL3",
"viewSourceCode": "View source code"
},
"appearance": {
"title": "Appearance",
"languageSection": "Language",
"language": "App language",
"languageSubtext": "Currently selected: $selectedLanguage",
"systemLanguage": "Default language"
},
"licenses": {
"title": "Open-Source Licenses",
"licensedUnder": "Licensed under $license"
},
"conversation": {
"title": "Chat",
"appearance": "Appearance",
"selectBackgroundImage": "Select background image",
"selectBackgroundImageDescription": "This image will be the background of all your chats",
"removeBackgroundImage": "Remove background image",
"removeBackgroundImageConfirmTitle": "Remove background image",
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
"newChatsSection": "New Conversations",
"newChatsMuteByDefault": "Mute new chats by default",
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental"
},
"debugging": {
"title": "Debugging options",
"generalSection": "General",
"generalEnableDebugging": "Enable debugging",
"generalEncryptionPassword": "Encryption password",
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
"generalLoggingIp": "Logging IP",
"generalLoggingIpSubtext": "The IP the logs should be sent to",
"generalLoggingPort": "Logging Port",
"generalLoggingPortSubtext": "The IP the logs should be sent to"
},
"network": {
"title": "Network",
"automaticDownloadsSection": "Automatic Downloads",
"automaticDownloadsText": "Moxxy will automatically download files on...",
"automaticDownloadsMaximumSize": "Maximum Download Size",
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
"wifi": "Wifi",
"mobileData": "Mobile data"
},
"privacy": {
"title": "Pricacy",
"generalSection": "General",
"showContactRequests": "Show contact requests",
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
"profilePictureVisibility": "Make profile picture public",
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
"autoAcceptSubscriptionRequests": "Auto-accept subscription requests",
"autoAcceptSubscriptionRequestsSubtext": "If enabled, subscription requests will be automatically accepted if the user is in the contact list.",
"conversationsSection": "Conversation",
"sendChatMarkers": "Send chat markers",
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
"sendChatStates": "Send chat states",
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
"redirectsSection": "Redirects",
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
"currentlySelected": "Currently selected: $proxy",
"redirectsTitle": "$serviceName Redirect",
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
"urlEmpty": "URL cannot be empty",
"urlInvalid": "Invalid URL",
"redirectDialogTitle": "$serviceName Redirect"
}
}
}
}

View File

@ -0,0 +1,224 @@
{
"@@name": "Deutsch",
"global": {
"title": "Moxxy",
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
"dialogAccept": "Okay",
"dialogCancel": "Abbrechen",
"yes": "Ja",
"no": "Nein"
},
"notifications": {
"permanent": {
"idle": "Bereit",
"ready": "Bereit zum Nachrichtenempfang",
"connecting": "Verbinde...",
"disconnect": "Keine Verbindung",
"error": "Fehler"
}
},
"messages": {
"image": "Bild",
"video": "Video",
"audio": "Audio",
"file": "Datei",
"retracted": "Die Nachricht wurde zurückgezogen",
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
},
"errors": {
"omemo": {
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht."
},
"connection": {
"connectionTimeout": "Verbindung zum Server nicht möglich"
},
"login": {
"saslFailed": "Ungültige Logindaten",
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
"noConnection": "Konnte keine Verbindung zum Server aufbauen",
"unspecified": "Unbestimmter Fehler"
}
},
"warnings": {
"message": {
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
}
},
"pages": {
"intro": {
"noAccount": "Kein XMPP-Account vorhanden? Einen zu erstellen ist sehr einfach.",
"loginButton": "Einloggen",
"registerButton": "Registrieren"
},
"login": {
"title": "Login",
"xmppAddress": "XMPP-Adresse",
"password": "Passwort",
"advancedOptions": "Fortgeschrittene Optionen",
"createAccount": "Account auf dem Server erstellen"
},
"conversations": {
"speeddialNewChat": "Neuer chat",
"speeddialJoinGroupchat": "Gruppenchat beitreten",
"overlaySettings": "Einstellungen",
"noOpenChats": "Du hast keine offenen chats",
"startChat": "Einen chat anfangen"
},
"conversation": {
"unencrypted": "Unverschlüsselt",
"encrypted": "Verschlüsselt",
"closeChat": "Chat schließen",
"closeChatConfirmTitle": "Chat schließen",
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
"blockUser": "Nutzer blockieren",
"online": "Online",
"retract": "Nachricht löschen",
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
"forward": "Weiterleiten",
"edit": "Bearbeiten"
},
"addcontact": {
"title": "Neuen Kontakt hinzufügen",
"xmppAddress": "XMPP-Adresse",
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
"buttonAddToContact": "Kontakt hinzufügen"
},
"newconversation": {
"title": "Neuer chat",
"addContact": "Kontakt hinzufügen",
"createGroupchat": "Gruppenchat erstellen"
},
"crop": {
"setProfilePicture": "Als Profilbild festlegen"
},
"shareselection": {
"shareWith": "Teilen mit...",
"confirmTitle": "Dateien senden?",
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
},
"profile": {
"self": {
"devices": "Geräte"
},
"conversation": {
"muteChatTooltip": "Chat stummschalten",
"unmuteChatTooltip": "Chat lautstellen",
"muteChat": "Stummschalten",
"unmuteChat": "Lautstellen",
"devices": "Geräte"
},
"owndevices": {
"title": "Eigene Geräte",
"thisDevice": "Dieses Gerät",
"otherDevices": "Andere Geräte",
"deleteDeviceConfirmTitle": "Gerät löschen",
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
"recreateOwnSessions": "Sessions neuerstellen",
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
"recreateOwnDevice": "Gerät neuerstellen",
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
},
"devices": {
"title": "Devices",
"recreateSessions": "Rebuild sessions",
"recreateSessionsConfirmTitle": "Rebuild sessions?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
}
},
"blocklist": {
"title": "Blockliste",
"noUsersBlocked": "Du hast niemanden blockiert",
"unblockAll": "Alle entblocken",
"unblockAllConfirmTitle": "Alle entblocken",
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
"unblockJidConfirmTitle": "${jid} entblocken?",
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
},
"settings": {
"settings": {
"title": "Einstellungen",
"conversationsSection": "Unterhaltungen",
"accountSection": "Account",
"signOut": "Abmelden",
"signOutConfirmTitle": "Abmelden",
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
"miscellaneousSection": "Unterschiedlich",
"debuggingSection": "Debugging"
},
"about": {
"title": "Über",
"licensed": "Lizensiert unter GPL3",
"viewSourceCode": "Quellcode anschauen"
},
"appearance": {
"title": "Aussehen",
"languageSection": "Sprache",
"language": "Appsprache",
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
"systemLanguage": "Systemsprache"
},
"licenses": {
"title": "Open-Source Lizenzen",
"licensedUnder": "Lizensiert unter $license"
},
"conversation": {
"title": "Chat",
"appearance": "Aussehen",
"selectBackgroundImage": "Hintergrundbild auswählen",
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
"removeBackgroundImage": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
"newChatsSection": "Neue Chats",
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell"
},
"debugging": {
"title": "Debuggingoptionen",
"generalSection": "Generell",
"generalEnableDebugging": "Debugging einschalten",
"generalEncryptionPassword": "Verschlüsselungspasswort",
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
"generalLoggingIp": "Logging-IP",
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
"generalLoggingPort": "Logging-Port",
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
},
"network": {
"title": "Netzwerk",
"automaticDownloadsSection": "Automatische Downloads",
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"wifi": "Wifi",
"mobileData": "Mobile Daten"
},
"privacy": {
"title": "Privatsphäre",
"generalSection": "Generell",
"showContactRequests": "Kontaktanfragen zeigen",
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
"profilePictureVisibility": "Öffentliches Profilbild",
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
"autoAcceptSubscriptionRequests": "Subscriptionanfragen automatisch annehmen",
"autoAcceptSubscriptionRequestsSubtext": "Wenn aktiviert, dann werden Subscriptionanfragen automatisch angenommen, wenn die Person in deiner Kontaktliste ist",
"conversationsSection": "Unterhaltungen",
"sendChatMarkers": "Chatmarker senden",
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
"sendChatStates": "Chatstates senden",
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
"redirectsSection": "Weiterleitungen",
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
"currentlySelected": "Aktuell ausgewählt: $proxy",
"redirectsTitle": "${serviceName}weiterleitung",
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
"urlEmpty": "URL kann nicht leer sein",
"urlInvalid": "Ungültige URL",
"redirectDialogTitle": "${serviceName}weiterleitung"
}
}
}
}

7
build.yaml Normal file
View File

@ -0,0 +1,7 @@
targets:
$default:
builders:
slang_build_runner:
options:
input_directory: assets/i18n
output_directory: lib/i18n

View File

@ -44,9 +44,7 @@
ripgrep # General utilities
];
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
JAVA_HOME = pinnedJDK;
ANDROID_AVD_HOME = (toString ./.) + "/.android/avd";
};
});
}

View File

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

View File

@ -7,13 +7,15 @@ files:
- JsonImplementation
attributes:
jid: String
displayName: String
preStart:
type: PreStartDoneEvent
deserialise: true
- name: LoginFailureEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
reason: String
reason: String?
- name: PreStartDoneEvent
extends: BackgroundEvent
implements:
@ -69,8 +71,7 @@ files:
extends: BackgroundEvent
implements:
- JsonImplementation
# Send by the service if a message has been received or returned by
# [SendMessageCommand].
# Send by the service if a message has been received or returned by # [SendMessageCommand].
- name: MessageAddedEvent
extends: BackgroundEvent
implements:
@ -109,7 +110,7 @@ files:
- JsonImplementation
attributes:
id: int
progress: double
progress: double?
# Triggered by [RosterService] if we receive a roster push.
- name: RosterDiffEvent
extends: BackgroundEvent
@ -172,6 +173,32 @@ files:
extends: BackgroundEvent
implements:
- JsonImplementation
- name: GetConversationOmemoFingerprintsResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
fingerprints:
type: List<OmemoDevice>
deserialise: true
- name: GetOwnOmemoFingerprintsResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
ownDeviceFingerprint: String
ownDeviceId: int
fingerprints:
type: List<OmemoDevice>
deserialise: true
- name: RegenerateOwnDeviceResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
device:
type: OmemoDevice
deserialise: true
generate_builder: true
builder_name: "Event"
builder_baseclass: "BackgroundEvent"
@ -190,6 +217,8 @@ files:
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
systemLocaleCode: String
- name: AddConversationCommand
extends: BackgroundCommand
implements:
@ -315,6 +344,56 @@ files:
attributes:
jid: String
muted: bool
- name: GetConversationOmemoFingerprintsCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
- name: SetOmemoDeviceEnabledCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
deviceId: int
enabled: bool
- name: RecreateSessionsCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
- name: SetOmemoEnabledCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
enabled: bool
- name: GetOwnOmemoFingerprintsCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
- name: RemoveOwnDeviceCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
deviceId: int
- name: RegenerateOwnDeviceCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
- name: RetractMessageComment
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
originId: String
conversationJid: String
generate_builder: true
# get${builder_Name}FromJson
builder_name: "Command"

View File

@ -1,10 +1,12 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
@ -13,9 +15,11 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/login_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
@ -36,10 +40,13 @@ import 'package:moxxyv2/ui/pages/crop.dart';
import 'package:moxxyv2/ui/pages/intro.dart';
import 'package:moxxyv2/ui/pages/login.dart';
import 'package:moxxyv2/ui/pages/newconversation.dart';
import 'package:moxxyv2/ui/pages/profile/devices.dart';
import 'package:moxxyv2/ui/pages/profile/own_devices.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
import 'package:moxxyv2/ui/pages/sendfiles.dart';
import 'package:moxxyv2/ui/pages/server_info.dart';
import 'package:moxxyv2/ui/pages/settings/about.dart';
import 'package:moxxyv2/ui/pages/settings/appearance/appearance.dart';
import 'package:moxxyv2/ui/pages/settings/appearance/cropbackground.dart';
import 'package:moxxyv2/ui/pages/settings/conversation.dart';
import 'package:moxxyv2/ui/pages/settings/debugging.dart';
@ -52,12 +59,12 @@ import 'package:moxxyv2/ui/pages/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/progress.dart';
import 'package:moxxyv2/ui/service/thumbnail.dart';
import 'package:moxxyv2/ui/theme.dart';
import 'package:page_transition/page_transition.dart';
import 'package:share_handler/share_handler.dart';
void setupLogging() {
Logger.root.level = Level.ALL;
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen((record) {
// ignore: avoid_print
print('[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}');
@ -68,7 +75,6 @@ void setupLogging() {
Future<void> setupUIServices() async {
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
GetIt.I.registerSingleton<UIDataService>(UIDataService());
GetIt.I.registerSingleton<ThumbnailCacheService>(ThumbnailCacheService());
}
void setupBlocs(GlobalKey<NavigatorState> navKey) {
@ -76,8 +82,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
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<AddContactBloc>(AddContactBloc());
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
@ -86,6 +91,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
GetIt.I.registerSingleton<ShareSelectionBloc>(ShareSelectionBloc());
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
}
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
@ -152,15 +159,23 @@ void main() async {
BlocProvider<ServerInfoBloc>(
create: (_) => GetIt.I.get<ServerInfoBloc>(),
),
BlocProvider<DevicesBloc>(
create: (_) => GetIt.I.get<DevicesBloc>(),
),
BlocProvider<OwnDevicesBloc>(
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
),
],
child: TranslationProvider(
child: MyApp(navKey),
),
),
);
}
class MyApp extends StatefulWidget {
const MyApp(this.navigationKey, { Key? key }) : super(key: key);
const MyApp(this.navigationKey, { super.key });
final GlobalKey<NavigatorState> navigationKey;
@override
@ -248,44 +263,12 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: LocaleSettings.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
title: 'Moxxy',
theme: ThemeData(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: primaryColor,
onPrimary: Colors.white,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: primaryColor,
),
),
// NOTE: Mainly for the SettingsSection
colorScheme: const ColorScheme.light(
secondary: primaryColor,
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: primaryColor,
onPrimary: Colors.white,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: primaryColor,
),
),
// NOTE: Mainly for the SettingsSection
colorScheme: const ColorScheme.dark(
secondary: primaryColor,
),
backgroundColor: const Color(0xff303030),
),
theme: getThemeData(context, Brightness.light),
darkTheme: getThemeData(context, Brightness.dark),
navigatorKey: widget.navigationKey,
onGenerateRoute: (settings) {
switch (settings.name) {
@ -314,6 +297,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case shareSelectionRoute: return ShareSelectionPage.route;
case serverInfoRoute: return ServerInfoPage.route;
case conversationSettingsRoute: return ConversationSettingsPage.route;
case devicesRoute: return DevicesPage.route;
case ownDevicesRoute: return OwnDevicesPage.route;
case appearanceRoute: return AppearanceSettingsPage.route;
}
return null;

View File

@ -5,6 +5,8 @@ import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
@ -12,14 +14,6 @@ import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0030/helpers.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0030/xep_0030.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0054.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0084.dart';
/// Removes line breaks and spaces from [original]. This might happen when we request the
/// avatar data. Returns the cleaned version.
@ -93,7 +87,10 @@ class AvatarService {
}
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
final items = (await _getDiscoManager().discoItemsQuery(jid)) ?? [];
final response = await _getDiscoManager().discoItemsQuery(jid);
final items = response.isType<DiscoError>() ?
<DiscoItem>[] :
response.get<List<DiscoItem>>();
final itemNodes = items.map((i) => i.node);
_log.finest('Disco items for $jid:');

View File

@ -1,9 +1,7 @@
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0191.dart';
enum BlockPushType {
block,

View File

@ -1,15 +1,13 @@
import 'dart:io' show Platform;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
import 'package:moxxyv2/xmpp/connection.dart';
class ConnectivityService {
ConnectivityService() : _log = Logger('ConnectivityService');
final Logger _log;

View File

@ -2,9 +2,10 @@ import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/xmpp/connection.dart';
class ConnectivityWatcherService {
@ -17,7 +18,7 @@ class ConnectivityWatcherService {
Future<void> _onTimerElapsed() async {
await GetIt.I.get<NotificationsService>().showWarningNotification(
'Moxxy',
'Could not connect to server',
t.errors.connection.connectionTimeout,
);
_stopTimer();
}

View File

@ -1,12 +1,12 @@
import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
class ConversationService {
ConversationService()
: _conversationCache = LRUCache(100),
_loadedConversations = false;
@ -57,23 +57,28 @@ class ConversationService {
Future<Conversation> updateConversation(int id, {
String? lastMessageBody,
int? lastChangeTimestamp,
bool? lastMessageRetracted,
int? lastMessageId,
bool? open,
int? unreadCounter,
String? avatarUrl,
ChatState? chatState,
bool? muted,
}
) async {
bool? encrypted,
}) async {
final conversation = await _getConversationById(id);
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
id,
lastMessageBody: lastMessageBody,
lastMessageRetracted: lastMessageRetracted,
lastMessageId: lastMessageId,
lastChangeTimestamp: lastChangeTimestamp,
open: open,
unreadCounter: unreadCounter,
avatarUrl: avatarUrl,
chatState: conversation?.chatState ?? ChatState.gone,
muted: muted,
encrypted: encrypted,
);
_conversationCache.cache(id, newConversation);
@ -83,6 +88,8 @@ class ConversationService {
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
Future<Conversation> addConversationFromData(
String title,
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
String avatarUrl,
String jid,
@ -90,9 +97,12 @@ class ConversationService {
int lastChangeTimestamp,
bool open,
bool muted,
bool encrypted,
) async {
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
title,
lastMessageId,
lastMessageRetracted,
lastMessageBody,
avatarUrl,
jid,
@ -100,9 +110,21 @@ class ConversationService {
lastChangeTimestamp,
open,
muted,
encrypted,
);
_conversationCache.cache(newConversation.id, newConversation);
return newConversation;
}
/// Returns true if the stanzas to the conversation with [jid] should be encrypted.
/// If not, returns false.
///
/// If the conversation does not exist, then the value of the preference for
/// enableOmemoByDefault is used.
Future<bool> shouldEncryptForConversation(JID jid) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final conversation = await getConversationByJid(jid.toString());
return conversation?.encrypted ?? prefs.enableOmemoByDefault;
}
}

View File

@ -0,0 +1,138 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/types.dart';
List<int> _randomBuffer(int length) {
final buf = List<int>.empty(growable: true);
final random = Random.secure();
for (var i = 0; i < length; i++) {
buf.add(random.nextInt(256));
}
return buf;
}
CipherAlgorithm _sfsToCipher(SFSEncryptionType type) {
switch (type) {
case SFSEncryptionType.aes128GcmNoPadding: return CipherAlgorithm.aes128GcmNoPadding;
case SFSEncryptionType.aes256GcmNoPadding: return CipherAlgorithm.aes256GcmNoPadding;
case SFSEncryptionType.aes256CbcPkcs7: return CipherAlgorithm.aes256CbcPkcs7;
}
}
class CryptographyService {
CryptographyService() : _log = Logger('CryptographyService');
final Logger _log;
/// Encrypt the file at path [source] and write the encrypted data to [dest]. For the
/// encryption, use the algorithm indicated by [encryption].
Future<EncryptionResult> encryptFile(String source, String dest, SFSEncryptionType encryption) async {
_log.finest('Beginning encryption routine for $source');
final key = encryption == SFSEncryptionType.aes128GcmNoPadding ?
_randomBuffer(16) :
_randomBuffer(32);
final iv = _randomBuffer(12);
final result = (await MoxplatformPlugin.crypto.encryptFile(
source,
dest,
Uint8List.fromList(key),
Uint8List.fromList(iv),
_sfsToCipher(encryption),
'SHA-256',
))!;
_log.finest('Encryption done for $source ($result)');
return EncryptionResult(
key,
iv,
<String, String>{
hashSha256: base64Encode(result.plaintextHash),
},
<String, String>{
hashSha256: base64Encode(result.ciphertextHash),
},
);
}
/// Decrypt the file at [source] and write the decrypted version to [dest]. For the
/// decryption, use the algorithm indicated by [encryption] with the key [key] and the
/// IV or nonce [iv].
Future<DecryptionResult> decryptFile(
String source,
String dest,
SFSEncryptionType encryption,
List<int> key,
List<int> iv,
Map<String, String> plaintextHashes,
Map<String, String> ciphertextHashes,
) async {
_log.finest('Beginning decryption for $source');
final result = await MoxplatformPlugin.crypto.encryptFile(
source,
dest,
Uint8List.fromList(key),
Uint8List.fromList(iv),
_sfsToCipher(encryption),
// TODO(Unknown): How to we get hash agility here?
'SHA-256',
);
_log.finest('Decryption done for $source (${result != null})');
var passedPlaintextIntegrityCheck = true;
var passedCiphertextIntegrityCheck = true;
for (final entry in plaintextHashes.entries) {
if (entry.key == hashSha256) {
if (base64Encode(result!.plaintextHash) != entry.value) {
passedPlaintextIntegrityCheck = false;
} else {
passedPlaintextIntegrityCheck = true;
}
break;
}
}
for (final entry in ciphertextHashes.entries) {
if (entry.key == hashSha256) {
if (base64Encode(result!.ciphertextHash) != entry.value) {
passedCiphertextIntegrityCheck = false;
} else {
passedCiphertextIntegrityCheck = true;
}
break;
}
}
return DecryptionResult(
result != null,
passedPlaintextIntegrityCheck,
passedCiphertextIntegrityCheck,
);
}
/// Read the file at [path] and calculate the base64-encoded hash using the algorithm
/// indicated by [hash].
Future<String> hashFile(String path, HashFunction hash) async {
String hashSpec;
if (hash == HashFunction.sha256) {
hashSpec = 'SHA-256';
} else if (hash == HashFunction.sha512) {
hashSpec = 'SHA-512';
} else {
// Android itself does not provide more
throw Exception();
}
_log.finest('Beginning hash generation of $path');
final data = await MoxplatformPlugin.crypto.hashFile(path, hashSpec);
_log.finest('Hash generation done for $path');
return base64Encode(data!);
}
}

View File

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

View File

@ -0,0 +1,64 @@
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
@immutable
class EncryptionResult {
const EncryptionResult(this.key, this.iv, this.plaintextHashes, this.ciphertextHashes);
final List<int> key;
final List<int> iv;
final Map<String, String> plaintextHashes;
final Map<String, String> ciphertextHashes;
}
@immutable
class EncryptionRequest {
const EncryptionRequest(this.source, this.dest, this.encryption);
final String source;
final String dest;
final SFSEncryptionType encryption;
}
@immutable
class DecryptionResult {
const DecryptionResult(
this.decryptionOkay,
this.plaintextOkay,
this.ciphertextOkay,
);
final bool decryptionOkay;
final bool plaintextOkay;
final bool ciphertextOkay;
}
@immutable
class DecryptionRequest {
const DecryptionRequest(
this.source,
this.dest,
this.encryption,
this.key,
this.iv,
this.plaintextHashes,
this.ciphertextHashes,
);
final String source;
final String dest;
final SFSEncryptionType encryption;
final List<int> key;
final List<int> iv;
final Map<String, String> plaintextHashes;
final Map<String, String> ciphertextHashes;
}
@immutable
class HashRequest {
const HashRequest(this.path, this.hash);
final String path;
final HashFunction hash;
}

View File

@ -3,6 +3,13 @@ const messsagesTable = 'Messages';
const rosterTable = 'RosterItems';
const mediaTable = 'SharedMedia';
const preferenceTable = 'Preferences';
const omemoDeviceTable = 'OmemoDevices';
const omemoDeviceListTable = 'OmemoDeviceList';
const omemoRatchetsTable = 'OmemoSessions';
const omemoTrustCacheTable = 'OmemoTrustCacheList';
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
const xmppStateTable = 'XmppState';
const typeString = 0;
const typeInt = 1;

View File

@ -7,6 +7,15 @@ Future<void> configureDatabase(Database db) async {
}
Future<void> createDatabase(Database db, int version) async {
// XMPP state
await db.execute(
'''
CREATE TABLE $xmppStateTable (
key TEXT PRIMARY KEY,
value TEXT
)''',
);
// Messages
await db.execute(
'''
@ -19,19 +28,30 @@ Future<void> createDatabase(Database db, int version) async {
conversationJid TEXT NOT NULL,
isMedia INTEGER NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
mediaUrl TEXT,
mediaType TEXT,
thumbnailData TEXT,
mediaWidth INTEGER,
mediaHeight INTEGER,
srcUrl TEXT,
key TEXT,
iv TEXT,
encryptionScheme TEXT,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id INTEGER,
filename TEXT,
plaintextHashes TEXT,
ciphertextHashes TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
mediaSize INTEGER,
isRetracted INTEGER,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messsagesTable (id)
)''',
);
@ -48,7 +68,10 @@ Future<void> createDatabase(Database db, int version) async {
unreadCounter INTEGER NOT NULL,
lastMessageBody TEXT NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER NOT NULL,
lastMessageRetracted INTEGER NOT NULL,
)''',
);
@ -79,6 +102,69 @@ Future<void> createDatabase(Database db, int version) async {
)''',
);
// OMEMO
await db.execute(
'''
CREATE TABLE $omemoRatchetsTable (
id INTEGER NOT NULL,
jid TEXT NOT NULL,
dhs TEXT NOT NULL,
dhs_pub TEXT NOT NULL,
dhr TEXT,
rk TEXT NOT NULL,
cks TEXT,
ckr TEXT,
ns INTEGER NOT NULL,
nr INTEGER NOT NULL,
pn INTEGER NOT NULL,
ik_pub TEXT NOT NULL,
session_ad TEXT NOT NULL,
acknowledged INTEGER NOT NULL,
mkskipped TEXT NOT NULL,
kex_timestamp INTEGER NOT NULL,
kex TEXT,
PRIMARY KEY (jid, id)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustCacheTable (
key TEXT PRIMARY KEY NOT NULL,
trust INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustDeviceListTable (
jid TEXT NOT NULL,
device INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoTrustEnableListTable (
key TEXT PRIMARY KEY NOT NULL,
enabled INTEGER NOT NULL
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceTable (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (jid, id)
)''',
);
await db.execute(
'''
CREATE TABLE $omemoDeviceListTable (
jid TEXT NOT NULL,
id INTEGER NOT NULL,
PRIMARY KEY (jid, id)
)''',
);
// Settings
await db.execute(
'''
@ -86,8 +172,7 @@ Future<void> createDatabase(Database db, int version) async {
key TEXT NOT NULL PRIMARY KEY,
type INTEGER NOT NULL,
value TEXT NOT NULL
);
''',
)''',
);
await db.insert(
preferenceTable,
@ -233,4 +318,20 @@ Future<void> createDatabase(Database db, int version) async {
'false',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'enableOmemoByDefault',
typeBool,
'false',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'languageLocaleCode',
typeString,
'default',
).toDatabaseJson(),
);
}

View File

@ -1,19 +1,27 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/creation.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/database/migrations/0000_language.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_xmpp_state.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
@ -21,7 +29,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
const databasePasswordKey = 'database_encryption_password';
class DatabaseService {
DatabaseService() : _log = Logger('DatabaseService');
late Database _db;
final FlutterSecureStorage _storage = const FlutterSecureStorage(
@ -50,9 +57,27 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 1,
version: 5,
onCreate: createDatabase,
onConfigure: configureDatabase,
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
_log.finest('Running migration for database version 2');
await upgradeFromV1ToV2(db);
}
if (oldVersion < 3) {
_log.finest('Running migration for database version 3');
await upgradeFromV2ToV3(db);
}
if (oldVersion < 4) {
_log.finest('Running migration for database version 4');
await upgradeFromV3ToV4(db);
}
if (oldVersion < 5) {
_log.finest('Running migration for database version 5');
await upgradeFromV4ToV5(db);
}
},
);
_log.finest('Database setup done');
@ -108,7 +133,7 @@ class DatabaseService {
final rawQuote = (await _db.query(
'Messages',
where: 'conversationJid = ? AND id = ?',
whereArgs: [jid, m['id']! as int],
whereArgs: [jid, m['quote_id']! as int],
)).first;
quotes = Message.fromDatabaseJson(rawQuote, null);
}
@ -123,13 +148,15 @@ class DatabaseService {
Future<Conversation> updateConversation(int id, {
String? lastMessageBody,
int? lastChangeTimestamp,
bool? lastMessageRetracted,
int? lastMessageId,
bool? open,
int? unreadCounter,
String? avatarUrl,
ChatState? chatState,
bool? muted,
}
) async {
bool? encrypted,
}) async {
final cd = (await _db.query(
'Conversations',
where: 'id = ?',
@ -148,6 +175,12 @@ class DatabaseService {
if (lastMessageBody != null) {
c['lastMessageBody'] = lastMessageBody;
}
if (lastMessageRetracted != null) {
c['lastMessageRetracted'] = boolToInt(lastMessageRetracted);
}
if (lastMessageId != null) {
c['lastMessageId'] = lastMessageId;
}
if (lastChangeTimestamp != null) {
c['lastChangeTimestamp'] = lastChangeTimestamp;
}
@ -163,6 +196,9 @@ class DatabaseService {
if (muted != null) {
c['muted'] = boolToInt(muted);
}
if (encrypted != null) {
c['encrypted'] = boolToInt(encrypted);
}
await _db.update(
'Conversations',
@ -184,6 +220,8 @@ class DatabaseService {
/// [Conversation] object can carry its database id.
Future<Conversation> addConversationFromData(
String title,
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
String avatarUrl,
String jid,
@ -191,10 +229,13 @@ class DatabaseService {
int lastChangeTimestamp,
bool open,
bool muted,
bool encrypted,
) async {
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final conversation = Conversation(
title,
lastMessageId,
lastMessageRetracted,
lastMessageBody,
avatarUrl,
jid,
@ -206,6 +247,7 @@ class DatabaseService {
rosterItem != null,
rosterItem?.subscription ?? 'none',
muted,
encrypted,
ChatState.gone,
);
@ -237,8 +279,12 @@ class DatabaseService {
bool isMedia,
String sid,
bool isFileUploadNotification,
bool encrypted,
{
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
String? mediaUrl,
String? mediaType,
String? thumbnailData,
@ -247,9 +293,16 @@ class DatabaseService {
String? originId,
String? quoteId,
String? filename,
int? errorType,
int? warningType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
}
) async {
final m = Message(
var m = Message(
sender,
body,
timestamp,
@ -258,8 +311,13 @@ class DatabaseService {
conversationJid,
isMedia,
isFileUploadNotification,
errorType: noError,
encrypted,
errorType: errorType,
warningType: warningType,
mediaUrl: mediaUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaType: mediaType,
thumbnailData: thumbnailData,
mediaWidth: mediaWidth,
@ -270,19 +328,24 @@ class DatabaseService {
acked: false,
originId: originId,
filename: filename,
plaintextHashes: plaintextHashes,
ciphertextHashes: ciphertextHashes,
isUploading: isUploading,
isDownloading: isDownloading,
mediaSize: mediaSize,
);
Message? quotes;
if (quoteId != null) {
quotes = await getMessageByXmppId(quoteId, conversationJid);
final quotes = await getMessageByXmppId(quoteId, conversationJid);
if (quotes == null) {
_log.warning('Failed to add quote for message with id $quoteId');
} else {
m = m.copyWith(quotes: quotes);
}
}
return m.copyWith(
id: await _db.insert('Messages', m.toDatabaseJson(quotes?.id)),
quotes: quotes,
id: await _db.insert('Messages', m.toDatabaseJson()),
);
}
@ -316,18 +379,45 @@ class DatabaseService {
return Message.fromDatabaseJson(msg, null);
}
Future<Message?> getMessageByOriginId(String id, String conversationJid) async {
final messagesRaw = await _db.query(
'Messages',
where: 'conversationJid = ? AND originId = ?',
whereArgs: [conversationJid, id],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
return Message.fromDatabaseJson(msg, null);
}
/// Updates the message item with id [id] inside the database.
Future<Message> updateMessage(int id, {
String? mediaUrl,
String? mediaType,
Object? body = notSpecified,
Object? mediaUrl = notSpecified,
Object? mediaType = notSpecified,
bool? isMedia,
bool? received,
bool? displayed,
bool? acked,
int? errorType,
Object? errorType = notSpecified,
Object? warningType = notSpecified,
bool? isFileUploadNotification,
String? srcUrl,
int? mediaWidth,
int? mediaHeight,
Object? srcUrl = notSpecified,
Object? key = notSpecified,
Object? iv = notSpecified,
Object? encryptionScheme = notSpecified,
Object? mediaWidth = notSpecified,
Object? mediaHeight = notSpecified,
bool? isDownloading,
bool? isUploading,
Object? mediaSize = notSpecified,
Object? originId = notSpecified,
Object? sid = notSpecified,
bool? isRetracted,
}) async {
final md = (await _db.query(
'Messages',
@ -337,11 +427,14 @@ class DatabaseService {
)).first;
final m = Map<String, dynamic>.from(md);
if (mediaUrl != null) {
m['mediaUrl'] = mediaUrl;
if (mediaUrl != notSpecified) {
m['mediaUrl'] = mediaUrl as String?;
}
if (mediaType != null) {
m['mediaType'] = mediaType;
if (mediaType != notSpecified) {
m['mediaType'] = mediaType as String?;
}
if (isMedia != null) {
m['isMedia'] = boolToInt(isMedia);
}
if (received != null) {
m['received'] = boolToInt(received);
@ -352,20 +445,50 @@ class DatabaseService {
if (acked != null) {
m['acked'] = boolToInt(acked);
}
if (errorType != null) {
m['errorType'] = errorType;
if (errorType != notSpecified) {
m['errorType'] = errorType as int?;
}
if (warningType != notSpecified) {
m['warningType'] = warningType as int?;
}
if (isFileUploadNotification != null) {
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
}
if (srcUrl != null) {
m['srcUrl'] = srcUrl;
if (srcUrl != notSpecified) {
m['srcUrl'] = srcUrl as String?;
}
if (mediaWidth != null) {
m['mediaWidth'] = mediaWidth;
if (mediaWidth != notSpecified) {
m['mediaWidth'] = mediaWidth as int?;
}
if (mediaHeight != null) {
m['mediaHeight'] = mediaHeight;
if (mediaHeight != notSpecified) {
m['mediaHeight'] = mediaHeight as int?;
}
if (mediaSize != notSpecified) {
m['mediaSize'] = mediaSize as int?;
}
if (key != notSpecified) {
m['key'] = key as String?;
}
if (iv != notSpecified) {
m['iv'] = iv as String?;
}
if (encryptionScheme != notSpecified) {
m['encryptionScheme'] = encryptionScheme as String?;
}
if (isDownloading != null) {
m['isDownloading'] = boolToInt(isDownloading);
}
if (isUploading != null) {
m['isUploading'] = boolToInt(isUploading);
}
if (sid != notSpecified) {
m['sid'] = sid as String?;
}
if (originId != notSpecified) {
m['originId'] = originId as String?;
}
if (isRetracted != null) {
m['isRetracted'] = boolToInt(isRetracted);
}
await _db.update(
@ -538,4 +661,275 @@ class DatabaseService {
await batch.commit();
}
Future<XmppState> getXmppState() async {
final json = <String, String?>{};
for (final row in await _db.query(xmppStateTable)) {
json[row['key']! as String] = row['value'] as String?;
}
return XmppState.fromDatabaseTuples(json);
}
Future<void> saveXmppState(XmppState state) async {
final batch = _db.batch();
for (final tuple in state.toDatabaseTuples().entries) {
batch.insert(
xmppStateTable,
<String, String?>{ 'key': tuple.key, 'value': tuple.value },
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<void> saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
final json = await ratchet.ratchet.toJson();
await _db.insert(
omemoRatchetsTable,
{
...json,
'mkskipped': jsonEncode(json['mkskipped']),
'acknowledged': boolToInt(json['acknowledged']! as bool),
'jid': ratchet.jid,
'id': ratchet.id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<OmemoDoubleRatchetWrapper>> loadRatchets() async {
final results = await _db.query(omemoRatchetsTable);
return results.map((ratchet) {
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
for (final i in json) {
final element = i as Map<String, dynamic>;
mkskipped.add({
'key': element['key']! as String,
'public': element['public']! as String,
'n': element['n']! as int,
});
}
return OmemoDoubleRatchetWrapper(
OmemoDoubleRatchet.fromJson(
{
...ratchet,
'acknowledged': intToBool(ratchet['acknowledged']! as int),
'mkskipped': mkskipped,
},
),
ratchet['id']! as int,
ratchet['jid']! as String,
);
}).toList();
}
Future<Map<RatchetMapKey, BTBVTrustState>> loadTrustCache() async {
final entries = await _db.query(omemoTrustCacheTable);
final mapEntries = entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
// TODO(PapaTutuWawa): Expose this from omemo_dart
BTBVTrustState state;
final value = entry['trust']! as int;
if (value == 1) {
state = BTBVTrustState.notTrusted;
} else if (value == 2) {
state = BTBVTrustState.blindTrust;
} else if (value == 3) {
state = BTBVTrustState.verified;
} else {
state = BTBVTrustState.notTrusted;
}
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
state,
);
});
return Map.fromEntries(mapEntries);
}
Future<void> saveTrustCache(Map<String, int> cache) async {
final batch = _db.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustCacheTable);
for (final entry in cache.entries) {
batch.insert(
omemoTrustCacheTable,
{
'key': entry.key,
'trust': entry.value,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<Map<RatchetMapKey, bool>> loadTrustEnablementList() async {
final entries = await _db.query(omemoTrustEnableListTable);
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
intToBool(entry['enabled']! as int),
);
});
return Map.fromEntries(mapEntries);
}
Future<void> saveTrustEnablementList(Map<String, bool> list) async {
final batch = _db.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustEnableListTable);
for (final entry in list.entries) {
batch.insert(
omemoTrustEnableListTable,
{
'key': entry.key,
'enabled': boolToInt(entry.value),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<Map<String, List<int>>> loadTrustDeviceList() async {
final entries = await _db.query(omemoTrustDeviceListTable);
final map = <String, List<int>>{};
for (final entry in entries) {
final key = entry['jid']! as String;
final device = entry['device']! as int;
if (map.containsKey(key)) {
map[key]!.add(device);
} else {
map[key] = [device];
}
}
return map;
}
Future<void> saveTrustDeviceList(Map<String, List<int>> list) async {
final batch = _db.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustDeviceListTable);
for (final entry in list.entries) {
for (final device in entry.value) {
batch.insert(
omemoTrustDeviceListTable,
{
'jid': entry.key,
'device': device,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit();
}
Future<void> saveOmemoDevice(Device device) async {
await _db.insert(
omemoDeviceTable,
{
'jid': device.jid,
'id': device.id,
'data': jsonEncode(await device.toJson()),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<Device?> loadOmemoDevice(String jid) async {
final data = await _db.query(
omemoDeviceTable,
where: 'jid = ?',
whereArgs: [jid],
limit: 1,
);
if (data.isEmpty) return null;
final deviceJson = jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
// NOTE: We need to do this because Dart otherwise complains about not being able
// to cast dynamic to List<int>.
final opks = List<Map<String, dynamic>>.empty(growable: true);
final opksIter = deviceJson['opks']! as List<dynamic>;
for (final _opk in opksIter) {
final opk = _opk as Map<String, dynamic>;
opks.add(<String, dynamic>{
'id': opk['id']! as int,
'public': opk['public']! as String,
'private': opk['private']! as String,
});
}
deviceJson['opks'] = opks;
return Device.fromJson(deviceJson);
}
Future<Map<String, List<int>>> loadOmemoDeviceList() async {
final list = await _db.query(omemoDeviceListTable);
final map = <String, List<int>>{};
for (final entry in list) {
final key = entry['jid']! as String;
final id = entry['id']! as int;
if (map.containsKey(key)) {
map[key]!.add(id);
} else {
map[key] = [id];
}
}
return map;
}
Future<void> saveOmemoDeviceList(Map<String, List<int>> list) async {
final batch = _db.batch();
// ignore: cascade_invocations
batch.delete(omemoDeviceListTable);
for (final entry in list.entries) {
for (final id in entry.value) {
batch.insert(
omemoDeviceListTable,
{
'jid': entry.key,
'id': id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit();
}
Future<void> emptyOmemoSessionTables() async {
final batch = _db.batch();
// ignore: cascade_invocations
batch
..delete(omemoRatchetsTable)
..delete(omemoTrustCacheTable)
..delete(omemoTrustEnableListTable);
await batch.commit();
}
}

View File

@ -0,0 +1,15 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV2ToV3(Database db) async {
// Set a default locale
await db.insert(
preferenceTable,
Preference(
'languageLocaleCode',
typeString,
'default',
).toDatabaseJson(),
);
}

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> upgradeFromV3ToV4(Database db) async {
// Mark all messages as not retracted
await db.execute(
'ALTER TABLE $messsagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
);
}

View File

@ -0,0 +1,14 @@
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> upgradeFromV4ToV5(Database db) async {
// Give all conversations a pseudo last message data
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageId INTEGER NOT NULL DEFAULT 0;',
);
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageRetracted INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
}

View File

@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV1ToV2(Database db) async {
// Create the table
await db.execute(
'''
CREATE TABLE $xmppStateTable (
key TEXT PRIMARY KEY,
value TEXT
)''',
);
}

View File

@ -1,16 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
@ -20,16 +27,7 @@ import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/jid.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/negotiators/namespaces.dart';
import 'package:moxxyv2/xmpp/settings.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0191.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0198/negotiator.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0352.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0363.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:permission_handler/permission_handler.dart';
void setupBackgroundEventHandler() {
@ -56,6 +54,14 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<SignOutCommand>(performSignOut),
EventTypeMatcher<SendFilesCommand>(performSendFiles),
EventTypeMatcher<SetConversationMuteStatusCommand>(performSetMuteState),
EventTypeMatcher<GetConversationOmemoFingerprintsCommand>(performGetOmemoFingerprints),
EventTypeMatcher<SetOmemoDeviceEnabledCommand>(performEnableOmemoKey),
EventTypeMatcher<RecreateSessionsCommand>(performRecreateSessions),
EventTypeMatcher<SetOmemoEnabledCommand>(performSetOmemoEnabled),
EventTypeMatcher<GetOwnOmemoFingerprintsCommand>(performGetOwnOmemoFingerprints),
EventTypeMatcher<RemoveOwnDeviceCommand>(performRemoveOwnDevice),
EventTypeMatcher<RegenerateOwnDeviceCommand>(performRegenerateOwnDevice),
EventTypeMatcher<RetractMessageComment>(performMessageRetraction),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -79,43 +85,31 @@ Future<void> performLogin(LoginCommand command, { dynamic extra }) async {
// ignore: avoid_dynamic_calls
if (result.success) {
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(true);
final settings = GetIt.I.get<XmppConnection>().getConnectionSettings();
sendEvent(
LoginSuccessfulEvent(
jid: settings.jid.toString(),
displayName: settings.jid.local,
preStart: await _buildPreStartDoneEvent(preferences),
),
id:id,
);
// TODO(Unknown): Send the data of the [PreStartDoneEvent]
} else {
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(false);
sendEvent(
LoginFailureEvent(
reason: result.reason!,
reason: xmppErrorToTranslatableString(result.error!),
),
id: id,
);
}
}
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
final id = extra as String;
// Prevent a race condition where the UI sends the prestart command before the service
// has finished setting everything up
GetIt.I.get<Logger>().finest('Waiting for preStart future to complete..');
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().finest('PreStart future done');
Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences) async {
final xmpp = GetIt.I.get<XmppService>();
final settings = await xmpp.getConnectionSettings();
final state = await xmpp.getXmppState();
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
if (settings != null) {
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
// Check some permissions
@ -129,8 +123,7 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
),);
}
sendEvent(
PreStartDoneEvent(
return PreStartDoneEvent(
state: 'logged_in',
jid: state.jid,
displayName: state.displayName ?? state.jid!.split('@').first,
@ -140,7 +133,35 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
preferences: preferences,
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
),
);
}
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
final id = extra as String;
// Prevent a race condition where the UI sends the prestart command before the service
// has finished setting everything up
GetIt.I.get<Logger>().finest('Waiting for preStart future to complete..');
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().finest('PreStart future done');
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
// Set the locale very early
GetIt.I.get<LanguageService>().defaultLocale = command.systemLocaleCode;
if (preferences.languageLocaleCode == 'default') {
LocaleSettings.setLocaleRaw(command.systemLocaleCode);
} else {
LocaleSettings.setLocaleRaw(preferences.languageLocaleCode);
}
GetIt.I.get<XmppService>().setNotificationText(
await GetIt.I.get<XmppConnection>().getConnectionState(),
);
final settings = await GetIt.I.get<XmppService>().getConnectionSettings();
if (settings != null) {
sendEvent(
await _buildPreStartDoneEvent(preferences),
id: id,
);
} else {
@ -185,6 +206,8 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
} else {
final conversation = await cs.addConversationFromData(
command.title,
-1,
false,
command.lastMessageBody,
command.avatarUrl,
command.jid,
@ -193,6 +216,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
true,
// TODO(PapaTutuWawa): Take as an argument
false,
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
);
sendEvent(
@ -261,6 +285,21 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
await GetIt.I.get<PreferencesService>().modifyPreferences((_) => command.preferences);
// Set the logging mode
if (!kDebugMode) {
final enableDebug = command.preferences.debugEnabled;
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
}
// Set the locale
final locale = command.preferences.languageLocaleCode == 'default' ?
GetIt.I.get<LanguageService>().defaultLocale :
command.preferences.languageLocaleCode;
LocaleSettings.setLocaleRaw(locale);
GetIt.I.get<XmppService>().setNotificationText(
await GetIt.I.get<XmppConnection>().getConnectionState(),
);
}
Future<void> performAddContact(AddContactCommand command, { dynamic extra }) async {
@ -288,6 +327,8 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
} else {
final c = await cs.addConversationFromData(
jid.split('@')[0],
-1,
false,
'',
'',
jid,
@ -296,6 +337,7 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
true,
// TODO(PapaTutuWawa): Take as an argument
false,
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
);
sendEvent(
AddContactResultEvent(conversation: c, added: true),
@ -311,22 +353,36 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
}
Future<void> performRequestDownload(RequestDownloadCommand command, { dynamic extra }) async {
sendEvent(MessageUpdatedEvent(message: command.message.copyWith(isDownloading: true)));
final ms = GetIt.I.get<MessageService>();
final srv = GetIt.I.get<HttpFileTransferService>();
final message = await ms.updateMessage(
command.message.id,
isDownloading: true,
);
sendEvent(MessageUpdatedEvent(message: message));
final metadata = await peekFile(command.message.srcUrl!);
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
// for ".../aaaaaaaaa", in which case we would've failed anyways.
final ext = command.message.srcUrl!.split('.').last;
final ext = message.srcUrl!.split('.').last;
final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext);
await srv.downloadFile(
FileDownloadJob(
command.message.srcUrl!,
command.message.id,
command.message.conversationJid,
MediaFileLocation(
message.srcUrl!,
message.filename ?? filenameFromUrl(message.srcUrl!),
message.encryptionScheme,
message.key != null ? base64Decode(message.key!) : null,
message.iv != null ? base64Decode(message.iv!) : null,
message.plaintextHashes,
message.ciphertextHashes,
),
message.id,
message.conversationJid,
mimeGuess,
),
);
@ -441,3 +497,139 @@ Future<void> performSetMuteState(SetConversationMuteStatusCommand command, { dyn
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
Future<void> performGetOmemoFingerprints(GetConversationOmemoFingerprintsCommand command, { dynamic extra }) async {
final id = extra as String;
final omemo = GetIt.I.get<OmemoService>();
sendEvent(
GetConversationOmemoFingerprintsResult(
fingerprints: await omemo.getOmemoKeysForJid(command.jid),
),
id: id,
);
}
Future<void> performEnableOmemoKey(SetOmemoDeviceEnabledCommand command, { dynamic extra }) async {
final id = extra as String;
final omemo = GetIt.I.get<OmemoService>();
await omemo.setOmemoKeyEnabled(command.jid, command.deviceId, command.enabled);
await performGetOmemoFingerprints(
GetConversationOmemoFingerprintsCommand(jid: command.jid),
extra: id,
);
}
Future<void> performRecreateSessions(RecreateSessionsCommand command, { dynamic extra }) async {
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
final conn = GetIt.I.get<XmppConnection>();
await conn.getManagerById<OmemoManager>(omemoManager)!.sendEmptyMessage(
JID.fromString(command.jid),
findNewSessions: true,
);
}
Future<void> performSetOmemoEnabled(SetOmemoEnabledCommand command, { dynamic extra }) async {
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(command.jid);
await cs.updateConversation(
conversation!.id,
encrypted: command.enabled,
);
}
Future<void> performGetOwnOmemoFingerprints(GetOwnOmemoFingerprintsCommand command, { dynamic extra }) async {
final id = extra as String;
final os = GetIt.I.get<OmemoService>();
final xs = GetIt.I.get<XmppService>();
await os.ensureInitialized();
final jid = (await xs.getConnectionSettings())!.jid;
sendEvent(
GetOwnOmemoFingerprintsResult(
ownDeviceFingerprint: await os.getDeviceFingerprint(),
ownDeviceId: await os.getDeviceId(),
fingerprints: await os.getOwnFingerprints(jid),
),
id: id,
);
}
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)!
.deleteDevice(command.deviceId);
}
Future<void> performRegenerateOwnDevice(RegenerateOwnDeviceCommand command, { dynamic extra }) async {
final id = extra as String;
final jid = GetIt.I.get<XmppConnection>()
.getConnectionSettings()
.jid.toBare()
.toString();
final device = await GetIt.I.get<OmemoService>().regenerateDevice(jid);
sendEvent(
RegenerateOwnDeviceResult(device: device),
id: id,
);
}
Future<void> performMessageRetraction(RetractMessageComment command, { dynamic extra }) async {
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
command.originId,
command.conversationJid,
);
if (msg == null) {
GetIt.I.get<Logger>().warning('Failed to find message ${command.conversationJid}#${command.originId} for message retraction');
return;
}
// Send the retraction
(GetIt.I.get<XmppConnection>().getManagerById(messageManager)! as MessageManager)
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageRetraction: MessageRetractionData(
command.originId,
t.messages.retractedFallback,
),
),
);
// Update the database
final retractedMessage = await GetIt.I.get<MessageService>().updateMessage(
msg.id,
isMedia: false,
mediaUrl: null,
mediaType: null,
warningType: null,
errorType: null,
srcUrl: null,
key: null,
iv: null,
encryptionScheme: null,
mediaWidth: null,
mediaHeight: null,
mediaSize: null,
isRetracted: true,
);
sendEvent(MessageUpdatedEvent(message: retractedMessage));
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(
command.conversationJid,
);
if (conversation != null && conversation.lastMessageId == msg.id) {
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: '',
lastMessageRetracted: true,
);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
}

View File

@ -1,23 +1,27 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:native_imaging/native_imaging.dart' as native;
/// Generate a blurhash thumbnail using native_imaging.
Future<String?> generateBlurhashThumbnail(String path) async {
Future<String?> _generateBlurhashThumbnailImpl(String path) async {
await native.init();
final bytes = await File(path).readAsBytes();
native.Image image;
int width;
int height;
try {
final dartCodec = await instantiateImageCodec(bytes);
final dartFrame = await dartCodec.getNextFrame();
final rgbaData = await dartFrame.image.toByteData();
if (rgbaData == null) return null;
final width = dartFrame.image.width;
final height = dartFrame.image.height;
width = dartFrame.image.width;
height = dartFrame.image.height;
dartFrame.image.dispose();
dartCodec.dispose();
@ -36,7 +40,37 @@ Future<String?> generateBlurhashThumbnail(String path) async {
return null;
}
final blurhash = image.toBlurhash(3, 3);
// Scale the image down as recommended by
// https://github.com/woltapp/blurhash#how-fast-is-encoding-decoding
final scaled = image.resample(
20,
(height * (width / height)).toInt(),
native.Transform.bilinear,
);
// Calculate the blurhash
final blurhash = scaled.toBlurhash(3, 3);
// Free resources
image.free();
scaled.free();
return blurhash;
}
/// Generate a blurhash thumbnail using native_imaging.
Future<String?> generateBlurhashThumbnail(String path) async {
return compute(_generateBlurhashThumbnailImpl, path);
}
/// Turn a XmppError into its corresponding translated string.
String xmppErrorToTranslatableString(XmppError error) {
if (error is StartTLSFailedError) {
return t.errors.login.startTlsFailed;
} else if (error is SaslFailedError) {
return t.errors.login.saslFailed;
} else if (error is NoConnectionError) {
return t.errors.login.noConnection;
}
return t.errors.login.unspecified;
}

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart' as dio;
import 'package:get_it/get_it.dart';
@ -9,8 +11,11 @@ import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/cryptography/types.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
@ -20,14 +25,12 @@ import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/message.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0363.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0447.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';
import 'package:random_string/random_string.dart';
import 'package:synchronized/synchronized.dart';
import 'package:uuid/uuid.dart';
/// This service is responsible for managing the up- and download of files using Http.
class HttpFileTransferService {
@ -137,10 +140,51 @@ class HttpFileTransferService {
}
}
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
final ms = GetIt.I.get<MessageService>();
// Notify UI of upload failure
for (final recipient in job.recipients) {
final msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
errorType: error,
isUploading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
}
await _pickNextUploadTask();
}
/// Actually attempt to upload the file described by the job [job].
Future<void> _performFileUpload(FileUploadJob job) async {
_log.finest('Beginning upload of ${job.path}');
final file = File(job.path);
var path = job.path;
final useEncryption = job.encryptMap.entries.every((entry) => entry.value);
EncryptionResult? encryption;
if (useEncryption) {
final tempDir = await getTemporaryDirectory();
final randomFilename = randomAlphaNumeric(
20,
provider: CoreRandomProvider.from(Random.secure()),
);
path = pathlib.join(tempDir.path, randomFilename);
try {
encryption = await GetIt.I.get<CryptographyService>().encryptFile(
job.path,
path,
SFSEncryptionType.aes256GcmNoPadding,
);
} catch (ex) {
_log.warning('Encrypting ${job.path} failed: $ex');
await _fileUploadFailed(job, messageFailedToEncryptFile);
return;
}
}
final file = File(path);
final data = await file.readAsBytes();
final stat = file.statSync();
@ -148,17 +192,16 @@ class HttpFileTransferService {
final conn = GetIt.I.get<XmppConnection>();
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
final slotResult = await httpManager.requestUploadSlot(
pathlib.basename(job.path),
pathlib.basename(path),
stat.size,
);
if (slotResult.isError()) {
if (slotResult.isType<HttpFileUploadError>()) {
_log.severe('Failed to request upload slot for ${job.path}!');
await _nextUploadJob();
await _fileUploadFailed(job, fileUploadFailedError);
return;
}
final slot = slotResult.getValue();
final slot = slotResult.get<HttpFileUploadSlot>();
try {
final response = await dio.Dio().putUri<dynamic>(
Uri.parse(slot.putUrl),
@ -187,38 +230,56 @@ class HttpFileTransferService {
if (response.statusCode != 201) {
// TODO(PapaTutuWawa): Trigger event
_log.severe('Upload failed');
// Notify UI of upload failure
for (final recipient in job.recipients) {
final msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
errorType: fileUploadFailedError,
);
sendEvent(
MessageUpdatedEvent(
message: msg.copyWith(isUploading: false),
),
);
}
await _fileUploadFailed(job, fileUploadFailedError);
return;
} else {
_log.fine('Upload was successful');
const uuid = Uuid();
for (final recipient in job.recipients) {
// Notify UI of upload completion
var msg = job.messageMap[recipient]!;
// Reset a stored error, if there was one
if (msg.errorType != null) {
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,
errorType: noError,
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');
}
}
sendEvent(
MessageUpdatedEvent(
message: msg.copyWith(isUploading: false),
),
);
// Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
@ -226,6 +287,7 @@ class HttpFileTransferService {
to: recipient,
body: slot.getUrl,
requestDeliveryReceipt: true,
id: msg.sid,
originId: msg.originId,
sfs: StatelessFileSharingData(
FileMetadataData(
@ -233,10 +295,12 @@ class HttpFileTransferService {
size: stat.size,
name: pathlib.basename(job.path),
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
slot.getUrl,
<StatelessFileSharingSource>[source],
),
funReplacement: msg.sid,
shouldEncrypt: job.encryptMap[recipient]!,
funReplacement: oldSid,
),
);
_log.finest('Sent message with file upload for ${job.path} to $recipient');
@ -249,15 +313,15 @@ class HttpFileTransferService {
}
}
} on dio.DioError {
// TODO(PapaTutuWawa): Check if this is a timeout
_log.finest('Upload failed due to connection error');
await _fileUploadFailed(job, fileUploadFailedError);
return;
}
await _nextUploadJob();
await _pickNextUploadTask();
}
Future<void> _nextUploadJob() async {
Future<void> _pickNextUploadTask() async {
// Free the upload resources for the next one
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
await _uploadLock.synchronized(() async {
@ -270,17 +334,38 @@ class HttpFileTransferService {
});
}
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
final ms = GetIt.I.get<MessageService>();
// Notify UI of download failure
final msg = await ms.updateMessage(
job.mId,
errorType: error,
isDownloading: false,
);
sendEvent(MessageUpdatedEvent(message: msg));
await _pickNextDownloadTask();
}
/// Actually attempt to download the file described by the job [job].
Future<void> _performFileDownload(FileDownloadJob job) async {
_log.finest('Downloading ${job.url}');
final uri = Uri.parse(job.url);
final filename = uri.pathSegments.last;
final filename = job.location.filename;
_log.finest('Downloading ${job.location.url} as $filename');
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
var downloadPath = downloadedPath;
if (job.location.key != null && job.location.iv != null) {
// The file was encrypted
final tempDir = await getTemporaryDirectory();
downloadPath = pathlib.join(tempDir.path, filename);
}
dio.Response<dynamic>? response;
try {
final response = await dio.Dio().downloadUri(
uri,
downloadedPath,
response = await dio.Dio().downloadUri(
Uri.parse(job.location.url),
downloadPath,
onReceiveProgress: (count, total) {
final progress = count.toDouble() / total.toDouble();
sendEvent(
@ -291,12 +376,58 @@ class HttpFileTransferService {
);
},
);
} on dio.DioError catch(err) {
// TODO(PapaTutuWawa): React if we received an error that is not related to the
// connection.
_log.finest('Failed to download: $err');
await _fileDownloadFailed(job, fileDownloadFailedError);
return;
}
if (!isRequestOkay(response.statusCode)) {
// TODO(PapaTutuWawa): Error handling
// TODO(PapaTutuWawa): Trigger event
_log.warning('HTTP GET of ${job.url} returned ${response.statusCode}');
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
await _fileDownloadFailed(job, fileDownloadFailedError);
return;
} else {
var integrityCheckPassed = true;
final conv = (await GetIt.I.get<ConversationService>()
.getConversationByJid(job.conversationJid))!;
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
if (decryptionKeysAvailable) {
// The file was downloaded and is now being decrypted
sendEvent(
ProgressEvent(
id: job.mId,
),
);
try {
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 ?? {},
);
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);
return;
}
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
}
// Check the MIME type
final notification = GetIt.I.get<NotificationsService>();
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
@ -309,9 +440,15 @@ class HttpFileTransferService {
// Find out the dimensions
// TODO(Unknown): Restrict to the library's supported file types
final size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
mediaWidth = size.width;
mediaHeight = size.height;
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);
@ -326,17 +463,24 @@ class HttpFileTransferService {
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.copyWith(isDownloading: false)));
sendEvent(MessageUpdatedEvent(message: msg));
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(msg, '');
}
final conv = (await GetIt.I.get<ConversationService>().getConversationByJid(job.conversationJid))!;
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
downloadedPath,
msg.timestamp,
@ -348,16 +492,16 @@ class HttpFileTransferService {
);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
} on dio.DioError catch(err) {
// TODO(PapaTutuWawa): React if we received an error that is not related to the
// connection.
_log.finest('Error: $err');
}
// Free the download resources for the next one
await _pickNextDownloadTask();
}
Future<void> _pickNextDownloadTask() async {
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
await _uploadLock.synchronized(() async {
if (_uploadQueue.isNotEmpty) {
await _downloadLock.synchronized(() async {
if (_downloadQueue.isNotEmpty) {
_currentDownloadJob = _downloadQueue.removeFirst();
unawaited(_performFileDownload(_currentDownloadJob!));
} else {

View File

@ -1,15 +1,17 @@
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
/// A job describing the download of a file.
@immutable
class FileUploadJob {
const FileUploadJob(this.recipients, this.path, this.mime, this.messageMap, this.thumbnails);
const FileUploadJob(this.recipients, this.path, this.mime, this.encryptMap, this.messageMap, this.thumbnails);
final List<String> recipients;
final String path;
final String? mime;
// Recipient -> Should encrypt
final Map<String, bool> encryptMap;
// Recipient -> Message
final Map<String, Message> messageMap;
final List<Thumbnail> thumbnails;
@ -21,19 +23,25 @@ class FileUploadJob {
path == other.path &&
messageMap == other.messageMap &&
mime == other.mime &&
thumbnails == other.thumbnails;
thumbnails == other.thumbnails &&
encryptMap == other.encryptMap;
}
@override
int get hashCode => path.hashCode ^ recipients.hashCode ^ messageMap.hashCode ^ mime.hashCode ^ thumbnails.hashCode;
int get hashCode => path.hashCode ^ recipients.hashCode ^ messageMap.hashCode ^ mime.hashCode ^ thumbnails.hashCode ^ encryptMap.hashCode;
}
/// A job describing the upload of a file.
@immutable
class FileDownloadJob {
const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess, {this.shouldShowNotification = true});
final String url;
const FileDownloadJob(
this.location,
this.mId,
this.conversationJid,
this.mimeGuess, {
this.shouldShowNotification = true,
});
final MediaFileLocation location;
final int mId;
final String conversationJid;
final String? mimeGuess;
@ -42,12 +50,13 @@ class FileDownloadJob {
@override
bool operator ==(Object other) {
return other is FileDownloadJob &&
url == other.url &&
location == other.location &&
mId == other.mId &&
conversationJid == other.conversationJid &&
mimeGuess == other.mimeGuess &&
shouldShowNotification == other.shouldShowNotification;
}
@override
int get hashCode => url.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode;
int get hashCode => location.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode;
}

View File

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

View File

@ -0,0 +1,5 @@
/// A simple wrapper around storing the system's default language.
class LanguageService {
LanguageService() : defaultLocale = 'en';
String defaultLocale;
}

View File

@ -1,13 +1,12 @@
import 'dart:collection';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/message.dart';
class MessageService {
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
final HashMap<String, List<Message>> _messageCache;
final Logger _log;
@ -36,8 +35,12 @@ class MessageService {
bool isMedia,
String sid,
bool isFileUploadNotification,
bool encrypted,
{
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
String? mediaUrl,
String? mediaType,
String? thumbnailData,
@ -46,6 +49,13 @@ class MessageService {
String? originId,
String? quoteId,
String? filename,
int? errorType,
int? warningType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
}
) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
@ -56,7 +66,11 @@ class MessageService {
isMedia,
sid,
isFileUploadNotification,
encrypted,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaUrl: mediaUrl,
mediaType: mediaType,
thumbnailData: thumbnailData,
@ -65,6 +79,13 @@ class MessageService {
originId: originId,
quoteId: quoteId,
filename: filename,
errorType: errorType,
warningType: warningType,
plaintextHashes: plaintextHashes,
ciphertextHashes: ciphertextHashes,
isUploading: isUploading,
isDownloading: isDownloading,
mediaSize: mediaSize,
);
// Only update the cache if the conversation already has been loaded. This prevents
@ -87,31 +108,66 @@ class MessageService {
);
}
Future<Message?> getMessageById(String conversationJid, int id) async {
if (!_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid);
}
return firstWhereOrNull(
_messageCache[conversationJid]!,
(message) => message.id == id,
);
}
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage(int id, {
String? mediaUrl,
String? mediaType,
Object? body = notSpecified,
Object? mediaUrl = notSpecified,
Object? mediaType = notSpecified,
bool? isMedia,
bool? received,
bool? displayed,
bool? acked,
int? errorType,
Object? errorType = notSpecified,
Object? warningType = notSpecified,
bool? isFileUploadNotification,
String? srcUrl,
int? mediaWidth,
int? mediaHeight,
Object? srcUrl = notSpecified,
Object? key = notSpecified,
Object? iv = notSpecified,
Object? encryptionScheme = notSpecified,
Object? mediaWidth = notSpecified,
Object? mediaHeight = notSpecified,
Object? mediaSize = notSpecified,
bool? isUploading,
bool? isDownloading,
Object? originId = notSpecified,
Object? sid = notSpecified,
bool? isRetracted,
}) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
id,
body: body,
mediaUrl: mediaUrl,
mediaType: mediaType,
received: received,
displayed: displayed,
acked: acked,
errorType: errorType,
warningType: warningType,
isFileUploadNotification: isFileUploadNotification,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: mediaSize,
isUploading: isUploading,
isDownloading: isDownloading,
originId: originId,
sid: sid,
isRetracted: isRetracted,
isMedia: isMedia,
);
if (_messageCache.containsKey(newMessage.conversationJid)) {

View File

@ -1,5 +1,4 @@
import 'package:moxxyv2/xmpp/xeps/xep_0030/helpers.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0030/xep_0030.dart';
import 'package:moxxmpp/moxxmpp.dart';
class MoxxyDiscoManager extends DiscoManager {
@override

View File

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

View File

@ -4,8 +4,8 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/xmpp/reconnect.dart';
import 'package:synchronized/synchronized.dart';
/// This class implements a reconnection policy that is connectivity aware with a random
@ -13,7 +13,7 @@ import 'package:synchronized/synchronized.dart';
/// connected. Otherwise, we idle until we have a connection again.
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
MoxxyReconnectionPolicy({ bool isTesting = false })
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
: _isTesting = isTesting,
_timerLock = Lock(),
_log = Logger('MoxxyReconnectionPolicy'),
@ -28,6 +28,9 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
/// Just for testing purposes
final bool _isTesting;
/// Maximum backoff time
final int? maxBackoffTime;
/// To be called when the conectivity changes
Future<void> onConnectivityChanged(bool regained, bool lost) async {
// Do nothing if we should not reconnect
@ -78,7 +81,18 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
Future<void> _attemptReconnection(bool immediately) async {
if (await testAndSetIsReconnecting()) {
// Attempt reconnecting
final seconds = _isTesting ? 9999 : Random().nextInt(15);
int seconds;
if (_isTesting) {
seconds = 9999;
} else {
final r = Random().nextInt(15);
if (maxBackoffTime != null) {
seconds = min(maxBackoffTime!, r);
} else {
seconds = r;
}
}
await _stopTimer();
if (immediately) {
_log.finest('Immediately attempting reconnection...');

View File

@ -1,8 +1,7 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/xmpp/roster.dart';
class MoxxyRosterManager extends RosterManager {
@override

View File

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

View File

@ -1,21 +1,18 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0198/xep_0198.dart';
class MoxxyStreamManagementManager extends StreamManagementManager {
@override
bool shouldTriggerAckedEvent(Stanza stanza) {
// TODO(PapaTutuWawa): Once OMEMO is supported, add the encrypted element here
return stanza.tag == 'message' &&
stanza.id != null && (
stanza.firstTag('body') != null ||
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null ||
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null
);
}
@ -30,7 +27,7 @@ class MoxxyStreamManagementManager extends StreamManagementManager {
Future<void> loadState() async {
final state = await GetIt.I.get<XmppService>().getXmppState();
if (state.smState != null) {
setState(state.smState!);
await setState(state.smState!);
}
}
}

View File

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

View File

@ -9,7 +9,6 @@ const maxNotificationId = 2147483647;
// TODO(Unknown): Add resolution dependent drawables for the notification icon
class NotificationsService {
NotificationsService() : _log = Logger('NotificationsService');
// ignore: unused_field
final Logger _log;

View File

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

View File

@ -0,0 +1,305 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/omemo/implementations.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:omemo_dart/omemo_dart.dart';
import 'package:synchronized/synchronized.dart';
class OmemoDoubleRatchetWrapper {
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
final OmemoDoubleRatchet ratchet;
final int id;
final String jid;
}
class OmemoService {
final Logger _log = Logger('OmemoService');
bool _initialized = false;
final Lock _lock = Lock();
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
late OmemoSessionManager omemoState;
Future<void> initializeIfNeeded(String jid) async {
final done = await _lock.synchronized(() => _initialized);
if (done) return;
final db = GetIt.I.get<DatabaseService>();
final device = await db.loadOmemoDevice(jid);
if (device == null) {
_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 {
_log.info('OMEMO marker found. Restoring OMEMO state...');
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet;
}
final db = GetIt.I.get<DatabaseService>();
omemoState = OmemoSessionManager(
device,
await db.loadOmemoDeviceList(),
ratchetMap,
await loadTrustManager(),
);
}
omemoState.eventStream.listen((event) async {
if (event is RatchetModifiedEvent) {
await GetIt.I.get<DatabaseService>().saveRatchet(
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
);
} else if (event is DeviceMapModifiedEvent) {
await commitDeviceMap(event.map);
} else if (event is DeviceModifiedEvent) {
await commitDevice(event.device);
// Publish it
await GetIt.I.get<XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)!
.publishBundle(await event.device.toBundle());
}
});
await _lock.synchronized(() {
_initialized = true;
for (final c in _waitingForInitialization) {
c.complete();
}
_waitingForInitialization.clear();
});
}
Future<OmemoDevice> regenerateDevice(String jid) async {
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
await _lock.synchronized(() {
_initialized = false;
});
_log.info('No OMEMO marker found. Generating OMEMO identity...');
final oldId = await omemoState.getDeviceId();
// Clear the database
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
// Regenerate 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());
// Remove the old device
final omemo = GetIt.I.get<XmppConnection>()
.getManagerById<OmemoManager>(omemoManager)!;
await omemo.deleteDevice(oldId);
// Publish the new one
await omemo.publishBundle(await omemoState.getDeviceBundle());
// Allow access again
await _lock.synchronized(() {
_initialized = true;
for (final c in _waitingForInitialization) {
c.complete();
}
_waitingForInitialization.clear();
});
// Return the OmemoDevice
return OmemoDevice(
await getDeviceFingerprint(),
true,
true,
true,
await getDeviceId(),
);
}
/// Ensures that the code following this *AWAITED* call can access every method
/// of the OmemoService.
Future<void> ensureInitialized() async {
final completer = await _lock.synchronized(() {
if (!_initialized) {
final c = Completer<void>();
_waitingForInitialization.add(c);
return c;
}
return null;
});
if (completer != null) {
await completer.future;
}
}
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
}
Future<void> commitDevice(Device device) async {
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
}
/// Requests our device list and checks if the current device is in it. If not, then
/// it will be published.
Future<Object?> publishDeviceIfNeeded() async {
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
await ensureInitialized();
_log.finest('publishDeviceIfNeeded: Done');
final conn = GetIt.I.get<XmppConnection>();
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!;
final dm = conn.getManagerById<DiscoManager>(discoManager)!;
final bareJid = conn.getConnectionSettings().jid.toBare();
final device = await omemoState.getDevice();
final bundlesRaw = await dm.discoItemsQuery(
bareJid.toString(),
node: omemoBundlesXmlns,
);
if (bundlesRaw.isType<DiscoError>()) {
await omemo.publishBundle(await device.toBundle());
return bundlesRaw.get<DiscoError>();
}
final bundleIds = bundlesRaw
.get<List<DiscoItem>>()
.where((item) => item.name != null)
.map((item) => int.parse(item.name!));
if (!bundleIds.contains(device.id)) {
final result = await omemo.publishBundle(await device.toBundle());
if (result.isType<OmemoError>()) return result.get<OmemoError>();
return null;
}
final idsRaw = await omemo.getDeviceList(bareJid);
final ids = idsRaw.isType<OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
if (!ids.contains(device.id)) {
final result = await omemo.publishBundle(await device.toBundle());
if (result.isType<OmemoError>()) return result.get<OmemoError>();
return null;
}
return null;
}
Future<List<OmemoDevice>> getOmemoKeysForJid(String jid) async {
await ensureInitialized();
final fingerprints = await omemoState.getHexFingerprintsForJid(jid);
final keys = List<OmemoDevice>.empty(growable: true);
for (final fp in fingerprints) {
keys.add(
OmemoDevice(
fp.fingerprint,
await omemoState.trustManager.isTrusted(jid, fp.deviceId),
// TODO(Unknown): Allow verifying OMEMO keys
false,
await omemoState.trustManager.isEnabled(jid, fp.deviceId),
fp.deviceId,
),
);
}
return keys;
}
Future<void> commitTrustManager(Map<String, dynamic> json) async {
await GetIt.I.get<DatabaseService>().saveTrustCache(
json['trust']! as Map<String, int>,
);
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
json['enable']! as Map<String, bool>,
);
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
json['devices']! as Map<String, List<int>>,
);
}
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
final db = GetIt.I.get<DatabaseService>();
return MoxxyBTBVTrustManager(
await db.loadTrustCache(),
await db.loadTrustEnablementList(),
await db.loadTrustDeviceList(),
);
}
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
await ensureInitialized();
await omemoState.trustManager.setEnabled(jid, deviceId, enabled);
}
Future<void> removeAllSessions(String jid) async {
await ensureInitialized();
await omemoState.removeAllRatchets(jid);
}
Future<int> getDeviceId() async {
await ensureInitialized();
return omemoState.getDeviceId();
}
Future<String> getDeviceFingerprint() async {
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
}
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
/// published on [ownJid]'s devices PubSub node.
/// Note that the list is made so that the current device is excluded.
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async {
final conn = GetIt.I.get<XmppConnection>();
final ownId = await getDeviceId();
final keys = List<OmemoDevice>.from(
await getOmemoKeysForJid(ownJid.toString()),
);
// TODO(PapaTutuWawa): This should be cached in the database and only requested if
// it's not cached.
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) {
// All devices that are publishes that is not the current device
if (device.id == ownId) continue;
final curveIk = await device.ik.toCurve25519();
keys.add(
OmemoDevice(
HEX.encode(await curveIk.getBytes()),
false,
false,
false,
device.id,
hasSessionWith: false,
),
);
}
}
return keys;
}
}

View File

@ -1,21 +1,17 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/roster.dart';
import 'package:moxxyv2/xmpp/types/error.dart';
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
@ -343,7 +339,7 @@ class RosterService {
Future<void> requestRoster() async {
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
MayFail<RosterRequestResult?> result;
Result<RosterRequestResult?, RosterError> result;
if (roster.rosterVersioningAvailable()) {
_log.fine('Stream supports roster versioning');
result = await roster.requestRosterPushes();
@ -353,17 +349,18 @@ class RosterService {
result = await roster.requestRoster();
}
if (result.isError()) {
if (result.isType<RosterError>()) {
_log.warning('Failed to request roster');
return;
}
if (result.getValue() != null) {
final value = result.get<RosterRequestResult?>();
if (value != null) {
final currentRoster = await getRoster();
sendEvent(
await processRosterDiff(
currentRoster,
result.getValue()!.items,
value.items,
false,
addRosterItemFromData,
updateRosterItem,

View File

@ -1,26 +1,33 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/events.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/managers/disco.dart';
import 'package:moxxyv2/service/managers/roster.dart';
import 'package:moxxyv2/service/managers/stream.dart';
import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/disco.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
import 'package:moxxyv2/service/moxxmpp/roster.dart';
import 'package:moxxyv2/service/moxxmpp/socket.dart';
import 'package:moxxyv2/service/moxxmpp/stream.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/xmpp.dart';
@ -29,33 +36,6 @@ import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/logging.dart';
import 'package:moxxyv2/ui/events.dart' as ui_events;
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/message.dart';
import 'package:moxxyv2/xmpp/negotiators/resource_binding.dart';
import 'package:moxxyv2/xmpp/negotiators/sasl/plain.dart';
import 'package:moxxyv2/xmpp/negotiators/sasl/scram.dart';
import 'package:moxxyv2/xmpp/negotiators/starttls.dart';
import 'package:moxxyv2/xmpp/ping.dart';
import 'package:moxxyv2/xmpp/presence.dart';
import 'package:moxxyv2/xmpp/roster.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0054.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0060.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0066.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0084.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0184.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0191.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0198/negotiator.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0280.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0333.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0352.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0359.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0363.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0385.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0447.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0461.dart';
Future<void> initializeServiceIfNeeded() async {
final logger = GetIt.I.get<Logger>();
@ -73,7 +53,9 @@ Future<void> initializeServiceIfNeeded() async {
// ignore: cascade_invocations
logger.info('Service is running. Sending pre start command');
await handler.getDataSender().sendData(
PerformPreStartCommand(),
PerformPreStartCommand(
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
awaitable: false,
);
} else {
@ -95,8 +77,7 @@ void sendEvent(BackgroundEvent event, { String? id }) {
}
void setupLogging() {
//Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.level = Level.ALL;
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen((record) {
final logMessageHeader = '[${record.level.name}] (${record.loggerName}) ${record.time}: ';
var msg = record.message;
@ -155,6 +136,7 @@ Future<void> entrypoint() async {
// Register singletons
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
GetIt.I.registerSingleton<LanguageService>(LanguageService());
setupLogging();
setupBackgroundEventHandler();
@ -171,30 +153,39 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<RosterService>(RosterService());
GetIt.I.registerSingleton<ConversationService>(ConversationService());
GetIt.I.registerSingleton<MessageService>(MessageService());
GetIt.I.registerSingleton<OmemoService>(OmemoService());
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp);
await GetIt.I.get<NotificationsService>().init();
if (!kDebugMode) {
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
}
// Init the UDPLogger
await initUDPLogger();
GetIt.I.registerSingleton<MoxxyReconnectionPolicy>(MoxxyReconnectionPolicy());
final connection = XmppConnection(GetIt.I.get<MoxxyReconnectionPolicy>())
..registerManagers([
final connection = XmppConnection(
GetIt.I.get<MoxxyReconnectionPolicy>(),
MoxxyTCPSocketWrapper(),
)..registerManagers([
MoxxyStreamManagementManager(),
MoxxyDiscoManager(),
MoxxyRosterManager(),
MoxxyOmemoManager(),
PingManager(),
MessageManager(),
PresenceManager(),
PresenceManager('http://moxxy.im'),
CSIManager(),
CarbonsManager(),
PubSubManager(),
VCardManager(),
UserAvatarManager(),
StableIdManager(),
SIMSManager(),
MessageDeliveryReceiptManager(),
ChatMarkerManager(),
OOBManager(),
@ -204,6 +195,10 @@ Future<void> entrypoint() async {
ChatStateManager(),
HttpFileUploadManager(),
FileUploadNotificationManager(),
EmeManager(),
CryptographicHashManager(),
DelayedDeliveryManager(),
MessageRetractionManager(),
])
..registerFeatureNegotiators([
ResourceBindingNegotiator(),
@ -211,11 +206,10 @@ Future<void> entrypoint() async {
StreamManagementNegotiator(),
CSINegotiator(),
RosterFeatureNegotiator(),
// TODO(Unknown): This one may not work
//SaslScramNegotiator(10, '', '', ScramHashType.sha512),
SaslPlainNegotiator(),
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
SaslPlainNegotiator(),
]);
GetIt.I.registerSingleton<XmppConnection>(connection);
@ -227,8 +221,16 @@ Future<void> entrypoint() async {
final settings = await xmpp.getConnectionSettings();
// Ensure we can access translations here
// TODO(Unknown): This does *NOT* allow us to get the system's locale as we have no
// window here.
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocale();
GetIt.I.get<Logger>().finest('Got settings');
if (settings != null) {
unawaited(GetIt.I.get<OmemoService>().initializeIfNeeded(settings.jid.toBare().toString()));
// The title of the notification will be changed as soon as the connection state
// of [XmppConnection] changes.
await connection.getManagerById<MoxxyStreamManagementManager>(smManager)!.loadState();
@ -236,7 +238,7 @@ Future<void> entrypoint() async {
} else {
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
'Idle',
t.notifications.permanent.idle,
);
}

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0198/state.dart';
import 'package:moxxmpp/moxxmpp.dart';
part 'state.freezed.dart';
part 'state.g.dart';
@ -29,6 +30,41 @@ class XmppState with _$XmppState {
@Default(false) bool askedStoragePermission,
}) = _XmppState;
const XmppState._();
// JSON serialization
factory XmppState.fromJson(Map<String, dynamic> json) => _$XmppStateFromJson(json);
factory XmppState.fromDatabaseTuples(Map<String, String?> tuples) {
final smStateString = tuples['smState'];
final isSmStateNotNull = smStateString != null && smStateString != 'null';
final json = <String, dynamic>{
'smState': isSmStateNotNull ?
jsonDecode(smStateString) as Map<String, dynamic> :
null,
'srid': tuples['srid'],
'resource': tuples['resource'],
'jid': tuples['jid'],
'displayName': tuples['displayName'],
'password': tuples['password'],
'lastRosterVersion': tuples['lastRosterVersion'],
'avatarUrl': tuples['avatarUrl'],
'avatarHash': tuples['avatarHash'],
'askedStoragePermission': tuples['askedStoragePermission'] == 'true',
};
return XmppState.fromJson(json);
}
Map<String, String?> toDatabaseTuples() {
final json = toJson()
..remove('smState')
..remove('askedStoragePermission');
return {
...json.cast<String, String?>(),
'smState': jsonEncode(smState?.toJson()),
'askedStoragePermission': askedStoragePermission ? 'true' : 'false',
};
}
}

View File

@ -1,9 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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;
@ -11,6 +9,8 @@ import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
@ -21,87 +21,24 @@ import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/migrator.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/xmpp/connection.dart';
import 'package:moxxyv2/xmpp/events.dart';
import 'package:moxxyv2/xmpp/jid.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/message.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/roster.dart';
import 'package:moxxyv2/xmpp/settings.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0184.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0333.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart';
const currentXmppStateVersion = 1;
const xmppStateKey = 'xmppState';
const xmppStateVersionKey = 'xmppState_version';
class _XmppStateMigrator extends Migrator<XmppState> {
_XmppStateMigrator() : super(currentXmppStateVersion, []);
final FlutterSecureStorage _storage = const FlutterSecureStorage(
// TODO(Unknown): Set other options
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
// TODO(Unknown): Deduplicate
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
return _storage.read(key: key);
} else {
return null;
}
}
@override
Future<Map<String, dynamic>?> loadRawData() async {
final raw = await _readKeyOrNull(xmppStateKey);
if (raw != null) return json.decode(raw) as Map<String, dynamic>;
return null;
}
@override
Future<int?> loadVersion() async {
final raw = await _readKeyOrNull(xmppStateVersionKey);
if (raw != null) return int.parse(raw);
return null;
}
@override
XmppState fromData(Map<String, dynamic> data) => XmppState.fromJson(data);
@override
XmppState fromDefault() => XmppState();
@override
Future<void> commit(int version, XmppState data) async {
await _storage.write(key: xmppStateVersionKey, value: currentXmppStateVersion.toString());
await _storage.write(key: xmppStateKey, value: json.encode(data.toJson()));
}
}
class XmppService {
XmppService() :
_currentlyOpenedChatJid = '',
_xmppConnectionSubscription = null,
@ -109,7 +46,6 @@ class XmppService {
_eventHandler = EventHandler(),
_appOpen = true,
_loginTriggeredFromUI = false,
_migrator = _XmppStateMigrator(),
_log = Logger('XmppService') {
_eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
@ -124,11 +60,11 @@ class XmppService {
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
EventTypeMatcher<BlocklistUnblockAllPushEvent>(_onBlocklistUnblockAllPush),
EventTypeMatcher<StanzaSendingCancelledEvent>(_onStanzaSendingCancelled),
]);
}
final Logger _log;
final EventHandler _eventHandler;
final _XmppStateMigrator _migrator;
bool _loginTriggeredFromUI;
bool _appOpen;
String _currentlyOpenedChatJid;
@ -138,14 +74,14 @@ class XmppService {
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
_state = await _migrator.load();
_state = await GetIt.I.get<DatabaseService>().getXmppState();
return _state!;
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
await _migrator.commit(currentXmppStateVersion, _state!);
await GetIt.I.get<DatabaseService>().saveXmppState(_state!);
}
/// Stores whether the app is open or not. Useful for notifications.
@ -207,6 +143,7 @@ class XmppService {
for (final recipient in recipients) {
final sid = conn.generateId();
final originId = conn.generateId();
final conversation = await cs.getConversationByJid(recipient);
final message = await ms.addMessageFromData(
body,
timestamp,
@ -215,9 +152,17 @@ class XmppService {
false,
sid,
false,
conversation!.encrypted,
originId: originId,
quoteId: quotedMessage?.sid,
);
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: body,
lastMessageId: message.id,
lastMessageRetracted: false,
lastChangeTimestamp: timestamp,
);
// Using the same ID should be fine.
sendEvent(
@ -232,42 +177,76 @@ class XmppService {
requestDeliveryReceipt: true,
id: sid,
originId: originId,
quoteBody: quotedMessage?.body,
quoteBody: createFallbackBodyForQuotedMessage(quotedMessage),
quoteFrom: quotedMessage?.sender,
quoteId: quotedMessage?.sid,
chatState: chatState,
shouldEncrypt: newConversation.encrypted,
),
);
final conversation = await cs.getConversationByJid(recipient);
final newConversation = await cs.updateConversation(
conversation!.id,
lastMessageBody: body,
lastChangeTimestamp: timestamp,
);
sendEvent(
ConversationUpdatedEvent(conversation: newConversation),
);
}
}
String? _getMessageSrcUrl(MessageEvent event) {
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
if (event.sfs != null) {
return event.sfs!.url;
} else if (event.sims != null) {
return event.sims!.url;
final source = firstWhereOrNull(
event.sfs!.sources,
(StatelessFileSharingSource source) {
return source is StatelessFileSharingUrlSource || source is StatelessFileSharingEncryptedSource;
},
);
final name = event.sfs?.metadata.name;
if (source is StatelessFileSharingUrlSource) {
return MediaFileLocation(
source.url,
name != null ?
escapeFilename(name) :
filenameFromUrl(source.url),
null,
null,
null,
event.sfs?.metadata.hashes,
null,
);
} else {
final esource = source! as StatelessFileSharingEncryptedSource;
return MediaFileLocation(
esource.source.url,
name != null ?
escapeFilename(name) :
filenameFromUrl(esource.source.url),
esource.encryption.toNamespace(),
esource.key,
esource.iv,
event.sfs?.metadata.hashes,
esource.hashes,
);
}
} else if (event.oob != null) {
return event.oob!.url;
return MediaFileLocation(
event.oob!.url!,
filenameFromUrl(event.oob!.url!),
null,
null,
null,
null,
null,
);
}
return null;
}
Future<void> _acknowledgeMessage(MessageEvent event) async {
final info = await GetIt.I.get<XmppConnection>().getDiscoManager().discoInfoQuery(event.fromJid.toString());
if (info == null) return;
final result = await GetIt.I.get<XmppConnection>().getDiscoManager().discoInfoQuery(event.fromJid.toString());
if (result.isType<DiscoError>()) return;
final info = result.get<DiscoInfo>();
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
@ -354,6 +333,10 @@ class XmppService {
final thumbnails = <String, List<Thumbnail>>{};
// Path -> Dimensions
final dimensions = <String, Size>{};
// Recipient -> Should encrypt
final encrypt = <String, bool>{};
// Recipient -> Last message Id
final lastMessageIds = <String, int>{};
// Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>();
@ -361,13 +344,20 @@ class XmppService {
final pathMime = lookupMimeType(path);
for (final recipient in recipients) {
final conversation = await cs.getConversationByJid(recipient);
encrypt[recipient] = conversation?.encrypted ?? prefs.enableOmemoByDefault;
// TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) {
try {
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
dimensions[path] = Size(
imageSize.width.toDouble(),
imageSize.height.toDouble(),
);
} catch (ex) {
_log.warning('Failed to get image dimensions for $path');
}
}
final msg = await ms.addMessageFromData(
@ -378,11 +368,14 @@ class XmppService {
true,
conn.generateId(),
false,
encrypt[recipient]!,
mediaUrl: path,
mediaType: pathMime,
originId: conn.generateId(),
mediaWidth: dimensions[path]?.width.toInt(),
mediaHeight: dimensions[path]?.height.toInt(),
filename: pathlib.basename(path),
isUploading: true,
);
if (messages.containsKey(path)) {
messages[path]![recipient] = msg;
@ -390,7 +383,11 @@ class XmppService {
messages[path] = { recipient: msg };
}
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true)));
if (path == paths.last) {
lastMessageIds[recipient] = msg.id;
}
sendEvent(MessageAddedEvent(message: msg));
}
}
@ -405,7 +402,8 @@ class XmppService {
// Update conversation
final updatedConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: mimeTypeToConversationBody(lastFileMime),
lastMessageBody: mimeTypeToEmoji(lastFileMime),
lastMessageId: lastMessageIds[recipient],
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
open: true,
);
@ -427,13 +425,16 @@ class XmppService {
final newConversation = await cs.addConversationFromData(
// TODO(Unknown): Should we use the JID parser?
rosterItem?.title ?? recipient.split('@').first,
mimeTypeToConversationBody(lastFileMime),
lastMessageIds[recipient]!,
false,
mimeTypeToEmoji(lastFileMime),
rosterItem?.avatarUrl ?? '',
recipient,
0,
DateTime.now().millisecondsSinceEpoch,
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
);
// Notify the UI
@ -480,6 +481,7 @@ class XmppService {
size: File(path).statSync().size,
thumbnails: thumbnails[path] ?? [],
),
shouldEncrypt: encrypt[recipient]!,
),
);
}
@ -489,6 +491,7 @@ class XmppService {
recipients,
path,
pathMime,
encrypt,
messages[path]!,
thumbnails[path] ?? [],
),
@ -498,33 +501,51 @@ class XmppService {
_log.finest('File upload done');
}
Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
switch (event.state) {
Future<void> _initializeOmemoService(String jid) async {
await GetIt.I.get<OmemoService>().initializeIfNeeded(jid);
final result = await GetIt.I.get<OmemoService>().publishDeviceIfNeeded();
if (result != null) {
// Notify the user that we could not publish the Omemo ~identity~ titty
await GetIt.I.get<NotificationsService>().showWarningNotification(
'Encryption',
t.errors.omemo.couldNotPublish,
);
}
}
/// Sets the permanent notification's title to the corresponding one for the
/// XmppConnection's state [state].
void setNotificationText(XmppConnectionState state) {
switch (state) {
case XmppConnectionState.connected:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
'Ready to receive messages',
t.notifications.permanent.ready,
);
break;
case XmppConnectionState.connecting:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
'Connecting...',
t.notifications.permanent.connecting,
);
break;
case XmppConnectionState.notConnected:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
'Disconnected',
t.notifications.permanent.disconnect,
);
break;
case XmppConnectionState.error:
GetIt.I.get<BackgroundService>().setNotification(
'Moxxy',
'Error',
t.notifications.permanent.error,
);
break;
}
}
Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
setNotificationText(event.state);
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
event.before, event.state,
@ -541,6 +562,8 @@ class XmppService {
),);
_log.finest('Connection connected. Is resumed? ${event.resumed}');
unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) {
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
@ -604,6 +627,8 @@ class XmppService {
final bare = event.from.toBare();
final conv = await cs.addConversationFromData(
bare.toString().split('@')[0],
-1,
false,
'',
'', // TODO(Unknown): avatarUrl
bare.toString(),
@ -611,6 +636,7 @@ class XmppService {
timestamp,
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
);
sendEvent(ConversationAddedEvent(conversation: conv));
@ -672,14 +698,13 @@ class XmppService {
/// Return true if [event] describes a message that we want to display.
bool _isMessageEventMessage(MessageEvent event) {
return event.body.isNotEmpty || event.sfs != null || event.sims != null || event.fun != null;
return event.body.isNotEmpty || event.sfs != null || event.fun != null;
}
/// Extract the thumbnail data from a message, if existent.
String? _getThumbnailData(MessageEvent event) {
final thumbnails = firstNotNull([
event.sfs?.metadata.thumbnails,
event.sims?.thumbnails,
event.fun?.thumbnails,
]) ?? [];
for (final i in thumbnails) {
@ -695,7 +720,6 @@ class XmppService {
String? _getMimeGuess(MessageEvent event) {
return firstNotNull([
event.sfs?.metadata.mediaType,
event.sims?.mediaType,
event.fun?.mediaType,
]);
}
@ -718,16 +742,68 @@ class XmppService {
}
/// Returns true if a file is embedded in [event]. If not, returns false.
/// [embeddedFileUrl] is the possible Url of the file. If no file is present, then
/// [embeddedFileUrl] is null.
bool _isFileEmbedded(MessageEvent event, String? embeddedFileUrl) {
/// [embeddedFile] is the possible source of the file. If no file is present, then
/// [embeddedFile] is null.
bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFileUrl != null
&& Uri.parse(embeddedFileUrl).scheme == 'https'
return embeddedFile != null
&& Uri.parse(embeddedFile.url).scheme == 'https'
&& implies(event.oob != null, event.body == event.oob?.url);
}
/// Handle a message retraction given the MessageEvent [event].
Future<void> _handleMessageRetraction(MessageEvent event, String conversationJid) async {
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
event.messageRetraction!.id,
conversationJid,
);
if (msg == null) {
_log.finest('Got message retraction for origin Id ${event.messageRetraction!.id}, but did not find the message');
return;
}
// Check if the retraction was sent by the original sender
if (JID.fromString(msg.sender).toBare().toString() != event.fromJid.toBare().toString()) {
_log.warning('Received invalid message retraction from ${event.fromJid.toBare().toString()} but its original sender is ${msg.sender}');
return;
}
final retractedMessage = await GetIt.I.get<MessageService>().updateMessage(
msg.id,
isMedia: false,
mediaUrl: null,
mediaType: null,
warningType: null,
errorType: null,
srcUrl: null,
key: null,
iv: null,
encryptionScheme: null,
mediaWidth: null,
mediaHeight: null,
mediaSize: null,
isRetracted: true,
);
sendEvent(MessageUpdatedEvent(message: retractedMessage));
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(conversationJid);
if (conversation != null) {
if (conversation.lastMessageId == msg.id) {
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: '',
lastMessageRetracted: true,
);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
} else {
_log.warning('Failed to find conversation with conversationJid $conversationJid');
}
}
/// Returns true if a file should be automatically downloaded. If it should not, it
/// returns false.
/// [conversationJid] refers to the JID of the conversation the message was received in.
@ -752,8 +828,13 @@ class XmppService {
return;
}
if (event.messageRetraction != null) {
await _handleMessageRetraction(event, conversationJid);
return;
}
// Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event)) return;
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
final state = await getXmppState();
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
@ -787,10 +868,10 @@ class XmppService {
}
// The Url of the file embedded in the message, if there is one.
final embeddedFileUrl = _getMessageSrcUrl(event);
final embeddedFile = _getMessageSrcUrl(event);
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl);
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
// Indicates if we should auto-download the file, if a file is specified in the message
final shouldDownload = await _shouldDownloadFile(conversationJid);
// The thumbnail for the embedded file.
@ -814,19 +895,25 @@ class XmppService {
isFileEmbedded || event.fun != null,
event.sid,
event.fun != null,
srcUrl: embeddedFileUrl,
event.encrypted,
srcUrl: embeddedFile?.url,
filename: event.fun?.name ?? embeddedFile?.filename,
key: embeddedFile?.keyBase64,
iv: embeddedFile?.ivBase64,
encryptionScheme: embeddedFile?.encryptionScheme,
mediaType: mimeGuess,
thumbnailData: thumbnailData,
mediaWidth: dimensions?.width.toInt(),
mediaHeight: dimensions?.height.toInt(),
quoteId: replyId,
filename: event.fun?.name,
originId: event.stanzaId.originId,
errorType: errorTypeFromException(event.other['encryption_error']),
);
// Attempt to auto-download the embedded file
if (isFileEmbedded && shouldDownload) {
final fts = GetIt.I.get<HttpFileTransferService>();
final metadata = await peekFile(embeddedFileUrl!);
final metadata = await peekFile(embeddedFile!.url);
if (metadata.mime != null) mimeGuess = metadata.mime;
@ -834,10 +921,13 @@ class XmppService {
// "always download".
if (prefs.maximumAutoDownloadSize == -1
|| (metadata.size != null && metadata.size! < prefs.maximumAutoDownloadSize * 1000000)) {
message = message.copyWith(isDownloading: true);
message = await ms.updateMessage(
message.id,
isDownloading: true,
);
await fts.downloadFile(
FileDownloadJob(
embeddedFileUrl,
embeddedFile,
message.id,
conversationJid,
mimeGuess,
@ -852,7 +942,7 @@ class XmppService {
final cs = GetIt.I.get<ConversationService>();
final ns = GetIt.I.get<NotificationsService>();
// The body to be displayed in the conversations list
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToConversationBody(mimeGuess) : messageBody;
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
// Specifies if we have the conversation this message goes to opened
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
// The conversation we're about to modify, if it exists
@ -867,6 +957,8 @@ class XmppService {
conversation.id,
lastMessageBody: conversationBody,
lastChangeTimestamp: messageTimestamp,
lastMessageId: message.id,
lastMessageRetracted: false,
// Do not increment the counter for messages we sent ourselves (via Carbons)
// or if we have the chat currently opened
unreadCounter: isConversationOpened || sent
@ -890,6 +982,8 @@ class XmppService {
// The conversation does not exist, so we must create it
final newConversation = await cs.addConversationFromData(
rosterItem?.title ?? conversationJid.split('@')[0],
message.id,
false,
conversationBody,
rosterItem?.avatarUrl ?? '',
conversationJid,
@ -897,6 +991,7 @@ class XmppService {
messageTimestamp,
true,
prefs.defaultMuteState,
message.encrypted,
);
// Notify the UI
@ -913,14 +1008,14 @@ class XmppService {
}
// Notify the UI of the message
sendEvent(
MessageAddedEvent(
message: message.copyWith(
if (message.isDownloading != (event.fun != null)) {
message = await ms.updateMessage(
message.id,
isDownloading: event.fun != null,
),
),
);
}
sendEvent(MessageAddedEvent(message: message));
}
Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async {
final ms = GetIt.I.get<MessageService>();
@ -945,31 +1040,36 @@ class XmppService {
}
// The Url of the file embedded in the message, if there is one.
final embeddedFileUrl = _getMessageSrcUrl(event);
final embeddedFile = _getMessageSrcUrl(event);
// Is there even a file we can download?
final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl);
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
if (isFileEmbedded) {
if (await _shouldDownloadFile(conversationJid)) {
message = message.copyWith(isDownloading: true);
final shouldDownload = await _shouldDownloadFile(conversationJid);
message = await ms.updateMessage(
message.id,
srcUrl: embeddedFile!.url,
key: embeddedFile.keyBase64,
iv: embeddedFile.ivBase64,
isFileUploadNotification: false,
isDownloading: shouldDownload,
sid: event.sid,
originId: event.stanzaId.originId,
);
// Tell the UI
sendEvent(MessageUpdatedEvent(message: message));
if (shouldDownload) {
await GetIt.I.get<HttpFileTransferService>().downloadFile(
FileDownloadJob(
embeddedFileUrl!,
embeddedFile,
message.id,
conversationJid,
null,
shouldShowNotification: false,
),
);
} else {
message = await ms.updateMessage(
message.id,
srcUrl: embeddedFileUrl,
isFileUploadNotification: false,
);
// Tell the UI
sendEvent(MessageUpdatedEvent(message: message.copyWith(isDownloading: false)));
}
} else {
_log.warning('Received a File Upload Notification replacement but the replacement contains no file!');
@ -1013,4 +1113,70 @@ class XmppService {
Future<void> _onBlocklistUnblockAllPush(BlocklistUnblockAllPushEvent event, { dynamic extra }) async {
GetIt.I.get<BlocklistService>().onUnblockAllPush();
}
Future<void> _onStanzaSendingCancelled(StanzaSendingCancelledEvent event, { dynamic extra }) async {
// We only really care about messages
if (event.data.stanza.tag != 'message') return;
final ms = GetIt.I.get<MessageService>();
final message = await ms.getMessageByStanzaId(
JID.fromString(event.data.stanza.to!).toBare().toString(),
event.data.stanza.id!,
);
if (message == null) {
_log.warning('Message could not be sent but we cannot find it in the database');
return;
}
final newMessage = await ms.updateMessage(
message.id,
errorType: errorTypeFromException(event.data.cancelReason),
);
// Tell the UI
sendEvent(MessageUpdatedEvent(message: newMessage));
}
/// Creates the fallback body for quoted messages.
/// If the quoted message contains text, it simply quotes the text.
/// If it contains a media file, the messageEmoji (usually an emoji
/// representing the mime type) is shown together with the file size
/// (from experience this information is sufficient, as most clients show
/// the file size, and including time information might be confusing and a
/// potential privacy issue).
/// This information is complemented either the srcUrl or if unavailable
/// by the body of the quoted message. For non-media messages, we always use
/// the body as fallback.
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
if (quotedMessage == null) {
return null;
}
if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored
String quoteMessageSize;
if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) {
quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) ';
} else {
quoteMessageSize = '';
}
// Create media url string, or use body if no srcUrl is stored
String quotedMediaUrl;
if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.srcUrl!}';
} else if (quotedMessage.body.isNotEmpty){
quotedMediaUrl = '${quotedMessage.body}';
} else {
quotedMediaUrl = '';
}
// Concatenate emoji, size string, and media url and return
return '${quotedMessage.messageEmoji} $quoteMessageSize$quotedMediaUrl';
} else {
return quotedMessage.body;
}
}
}

View File

@ -1,2 +1,34 @@
import 'package:moxxmpp/moxxmpp.dart';
import 'package:omemo_dart/omemo_dart.dart';
const noError = 0;
const fileUploadFailedError = 1;
const messageNotEncryptedForDevice = 2;
const messageInvalidHMAC = 3;
const messageNoDecryptionKey = 4;
const messageInvalidAffixElements = 5;
const messageInvalidNumber = 6;
const messageFailedToEncrypt = 7;
const messageFailedToDecryptFile = 8;
const messageContactDoesNotSupportOmemo = 9;
const messageChatEncryptedButFileNot = 10;
const messageFailedToEncryptFile = 11;
const fileDownloadFailedError = 12;
int errorTypeFromException(dynamic exception) {
if (exception is NoDecryptionKeyException) {
return messageNoDecryptionKey;
} else if (exception is InvalidMessageHMACException) {
return messageInvalidHMAC;
} else if (exception is NotEncryptedForDeviceException) {
return messageNoDecryptionKey;
} else if (exception is InvalidAffixElementsException) {
return messageInvalidAffixElements;
} else if (exception is EncryptionFailedException) {
return messageFailedToEncrypt;
} else if (exception is OmemoNotSupportedForContactException) {
return messageContactDoesNotSupportOmemo;
}
return noError;
}

View File

@ -20,7 +20,7 @@ abstract class EventMatcher<E> {
/// Matches an event according to if the event "is T".
class EventTypeMatcher<T> extends EventMatcher<T> {
EventTypeMatcher(EventCallbackType<T> callback) : super(callback);
EventTypeMatcher(super.callback);
@override
bool matches(dynamic event) => event is T;
@ -34,7 +34,6 @@ class EventTypeMatcher<T> extends EventMatcher<T> {
/// A simple system for registering event handlers. Those handlers are checked whenever
/// [run] is called.
class EventHandler {
EventHandler() : _matchers = List.empty(growable: true);
final List<EventMatcher<dynamic>> _matchers;

View File

@ -2,6 +2,7 @@ import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/roster.dart';

View File

@ -1,6 +1,6 @@
import 'dart:core';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:synchronized/synchronized.dart';
/// Add a leading zero, if required, to ensure that an integer is rendered
@ -15,23 +15,6 @@ String padInt(int i) {
return i.toString();
}
/// A wrapper around List<T>.firstWhere that does not throw but instead just
/// returns true if [test] returns true for an element or false if [test] never
/// returned true.
bool listContains<T>(List<T> list, bool Function(T element) test) {
return firstWhereOrNull<T>(list, test) != null;
}
/// A wrapper around [List<T>.firstWhere] that does not throw but instead just
/// return null if [test] never returned true
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
try {
return list.firstWhere(test);
} catch(e) {
return null;
}
}
/// Format the timestamp of a conversation change into a nice string.
/// timestamp and now are both in millisecondsSinceEpoch.
/// Ensures that now >= timestamp
@ -218,19 +201,19 @@ String? guessMimeTypeFromExtension(String ext) {
return null;
}
/// Show a combinatio of an emoji and its file type
String mimeTypeToConversationBody(String? mime) {
/// Return an emoji for the MIME type [mime]. If [addTypeName] id true, then a human readable
/// name for the MIME type will be appended.
String mimeTypeToEmoji(String? mime, {bool addTypeName = true}) {
if (mime != null) {
if (mime.startsWith('image/')) {
return '📷 Image';
} else if (mime.startsWith('video/')) {
return '🎞️ Video';
} else if (mime.startsWith('audio/')) {
return '🎵 Audio';
if (mime.startsWith('image')) {
return '🖼️${addTypeName ? " ${t.messages.image}" : ""}';
} else if (mime.startsWith('audio')) {
return '🎙${addTypeName ? " ${t.messages.audio}" : ""}';
} else if (mime.startsWith('video')) {
return '🎬${addTypeName ? " ${t.messages.video}" : ""}';
}
}
return '📁 File';
return '📁${addTypeName ? " ${t.messages.file}" : ""}';
}
/// Parse an Uri and return the "filename".
@ -238,30 +221,15 @@ String filenameFromUrl(String url) {
return Uri.parse(url).pathSegments.last;
}
ChatState chatStateFromString(String raw) {
switch(raw) {
case 'active': {
return ChatState.active;
/// Attempts to escape [filename] such that it cannot be expanded into another path, i.e.
/// make "../" not dangerous.
String escapeFilename(String filename) {
return filename
.replaceAll('/', '%2F')
// ignore: use_raw_strings
.replaceAll('\\', '%5C')
.replaceAll('../', '..%2F');
}
case 'composing': {
return ChatState.composing;
}
case 'paused': {
return ChatState.paused;
}
case 'inactive': {
return ChatState.inactive;
}
case 'gone': {
return ChatState.gone;
}
default: {
return ChatState.gone;
}
}
}
String chatStateToString(ChatState state) => state.toString().split('.').last;
/// Return a version of the filename [filename] with [suffix] attached to the file's
/// name while keeping the extension in [filename] intact.
@ -309,3 +277,16 @@ bool isSent(Message message, String jid) {
// TODO(PapaTutuWawa): Does this work?
return message.sender.split('/').first == jid.split('/').first;
}
/// Convert the file size [size] in bytes to a human readable string. This is what
/// Conversations does.
String fileSizeToString(int size) {
// See https://github.com/iNPUTmice/Conversations/blob/d435c1f2aef1454141d4f5099224b5a03d579dba/src/main/java/eu/siacs/conversations/utils/UIHelper.java#L605
if (size > (1.5 * 1024 * 1024)) {
return '${(size * 1.0 / (1024 * 1024)).round()} MiB';
} else if (size >= 1024) {
return '${(size * 1.0 / 1024).round()} KiB';
} else {
return '$size B';
}
}

View File

@ -1,57 +0,0 @@
class Migration<T> {
Migration(this.version, this.migrationFunction);
final int version;
/// Return a version that is upgraded to the newest version.
final T Function(Map<String, dynamic>) migrationFunction;
bool canMigrate(int version) => version <= this.version;
}
abstract class Migrator<T> {
Migrator(this.latestVersion, this.migrations) {
migrations.sort((a, b) => -1 * a.version.compareTo(b.version));
}
final int latestVersion;
final List<Migration<T>> migrations;
/// Override: Return the raw data or null if not set yet.
Future<Map<String, dynamic>?> loadRawData();
/// Override: Return the version or null if not set yet.
Future<int?> loadVersion();
/// Override: Return [T] from [data] if the data is already at the newest version.
T fromData(Map<String, dynamic> data);
/// Override: If no data is available
T fromDefault();
/// Override: Commit the latest version and data back to the store.
Future<void> commit(int version, T data);
Future<T> load() async {
final version = await loadVersion();
final data = await loadRawData();
if (version == null || data == null) {
final ret = fromDefault();
await commit(latestVersion, ret);
return ret;
}
if (version == latestVersion) return fromData(data);
for (final migration in migrations) {
if (migration.canMigrate(version)) {
final ret = migration.migrationFunction(data);
await commit(latestVersion, ret);
return ret;
}
}
final ret = fromDefault();
await commit(latestVersion, ret);
return ret;
}
}

View File

@ -1,8 +1,7 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
part 'conversation.freezed.dart';
part 'conversation.g.dart';
@ -23,6 +22,9 @@ class ConversationChatStateConverter implements JsonConverter<ChatState, Map<Str
class Conversation with _$Conversation {
factory Conversation(
String title,
// NOTE: The internal database Id of the message
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
String avatarUrl,
String jid,
@ -39,6 +41,8 @@ class Conversation with _$Conversation {
String subscription,
// Whether the chat is muted (true = muted, false = not muted)
bool muted,
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
bool encrypted,
// The current chat state
@ConversationChatStateConverter() ChatState chatState,
) = _Conversation;
@ -56,7 +60,9 @@ class Conversation with _$Conversation {
'sharedMedia': sharedMedia,
'inRoster': inRoster,
'subscription': subscription,
'encrypted': intToBool(json['encrypted']! as int),
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
'lastMessageRetracted': intToBool(json['lastMessageRetracted']! as int)
});
}
@ -72,6 +78,8 @@ class Conversation with _$Conversation {
...map,
'open': boolToInt(open),
'muted': boolToInt(muted),
'encrypted': boolToInt(encrypted),
'lastMessageRetracted': boolToInt(lastMessageRetracted),
};
}
}

View File

@ -1,9 +1,25 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart';
part 'message.g.dart';
Map<String, String>? _optionalJsonDecode(String? data) {
if (data == null) return null;
return jsonDecode(data) as Map<String, String>;
}
String? _optionalJsonEncode(Map<String, String>? data) {
if (data == null) return null;
return jsonEncode(data);
}
@freezed
class Message with _$Message {
// NOTE: id is the database id of the message
@ -19,8 +35,10 @@ class Message with _$Message {
String conversationJid,
bool isMedia,
bool isFileUploadNotification,
bool encrypted,
{
int? errorType,
int? warningType,
String? mediaUrl,
@Default(false) bool isDownloading,
@Default(false) bool isUploading,
@ -29,12 +47,19 @@ class Message with _$Message {
int? mediaWidth,
int? mediaHeight,
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
@Default(false) bool received,
@Default(false) bool displayed,
@Default(false) bool acked,
@Default(false) bool isRetracted,
String? originId,
Message? quotes,
String? filename,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
int? mediaSize,
}
) = _Message;
@ -51,15 +76,19 @@ class Message with _$Message {
'acked': intToBool(json['acked']! as int),
'isMedia': intToBool(json['isMedia']! as int),
'isFileUploadNotification': intToBool(json['isFileUploadNotification']! as int),
'encrypted': intToBool(json['encrypted']! as int),
'plaintextHashes': _optionalJsonDecode(json['plaintextHashes'] as String?),
'ciphertextHashes': _optionalJsonDecode(json['ciphertextHashes'] as String?),
'isDownloading': intToBool(json['isDownloading']! as int),
'isUploading': intToBool(json['isUploading']! as int),
'isRetracted': intToBool(json['isRetracted']! as int),
}).copyWith(quotes: quotes);
}
Map<String, dynamic> toDatabaseJson(int? quoteId) {
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('id')
..remove('quotes')
..remove('isDownloading')
..remove('isUploading');
..remove('quotes');
return {
...map,
@ -68,7 +97,49 @@ class Message with _$Message {
'received': boolToInt(received),
'displayed': boolToInt(displayed),
'acked': boolToInt(acked),
'quote_id': quoteId,
'encrypted': boolToInt(encrypted),
// NOTE: Message.quote_id is a foreign-key
'quote_id': quotes?.id,
'plaintextHashes': _optionalJsonEncode(plaintextHashes),
'ciphertextHashes': _optionalJsonEncode(ciphertextHashes),
'isDownloading': boolToInt(isDownloading),
'isUploading': boolToInt(isUploading),
'isRetracted': boolToInt(isRetracted),
};
}
/// Returns true if the message is an error. If not, then returns false.
bool isError() {
return errorType != null && errorType != noError;
}
/// Returns true if the message is a warning. If not, then returns false.
bool isWarning() {
return warningType != null && warningType != noWarning;
}
/// Returns a representative emoji for a message. Its primary purpose is
/// to provide a universal fallback for quoted media messages.
String get messageEmoji {
return mimeTypeToEmoji(mediaType, addTypeName: false);
}
/// Returns true if the message can be quoted. False if not.
bool get isQuotable => !isError() && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
/// 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).
bool canRetract(bool sentBySelf) {
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
}
/// 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).
bool canEdit(bool sentBySelf) {
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading;
}
/// Returns true if the message can open the selection menu by longpressing. False if
/// not.
bool get isLongpressable => !isRetracted;
}

View File

@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'omemo_device.freezed.dart';
part 'omemo_device.g.dart';
/// This model is just for communication between UI and the backend.
@freezed
class OmemoDevice with _$OmemoDevice {
factory OmemoDevice(
String fingerprint,
bool trusted,
bool verified,
bool enabled,
int deviceId,
{
@Default(true) bool hasSessionWith,
}
) = _OmemoDevice;
/// JSON
factory OmemoDevice.fromJson(Map<String, dynamic> json) => _$OmemoDeviceFromJson(json);
}

View File

@ -3,8 +3,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'preferences.freezed.dart';
part 'preferences.g.dart';
// TODO(Unknown): Move into the models directory
@freezed
class PreferencesState with _$PreferencesState {
factory PreferencesState({
@ -26,6 +24,10 @@ class PreferencesState with _$PreferencesState {
@Default(false) bool enableTwitterRedirect,
@Default(false) bool enableYoutubeRedirect,
@Default(false) bool defaultMuteState,
@Default(false) bool enableOmemoByDefault,
// NOTE: A value of 'default' means that the system's configured language should
// be used
@Default('default') String languageLocaleCode,
}) = _PreferencesState;
// JSON serialization

View File

@ -0,0 +1,15 @@
import 'package:moxxyv2/i18n/strings.g.dart';
const noWarning = 0;
const warningFileIntegrityCheckFailed = 1;
String warningToTranslatableString(int warning) {
assert(warning != noWarning, 'Calling warningToTranslatableString with noWarning makes no sense');
switch (warning) {
case warningFileIntegrityCheckFailed: return t.warnings.message.integrityCheckFailed;
}
assert(false, 'Invalid warning code $warning used');
return '';
}

View File

@ -4,10 +4,11 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.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:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart' as events;
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
@ -15,7 +16,6 @@ import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
part 'conversation_bloc.freezed.dart';
part 'conversation_event.dart';
@ -45,6 +45,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<FilePickerRequestedEvent>(_onFilePickerRequested);
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
on<OwnJidReceivedEvent>(_onOwnJidReceived);
on<OmemoSetEvent>(_onOmemoSet);
on<MessageRetractedEvent>(_onMessageRetracted);
}
/// The current chat state with the conversation partner
ChatState _currentChatState;
@ -326,4 +328,29 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
Future<void> _onOwnJidReceived(OwnJidReceivedEvent event, Emitter<ConversationState> emit) async {
emit(state.copyWith(jid: event.jid));
}
Future<void> _onOmemoSet(OmemoSetEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
conversation: state.conversation!.copyWith(
encrypted: event.enabled,
),
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
SetOmemoEnabledCommand(enabled: event.enabled, jid: state.conversation!.jid),
awaitable: false,
);
}
Future<void> _onMessageRetracted(MessageRetractedEvent event, Emitter<ConversationState> emit) async {
await MoxplatformPlugin.handler.getDataSender().sendData(
RetractMessageComment(
originId: event.id,
conversationJid: state.conversation!.jid,
),
awaitable: false,
);
}
}

View File

@ -119,3 +119,15 @@ class OwnJidReceivedEvent extends ConversationEvent {
OwnJidReceivedEvent(this.jid);
final String jid;
}
/// Triggered when we enable or disable Omemo in the chat
class OmemoSetEvent extends ConversationEvent {
OmemoSetEvent(this.enabled);
final bool enabled;
}
/// Triggered when a message should be retracted
class MessageRetractedEvent extends ConversationEvent {
MessageRetractedEvent(this.id);
final String id;
}

View File

@ -0,0 +1,69 @@
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
part 'devices_bloc.freezed.dart';
part 'devices_event.dart';
part 'devices_state.dart';
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
DevicesBloc() : super(DevicesState()) {
on<DevicesRequestedEvent>(_onRequested);
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
on<SessionsRecreatedEvent>(_onSessionsRecreated);
}
Future<void> _onRequested(DevicesRequestedEvent event, Emitter<DevicesState> emit) async {
emit(state.copyWith(working: true, jid: event.jid));
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(devicesRoute),
),
);
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetConversationOmemoFingerprintsCommand(
jid: event.jid,
),
) as GetConversationOmemoFingerprintsResult;
emit(
state.copyWith(
working: false,
devices: result.fingerprints,
),
);
}
Future<void> _onDeviceEnabledSet(DeviceEnabledSetEvent event, Emitter<DevicesState> emit) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
SetOmemoDeviceEnabledCommand(
jid: state.jid,
deviceId: event.deviceId,
enabled: event.enabled,
),
) as GetConversationOmemoFingerprintsResult;
emit(state.copyWith(devices: result.fingerprints));
}
Future<void> _onSessionsRecreated(SessionsRecreatedEvent event, Emitter<DevicesState> emit) async {
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
RecreateSessionsCommand(jid: state.jid),
awaitable: false,
);
emit(state.copyWith(devices: <OmemoDevice>[]));
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
}
}

View File

@ -0,0 +1,21 @@
part of 'devices_bloc.dart';
abstract class DevicesEvent {}
/// Triggered when the user requested the key page
class DevicesRequestedEvent extends DevicesEvent {
DevicesRequestedEvent(this.jid);
final String jid;
}
/// Triggered by the UI when we want to enable or disable a key
class DeviceEnabledSetEvent extends DevicesEvent {
DeviceEnabledSetEvent(this.deviceId, this.enabled);
final int deviceId;
final bool enabled;
}
/// Triggered by the UI when all OMEMO sessions should be recreated
class SessionsRecreatedEvent extends DevicesEvent {}

View File

@ -0,0 +1,10 @@
part of 'devices_bloc.dart';
@freezed
class DevicesState with _$DevicesState {
factory DevicesState({
@Default(false) bool working,
@Default([]) List<OmemoDevice> devices,
@Default('') String jid,
}) = _DevicesState;
}

View File

@ -5,7 +5,6 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -73,16 +72,17 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
);
if (result is LoginSuccessfulEvent) {
GetIt.I.get<UIDataService>().isLoggedIn = true;
emit(state.copyWith(working: false));
GetIt.I.get<UIDataService>().ownJid = state.jid;
// Update the UIDataService
GetIt.I.get<UIDataService>().processPreStartDoneEvent(result.preStart);
// Set up BLoCs
GetIt.I.get<ConversationsBloc>().add(
ConversationsInitEvent(
result.displayName,
result.preStart.displayName!,
state.jid,
// TODO(Unknown): ???
<Conversation>[],
result.preStart.conversations!,
),
);
GetIt.I.get<NavigationBloc>().add(
@ -98,7 +98,10 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
return emit(
state.copyWith(
working: false,
passwordState: LoginFormState(false, error: result.reason),
passwordState: LoginFormState(
false,
error: result.reason ?? 'Failed to connect',
),
),
);
}

View File

@ -1,10 +1,10 @@
import 'package:bloc/bloc.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/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;

View File

@ -0,0 +1,127 @@
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
part 'own_devices_bloc.freezed.dart';
part 'own_devices_event.dart';
part 'own_devices_state.dart';
class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
OwnDevicesBloc() : super(OwnDevicesState()) {
on<OwnDevicesRequestedEvent>(_onRequested);
on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet);
on<OwnSessionsRecreatedEvent>(_onSessionsRecreated);
on<OwnDeviceRemovedEvent>(_onDeviceRemoved);
on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated);
}
Future<void> _onRequested(OwnDevicesRequestedEvent event, Emitter<OwnDevicesState> emit) async {
emit(state.copyWith(working: true));
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(ownDevicesRoute),
),
);
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetOwnOmemoFingerprintsCommand(),
) as GetOwnOmemoFingerprintsResult;
emit(
state.copyWith(
working: false,
deviceFingerprint: result.ownDeviceFingerprint,
deviceId: result.ownDeviceId,
keys: result.fingerprints,
),
);
}
Future<void> _onDeviceEnabledSet(OwnDeviceEnabledSetEvent event, Emitter<OwnDevicesState> emit) async {
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
SetOmemoDeviceEnabledCommand(
jid: GetIt.I.get<UIDataService>().ownJid!,
deviceId: event.deviceId,
enabled: event.enabled,
),
awaitable: false,
);
emit(
state.copyWith(
keys: state.keys.map((key) {
if (key.deviceId == event.deviceId) {
return key.copyWith(enabled: event.enabled);
}
return key;
}).toList(),
),
);
}
Future<void> _onSessionsRecreated(OwnSessionsRecreatedEvent event, Emitter<OwnDevicesState> emit) async {
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!),
awaitable: false,
);
emit(
state.copyWith(
keys: List.from(
state.keys.map((key) => key.copyWith(
hasSessionWith: false,
),),
),
),
);
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
}
Future<void> _onDeviceRemoved(OwnDeviceRemovedEvent event, Emitter<OwnDevicesState> emit) async {
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveOwnDeviceCommand(deviceId: event.deviceId),
awaitable: false,
);
emit(
state.copyWith(
keys: List.from(
state.keys
.where((key) => key.deviceId != event.deviceId),
),
),
);
}
Future<void> _onDeviceRegenerated(OwnDeviceRegeneratedEvent event, Emitter<OwnDevicesState> emit) async {
emit(state.copyWith(working: true));
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
RegenerateOwnDeviceCommand(),
) as RegenerateOwnDeviceResult;
// Update the UI state
emit(
state.copyWith(
deviceId: result.device.deviceId,
deviceFingerprint: result.device.fingerprint,
working: false,
),
);
}
}

View File

@ -0,0 +1,27 @@
part of 'own_devices_bloc.dart';
abstract class OwnDevicesEvent {}
/// Triggered when the user requested the own keys page
class OwnDevicesRequestedEvent extends OwnDevicesEvent {}
/// Triggered by the UI when we want to enable or disable a key
class OwnDeviceEnabledSetEvent extends OwnDevicesEvent {
OwnDeviceEnabledSetEvent(this.deviceId, this.enabled);
final int deviceId;
final bool enabled;
}
/// Triggered by the UI when all OMEMO sessions should be recreated
class OwnSessionsRecreatedEvent extends OwnDevicesEvent {}
/// Triggered by the UI when the OMEMO device should be regenerated
class OwnDeviceRegeneratedEvent extends OwnDevicesEvent {}
/// Triggered by the UI when the device with id [deviceId] should be removed.
class OwnDeviceRemovedEvent extends OwnDevicesEvent {
OwnDeviceRemovedEvent(this.deviceId);
final int deviceId;
}

View File

@ -0,0 +1,11 @@
part of 'own_devices_bloc.dart';
@freezed
class OwnDevicesState with _$OwnDevicesState {
factory OwnDevicesState({
@Default(false) bool working,
@Default([]) List<OmemoDevice> keys,
@Default(-1) int deviceId,
@Default('') String deviceFingerprint,
}) = _OwnDevicesState;
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
@ -40,6 +41,11 @@ class PreferencesBloc extends Bloc<PreferencesEvent, PreferencesState> {
);
}
if (!kDebugMode) {
final enableDebug = event.preferences.debugEnabled;
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
}
emit(event.preferences);
}

View File

@ -3,16 +3,16 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:move_to_background/move_to_background.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
part 'share_selection_bloc.freezed.dart';
part 'share_selection_event.dart';
@ -26,11 +26,12 @@ enum ShareSelectionType {
/// Create a common ground between Conversations and RosterItems
class ShareListItem {
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation);
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation, this.isEncrypted);
final String avatarPath;
final String jid;
final String title;
final bool isConversation;
final bool isEncrypted;
}
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
@ -65,6 +66,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
c.jid,
c.title,
true,
c.encrypted,
);
}),
);
@ -80,6 +82,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
rosterItem.jid,
rosterItem.title,
false,
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
),
);
} else {
@ -88,6 +91,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
rosterItem.jid,
rosterItem.title,
false,
items[index].isEncrypted,
);
}
}

View File

@ -9,12 +9,19 @@ const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, botto
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
const int primaryColorHexRGBO = 0xffcf4aff;
const int primaryColorAltHexRGB = 0xff9c18cd;
const int primaryColorDisabledHexRGB = 0xff9a7fa9;
const int textColorDisabledHexRGB = 0xffcacaca;
const Color primaryColor = Color(primaryColorHexRGBO);
const Color primaryColorAlt = Color(primaryColorAltHexRGB);
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
const Color textColorDisabled = Color(textColorDisabledHexRGB);
const Color bubbleColorSent = Color(0xffa139f0);
const Color bubbleColorSent = Color(0xff7e0bce);
const Color bubbleColorSentQuoted = bubbleColorSent;
const Color bubbleColorReceived = Color(0xff222222);
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
const Color bubbleColorUnencrypted = Color(0xffd40000);
const double paddingVeryLarge = 64;
@ -53,6 +60,9 @@ const String privacyRoute = '$settingsRoute/privacy';
const String networkRoute = '$settingsRoute/network';
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String conversationSettingsRoute = '$settingsRoute/conversation';
const String appearanceRoute = '$settingsRoute/appearance';
const String blocklistRoute = '/blocklist';
const String shareSelectionRoute = '/share_selection';
const String serverInfoRoute = '$profileRoute/server_info';
const String devicesRoute = '$profileRoute/devices';
const String ownDevicesRoute = '$profileRoute/own_devices';

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/awaitabledatasender.dart';
@ -29,7 +29,7 @@ void setupEventHandler() {
EventTypeMatcher<ProgressEvent>(onProgress),
EventTypeMatcher<SelfAvatarChangedEvent>(onSelfAvatarChanged),
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady)
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -139,7 +139,9 @@ Future<void> onServiceReady(ServiceReadyEvent event, { dynamic extra }) async {
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().fine('onServiceReady: Done');
await MoxplatformPlugin.handler.getDataSender().sendData(
PerformPreStartCommand(),
PerformPreStartCommand(
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
awaitable: false,
);
}

View File

@ -1,37 +1,43 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
/// Shows a dialog asking the user if they are sure that they want to proceed with an
/// action.
Future<void> showConfirmationDialog(String title, String body, BuildContext context, void Function() callback) async {
await showDialog<dynamic>(
/// action. Resolves to true if the user pressed the confirm button. Returns false if
/// the cancel button was pressed.
Future<bool> showConfirmationDialog(String title, String body, BuildContext context) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(title),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
content: Text(body),
actions: [
TextButton(
onPressed: callback,
child: const Text('Yes'),
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.global.yes),
),
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('No'),
child: Text(t.global.no),
)
],
),
);
return result != null;
}
/// Shows a dialog telling the user that the [feature] feature is not implemented.
@ -42,6 +48,9 @@ Future<void> showNotImplementedDialog(String feature, BuildContext context) asyn
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Not Implemented'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
content: SingleChildScrollView(
child: ListBody(
children: [
@ -51,7 +60,7 @@ Future<void> showNotImplementedDialog(String feature, BuildContext context) asyn
),
actions: [
TextButton(
child: const Text('Okay'),
child: Text(t.global.dialogAccept),
onPressed: () => Navigator.of(context).pop(),
)
],
@ -67,11 +76,14 @@ Future<void> showInfoDialog(String title, String body, BuildContext context) asy
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(title),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
content: Text(body),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Okay'),
child: Text(t.global.dialogAccept),
)
],
),
@ -154,3 +166,16 @@ Color getTileColor(BuildContext context) {
case Brightness.dark: return tileColorDark;
}
}
/// Return the corresponding language name (in its language) for the given
/// language code [localeCode], e.g. "de", "en", ...
String localeCodeToLanguageName(String localeCode) {
switch (localeCode) {
case 'de': return 'Deutsch';
case 'en': return 'English';
case 'default': return t.pages.settings.appearance.systemLanguage;
}
assert(false, 'Language code $localeCode has no name');
return '';
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -8,7 +9,7 @@ import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class AddContactPage extends StatelessWidget {
const AddContactPage({ Key? key }) : super(key: key);
const AddContactPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const AddContactPage(),
@ -21,7 +22,7 @@ class AddContactPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<AddContactBloc, AddContactState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple('Add new contact'),
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
body: Column(
children: [
Visibility(
@ -32,7 +33,7 @@ class AddContactPage extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
child: CustomTextField(
labelText: 'XMPP-Address',
labelText: t.pages.addcontact.xmppAddress,
onChanged: (value) => context.read<AddContactBloc>().add(
JidChangedEvent(value),
),
@ -52,9 +53,7 @@ class AddContactPage extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
child: const Text(
'You can add a contact either by typing in their XMPP address or by scanning their QR code',
),
child: Text(t.pages.addcontact.subtitle),
),
Padding(
@ -63,10 +62,10 @@ class AddContactPage extends StatelessWidget {
children: [
Expanded(
child: RoundedButton(
color: Colors.purple,
cornerRadius: 32,
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
child: const Text('Add to contacts'),
enabled: !state.working,
child: Text(t.pages.addcontact.buttonAddToContact),
),
)
],

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -10,7 +11,7 @@ enum BlocklistOptions {
}
class BlocklistPage extends StatelessWidget {
const BlocklistPage({ Key? key }) : super(key: key);
const BlocklistPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const BlocklistPage(),
@ -30,9 +31,9 @@ class BlocklistPage extends StatelessWidget {
padding: const EdgeInsets.only(top: 8),
child: Image.asset('assets/images/happy_news.png'),
),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('You have no users blocked'),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(t.pages.blocklist.noUsersBlocked),
)
],
),
@ -58,16 +59,19 @@ class BlocklistPage extends StatelessWidget {
IconButton(
icon: const Icon(Icons.delete),
color: Colors.red,
onPressed: () => showConfirmationDialog(
'Unblock $jid?',
'Are you sure you want to unblock $jid? You will receive messages from this user again.',
onPressed: () async {
final result = await showConfirmationDialog(
t.pages.blocklist.unblockJidConfirmTitle(jid: jid),
t.pages.blocklist.unblockJidConfirmBody(jid: jid),
context,
() {
);
if (result) {
// ignore: use_build_context_synchronously
context.read<BlocklistBloc>().add(UnblockedJidEvent(jid));
Navigator.of(context).pop();
}
},
),
)
],
),
);
@ -80,29 +84,33 @@ class BlocklistPage extends StatelessWidget {
return BlocBuilder<BlocklistBloc, BlocklistState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
'Blocklist',
t.pages.blocklist.title,
extra: [
Expanded(child: Container()),
PopupMenuButton(
onSelected: (BlocklistOptions result) {
onSelected: (BlocklistOptions result) async {
if (result == BlocklistOptions.unblockAll) {
showConfirmationDialog(
'Are you sure?',
'Are you sure you want to unblock all users?',
final result = await showConfirmationDialog(
t.pages.blocklist.unblockAllConfirmTitle,
t.pages.blocklist.unblockAllConfirmBody,
context,
() {
);
if (result) {
// ignore: use_build_context_synchronously
context.read<BlocklistBloc>().add(UnblockedAllEvent());
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
);
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
const PopupMenuItem(
PopupMenuItem(
value: BlocklistOptions.unblockAll,
child: Text('Unblock all'),
)
child: Text(t.pages.blocklist.unblockAll),
),
],
)
],

View File

@ -12,8 +12,7 @@ import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationBottomRow extends StatelessWidget {
const ConversationBottomRow(this.controller, this.isSpeedDialOpen, {Key? key}) : super(key: key);
const ConversationBottomRow(this.controller, this.isSpeedDialOpen, { super.key });
final TextEditingController controller;
final ValueNotifier<bool> isSpeedDialOpen;

View File

@ -10,10 +10,9 @@ import 'package:moxxyv2/ui/pages/conversation/bottom.dart';
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class ConversationPage extends StatefulWidget {
const ConversationPage({ Key? key }) : super(key: key);
const ConversationPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const ConversationPage(),
@ -27,7 +26,6 @@ class ConversationPage extends StatefulWidget {
}
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
ConversationPageState() :
_isSpeedDialOpen = ValueNotifier(false),
_controller = TextEditingController(),
@ -84,6 +82,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
return ChatBubble(
message: item,
sentBySelf: isSent(item, jid),
chatEncrypted: state.conversation!.encrypted,
start: start,
end: end,
between: between,
@ -106,21 +105,25 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
Expanded(
child: TextButton(
child: const Text('Add to contacts'),
onPressed: () {
onPressed: () async {
final jid = state.conversation!.jid;
showConfirmationDialog(
final result = await showConfirmationDialog(
'Add $jid to your contacts?',
'Are you sure you want to add $jid to your conacts?',
context,
() {
);
if (result) {
// TODO(Unknown): Maybe show a progress indicator
// TODO(Unknown): Have the page update its state once the addition is done
// ignore: use_build_context_synchronously
context.read<ConversationBloc>().add(
JidAddedEvent(jid),
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
);
},
),
),
@ -213,7 +216,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
child: Scaffold(
// TODO(Unknown): Maybe replace the scaffold itself to prevent transparency
backgroundColor: const Color.fromRGBO(0, 0, 0, 0),
appBar: const BorderlessTopbar(ConversationTopbarWidget()),
appBar: const ConversationTopbar(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -229,7 +232,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
BlocBuilder<ConversationBloc, ConversationState>(
// NOTE: We don't need to update when the jid changes as it should
// be static over the entire lifetime of the BLoC.
buildWhen: (prev, next) => prev.messages != next.messages,
buildWhen: (prev, next) => prev.messages != next.messages || prev.conversation!.encrypted != next.conversation!.encrypted,
builder: (context, state) => Expanded(
child: ListView.builder(
reverse: true,

View File

@ -4,14 +4,18 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/helpers.dart';
/// Sends a block command to the service to block [jid].
void blockJid(String jid, BuildContext context) {
showConfirmationDialog(
Future<void> blockJid(String jid, BuildContext context) async {
final result = await showConfirmationDialog(
'Block $jid?',
"Are you sure you want to block $jid? You won't receive messages from them until you unblock them.",
context,
() {
);
if (result) {
// ignore: use_build_context_synchronously
context.read<ConversationBloc>().add(JidBlockedEvent(jid));
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
);
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
@ -9,7 +11,6 @@ import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
enum ConversationOption {
close,
@ -36,26 +37,31 @@ PopupMenuItem<dynamic> popupItemWithIcon(dynamic value, String text, IconData ic
);
}
/// A custom version of the Topbar NameAndAvatar style to integrate with
/// bloc.
// TODO(Unknown): If the display name is too long, then it will cause an overflow.
class ConversationTopbarWidget extends StatelessWidget {
const ConversationTopbarWidget({ Key? key }) : super(key: key);
/// A custom version of the BorderlessTopbar to display the conversation topbar
/// as it should
// TODO(PapaTutuWawa): The conversation title may overflow the Topbar
// TODO(Unknown): Maybe merge with BorderlessTopbar
class ConversationTopbar extends StatelessWidget implements PreferredSizeWidget {
const ConversationTopbar({ super.key });
@override
Size get preferredSize => const Size.fromHeight(60);
bool _shouldRebuild(ConversationState prev, ConversationState next) {
return prev.conversation?.title != next.conversation?.title
|| prev.conversation?.avatarUrl != next.conversation?.avatarUrl
|| prev.conversation?.chatState != next.conversation?.chatState
|| prev.conversation?.jid != next.conversation?.jid;
|| prev.conversation?.jid != next.conversation?.jid
|| prev.conversation?.encrypted != next.conversation?.encrypted;
}
Widget _buildChatState(ChatState state) {
switch (state) {
case ChatState.paused:
case ChatState.active:
return const Text(
'Online',
style: TextStyle(
return Text(
t.pages.conversation.online,
style: const TextStyle(
color: Colors.green,
),
);
@ -68,20 +74,26 @@ class ConversationTopbarWidget extends StatelessWidget {
}
}
bool _isChatStateVisible(ChatState state) {
return state != ChatState.inactive && state != ChatState.gone;
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: _shouldRebuild,
builder: (context, state) {
return TopbarAvatarAndName(
IntrinsicHeight(
child: Column(
return SizedBox(
width: MediaQuery.of(context).size.width,
child: SafeArea(
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.all(8),
child: Flex(
direction: Axis.horizontal,
children: [
TopbarTitleText(state.conversation!.title),
_buildChatState(state.conversation!.chatState)
],
),
),
const BackButton(),
Hero(
tag: 'conversation_profile_picture',
child: Material(
@ -93,57 +105,106 @@ class ConversationTopbarWidget extends StatelessWidget {
),
),
),
() => GetIt.I.get<profile.ProfileBloc>().add(
Expanded(
child: InkWell(
onTap: () => GetIt.I.get<profile.ProfileBloc>().add(
profile.ProfilePageRequestedEvent(
false,
conversation: context.read<ConversationBloc>().state.conversation,
),
),
extra: [
// ignore: implicit_dynamic_type
PopupMenuButton(
onSelected: (result) {
if (result == EncryptionOption.omemo) {
showNotImplementedDialog('End-to-End encryption', context);
}
},
icon: const Icon(Icons.lock_open),
itemBuilder: (BuildContext c) => [
popupItemWithIcon(EncryptionOption.none, 'Unencrypted', Icons.lock_open),
popupItemWithIcon(EncryptionOption.omemo, 'Encrypted', Icons.lock),
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
top: _isChatStateVisible(state.conversation!.chatState) ?
0 :
10,
left: 0,
right: 0,
curve: Curves.easeInOutCubic,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TopbarTitleText(state.conversation!.title),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
opacity: _isChatStateVisible(state.conversation!.chatState) ?
1.0 :
0.0,
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 100),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildChatState(state.conversation!.chatState),
],
),
),
),
],
),
),
),
// ignore: implicit_dynamic_type
PopupMenuButton(
onSelected: (result) {
if (result == EncryptionOption.omemo && state.conversation!.encrypted == false) {
context.read<ConversationBloc>().add(OmemoSetEvent(true));
} else if (result == EncryptionOption.none && state.conversation!.encrypted == true) {
context.read<ConversationBloc>().add(OmemoSetEvent(false));
}
},
icon: state.conversation!.encrypted ?
const Icon(Icons.lock) :
const Icon(Icons.lock_open),
itemBuilder: (BuildContext c) => [
popupItemWithIcon(EncryptionOption.none, t.pages.conversation.unencrypted, Icons.lock_open),
popupItemWithIcon(EncryptionOption.omemo, t.pages.conversation.encrypted, Icons.lock),
],
),
// ignore: implicit_dynamic_type
PopupMenuButton(
onSelected: (result) async {
switch (result) {
case ConversationOption.close: {
showConfirmationDialog(
'Close Chat',
'Are you sure you want to close this chat?',
final result = await showConfirmationDialog(
t.pages.conversation.closeChatConfirmTitle,
t.pages.conversation.closeChatConfirmSubtext,
context,
() {
);
if (result) {
// ignore: use_build_context_synchronously
context.read<ConversationsBloc>().add(
ConversationClosedEvent(state.conversation!.jid),
);
Navigator.of(context).pop();
}
);
}
break;
case ConversationOption.block: {
blockJid(state.conversation!.jid, context);
await blockJid(state.conversation!.jid, context);
}
break;
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext c) => [
popupItemWithIcon(ConversationOption.close, 'Close chat', Icons.close),
popupItemWithIcon(ConversationOption.block, 'Block contact', Icons.block)
popupItemWithIcon(ConversationOption.close, t.pages.conversation.closeChat, Icons.close),
popupItemWithIcon(ConversationOption.block, t.pages.conversation.blockUser, Icons.block)
],
)
),
],
),
),
),
),
);
},
);

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
@ -10,14 +12,13 @@ import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/conversation.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
enum ConversationsOptions {
settings
}
class ConversationsPage extends StatelessWidget {
const ConversationsPage({ Key? key }) : super(key: key);
const ConversationsPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const ConversationsPage(),
@ -34,6 +35,7 @@ class ConversationsPage extends StatelessWidget {
itemCount: state.conversations.length,
itemBuilder: (_context, index) {
final item = state.conversations[index];
return Dismissible(
key: ValueKey('conversation;$item'),
onDismissed: (direction) => context.read<ConversationsBloc>().add(
@ -65,6 +67,7 @@ class ConversationsPage extends StatelessWidget {
item.lastChangeTimestamp,
true,
typingIndicator: item.chatState == ChatState.composing,
lastMessageRetracted: item.lastMessageRetracted,
key: ValueKey('conversationRow;${item.jid}'),
),
),
@ -82,12 +85,12 @@ class ConversationsPage extends StatelessWidget {
// TODO(Unknown): Maybe somehow render the svg
child: Image.asset('assets/images/begin_chat.png'),
),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('You have no open chats'),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(t.pages.conversations.noOpenChats),
),
TextButton(
child: const Text('Start a chat'),
child: Text(t.pages.conversations.startChat),
onPressed: () => Navigator.pushNamed(context, newConversationRoute),
)
],
@ -132,9 +135,9 @@ class ConversationsPage extends StatelessWidget {
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
const PopupMenuItem(
PopupMenuItem(
value: ConversationsOptions.settings,
child: Text('Settings'),
child: Text(t.pages.conversations.overlaySettings),
)
],
)
@ -155,7 +158,7 @@ class ConversationsPage extends StatelessWidget {
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
label: 'Join groupchat',
label: t.pages.conversations.speeddialJoinGroupchat,
),
SpeedDialChild(
child: const Icon(Icons.person_add),
@ -163,7 +166,7 @@ class ConversationsPage extends StatelessWidget {
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
label: 'New chat',
label: t.pages.conversations.speeddialNewChat,
)
],
),

View File

@ -1,13 +1,13 @@
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/button.dart';
class CropPage extends StatelessWidget {
CropPage({ Key? key }) : _controller = CropController(), super(key: key);
CropPage({ super.key }) : _controller = CropController();
final CropController _controller;
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
@ -59,10 +59,9 @@ class CropPage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
RoundedButton(
color: primaryColor,
cornerRadius: 100,
onTap: _controller.crop,
child: const Text('Set as profile picture'),
child: Text(t.pages.crop.setProfilePicture),
),
],
),

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/button.dart';
class Intro extends StatelessWidget {
const Intro({ Key? key }) : super(key: key);
const Intro({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const Intro(),
@ -36,11 +37,11 @@ class Intro extends StatelessWidget {
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: paddingVeryLarge),
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
child: Text(
'An experiment into building a modern, easy and beautiful XMPP client.',
style: TextStyle(
t.global.moxxySubtitle,
style: const TextStyle(
fontSize: fontsizeBody,
),
),
@ -51,23 +52,22 @@ class Intro extends StatelessWidget {
children: [
Expanded(
child: RoundedButton(
color: Colors.purple,
cornerRadius: 32,
onTap: () => Navigator.of(context).pushNamed(
loginRoute,
),
child: const Text('Login'),
child: Text(t.pages.intro.loginButton),
),
)
],
),
),
const Spacer(),
const Padding(
padding: EdgeInsets.symmetric(horizontal: paddingVeryLarge),
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
child: Text(
'Have no XMPP account? No worries, creating one is really easy.',
style: TextStyle(
t.pages.intro.noAccount,
style: const TextStyle(
fontSize: fontsizeBody,
),
),
@ -78,7 +78,7 @@ class Intro extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(bottom: paddingVeryLarge)),
child: TextButton(
child: const Text('Register'),
child: Text(t.pages.intro.registerButton),
onPressed: () {
// Navigator.pushNamed(context, registrationRoute);
showNotImplementedDialog('registration', context);

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/login_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/button.dart';
@ -7,7 +8,7 @@ import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class Login extends StatelessWidget {
const Login({ Key? key }) : super(key: key);
const Login({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const Login(),
@ -21,7 +22,7 @@ class Login extends StatelessWidget {
builder: (BuildContext context, LoginState state) => WillPopScope(
onWillPop: () async => !state.working,
child: Scaffold(
appBar: BorderlessTopbar.simple('Login'),
appBar: BorderlessTopbar.simple(t.pages.login.title),
body: Column(
children: [
Visibility(
@ -35,7 +36,7 @@ class Login extends StatelessWidget {
child: CustomTextField(
// ignore: avoid_dynamic_calls
errorText: state.jidState.error,
labelText: 'XMPP-Address',
labelText: t.pages.login.xmppAddress,
enabled: !state.working,
cornerRadius: textfieldRadiusRegular,
borderColor: primaryColor,
@ -49,7 +50,7 @@ class Login extends StatelessWidget {
child: CustomTextField(
// ignore: avoid_dynamic_calls
errorText: state.passwordState.error,
labelText: 'Password',
labelText: t.pages.login.password,
suffixIcon: Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: InkWell(
@ -71,12 +72,12 @@ class Login extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
child: ExpansionTile(
title: const Text('Advanced options'),
title: Text(t.pages.login.advancedOptions),
children: [
Column(
children: [
SwitchListTile(
title: const Text('Create account on server'),
title: Text(t.pages.login.createAccount),
value: false,
// TODO(Unknown): Implement
onChanged: state.working ? null : (value) {},
@ -92,9 +93,9 @@ class Login extends StatelessWidget {
children: [
Expanded(
child: RoundedButton(
color: Colors.purple,
cornerRadius: 32,
onTap: state.working ? null : () => context.read<LoginBloc>().add(LoginSubmittedEvent()),
enabled: !state.working,
onTap: () => context.read<LoginBloc>().add(LoginSubmittedEvent()),
child: const Text('Login'),
),
)

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -9,7 +10,7 @@ import 'package:moxxyv2/ui/widgets/conversation.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class NewConversationPage extends StatelessWidget {
const NewConversationPage({ Key? key }) : super(key: key);
const NewConversationPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const NewConversationPage(),
@ -49,7 +50,7 @@ class NewConversationPage extends StatelessWidget {
Widget build(BuildContext context) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
return Scaffold(
appBar: BorderlessTopbar.simple('Start new chat'),
appBar: BorderlessTopbar.simple(t.pages.newconversation.title),
body: BlocBuilder<NewConversationBloc, NewConversationState>(
builder: (BuildContext context, NewConversationState state) => ListView.builder(
itemCount: state.roster.length + 2,
@ -57,12 +58,12 @@ class NewConversationPage extends StatelessWidget {
switch(index) {
case 0: return _renderIconEntry(
Icons.person_add,
'Add contact',
t.pages.newconversation.addContact,
() => Navigator.pushNamed(context, addContactRoute),
);
case 1: return _renderIconEntry(
Icons.group_add,
'Create groupchat',
t.pages.newconversation.createGroupchat,
() => showNotImplementedDialog('groupchat', context),
);
default:

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -9,8 +11,7 @@ import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
//import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationProfileHeader extends StatelessWidget {
const ConversationProfileHeader(this.conversation, { Key? key }) : super(key: key);
const ConversationProfileHeader(this.conversation, { super.key });
final Conversation conversation;
@override
@ -55,8 +56,8 @@ class ConversationProfileHeader extends StatelessWidget {
children: [
Tooltip(
message: conversation.muted ?
'Unmute chat' :
'Mute chat',
t.pages.profile.conversation.unmuteChatTooltip :
t.pages.profile.conversation.muteChatTooltip,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -84,8 +85,38 @@ class ConversationProfileHeader extends StatelessWidget {
),
Text(
conversation.muted ?
'Unmute' :
'Mute',
t.pages.profile.conversation.unmuteChat :
t.pages.profile.conversation.muteChat,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
],
),
),
// TODO(PapaTutuWawa): Only show when the chat partner has OMEMO keys
Tooltip(
message: t.pages.profile.conversation.devices,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SharedMediaContainer(
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColoredBox(
color: getTileColor(context),
child: const Icon(
Icons.security_outlined,
size: 32,
),
),
),
onTap: () {
GetIt.I.get<DevicesBloc>().add(DevicesRequestedEvent(conversation.jid));
},
),
Text(
t.pages.profile.conversation.devices,
style: const TextStyle(
fontSize: fontsizeAppbar,
),

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/profile/widgets.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
enum DevicesOptions {
recreateSessions,
}
class DevicesPage extends StatelessWidget {
const DevicesPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const DevicesPage(),
settings: const RouteSettings(
name: devicesRoute,
),
);
Widget _buildBody(BuildContext context, DevicesState state) {
if (state.working) {
return const Center(
child: CircularProgressIndicator(),
);
}
final hasVerifiedDevices = state.devices.any((item) => item.verified);
return ListView.builder(
itemCount: state.devices.length,
itemBuilder: (context, index) {
final item = state.devices[index];
final fingerprint = item.fingerprint;
return FingerprintListItem(
fingerprint,
item.enabled,
item.verified,
hasVerifiedDevices,
onVerifiedPressed: () {
if (item.verified) return;
// TODO(PapaTutuWawa): Implement
showNotImplementedDialog('verification feature', context);
},
onEnableValueChanged: (value) {
context.read<DevicesBloc>().add(
DeviceEnabledSetEvent(
item.deviceId,
value,
),
);
},
);
},
);
}
Future<void> _recreateSessions(BuildContext context) async {
final result = await showConfirmationDialog(
t.pages.profile.devices.recreateSessionsConfirmTitle,
t.pages.profile.devices.recreateSessionsConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<DevicesBloc>().add(SessionsRecreatedEvent());
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<DevicesBloc, DevicesState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
t.pages.profile.devices.title,
extra: [
const Spacer(),
PopupMenuButton(
onSelected: (DevicesOptions result) {
if (result == DevicesOptions.recreateSessions) {
_recreateSessions(context);
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
value: DevicesOptions.recreateSessions,
enabled: state.devices.isNotEmpty,
child: Text(t.pages.profile.devices.recreateSessions),
)
],
),
],
),
body: _buildBody(context, state),
),
);
}
}

View File

@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/profile/widgets.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:qr_flutter/qr_flutter.dart';
enum OwnDevicesOptions {
recreateSessions,
recreateDevice,
}
class OwnDevicesPage extends StatelessWidget {
const OwnDevicesPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const OwnDevicesPage(),
settings: const RouteSettings(
name: ownDevicesRoute,
),
);
Future<void> _showDeviceQRCode(BuildContext context, int deviceId, String fingerprint) async {
final jid = GetIt.I.get<UIDataService>().ownJid;
await showDialog<dynamic>(
context: context,
builder: (BuildContext context) => SimpleDialog(
children: [
Center(
child: SizedBox(
width: 220,
height: 220,
child: QrImage(
data: 'xmpp:$jid?omemo-sid-$deviceId=$fingerprint',
size: 220,
backgroundColor: Colors.white,
embeddedImage: const AssetImage('assets/images/logo.png'),
embeddedImageStyle: QrEmbeddedImageStyle(
size: const Size(50, 50),
),
),
),
)
],
),
);
}
Widget _buildBody(BuildContext context, OwnDevicesState state) {
if (state.working) {
return const Center(
child: CircularProgressIndicator(),
);
}
final hasVerifiedDevices = state.keys.any((item) => item.verified);
return ListView.builder(
itemCount: state.keys.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
),
child: Text(
t.pages.profile.owndevices.thisDevice,
style: const TextStyle(
fontSize: fontsizeSubtitle,
),
),
),
),
FingerprintListItem(
state.deviceFingerprint,
true,
true,
true,
onShowQrCodePressed: () {
_showDeviceQRCode(context, state.deviceId, state.deviceFingerprint);
},
),
...state.keys.isNotEmpty ?
[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 32,
left: 16,
),
child: Text(
t.pages.profile.owndevices.otherDevices,
style: const TextStyle(
fontSize: fontsizeSubtitle,
),
),
),
),
] :
[],
],
);
}
final item = state.keys[index - 1];
final fingerprint = item.fingerprint;
return FingerprintListItem(
fingerprint,
item.enabled,
item.verified,
hasVerifiedDevices,
onVerifiedPressed: !item.hasSessionWith ?
null :
() {
if (item.verified) return;
// TODO(PapaTutuWawa): Implement
showNotImplementedDialog('verification feature', context);
},
onEnableValueChanged: !item.hasSessionWith ?
null :
(value) {
context.read<OwnDevicesBloc>().add(
OwnDeviceEnabledSetEvent(
item.deviceId,
value,
),
);
},
onDeletePressed: () async {
final result = await showConfirmationDialog(
t.pages.profile.owndevices.deleteDeviceConfirmTitle,
t.pages.profile.owndevices.deleteDeviceConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<OwnDevicesBloc>().add(OwnDeviceRemovedEvent(item.deviceId));
}
},
);
},
);
}
Future<void> _recreateSessions(BuildContext context) async {
final result = await showConfirmationDialog(
t.pages.profile.owndevices.recreateOwnSessionsConfirmTitle,
t.pages.profile.owndevices.recreateOwnSessionsConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<OwnDevicesBloc>().add(OwnSessionsRecreatedEvent());
}
}
Future<void> _recreateDevice(BuildContext context) async {
final result = await showConfirmationDialog(
t.pages.profile.owndevices.recreateOwnDeviceConfirmTitle,
t.pages.profile.owndevices.recreateOwnDeviceConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<OwnDevicesBloc>().add(OwnDeviceRegeneratedEvent());
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
t.pages.profile.owndevices.title,
extra: [
const Spacer(),
PopupMenuButton(
onSelected: (OwnDevicesOptions result) {
switch (result) {
case OwnDevicesOptions.recreateSessions:
_recreateSessions(context);
break;
case OwnDevicesOptions.recreateDevice:
_recreateDevice(context);
break;
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
value: OwnDevicesOptions.recreateSessions,
enabled: state.keys.isNotEmpty,
child: Text(t.pages.profile.owndevices.recreateOwnSessions),
),
PopupMenuItem(
value: OwnDevicesOptions.recreateDevice,
child: Text(t.pages.profile.owndevices.recreateOwnDevice),
),
],
),
],
),
body: _buildBody(context, state),
),
);
}
}

View File

@ -9,7 +9,7 @@ import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/media.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({ Key? key }) : super(key: key);
const ProfilePage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const ProfilePage(),

View File

@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:qr_flutter/qr_flutter.dart';
class SelfProfileHeader extends StatelessWidget {
@ -10,10 +15,8 @@ class SelfProfileHeader extends StatelessWidget {
this.avatarUrl,
this.displayName,
this.setAvatar,
{
Key? key,
}
) : super(key: key);
{ super.key, }
);
final String jid;
final String avatarUrl;
final String displayName;
@ -103,6 +106,43 @@ class SelfProfileHeader extends StatelessWidget {
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Tooltip(
message: t.pages.profile.self.devices,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SharedMediaContainer(
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColoredBox(
color: getTileColor(context),
child: const Icon(
Icons.security_outlined,
size: 32,
),
),
),
onTap: () {
GetIt.I.get<OwnDevicesBloc>().add(OwnDevicesRequestedEvent());
},
),
Text(
t.pages.profile.self.devices,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
],
),
),
],
),
),
],
);
}

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/ui/constants.dart';
class FingerprintListItem extends StatelessWidget {
const FingerprintListItem(
this.fingerprint,
this.enabled,
this.verified,
this.hasVerifiedKeys,
{
this.onVerifiedPressed,
this.onEnableValueChanged,
this.onShowQrCodePressed,
this.onDeletePressed,
super.key,
}
);
final String fingerprint;
final bool enabled;
final bool verified;
final bool hasVerifiedKeys;
final void Function()? onVerifiedPressed;
final void Function(bool value)? onEnableValueChanged;
final void Function()? onShowQrCodePressed;
final void Function()? onDeletePressed;
@override
Widget build(BuildContext context) {
final parts = List<String>.empty(growable: true);
for (var i = 0; i < 8; i++) {
final part = fingerprint.substring(i*8, (i+1)*8);
parts.add(part);
}
final width = MediaQuery.of(context).size.width;
final fontSize = width * 0.04;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
color: !verified && hasVerifiedKeys ? Colors.red : null,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: parts
.map((part_) => Text(
part_,
style: TextStyle(
fontFamily: 'RobotoMono',
fontSize: fontSize,
),
),).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
...onEnableValueChanged != null ?
[
Switch(
value: enabled,
onChanged: onEnableValueChanged,
),
] :
[],
...onVerifiedPressed != null ?
[
IconButton(
icon: Icon(
verified ?
Icons.verified_user :
Icons.qr_code_scanner,
),
onPressed: onVerifiedPressed,
),
] :
[],
...onShowQrCodePressed != null ?
[
IconButton(
icon: const Icon(Icons.qr_code),
onPressed: onShowQrCodePressed,
),
] :
[],
...onDeletePressed != null ?
[
IconButton(
icon: const Icon(Icons.delete),
onPressed: onDeletePressed,
),
] :
[],
],
),
],
),
),
),
);
}
}

View File

@ -10,7 +10,6 @@ import 'package:moxxyv2/ui/widgets/cancel_button.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
import 'package:moxxyv2/ui/widgets/chat/thumbnail.dart';
import 'package:path/path.dart' as pathlib;
Widget _deleteIconWithShadow() {
@ -24,8 +23,7 @@ Widget _deleteIconWithShadow() {
}
class SendFilesPage extends StatelessWidget {
const SendFilesPage({ Key? key }) : super(key: key);
const SendFilesPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const SendFilesPage(),
@ -126,14 +124,14 @@ class SendFilesPage extends StatelessWidget {
return Image.file(
File(path),
);
} else if (mime.startsWith('video/')) {
} /*else if (mime.startsWith('video/')) {
// Render the video thumbnail
// TODO(PapaTutuWawa): Maybe allow playing the video back inline
return VideoThumbnailWidget(
path,
Image.memory,
);
} else {
}*/ else {
// Generic file
final width = MediaQuery.of(context).size.width;
return Center(

View File

@ -9,7 +9,7 @@ const TextStyle _labelStyle = TextStyle(
);
class ServerInfoPage extends StatelessWidget {
const ServerInfoPage({ Key? key }) : super(key: key);
const ServerInfoPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const ServerInfoPage(),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:url_launcher/url_launcher.dart';
@ -6,7 +7,7 @@ import 'package:url_launcher/url_launcher.dart';
// TODO(PapaTutuWawa): Include license text
// TODO(Unknown): Maybe include the version number
class SettingsAboutPage extends StatelessWidget {
const SettingsAboutPage({ Key? key }) : super(key: key);
const SettingsAboutPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const SettingsAboutPage(),
@ -16,7 +17,7 @@ class SettingsAboutPage extends StatelessWidget {
);
Future<void> _openUrl(String url) async {
if (!await launchUrl(Uri.parse(url))) {
if (!await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication)) {
// TODO(Unknown): Show a popup to copy the url
}
}
@ -24,7 +25,7 @@ class SettingsAboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('About'),
appBar: BorderlessTopbar.simple(t.pages.settings.about.title),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
child: Column(
@ -33,26 +34,26 @@ class SettingsAboutPage extends StatelessWidget {
'assets/images/logo.png',
width: 200, height: 200,
),
const Text(
'moxxy',
style: TextStyle(
Text(
t.global.title,
style: const TextStyle(
fontSize: 40,
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'An experimental XMPP client that is beautiful, modern and easy to use',
style: TextStyle(
t.global.moxxySubtitle,
style: const TextStyle(
fontSize: 15,
),
),
),
const Text('Licensed under GPL3'),
Text(t.pages.settings.about.licensed),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ElevatedButton(
child: const Text('View source code'),
child: Text(t.pages.settings.about.viewSourceCode),
onPressed: () => _openUrl('https://github.com/PapaTutuWawa/moxxyv2'),
),
)

View File

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:settings_ui/settings_ui.dart';
Widget _buildLanguageOption(BuildContext context, String localeCode, PreferencesState state) {
final selected = state.languageLocaleCode == localeCode;
return SimpleDialogOption(
onPressed: () => Navigator.pop(context, localeCode),
child: Flex(
direction: Axis.horizontal,
children: [
Text(
localeCodeToLanguageName(localeCode),
style: const TextStyle(
fontSize: 16,
),
),
...selected ? [
const Spacer(),
const Icon(Icons.check),
] : [],
],
),
);
}
class AppearanceSettingsPage extends StatelessWidget {
const AppearanceSettingsPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const AppearanceSettingsPage(),
settings: const RouteSettings(
name: appearanceRoute,
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.appearance.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => SettingsList(
sections: [
SettingsSection(
title: Text(t.pages.settings.appearance.languageSection),
tiles: [
SettingsTile(
title: Text(t.pages.settings.appearance.language),
description: Text(
t.pages.settings.appearance.languageSubtext(
selectedLanguage: localeCodeToLanguageName(state.languageLocaleCode),
),
),
onPressed: (context) async {
final result = await showDialog<String>(
context: context,
builder: (context) {
return SimpleDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
title: Text(t.pages.settings.appearance.language),
children: [
_buildLanguageOption(context, 'default', state),
_buildLanguageOption(context, 'de', state),
_buildLanguageOption(context, 'en', state),
],
);
},
);
if (result == null) {
// Do nothing as the dialog was dismissed
return;
}
// Change preferences and set the app's locale
// ignore: use_build_context_synchronously
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
state.copyWith(languageLocaleCode: result),
),
);
if (result == 'default') {
LocaleSettings.useDeviceLocale();
} else {
LocaleSettings.setLocaleRaw(result);
}
},
),
],
),
],
),
),
);
}
}

View File

@ -9,8 +9,7 @@ import 'package:moxxyv2/ui/widgets/button.dart';
import 'package:moxxyv2/ui/widgets/cancel_button.dart';
class CropBackgroundPage extends StatefulWidget {
const CropBackgroundPage({ Key? key }) : super(key: key);
const CropBackgroundPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (context) => const CropBackgroundPage(),
@ -205,11 +204,8 @@ class CropBackgroundPageState extends State<CropBackgroundPage> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
RoundedButton(
color: primaryColor,
cornerRadius: 100,
onTap: () {
if (state.isWorking) return;
context.read<CropBackgroundBloc>().add(
BackgroundSetEvent(
_x,
@ -220,6 +216,7 @@ class CropBackgroundPageState extends State<CropBackgroundPage> {
),
);
},
enabled: !state.isWorking,
child: const Text('Set as background image'),
),
],

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
@ -14,8 +15,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:settings_ui/settings_ui.dart';
class ConversationSettingsPage extends StatelessWidget {
const ConversationSettingsPage({ Key? key }): super(key: key);
const ConversationSettingsPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const ConversationSettingsPage(),
@ -64,16 +64,16 @@ class ConversationSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Chat'),
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => SettingsList(
sections: [
SettingsSection(
title: const Text('Appearance'),
title: Text(t.pages.settings.conversation.appearance),
tiles: [
SettingsTile(
title: const Text('Select background image'),
description: const Text('This image will be the background of all your chats'),
title: Text(t.pages.settings.conversation.selectBackgroundImage),
description: Text(t.pages.settings.conversation.selectBackgroundImageDescription),
onPressed: (context) async {
final backgroundPath = await _pickBackgroundImage();
@ -86,27 +86,27 @@ class ConversationSettingsPage extends StatelessWidget {
},
),
SettingsTile(
title: const Text('Remove background image'),
onPressed: (context) {
showConfirmationDialog(
'Are you sure?',
'Are you sure you want to remove your conversation background image?',
title: Text(t.pages.settings.conversation.removeBackgroundImage),
onPressed: (context) async {
final result = await showConfirmationDialog(
t.pages.settings.conversation.removeBackgroundImageConfirmTitle,
t.pages.settings.conversation.removeBackgroundImageConfirmBody,
context,
() async {
await _removeBackgroundImage(context, state);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
);
if (result) {
// ignore: use_build_context_synchronously
await _removeBackgroundImage(context, state);
}
},
),
],
),
SettingsSection(
title: const Text('New Conversations'),
title: Text(t.pages.settings.conversation.newChatsSection),
tiles: [
SettingsTile.switchTile(
title: const Text('Mute new chats by default'),
title: Text(t.pages.settings.conversation.newChatsMuteByDefault),
initialValue: state.defaultMuteState,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -114,6 +114,15 @@ class ConversationSettingsPage extends StatelessWidget {
),
),
),
SettingsTile.switchTile(
title: Text(t.pages.settings.conversation.newChatsE2EE),
initialValue: state.enableOmemoByDefault,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
state.copyWith(enableOmemoByDefault: value),
),
),
),
],
),
],

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -7,8 +8,10 @@ import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:settings_ui/settings_ui.dart';
class DebuggingPage extends StatelessWidget {
DebuggingPage({ Key? key }) : _ipController = TextEditingController(), _passphraseController = TextEditingController(), _portController = TextEditingController(), super(key: key);
DebuggingPage({ super.key })
: _ipController = TextEditingController(),
_passphraseController = TextEditingController(),
_portController = TextEditingController();
final TextEditingController _ipController;
final TextEditingController _portController;
final TextEditingController _passphraseController;
@ -23,15 +26,15 @@ class DebuggingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Debugging'),
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => SettingsList(
sections: [
SettingsSection(
title: const Text('General'),
title: Text(t.pages.settings.debugging.generalSection),
tiles: [
SettingsTile.switchTile(
title: const Text('Enable debugging'),
title: Text(t.pages.settings.debugging.generalEnableDebugging),
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
state.copyWith(debugEnabled: value),
@ -40,14 +43,14 @@ class DebuggingPage extends StatelessWidget {
initialValue: state.debugEnabled,
),
SettingsTile(
title: const Text('Encryption password'),
description: const Text('The logs may contain sensitive information so pick a strong passphrase'),
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
description: Text(t.pages.settings.debugging.generalEncryptionPasswordSubtext),
onPressed: (context) {
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) => AlertDialog(
title: const Text('Debug Passphrase'),
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
content: TextField(
minLines: 1,
obscureText: true,
@ -55,7 +58,7 @@ class DebuggingPage extends StatelessWidget {
),
actions: [
TextButton(
child: const Text('Okay'),
child: Text(t.global.dialogAccept),
onPressed: () {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -71,21 +74,21 @@ class DebuggingPage extends StatelessWidget {
},
),
SettingsTile(
title: const Text('Logging IP'),
description: const Text('The IP the logs should be sent to'),
title: Text(t.pages.settings.debugging.generalLoggingIp),
description: Text(t.pages.settings.debugging.generalLoggingIpSubtext),
onPressed: (context) {
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) => AlertDialog(
title: const Text('Logging IP'),
title: Text(t.pages.settings.debugging.generalLoggingIp),
content: TextField(
minLines: 1,
controller: _ipController,
),
actions: [
TextButton(
child: const Text('Okay'),
child: Text(t.global.dialogAccept),
onPressed: () {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -101,14 +104,14 @@ class DebuggingPage extends StatelessWidget {
},
),
SettingsTile(
title: const Text('Logging Port'),
description: const Text('The Port the logs should be sent to'),
title: Text(t.pages.settings.debugging.generalLoggingPort),
description: Text(t.pages.settings.debugging.generalLoggingPortSubtext),
onPressed: (context) {
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) => AlertDialog(
title: const Text('Logging Port'),
title: Text(t.pages.settings.debugging.generalLoggingPort),
content: TextField(
minLines: 1,
controller: _portController,
@ -116,7 +119,7 @@ class DebuggingPage extends StatelessWidget {
),
actions: [
TextButton(
child: const Text('Okay'),
child: Text(t.global.dialogAccept),
onPressed: () {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:url_launcher/url_launcher.dart';
@ -14,8 +15,7 @@ class Library {
}
class LicenseRow extends StatelessWidget {
const LicenseRow({ required this.library, Key? key }) : super(key: key);
const LicenseRow({ required this.library, super.key });
final Library library;
Future<void> _openUrl() async {
@ -32,14 +32,14 @@ class LicenseRow extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
title: Text(library.name),
subtitle: Text('Licensed under ${library.license}'),
subtitle: Text(t.pages.settings.licenses.licensedUnder(license: library.license)),
onTap: _openUrl,
);
}
}
class SettingsLicensesPage extends StatelessWidget {
const SettingsLicensesPage({ Key? key }) : super(key: key);
const SettingsLicensesPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const SettingsLicensesPage(),
@ -51,7 +51,7 @@ class SettingsLicensesPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Licenses'),
appBar: BorderlessTopbar.simple(t.pages.settings.licenses.title),
body: ListView.builder(
itemCount: usedLibraryList.length,
itemBuilder: (context, index) => LicenseRow(library: usedLibraryList[index]),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -22,8 +23,7 @@ const _autoDownloadSizes = <_AutoDownloadSizes>[
];
class NetworkPage extends StatelessWidget {
const NetworkPage({ Key? key }): super(key: key);
const NetworkPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const NetworkPage(),
@ -67,18 +67,18 @@ class NetworkPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Network'),
appBar: BorderlessTopbar.simple(t.pages.settings.network.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => SettingsList(
sections: [
SettingsSection(
title: const Text('Automatic Downloads'),
title: Text(t.pages.settings.network.automaticDownloadsSection),
tiles: [
SettingsTile(
title: const Text('Moxxy will automatically download files on...'),
title: Text(t.pages.settings.network.automaticDownloadsText),
),
SettingsTile.switchTile(
title: const Text('Wifi'),
title: Text(t.pages.settings.network.wifi),
initialValue: state.autoDownloadWifi,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -87,7 +87,7 @@ class NetworkPage extends StatelessWidget {
),
),
SettingsTile.switchTile(
title: const Text('Mobile Data'),
title: Text(t.pages.settings.network.mobileData),
initialValue: state.autoDownloadMobile,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -96,8 +96,8 @@ class NetworkPage extends StatelessWidget {
),
),
SettingsTile(
title: const Text('Maximum Download Size'),
description: const Text('The maximum file size for a file to be automatically downloaded'),
title: Text(t.pages.settings.network.automaticDownloadsMaximumSize),
description: Text(t.pages.settings.network.automaticDownloadsMaximumSizeSubtext),
onPressed: (context) {
showModalBottomSheet<dynamic>(
context: context,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -8,7 +9,7 @@ import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:settings_ui/settings_ui.dart';
class PrivacyPage extends StatelessWidget {
const PrivacyPage({ Key? key }): super(key: key);
const PrivacyPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const PrivacyPage(),
@ -20,16 +21,16 @@ class PrivacyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Privacy'),
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => SettingsList(
sections: [
SettingsSection(
title: const Text('General'),
title: Text(t.pages.settings.privacy.generalSection),
tiles: [
SettingsTile.switchTile(
title: const Text('Show contact requests'),
description: const Text('This will show people who added you to their contact list but sent no message yet'),
title: Text(t.pages.settings.privacy.showContactRequests),
description: Text(t.pages.settings.privacy.showContactRequestsSubtext),
initialValue: state.showSubscriptionRequests,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -38,8 +39,8 @@ class PrivacyPage extends StatelessWidget {
),
),
SettingsTile.switchTile(
title: const Text('Make profile picture public'),
description: const Text('If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.'),
title: Text(t.pages.settings.privacy.profilePictureVisibility),
description: Text(t.pages.settings.privacy.profilePictureVisibilitSubtext),
initialValue: state.isAvatarPublic,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -48,8 +49,8 @@ class PrivacyPage extends StatelessWidget {
),
),
SettingsTile.switchTile(
title: const Text('Auto-accept subscription requests'),
description: const Text('If enabled, subscription requests will be automatically accepted if the user is in the contact list.'),
title: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequests),
description: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequestsSubtext),
initialValue: state.autoAcceptSubscriptionRequests,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -60,11 +61,11 @@ class PrivacyPage extends StatelessWidget {
],
),
SettingsSection(
title: const Text('Conversation'),
title: Text(t.pages.settings.privacy.conversationsSection),
tiles: [
SettingsTile.switchTile(
title: const Text('Send chat markers'),
description: const Text('This will tell your conversation partner if you received or read a message'),
title: Text(t.pages.settings.privacy.sendChatMarkers),
description: Text(t.pages.settings.privacy.sendChatMarkersSubtext),
initialValue: state.sendChatMarkers,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -73,8 +74,8 @@ class PrivacyPage extends StatelessWidget {
),
),
SettingsTile.switchTile(
title: const Text('Send chat states'),
description: const Text('This will show your conversation partner if you are typing or looking at the chat'),
title: Text(t.pages.settings.privacy.sendChatStates),
description: Text(t.pages.settings.privacy.sendChatStatesSubtext),
initialValue: state.sendChatStates,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
@ -85,7 +86,7 @@ class PrivacyPage extends StatelessWidget {
],
),
SettingsSection(
title: const Text('Redirects'),
title: Text(t.pages.settings.privacy.redirectsSection),
tiles: [
RedirectSettingsTile(
'Youtube',

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